├── .github └── FUNDING.yml ├── assets ├── robots.txt ├── app.ico ├── app.png ├── favicon.ico ├── mic-white.png ├── mic-white@2x.png ├── mic-blackTemplate.png └── mic-blackTemplate@2x.png ├── docs ├── layouts │ ├── shortcodes │ │ ├── baseurl.html │ │ ├── icon-warn.html │ │ ├── icon-external.html │ │ ├── icon-info.html │ │ ├── img.html │ │ └── screenshots.html │ ├── index.html │ ├── _default │ │ ├── single.html │ │ ├── list.html │ │ └── baseof.html │ ├── partials │ │ ├── footer.html │ │ └── head.html │ └── 404.html ├── assets │ ├── fonts │ │ ├── beon.woff2 │ │ ├── raleway-v14-latin-200.woff2 │ │ └── raleway-v14-latin-700.woff2 │ ├── images │ │ ├── README.jpg │ │ ├── favicon.ico │ │ └── logo_mic.png │ └── css │ │ ├── variables.scss │ │ └── logo.scss ├── archetypes │ └── default.md ├── content │ └── docs │ │ ├── _index.md │ │ ├── karaoke-eternal-app │ │ ├── app-player.jpg │ │ ├── app-queue.png │ │ ├── app-account.png │ │ ├── app-library.png │ │ ├── app-library2.png │ │ └── app-displayctrl.png │ │ └── karaoke-eternal-server │ │ ├── server-macos.png │ │ ├── server-windows.png │ │ └── server-synology.png └── config.yaml ├── src ├── routes │ ├── Library │ │ ├── views │ │ │ ├── LibraryView.css │ │ │ └── LibraryView.tsx │ │ ├── components │ │ │ ├── SearchResults │ │ │ │ └── SearchResults.css │ │ │ ├── AlphaPicker │ │ │ │ ├── AlphaPicker.css │ │ │ │ └── AlphaPicker.tsx │ │ │ ├── LibraryHeader │ │ │ │ ├── LibraryHeaderContainer.ts │ │ │ │ ├── LibraryHeader.css │ │ │ │ └── LibraryHeader.tsx │ │ │ ├── ArtistItem │ │ │ │ ├── ArtistItem.css │ │ │ │ └── ArtistItem.tsx │ │ │ ├── SongItem │ │ │ │ └── SongItem.css │ │ │ └── SongList │ │ │ │ └── SongList.tsx │ │ └── modules │ │ │ ├── artists.ts │ │ │ ├── songs.ts │ │ │ └── starCounts.ts │ ├── Queue │ │ ├── components │ │ │ ├── QueueList │ │ │ │ └── index.ts │ │ │ ├── QueueListAnimator │ │ │ │ ├── QueueListAnimator.css │ │ │ │ └── QueueListAnimator.tsx │ │ │ └── QueueItem │ │ │ │ └── QueueItem.css │ │ ├── views │ │ │ ├── QueueView.css │ │ │ └── QueueView.tsx │ │ ├── selectors │ │ │ ├── getPlayerHistory.ts │ │ │ └── getWaits.ts │ │ └── modules │ │ │ └── queue.ts │ ├── Account │ │ ├── components │ │ │ ├── Prefs │ │ │ │ ├── Prefs.css │ │ │ │ ├── PathPrefs │ │ │ │ │ ├── PathInfo │ │ │ │ │ │ ├── PathInfo.css │ │ │ │ │ │ └── PathInfo.tsx │ │ │ │ │ ├── PathChooser │ │ │ │ │ │ ├── PathItem │ │ │ │ │ │ │ ├── PathItem.css │ │ │ │ │ │ │ └── PathItem.tsx │ │ │ │ │ │ └── PathChooser.css │ │ │ │ │ ├── PathPrefs.css │ │ │ │ │ └── PathItem │ │ │ │ │ │ ├── PathItem.css │ │ │ │ │ │ └── PathItem.tsx │ │ │ │ ├── PlayerPrefs │ │ │ │ │ ├── PlayerPrefs.css │ │ │ │ │ └── PlayerPrefs.tsx │ │ │ │ └── Prefs.tsx │ │ │ ├── Account │ │ │ │ ├── Account.css │ │ │ │ └── Account.tsx │ │ │ ├── AccountForm │ │ │ │ └── AccountForm.css │ │ │ ├── Users │ │ │ │ ├── EditUser │ │ │ │ │ ├── EditUser.css │ │ │ │ │ └── EditUser.tsx │ │ │ │ └── Users.css │ │ │ ├── Rooms │ │ │ │ ├── EditRoom │ │ │ │ │ ├── EditRoom.css │ │ │ │ │ ├── UserPrefs │ │ │ │ │ │ ├── UserPrefs.css │ │ │ │ │ │ └── UserPrefs.tsx │ │ │ │ │ └── QRPrefs │ │ │ │ │ │ └── QRPrefs.css │ │ │ │ └── Rooms.css │ │ │ └── About │ │ │ │ └── About.css │ │ ├── views │ │ │ ├── SignedOutView │ │ │ │ ├── SignIn │ │ │ │ │ ├── SignIn.css │ │ │ │ │ └── SignIn.tsx │ │ │ │ ├── SelectRoom │ │ │ │ │ └── SelectRoom.css │ │ │ │ ├── Create │ │ │ │ │ ├── Create.css │ │ │ │ │ └── Create.tsx │ │ │ │ └── SignedOutView.css │ │ │ ├── AccountView.css │ │ │ ├── FirstRun │ │ │ │ ├── FirstRun.css │ │ │ │ └── FirstRun.tsx │ │ │ ├── AccountView.tsx │ │ │ └── SignedInView │ │ │ │ └── SignedInView.tsx │ │ └── selectors │ │ │ ├── getRoomList.ts │ │ │ └── getUsers.ts │ └── Player │ │ ├── components │ │ ├── Player │ │ │ ├── MP4Player │ │ │ │ ├── MP4Player.css │ │ │ │ └── MP4Player.tsx │ │ │ ├── PlayerVisualizer │ │ │ │ └── PlayerVisualizer.css │ │ │ └── CDGPlayer │ │ │ │ └── CDGPlayer.css │ │ ├── PlayerQR │ │ │ └── PlayerQR.css │ │ └── PlayerTextOverlay │ │ │ ├── ColorCycle │ │ │ ├── ColorCycle.css │ │ │ └── ColorCycle.tsx │ │ │ ├── PlayerTextOverlay.css │ │ │ ├── UpNow │ │ │ ├── UpNow.css │ │ │ └── UpNow.tsx │ │ │ └── PlayerTextOverlay.tsx │ │ ├── views │ │ ├── PlayerView.css │ │ └── PlayerView.tsx │ │ └── selectors │ │ └── getRoomPrefs.ts ├── components │ ├── UserImage │ │ ├── UserImage.css │ │ └── UserImage.tsx │ ├── TextOverlay │ │ ├── TextOverlay.css │ │ └── TextOverlay.tsx │ ├── Buttons │ │ ├── Buttons.css │ │ └── Buttons.tsx │ ├── PaddedList │ │ ├── PaddedList.css │ │ └── PaddedList.tsx │ ├── Header │ │ ├── PlaybackCtrl │ │ │ ├── NoPlayer │ │ │ │ ├── NoPlayer.css │ │ │ │ └── NoPlayer.tsx │ │ │ ├── VolumeSlider │ │ │ │ ├── VolumeSlider.css │ │ │ │ └── VolumeSlider.tsx │ │ │ ├── PlaybackCtrl.css │ │ │ └── DisplayCtrl │ │ │ │ └── DisplayCtrl.css │ │ ├── Header.css │ │ ├── ProgressBar │ │ │ ├── ProgressBar.css │ │ │ └── ProgressBar.tsx │ │ └── UpNext │ │ │ ├── UpNext.css │ │ │ └── UpNext.tsx │ ├── SongInfo │ │ └── SongInfo.css │ ├── Spinner │ │ ├── Spinner.tsx │ │ └── Spinner.css │ ├── Logo │ │ ├── Logo.tsx │ │ └── Logo.css │ ├── Icon │ │ └── Icon.tsx │ ├── App │ │ ├── App.tsx │ │ ├── CoreLayout │ │ │ └── CoreLayout.tsx │ │ └── Routes │ │ │ └── Routes.tsx │ ├── InputRadio │ │ ├── InputRadio.css │ │ └── InputRadio.tsx │ ├── Panel │ │ ├── Panel.css │ │ └── Panel.tsx │ ├── InputCheckbox │ │ ├── InputCheckbox.tsx │ │ └── InputCheckbox.css │ ├── Navigation │ │ ├── Navigation.css │ │ └── Navigation.tsx │ ├── InputImage │ │ └── InputImage.css │ ├── Accordion │ │ ├── Accordion.module.css │ │ └── Accordion.tsx │ ├── ToggleAnimation │ │ └── ToggleAnimation.tsx │ ├── Button │ │ └── Button.css │ ├── Slider │ │ ├── Slider.css │ │ └── Slider.tsx │ └── Modal │ │ ├── Modal.css │ │ └── Modal.tsx ├── lib │ ├── socket.ts │ ├── getWebGLSupport.ts │ ├── AppRouter.tsx │ ├── util.ts │ ├── dateTime.ts │ └── HttpApi.ts ├── types │ ├── redux-throttle.d.ts │ └── butterchurn.d.ts ├── index.html ├── store │ ├── hooks.ts │ ├── Persistor.ts │ ├── reducers.ts │ ├── socketMiddleware.ts │ ├── modules │ │ ├── songInfo.ts │ │ └── status.ts │ └── store.ts ├── styles │ └── fonts.css └── main.tsx ├── server ├── lib │ ├── Errors.js │ ├── schemas │ │ ├── 004-paths-rooms-data.sql │ │ ├── 002-replaygain.sql │ │ ├── 003-queue-linked-list.sql │ │ └── 005-roles.sql │ ├── getFolders.js │ ├── parseCookie.js │ ├── accumulatedThrottle.js │ ├── getWindowsDrives.js │ ├── getIPAddress.js │ ├── getCdgName.js │ ├── bcrypt.js │ ├── getPermutations.js │ ├── getHotMiddleware.js │ ├── pushQueuesAndLibrary.js │ ├── Log.js │ ├── util.js │ └── Database.js ├── Media │ ├── fileTypes.js │ └── ipc.js ├── Library │ ├── ipc.js │ ├── router.js │ └── socket.js ├── Scanner │ ├── Scanner.js │ ├── FileScanner │ │ ├── getConfig.js │ │ └── getFiles.js │ └── ScannerQueue.js ├── Rooms │ └── socket.js ├── Prefs │ └── socket.js ├── scannerWorker.js ├── watcherWorker.js ├── Player │ └── socket.js └── Queue │ └── socket.js ├── config ├── declarations.d.ts ├── babel.config.json └── webpack.license.config.js ├── .gitignore ├── tsconfig.json ├── LICENSE ├── eslint.config.js └── shared └── types.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: bhj 2 | -------------------------------------------------------------------------------- /assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /docs/layouts/shortcodes/baseurl.html: -------------------------------------------------------------------------------- 1 | {{ .Site.BaseURL }} -------------------------------------------------------------------------------- /assets/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/app.ico -------------------------------------------------------------------------------- /assets/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/app.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /assets/mic-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/mic-white.png -------------------------------------------------------------------------------- /assets/mic-white@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/mic-white@2x.png -------------------------------------------------------------------------------- /docs/layouts/index.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |

{{ .Title }}

3 | {{ .Content }} 4 | {{ end }} -------------------------------------------------------------------------------- /src/routes/Library/views/LibraryView.css: -------------------------------------------------------------------------------- 1 | .empty h1 { 2 | font-family: var(--font-family-custom); 3 | } 4 | -------------------------------------------------------------------------------- /assets/mic-blackTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/mic-blackTemplate.png -------------------------------------------------------------------------------- /docs/assets/fonts/beon.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/assets/fonts/beon.woff2 -------------------------------------------------------------------------------- /docs/assets/images/README.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/assets/images/README.jpg -------------------------------------------------------------------------------- /assets/mic-blackTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/assets/mic-blackTemplate@2x.png -------------------------------------------------------------------------------- /docs/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/assets/images/favicon.ico -------------------------------------------------------------------------------- /docs/assets/images/logo_mic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/assets/images/logo_mic.png -------------------------------------------------------------------------------- /docs/layouts/_default/single.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |

{{ .Title }}

3 | {{ .Content }} 4 | {{ end }} -------------------------------------------------------------------------------- /src/routes/Queue/components/QueueList/index.ts: -------------------------------------------------------------------------------- 1 | import QueueList from './QueueList' 2 | 3 | export default QueueList 4 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/Prefs.css: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 8px; 5 | } 6 | -------------------------------------------------------------------------------- /docs/archetypes/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{ replace .Name "-" " " | title }}" 3 | date: {{ .Date }} 4 | draft: true 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /docs/layouts/partials/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/Player/components/Player/MP4Player/MP4Player.css: -------------------------------------------------------------------------------- 1 | .video { 2 | object-position: 50% 50%; 3 | object-fit: contain; 4 | } 5 | -------------------------------------------------------------------------------- /src/routes/Player/components/Player/PlayerVisualizer/PlayerVisualizer.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | z-index: -1; 4 | } 5 | -------------------------------------------------------------------------------- /docs/assets/fonts/raleway-v14-latin-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/assets/fonts/raleway-v14-latin-200.woff2 -------------------------------------------------------------------------------- /docs/assets/fonts/raleway-v14-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/assets/fonts/raleway-v14-latin-700.woff2 -------------------------------------------------------------------------------- /docs/content/docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | description: Documentation for Karaoke Eternal (the app) and Karaoke Eternal Server 4 | --- 5 | -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-app/app-player.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-app/app-player.jpg -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-app/app-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-app/app-queue.png -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-app/app-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-app/app-account.png -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-app/app-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-app/app-library.png -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-app/app-library2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-app/app-library2.png -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-app/app-displayctrl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-app/app-displayctrl.png -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-server/server-macos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-server/server-macos.png -------------------------------------------------------------------------------- /docs/layouts/shortcodes/icon-warn.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/UserImage/UserImage.css: -------------------------------------------------------------------------------- 1 | .image { 2 | border-radius: var(--border-radius); 3 | } 4 | 5 | .placeholder { 6 | color: var(--btn-bg-color); 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/Player/views/PlayerView.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | position: absolute; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-server/server-windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-server/server-windows.png -------------------------------------------------------------------------------- /docs/content/docs/karaoke-eternal-server/server-synology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bhj/KaraokeEternal/HEAD/docs/content/docs/karaoke-eternal-server/server-synology.png -------------------------------------------------------------------------------- /server/lib/Errors.js: -------------------------------------------------------------------------------- 1 | export class ValidationError extends Error { 2 | constructor (message) { 3 | super(message) 4 | this.name = 'ValidationError' 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/layouts/404.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |

Oops...

3 |

The page you are trying to reach is no longer in service. Please check the URL and try again.

4 | {{ end }} -------------------------------------------------------------------------------- /src/components/TextOverlay/TextOverlay.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | height: 100%; 4 | text-align: center; 5 | align-items: center; 6 | } 7 | 8 | .text { 9 | flex: 1; 10 | } 11 | -------------------------------------------------------------------------------- /config/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | const classes: { [key: string]: string } 3 | export = classes 4 | } 5 | 6 | interface Window { 7 | webkitAudioContext: typeof AudioContext 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/socket.ts: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client' 2 | 3 | const socket = io({ 4 | autoConnect: false, 5 | path: new URL(document.baseURI).pathname + 'socket.io', 6 | }) 7 | 8 | export default socket 9 | -------------------------------------------------------------------------------- /src/routes/Account/views/SignedOutView/SignIn/SignIn.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--space-s); 5 | 6 | button { 7 | margin-top: var(--space-s); 8 | } 9 | } -------------------------------------------------------------------------------- /src/components/Buttons/Buttons.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | transition: width var(--spring-duration) var(--spring-function); 5 | } 6 | 7 | .btnHide { 8 | opacity: 0; 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | coverage/ 3 | dist/ 4 | docs/public/ 5 | docs/resources/ 6 | node_modules/ 7 | 8 | .DS_Store 9 | *.log 10 | *.sqlite3 11 | *.sqlite3-shm 12 | *.sqlite3-wal 13 | .hugo_build.lock 14 | .nova 15 | .vscode 16 | -------------------------------------------------------------------------------- /src/routes/Queue/views/QueueView.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow-x: hidden; 3 | overflow-y: auto; 4 | -webkit-overflow-scrolling: touch; 5 | } 6 | 7 | .container h1 { 8 | font-family: var(--font-family-custom); 9 | } 10 | -------------------------------------------------------------------------------- /docs/layouts/_default/list.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |

{{ .Title }}

3 | 10 | {{ end }} -------------------------------------------------------------------------------- /src/lib/getWebGLSupport.ts: -------------------------------------------------------------------------------- 1 | export default function isWebGLSupported () { 2 | try { 3 | return !!window.WebGLRenderingContext && !!document.createElement('canvas').getContext('webgl2') 4 | } catch { 5 | return false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/Account/components/Account/Account.css: -------------------------------------------------------------------------------- 1 | .content p { 2 | margin-top: 10px; 3 | } 4 | 5 | .signOut { 6 | margin-top: var(--input-margin); 7 | } 8 | 9 | .updateAccount { 10 | margin-top: var(--input-margin); 11 | } 12 | -------------------------------------------------------------------------------- /docs/layouts/shortcodes/icon-external.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/routes/Account/views/AccountView.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 0 auto; 3 | overflow-x: hidden; 4 | overflow-y: auto; 5 | -webkit-overflow-scrolling: touch; 6 | 7 | & > div { 8 | margin: var(--space-m) auto; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/Account/components/AccountForm/AccountForm.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--space-s); 5 | } 6 | 7 | .userDisplayContainer { 8 | display: flex; 9 | gap: var(--space-s); 10 | } 11 | -------------------------------------------------------------------------------- /docs/layouts/shortcodes/icon-info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /config/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": "defaults", 3 | "plugins": [ 4 | "@babel/plugin-transform-runtime", 5 | ], 6 | "presets": [ 7 | "@babel/preset-typescript", 8 | "@babel/preset-env", 9 | "@babel/preset-react" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/routes/Library/components/SearchResults/SearchResults.css: -------------------------------------------------------------------------------- 1 | .artistsHeading, 2 | .songsHeading { 3 | color: var(--search-results-heading-color); 4 | background-color: var(--search-results-heading-bg-color); 5 | font-size: 18px; 6 | text-align: center; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/PaddedList/PaddedList.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: hidden auto !important; /* allow scrolling on Y-axis only */ 3 | scrollbar-width: none; /* Firefox */ 4 | } 5 | 6 | .container::-webkit-scrollbar { 7 | display: none; /* Safari and Chrome */ 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Header/PlaybackCtrl/NoPlayer/NoPlayer.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 16px; 3 | color: var(--header-notice-color); 4 | background-color: var(--header-notice-bg-color); 5 | } 6 | 7 | .msg { 8 | margin: 0; 9 | padding: 5px; 10 | text-align: center; 11 | } 12 | -------------------------------------------------------------------------------- /server/lib/schemas/004-paths-rooms-data.sql: -------------------------------------------------------------------------------- 1 | -- Up 2 | ALTER TABLE "paths" ADD COLUMN "data" text NOT NULL DEFAULT('{}'); 3 | ALTER TABLE "rooms" ADD COLUMN "data" text NOT NULL DEFAULT('{}'); 4 | 5 | -- Down 6 | ALTER TABLE "paths" DROP COLUMN "data"; 7 | ALTER TABLE "rooms" DROP COLUMN "data"; 8 | -------------------------------------------------------------------------------- /docs/config.yaml: -------------------------------------------------------------------------------- 1 | baseURL: https://www.karaoke-eternal.com/ 2 | languageCode: en-us 3 | title: Karaoke Eternal 4 | permalinks: 5 | page: "/:slug/" 6 | pygmentsStyle: solarized-dark256 7 | markup: 8 | goldmark: 9 | renderer: 10 | unsafe: true 11 | disableKinds: ["taxonomy", "RSS"] 12 | -------------------------------------------------------------------------------- /src/routes/Player/components/Player/CDGPlayer/CDGPlayer.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: transparent; 3 | position: relative; 4 | } 5 | 6 | .backdrop { 7 | border-radius: var(--border-radius); 8 | position: absolute; 9 | } 10 | 11 | .canvas { 12 | position: relative; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Header/Header.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | background-color: var(--chrome-bg-color); 7 | border-bottom: 1px solid var(--chrome-border-color); 8 | z-index: 1; 9 | 10 | &:empty { 11 | display: none; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/Account/views/SignedOutView/SelectRoom/SelectRoom.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--space-s); 5 | 6 | input[type="password"] { 7 | margin: var(--space-s) 0; 8 | } 9 | } 10 | 11 | .hidden { 12 | display: none !important; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/Account/views/SignedOutView/Create/Create.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | gap: var(--space-s); 5 | 6 | button { 7 | margin-top: var(--space-s); 8 | } 9 | } 10 | 11 | .userDisplayContainer { 12 | display: flex; 13 | gap: var(--space-s); 14 | } 15 | -------------------------------------------------------------------------------- /src/types/redux-throttle.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-throttle' { 2 | import type { Middleware } from 'redux' 3 | export const CANCEL: unique string 4 | export default function middleware (defaultWait: number, defaultThrottleOption: { 5 | leading?: boolean 6 | trailing?: boolean 7 | }): Middleware 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/Account/components/Users/EditUser/EditUser.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | min-width: 360px; 3 | } 4 | 5 | .btnContainer { 6 | display: flex; 7 | flex-direction: column; 8 | gap: var(--space-s); 9 | margin-top: var(--space-m); 10 | } 11 | 12 | .field { 13 | margin-bottom: var(--input-margin); 14 | } 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Karaoke Eternal 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathInfo/PathInfo.css: -------------------------------------------------------------------------------- 1 | .path { 2 | overflow-wrap: anywhere; 3 | } 4 | 5 | .label { 6 | font-weight: bold; 7 | color: #777; 8 | } 9 | 10 | .form { 11 | display: flex; 12 | flex-direction: column; 13 | gap: var(--space-m); 14 | margin-bottom: var(--space-m); 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/Library/components/AlphaPicker/AlphaPicker.css: -------------------------------------------------------------------------------- 1 | .container { 2 | align-items: center; 3 | color: var(--alpha-picker-color); 4 | display: flex; 5 | flex-direction: column; 6 | font-size: 2vh; 7 | padding: 5px 0; 8 | position: fixed; 9 | right: 0; 10 | width: 32px; 11 | cursor: pointer; 12 | touch-action: none; 13 | } 14 | -------------------------------------------------------------------------------- /server/lib/schemas/002-replaygain.sql: -------------------------------------------------------------------------------- 1 | -- Up 2 | ALTER TABLE "media" ADD COLUMN "rgTrackGain" REAL; 3 | ALTER TABLE "media" ADD COLUMN "rgTrackPeak" REAL; 4 | 5 | INSERT OR IGNORE INTO prefs (key,data) VALUES ('isReplayGainEnabled','false'); 6 | 7 | -- Down 8 | ALTER TABLE "media" DROP COLUMN "rgTrackGain"; 9 | ALTER TABLE "media" DROP COLUMN "rgTrackPeak"; 10 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathChooser/PathItem/PathItem.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | height: 40px; 4 | cursor: pointer; 5 | align-items: center; 6 | } 7 | 8 | .path { 9 | flex: 1; 10 | font-size: 16px; 11 | margin-left: 5px; 12 | overflow: hidden; 13 | text-overflow: ellipsis; 14 | white-space: nowrap; 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/Player/components/PlayerQR/PlayerQR.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | bottom: 3vh; 4 | left: 3vh; 5 | opacity: 1; 6 | transition: opacity 0.3s ease; 7 | 8 | &.alternate { 9 | left: unset; 10 | right: 3vh; 11 | } 12 | } 13 | 14 | .enterActive { 15 | opacity: 1; 16 | } 17 | 18 | .exitActive { 19 | opacity: 0; 20 | } 21 | -------------------------------------------------------------------------------- /server/Media/fileTypes.js: -------------------------------------------------------------------------------- 1 | const exported = { 2 | '.cdg': { mimeType: 'application/octet-stream', scan: false }, 3 | '.m4a': { mimeType: 'audio/mp4', requiresCDG: true }, 4 | '.mp3': { mimeType: 'audio/mpeg', requiresCDG: true }, 5 | '.mp4': { mimeType: 'video/mp4' }, 6 | '.zip': { mimeType: 'application/octet-stream' }, 7 | } 8 | 9 | export default exported 10 | -------------------------------------------------------------------------------- /src/components/SongInfo/SongInfo.css: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .mediaContainer { 8 | flex: 1; 9 | margin: .5rem 0; 10 | overflow: scroll; 11 | } 12 | 13 | .media { 14 | margin-bottom: 1em; 15 | overflow-wrap: break-word; 16 | } 17 | 18 | .label { 19 | font-weight: bold; 20 | color: #777; 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/Queue/selectors/getPlayerHistory.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store/store' 2 | import { createSelector } from '@reduxjs/toolkit' 3 | 4 | const getPlayerHistoryJSON = (state: RootState) => state.status.historyJSON 5 | 6 | const getPlayerHistory = createSelector( 7 | [getPlayerHistoryJSON], 8 | history => JSON.parse(history) as number[], 9 | ) 10 | 11 | export default getPlayerHistory 12 | -------------------------------------------------------------------------------- /src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux' 2 | import type { TypedUseSelectorHook } from 'react-redux' 3 | import type { RootState, AppDispatch } from './store' 4 | 5 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 6 | export const useAppDispatch: () => AppDispatch = useDispatch 7 | export const useAppSelector: TypedUseSelectorHook = useSelector 8 | -------------------------------------------------------------------------------- /server/lib/getFolders.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import { promisify } from 'util' 4 | const readdir = promisify(fs.readdir) 5 | 6 | const getFolders = dir => readdir(dir, { withFileTypes: true }) 7 | .then(list => Promise.all(list.map(ent => ent.isDirectory() ? path.resolve(dir, ent.name) : null))) 8 | .then(list => list.filter(f => !!f).sort()) 9 | 10 | export default getFolders 11 | -------------------------------------------------------------------------------- /src/routes/Account/views/FirstRun/FirstRun.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | align-items: baseline; 3 | color: hsl(var(--hue-blue), 10%, 60%); 4 | display: flex; 5 | flex-wrap: wrap; 6 | } 7 | 8 | .heading h2 { 9 | color: hsl(var(--hue-blue), 10%, 80%); 10 | flex: 1; 11 | margin: 0 1em 0 0; 12 | font-size: 32px; 13 | font-family: var(--font-family-custom); 14 | font-weight: 200; 15 | white-space: nowrap; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/AppRouter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createBrowserRouter } from 'react-router-dom' 3 | import App from 'components/App/App' 4 | 5 | const basename = new URL(document.baseURI).pathname 6 | 7 | const AppRouter = createBrowserRouter([ 8 | // https://github.com/remix-run/react-router/issues/9422#issuecomment-1302564759 9 | { path: '*', element: }, 10 | ], { basename }) 11 | 12 | export default AppRouter 13 | -------------------------------------------------------------------------------- /src/components/Header/PlaybackCtrl/NoPlayer/NoPlayer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import styles from './NoPlayer.css' 4 | 5 | const NoPlayer = () => ( 6 |
7 |

8 | No player in room ( 9 | Launch Player 10 | ) 11 |

12 |
13 | ) 14 | 15 | export default NoPlayer 16 | -------------------------------------------------------------------------------- /src/routes/Queue/components/QueueListAnimator/QueueListAnimator.css: -------------------------------------------------------------------------------- 1 | .itemEnter { 2 | animation: enter .3s; 3 | } 4 | 5 | .itemExit { 6 | animation: exit .5s; 7 | } 8 | 9 | @keyframes enter { 10 | 0% { opacity: 0; transform: scale3d(0, 0, 0); } 11 | 100% { opacity: 1; transform: scale3d(1, 1, 1); } 12 | } 13 | 14 | @keyframes exit { 15 | 0% { opacity: 1; transform: scale3d(1, 1, 1); } 16 | 100% { opacity: 0; transform: scale3d(0, 0, 0); } 17 | } 18 | -------------------------------------------------------------------------------- /server/lib/parseCookie.js: -------------------------------------------------------------------------------- 1 | // cookie helper based on 2 | // http://stackoverflow.com/questions/3393854/get-and-set-a-single-cookie-with-node-js-http-server 3 | export default function parseCookie (cookie) { 4 | const list = {} 5 | 6 | if (cookie) { 7 | cookie.split(';') 8 | .forEach((c) => { 9 | const parts = c.split('=') 10 | list[parts.shift().trim()] = decodeURI(parts.join('=')) 11 | }) 12 | } 13 | 14 | return list 15 | } 16 | -------------------------------------------------------------------------------- /src/routes/Account/components/Rooms/EditRoom/EditRoom.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | min-width: 360px; 3 | } 4 | 5 | .fieldContainer, 6 | .prefsContainer, 7 | .btnContainer { 8 | display: flex; 9 | flex-direction: column; 10 | gap: var(--space-s); 11 | margin: var(--space-l) 0; 12 | 13 | &:first-child { 14 | margin-top: 0; 15 | } 16 | 17 | &:last-child { 18 | margin-bottom: 0; 19 | } 20 | } 21 | 22 | .fieldContainer select { 23 | width: fit-content; 24 | } -------------------------------------------------------------------------------- /src/components/Header/PlaybackCtrl/VolumeSlider/VolumeSlider.css: -------------------------------------------------------------------------------- 1 | .slider { 2 | width: 100%; 3 | margin: 0 34px 0 30px; 4 | } 5 | 6 | .handle { 7 | cursor: pointer; 8 | color: var(--transport-btn-bg-color); 9 | filter: var(--transport-btn-filter); 10 | position: absolute; 11 | touch-action: pan-x; 12 | outline: none; 13 | 14 | &:focus-visible { 15 | outline: var(--focus-outline); 16 | } 17 | 18 | svg { 19 | height: var(--icon-size-xl); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PlayerPrefs/PlayerPrefs.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | button { 3 | display: flex; 4 | align-items: center; 5 | gap: var(--space-s); 6 | padding: var(--space-s); 7 | font-size: var(--font-size-m); 8 | } 9 | 10 | svg { 11 | height: var(--icon-size-l); 12 | } 13 | } 14 | 15 | .content { 16 | padding: var(--container-padding); 17 | } 18 | 19 | .content label { 20 | padding: var(--container-padding); 21 | cursor: pointer; 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/Prefs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Panel from 'components/Panel/Panel' 3 | import PathPrefs from './PathPrefs/PathPrefs' 4 | import PlayerPrefs from './PlayerPrefs/PlayerPrefs' 5 | import styles from './Prefs.css' 6 | 7 | const Prefs = () => ( 8 | 9 | <> 10 | 11 | 12 | 13 | 14 | ) 15 | 16 | export default Prefs 17 | -------------------------------------------------------------------------------- /src/routes/Account/components/Rooms/EditRoom/UserPrefs/UserPrefs.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | button { 3 | display: flex; 4 | align-items: center; 5 | gap: var(--space-s); 6 | padding: var(--space-s); 7 | font-size: var(--font-size-s); 8 | } 9 | 10 | svg { 11 | height: var(--icon-size-m); 12 | } 13 | } 14 | 15 | .content { 16 | display: flex; 17 | flex-direction: column; 18 | gap: var(--space-m); 19 | padding: var(--space-m) var(--container-padding); 20 | } 21 | -------------------------------------------------------------------------------- /server/Media/ipc.js: -------------------------------------------------------------------------------- 1 | import Media from './Media.js' 2 | import { MEDIA_ADD, MEDIA_CLEANUP, MEDIA_REMOVE, MEDIA_UPDATE } from '../../shared/actionTypes.ts' 3 | 4 | /** 5 | * IPC action handlers 6 | */ 7 | export default function () { 8 | return { 9 | [MEDIA_ADD]: async ({ payload }) => Media.add(payload), 10 | [MEDIA_CLEANUP]: Media.cleanup, 11 | [MEDIA_REMOVE]: async ({ payload }) => Media.remove(payload), 12 | [MEDIA_UPDATE]: async ({ payload }) => Media.update(payload), 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/lib/accumulatedThrottle.js: -------------------------------------------------------------------------------- 1 | function accumulatedThrottle (callback, wait) { 2 | let timeoutID 3 | let accumulated = [] 4 | 5 | return function (...args) { 6 | accumulated.push(args) 7 | 8 | if (!timeoutID) { 9 | timeoutID = setTimeout(function () { 10 | callback(accumulated) 11 | accumulated = [] 12 | 13 | clearTimeout(timeoutID) 14 | timeoutID = undefined 15 | }, wait) 16 | } 17 | } 18 | } 19 | 20 | export default accumulatedThrottle 21 | -------------------------------------------------------------------------------- /src/components/TextOverlay/TextOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './TextOverlay.css' 4 | 5 | interface TextOverlayProps { 6 | children: React.ReactNode 7 | className?: string 8 | } 9 | 10 | export const TextOverlay = (props: TextOverlayProps) => ( 11 |
12 |
13 | {props.children} 14 |
15 |
16 | ) 17 | 18 | export default TextOverlay 19 | -------------------------------------------------------------------------------- /server/lib/getWindowsDrives.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | export default function () { 4 | const possibleDrives = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letter => `${letter}:\\`) 5 | const existingDrives = possibleDrives.filter((drive) => { 6 | try { 7 | fs.accessSync(drive, fs.constants.R_OK) 8 | return true 9 | } catch { 10 | return false 11 | } 12 | }) 13 | 14 | return existingDrives.map(drive => ({ 15 | path: drive, 16 | label: drive.substring(0, 2), 17 | })) 18 | } 19 | -------------------------------------------------------------------------------- /server/Library/ipc.js: -------------------------------------------------------------------------------- 1 | import Library from './Library.js' 2 | import throttle from '@jcoreio/async-throttle' 3 | import { SCANNER_WORKER_STATUS, LIBRARY_MATCH_SONG } from '../../shared/actionTypes.ts' 4 | 5 | /** 6 | * IPC action handlers 7 | */ 8 | export default function (io) { 9 | const emit = throttle(action => io.emit('action', action), 1000) 10 | 11 | return { 12 | [LIBRARY_MATCH_SONG]: async ({ payload }) => Library.matchSong(payload), 13 | [SCANNER_WORKER_STATUS]: action => emit(action), 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/lib/schemas/003-queue-linked-list.sql: -------------------------------------------------------------------------------- 1 | -- Up 2 | ALTER TABLE "queue" ADD COLUMN "prevQueueId" INTEGER REFERENCES queue(queueId) DEFERRABLE INITIALLY DEFERRED; 3 | 4 | CREATE INDEX IF NOT EXISTS idxPrevQueueId ON "queue" ("prevQueueId" ASC); 5 | 6 | UPDATE queue 7 | SET prevQueueId = ( 8 | SELECT MAX(q.queueId) 9 | FROM queue q 10 | WHERE q.queueId < queue.queueId AND q.roomId = queue.roomId 11 | ); 12 | 13 | -- Down 14 | DROP INDEX IF EXISTS idxPrevQueueId; 15 | 16 | ALTER TABLE "queue" DROP COLUMN "prevQueueId"; 17 | -------------------------------------------------------------------------------- /src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Beon'; 3 | font-style: normal; 4 | font-weight: normal; 5 | src: url('~fonts/beon.woff2') format('woff2'); 6 | } 7 | 8 | @font-face { 9 | font-family: 'Raleway'; 10 | font-style: normal; 11 | font-weight: 200; 12 | src: url('~fonts/raleway-v14-latin-200.woff2') format('woff2'); 13 | } 14 | 15 | @font-face { 16 | font-family: 'Raleway'; 17 | font-style: normal; 18 | font-weight: 700; 19 | src: url('~fonts/raleway-v14-latin-700.woff2') format('woff2'); 20 | } 21 | -------------------------------------------------------------------------------- /server/lib/getIPAddress.js: -------------------------------------------------------------------------------- 1 | // https://gist.github.com/savokiss/96de34d4ca2d37cbb8e0799798c4c2d3 2 | import os from 'os' 3 | 4 | export default function () { 5 | const interfaces = os.networkInterfaces() 6 | 7 | for (const devName in interfaces) { 8 | const iface = interfaces[devName] 9 | 10 | for (let i = 0; i < iface.length; i++) { 11 | const alias = iface[i] 12 | 13 | if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) { 14 | return alias.address 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './Spinner.css' 4 | 5 | const Spinner = () => ( 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | ) 14 | 15 | export default Spinner 16 | -------------------------------------------------------------------------------- /src/store/Persistor.ts: -------------------------------------------------------------------------------- 1 | import { Store } from '@reduxjs/toolkit' 2 | import { persistStore } from 'redux-persist' 3 | 4 | export default class Persistor { 5 | private static instance: ReturnType 6 | 7 | public static init (store: Store, cb: () => void) { 8 | this.instance = persistStore(store, null, cb) 9 | return this.instance 10 | } 11 | 12 | public static get (): typeof this.instance { 13 | if (!this.instance) { 14 | throw new Error('persistor not initialized') 15 | } 16 | 17 | return this.instance 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/Player/components/PlayerTextOverlay/ColorCycle/ColorCycle.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 5vw; 3 | text-rendering: optimizeLegibility; 4 | text-shadow: 0px 5px 5px #000; 5 | } 6 | 7 | .char { 8 | animation: color-cycle; 9 | animation-duration: 10s; 10 | animation-direction: alternate; 11 | animation-iteration-count: infinite; 12 | } 13 | 14 | @keyframes color-cycle { 15 | 0% {color: red;} 16 | 16% {color: orange;} 17 | 32% {color: yellow;} 18 | 49% {color: green;} 19 | 66% {color: blue;} 20 | 83% {color: indigo;} 21 | 100% {color: violet;} 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './Logo.css' 4 | 5 | interface LogoProps { 6 | className?: string 7 | } 8 | 9 | const Logo = (props: LogoProps) => { 10 | return ( 11 |
12 |

13 | Karaoke 14 | 15 | Eterna 16 | l 17 | 18 |

19 |
20 | ) 21 | } 22 | 23 | export default Logo 24 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a FormData object into a plain JavaScript object. 3 | * If a key appears multiple times (e.g., checkboxes or multi-select inputs), 4 | * it stores them as an array. 5 | */ 6 | export const getFormData = (formData: FormData): Record => { 7 | const obj: Record = {} 8 | 9 | formData.forEach((value, key) => { 10 | if (obj[key]) { 11 | obj[key] = [].concat(obj[key], value as string) 12 | } else { 13 | obj[key] = value as string 14 | } 15 | }) 16 | 17 | return obj 18 | } 19 | -------------------------------------------------------------------------------- /src/routes/Account/components/Rooms/EditRoom/QRPrefs/QRPrefs.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | button { 3 | display: flex; 4 | align-items: center; 5 | gap: var(--space-s); 6 | padding: var(--space-s); 7 | font-size: var(--font-size-s); 8 | } 9 | 10 | svg { 11 | height: var(--icon-size-m); 12 | } 13 | } 14 | 15 | .slider { 16 | margin: 0 1.25rem; 17 | } 18 | 19 | .content { 20 | display: flex; 21 | flex-direction: column; 22 | gap: var(--space-m); 23 | padding: var(--space-m) var(--container-padding); 24 | } 25 | 26 | .hidden { 27 | display: none; 28 | } 29 | -------------------------------------------------------------------------------- /server/lib/getCdgName.js: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util' 2 | import fs from 'fs' 3 | import getPerms from './getPermutations.js' 4 | const stat = promisify(fs.stat) 5 | 6 | export default async function getCdgName (file) { 7 | // upper and lowercase permutations since fs may be case-sensitive 8 | for (const ext of getPerms('cdg')) { 9 | const cdg = file.substring(0, file.lastIndexOf('.') + 1) + ext 10 | 11 | try { 12 | await stat(cdg) 13 | return cdg 14 | } catch { 15 | // try another permutation 16 | } 17 | } // end for 18 | 19 | return false 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import icons from './icons' 3 | 4 | interface IconProps { 5 | className?: string 6 | icon: keyof typeof icons 7 | size?: number 8 | } 9 | 10 | const Icon = (props: IconProps) => { 11 | const { size, icon, ...restProps } = props 12 | 13 | return ( 14 | 20 | 21 | 22 | ) 23 | } 24 | 25 | export default Icon 26 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathChooser/PathChooser.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | width: 90%; 3 | height: 95%; 4 | } 5 | 6 | .container { 7 | display: flex; 8 | flex-direction: column; 9 | height: 100%; 10 | } 11 | 12 | .folderCurrent { 13 | padding: 10px; 14 | border: 1px solid var(--text-color); 15 | border-radius: var(--border-radius); 16 | background: var(--panel-bg-color); 17 | overflow-wrap: anywhere; 18 | } 19 | 20 | .folderList { 21 | flex: 1; 22 | margin: var(--space-s) 0; 23 | overflow: auto; 24 | } 25 | 26 | .btnContainer { 27 | display: flex; 28 | gap: var(--space-m); 29 | } 30 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathPrefs.css: -------------------------------------------------------------------------------- 1 | .heading { 2 | button { 3 | display: flex; 4 | align-items: center; 5 | gap: var(--space-s); 6 | padding: var(--space-s); 7 | font-size: var(--font-size-m); 8 | } 9 | 10 | svg { 11 | height: var(--icon-size-l); 12 | } 13 | } 14 | 15 | .content { 16 | display: flex; 17 | flex-direction: column; 18 | gap: var(--space-s); 19 | padding: var(--container-padding); 20 | } 21 | 22 | .btnContainer { 23 | display: flex; 24 | align-items: flex-end; 25 | gap: var(--space-m); 26 | 27 | button { 28 | flex: 1; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/routes/Account/selectors/getRoomList.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store/store' 2 | import { createSelector } from '@reduxjs/toolkit' 3 | 4 | const getResult = (state: RootState) => state.rooms.result 5 | const getEntities = (state: RootState) => state.rooms.entities 6 | const getFilterStatus = (state: RootState) => state.rooms.filterStatus 7 | 8 | const getRoomList = createSelector( 9 | [getResult, getEntities, getFilterStatus], 10 | (result, entities, status) => ({ 11 | result: result.filter(roomId => status === false || entities[roomId].status === status), 12 | entities, 13 | })) 14 | 15 | export default getRoomList 16 | -------------------------------------------------------------------------------- /server/lib/bcrypt.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt' 2 | 3 | function hash (myPlaintextPassword, saltRounds) { 4 | return new Promise(function (resolve, reject) { 5 | bcrypt.hash(myPlaintextPassword, saltRounds, function (err, hash) { 6 | if (err) return reject(err) 7 | return resolve(hash) 8 | }) 9 | }) 10 | } 11 | 12 | function compare (data, hash) { 13 | return new Promise(function (resolve, reject) { 14 | bcrypt.compare(data, hash, function (err, matched) { 15 | if (err) return reject(err) 16 | return resolve(matched) 17 | }) 18 | }) 19 | } 20 | 21 | export default { 22 | hash, 23 | compare, 24 | } 25 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { PersistGate } from 'redux-persist/es/integration/react' 4 | import store from 'store/store' 5 | import Persistor from 'store/Persistor' 6 | import CoreLayout from './CoreLayout/CoreLayout' 7 | import Spinner from '../Spinner/Spinner' 8 | 9 | const App = () => ( 10 | 11 | } persistor={Persistor.get()}> 12 | }> 13 | 14 | 15 | 16 | 17 | ) 18 | 19 | export default App 20 | -------------------------------------------------------------------------------- /src/routes/Library/components/LibraryHeader/LibraryHeaderContainer.ts: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import LibraryHeader from './LibraryHeader' 3 | import { setFilterStr, resetFilterStr, toggleFilterStarred } from '../../modules/library' 4 | import { RootState } from 'store/store' 5 | 6 | const mapActionCreators = { 7 | setFilterStr, 8 | resetFilterStr, 9 | toggleFilterStarred, 10 | } 11 | 12 | const mapStateToProps = (state: RootState) => { 13 | return { 14 | filterStr: state.library.filterStr, 15 | filterStarred: state.library.filterStarred, 16 | } 17 | } 18 | 19 | export default connect(mapStateToProps, mapActionCreators)(LibraryHeader) 20 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathChooser/PathItem/PathItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Icon from 'components/Icon/Icon' 3 | import styles from './PathItem.css' 4 | 5 | interface PathItemProps { 6 | path: string 7 | onSelect?(event: React.MouseEvent): void 8 | } 9 | 10 | const PathItem = (props: PathItemProps) => { 11 | return ( 12 |
13 |
14 | 15 |
16 |
17 | {props.path} 18 |
19 |
20 | ) 21 | } 22 | 23 | export default PathItem 24 | -------------------------------------------------------------------------------- /server/Library/router.js: -------------------------------------------------------------------------------- 1 | import KoaRouter from '@koa/router' 2 | import Media from '../Media/Media.js' 3 | const router = new KoaRouter({ prefix: '/api' }) 4 | 5 | // lists underlying media for a given song 6 | router.get('/song/:songId', async (ctx) => { 7 | // must be admin 8 | if (!ctx.user.isAdmin) { 9 | ctx.throw(401) 10 | } 11 | 12 | const songId = parseInt(ctx.params.songId, 10) 13 | 14 | if (Number.isNaN(songId)) { 15 | ctx.throw(401, 'Invalid songId') 16 | } 17 | 18 | const res = await Media.search({ songId }) 19 | 20 | if (!res.result.length) { 21 | ctx.throw(404) 22 | } 23 | 24 | ctx.body = res 25 | }) 26 | 27 | export default router 28 | -------------------------------------------------------------------------------- /src/routes/Account/components/Rooms/Rooms.css: -------------------------------------------------------------------------------- 1 | .roomsFilter { 2 | max-width: 50%; 3 | } 4 | 5 | .showAll { 6 | cursor: pointer; 7 | } 8 | 9 | .table { 10 | width: 100%; 11 | } 12 | 13 | .table > * { 14 | outline: none; 15 | } 16 | 17 | .table th { 18 | color: hsl(var(--hue-blue), 10%, 60%); 19 | font-weight: normal; 20 | text-align: left; 21 | } 22 | 23 | .table th:first-child { 24 | width: 100%; 25 | } 26 | 27 | .table th:nth-child(2) { 28 | min-width: 70px; 29 | } 30 | 31 | .table td { 32 | height: 25px; 33 | overflow: hidden; 34 | white-space: nowrap; 35 | } 36 | 37 | .table td:first-child { 38 | text-overflow: ellipsis; 39 | max-width: 0; 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/Account/components/Users/Users.css: -------------------------------------------------------------------------------- 1 | .usersFilter { 2 | max-width: 50%; 3 | } 4 | 5 | .showAll { 6 | cursor: pointer; 7 | } 8 | 9 | .table { 10 | width: 100%; 11 | } 12 | 13 | .table > * { 14 | outline: none; 15 | } 16 | 17 | .table th { 18 | color: hsl(var(--hue-blue), 10%, 60%); 19 | font-weight: normal; 20 | text-align: left; 21 | } 22 | 23 | .table th:first-child { 24 | width: 100%; 25 | } 26 | 27 | .table th:nth-child(2) { 28 | min-width: 70px; 29 | } 30 | 31 | .table td { 32 | height: 25px; 33 | overflow: hidden; 34 | white-space: nowrap; 35 | } 36 | 37 | .table td:first-child { 38 | text-overflow: ellipsis; 39 | max-width: 0; 40 | } 41 | -------------------------------------------------------------------------------- /server/lib/getPermutations.js: -------------------------------------------------------------------------------- 1 | // return all uppercase and lowercase permutations of str 2 | // based on https://stackoverflow.com/a/27995370 3 | function getPermutations (str) { 4 | const results = [] 5 | const arr = str.split('') 6 | const len = Math.pow(arr.length, 2) 7 | 8 | for (let i = 0; i < len; i++) { 9 | for (let k = 0, j = i; k < arr.length; k++, j >>= 1) { 10 | arr[k] = (j & 1) ? arr[k].toUpperCase() : arr[k].toLowerCase() 11 | } 12 | 13 | const combo = arr.join('') 14 | results.push(combo) 15 | } 16 | 17 | // remove duplicates 18 | return results.filter((ext, pos, self) => self.indexOf(ext) === pos) 19 | } 20 | 21 | export default getPermutations 22 | -------------------------------------------------------------------------------- /src/components/InputRadio/InputRadio.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline-flex; 3 | align-items: center; 4 | gap: var(--space-s); 5 | cursor: pointer; 6 | 7 | input[type="radio"] { 8 | -webkit-appearance: none; 9 | appearance: none; 10 | min-width: 1.5em; 11 | min-height: 1.5em; 12 | border-radius: 50%; 13 | border: 2px solid var(--btn-bg-color); 14 | outline: none; 15 | 16 | &:checked, 17 | &:focus:checked, 18 | &:hover:checked { 19 | background: radial-gradient( 20 | circle, 21 | var(--btn-active-bg-color) 0%, 22 | var(--btn-active-bg-color) 60%, 23 | #000 60%, 24 | #000 100% 25 | ); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Panel/Panel.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: var(--panel-bg-color); 3 | border-radius: 12px; 4 | overflow: hidden; 5 | } 6 | 7 | .titleContainer { 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | padding: var(--space-s) var(--container-padding); 12 | color: hsl(var(--hue-blue), 10%, 80%); 13 | background-color: hsl(var(--hue-blue), 20%, 20%); 14 | background-image: linear-gradient(135deg, hsl(209,20%,20%) 0%, hsl(209,20%,10%) 100%); 15 | 16 | h1 { 17 | margin: 0; 18 | font-size: var(--font-size-l); 19 | font-weight: var(--font-weight-bold); 20 | } 21 | } 22 | 23 | .content { 24 | padding: var(--container-padding); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "*": ["./src/*"], 5 | "shared/*": ["./shared/*"], 6 | }, 7 | "skipLibCheck": true, 8 | "noImplicitAny": true, 9 | "downlevelIteration": true, 10 | "esModuleInterop": true, 11 | "moduleResolution": "Bundler", 12 | "module": "esnext", 13 | "jsx": "react", 14 | "typeRoots": [ 15 | "types", 16 | "node_modules/@types" 17 | ], 18 | "plugins": [ 19 | { 20 | "name": "typescript-plugin-css-modules", 21 | "options": { 22 | "customMatcher": "\\.css$", 23 | }, 24 | }, 25 | ], 26 | }, 27 | "include": ["src/**/*", "config/declarations.d.ts"], 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2022 RadRoot LLC 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /docs/layouts/shortcodes/img.html: -------------------------------------------------------------------------------- 1 | {{ $img := .Get 0 }} 2 | {{ $density := default "2x" (.Get 2) }} 3 | {{ range where .Site.Pages ".Section" "docs" }} 4 | {{ with .Page.Resources.GetMatch $img }} 5 | {{ $img = . }} 6 | {{ end }} 7 | {{ end }} 8 |
9 | 10 | {{ (.Get 1) }} 11 | 12 | 13 | {{ (.Get 1) }} 14 | 15 |
16 |

{{ (.Get 1) }}

17 |
18 |
19 | {{ .Inner }} 20 | -------------------------------------------------------------------------------- /src/components/Panel/Panel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './Panel.css' 4 | 5 | interface PanelProps { 6 | children: React.ReactElement 7 | className?: string 8 | contentClassName?: string 9 | title: string 10 | titleComponent?: React.ReactElement 11 | } 12 | 13 | const Panel = ({ children, className, contentClassName, title, titleComponent }: PanelProps) => ( 14 |
15 |
16 |

{title}

17 | {titleComponent} 18 |
19 |
20 | {children} 21 |
22 |
23 | ) 24 | 25 | export default Panel 26 | -------------------------------------------------------------------------------- /docs/layouts/shortcodes/screenshots.html: -------------------------------------------------------------------------------- 1 |
    2 | {{ with .Site.GetPage "docs/karaoke-eternal-app" }} 3 | {{ range sort ((.Resources.ByType "image").Match "app-*") ".Params.galleryOrder" }} 4 | {{ if .Params.galleryOrder }} 5 |
  • 6 | 7 | {{.Title }} 8 | 9 | 10 | {{.Title }} 11 | 12 |
  • 13 | {{ end }} 14 | {{ end }} 15 | {{ end }} 16 |
-------------------------------------------------------------------------------- /src/routes/Player/components/PlayerTextOverlay/PlayerTextOverlay.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-family: var(--font-family-custom); 3 | position: absolute; 4 | top: 0; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | flex-direction: column; 9 | } 10 | 11 | .backdrop { 12 | background-color: var(--chrome-bg-color); 13 | border-radius: var(--border-radius); 14 | padding: var(--space-m) var(--space-xl); 15 | } 16 | 17 | @supports (backdrop-filter: saturate(50%) blur(20px)) or (-webkit-backdrop-filter: saturate(50%) blur(20px)) { 18 | .backdrop { 19 | background-color: transparent !important; 20 | -webkit-backdrop-filter: saturate(50%) blur(20px); 21 | backdrop-filter: saturate(50%) blur(20px); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/Scanner/Scanner.js: -------------------------------------------------------------------------------- 1 | import IPC from '../lib/IPCBridge.js' 2 | import { SCANNER_WORKER_STATUS } from '../../shared/actionTypes.ts' 3 | 4 | class Scanner { 5 | constructor (qStats) { 6 | this.isCanceling = false 7 | this.emitStatus = this.getStatusEmitter(qStats) 8 | } 9 | 10 | cancel () { 11 | this.isCanceling = true 12 | } 13 | 14 | getStatusEmitter ({ length }) { 15 | return (text, progress, isScanning = true) => { 16 | IPC.send({ 17 | type: SCANNER_WORKER_STATUS, 18 | payload: { 19 | isScanning, 20 | pct: (progress / length) * 100, 21 | text: length === 1 ? text : `[1/${length}] ${text}`, 22 | }, 23 | }) 24 | } 25 | } 26 | } 27 | 28 | export default Scanner 29 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathItem/PathItem.css: -------------------------------------------------------------------------------- 1 | .pathItem { 2 | display: flex; 3 | align-items: center; 4 | background-color: var(--panel-bg-color); 5 | padding: var(--space-xs); 6 | margin-bottom: var(--space-s); 7 | border-radius: var(--border-radius); 8 | } 9 | 10 | .pathName { 11 | flex: 1; 12 | user-select: text; 13 | text-overflow: ellipsis; 14 | overflow: hidden; 15 | direction: rtl; 16 | text-align: left; 17 | } 18 | 19 | .btnDrag { 20 | cursor: grab; 21 | color: var(--btn-bg-color); 22 | height: var(--icon-size-l); 23 | } 24 | 25 | .btnRefresh, 26 | .btnInfo { 27 | color: var(--btn-active-bg-color); 28 | padding: var(--space-xs); 29 | 30 | svg { 31 | height: var(--icon-size-l); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/InputCheckbox/InputCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react' 2 | import clsx from 'clsx' 3 | import styles from './InputCheckbox.css' 4 | 5 | interface InputCheckboxProps extends React.InputHTMLAttributes { 6 | label?: string 7 | className?: string 8 | } 9 | 10 | const InputCheckbox = forwardRef(({ 11 | label, 12 | className, 13 | ...rest 14 | }, ref) => { 15 | return ( 16 | 24 | ) 25 | }) 26 | 27 | InputCheckbox.displayName = 'InputCheckbox' 28 | 29 | export default InputCheckbox 30 | -------------------------------------------------------------------------------- /src/components/Header/PlaybackCtrl/PlaybackCtrl.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | padding: 0 var(--container-padding); 5 | } 6 | 7 | .btn { 8 | color: var(--transport-btn-bg-color); 9 | filter: var(--transport-btn-filter); 10 | 11 | svg { 12 | height: var(--icon-size-xl); 13 | } 14 | } 15 | 16 | .btnAnimate { 17 | animation: var(--animation-btn-danger-activate); 18 | } 19 | 20 | .play, 21 | .pause { 22 | svg { 23 | height: 2.66rem; 24 | } 25 | } 26 | 27 | .next { 28 | margin: 0 var(--space-s); 29 | } 30 | 31 | .displayCtrl { 32 | color: var(--btn-active-bg-color); 33 | filter: var(--btn-active-filter); 34 | } 35 | 36 | .fullscreen { 37 | color: var(--btn-active-bg-color); 38 | filter: var(--btn-active-filter); 39 | } 40 | -------------------------------------------------------------------------------- /docs/assets/css/variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --hue-blue: 209; 3 | --hue-pink: 270; 4 | 5 | --bg-color: #000; 6 | --text-color: #hsl(var(--hue-blue), 10%, 87%); 7 | --link-color: hsl(var(--hue-blue), 92%, 70%); 8 | --text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.5); 9 | --box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26); 10 | 11 | --btn-bg-color: hsl(var(--hue-blue), 10%, 50%); 12 | --btn-active-bg-color: var(--link-color); 13 | 14 | --info-color: var(--link-color); 15 | --info-bg-color: hsl(var(--hue-blue), 100%, 17%); 16 | --warn-color: hsl(var(--hue-pink), 100%, 50%); 17 | --warn-bg-color: hsl(var(--hue-pink), 50%, 20%); 18 | --logo-color: hsl(var(--hue-blue), 10%, 85%); 19 | 20 | --text-shadow-glow: 0 0 5px hsl(var(--hue-pink), 100%, 45%), 0 0 10px hsl(var(--hue-pink), 100%, 45%); 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/Library/components/LibraryHeader/LibraryHeader.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | padding-right: var(--container-padding); 5 | } 6 | 7 | .searchInput { 8 | flex: 1; 9 | margin: 0; 10 | margin-left: -4px; 11 | border: none !important; 12 | color: var(--text-color) !important; 13 | background-color: transparent !important; 14 | box-shadow: none !important; 15 | } 16 | 17 | .searchInput::placeholder { 18 | color: var(--input-placeholder-color) !important; 19 | } 20 | 21 | .btn { 22 | cursor: pointer; 23 | color: var(--btn-bg-color); 24 | } 25 | 26 | .btnActive { 27 | composes: btn; 28 | color: var(--btn-active-bg-color); 29 | filter: var(--btn-active-filter); 30 | } 31 | 32 | .btnAnimate { 33 | animation: var(--animation-bounce); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Header/ProgressBar/ProgressBar.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | height: 40px; 5 | width: 100%; 6 | background-image: linear-gradient(to right, var(--active-item-from-bg-color), var(--active-item-from-bg-color)); 7 | background-repeat: no-repeat; 8 | } 9 | 10 | .text { 11 | flex: 1; 12 | font-size: 16px; 13 | text-align: center; 14 | white-space: nowrap; 15 | text-overflow: ellipsis; 16 | overflow: hidden; 17 | color: var(--text-color); 18 | } 19 | 20 | .btn { 21 | cursor: pointer; 22 | } 23 | 24 | .cancel { 25 | composes: btn; 26 | color: var(--btn-danger-bg-color); 27 | filter: var(--btn-danger-filter); 28 | } 29 | 30 | .close { 31 | composes: btn; 32 | color: var(--btn-active-bg-color); 33 | filter: var(--btn-active-filter); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Navigation/Navigation.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | bottom: 0; 4 | left: 0; 5 | display: flex; 6 | width: 100%; 7 | background-color: var(--chrome-bg-color); 8 | border-top: 1px solid var(--chrome-border-color); 9 | z-index: 99; 10 | 11 | a { 12 | flex: 1; 13 | padding: var(--space-xs) 0; 14 | } 15 | 16 | span { 17 | display: flex; 18 | justify-content: center; 19 | color: var(--nav-link-color); 20 | filter: drop-shadow(0px 1px 0px hsl(var(--hue-blue), 100%, 30%)); 21 | 22 | svg { 23 | height: var(--icon-size-xl); 24 | } 25 | } 26 | 27 | .active span { 28 | color: var(--nav-active-color); 29 | filter: var(--nav-active-filter); 30 | } 31 | } 32 | 33 | .btnAnimate { 34 | animation: var(--animation-nav-activate); 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/Account/views/SignedOutView/SignedOutView.css: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 0 auto; 3 | overflow: hidden; 4 | padding: var(--container-padding); 5 | 6 | h2 { 7 | margin: var(--space-l) 0 var(--space-m) 0; 8 | font-family: var(--font-family-custom); 9 | font-size: var(--font-size-l); 10 | color: hsl(var(--hue-blue), 10%, 60%); 11 | } 12 | } 13 | 14 | .logo { 15 | margin-bottom: var(--space-xl); 16 | text-align: center; 17 | font-size: 30px; 18 | 19 | @media (min-width: 42em) { 20 | margin: var(--space-xl) 0; 21 | font-size: 36px; 22 | } 23 | } 24 | 25 | .radioContainer { 26 | display: flex; 27 | flex-direction: column; 28 | gap: var(--space-s); 29 | white-space: nowrap; 30 | margin-bottom: var(--space-m); 31 | } 32 | 33 | .hidden { 34 | display: none; 35 | } 36 | -------------------------------------------------------------------------------- /server/lib/getHotMiddleware.js: -------------------------------------------------------------------------------- 1 | // based on https://github.com/tnnevol/webpack-hot-middleware-for-koa2 2 | 3 | // eslint-disable-next-line n/no-unpublished-import 4 | import webpackHotMiddleware from 'webpack-hot-middleware' 5 | 6 | export default (compiler, opts) => { 7 | const middleware = webpackHotMiddleware(compiler, opts) 8 | 9 | return async (ctx, next) => { 10 | const { end: originalEnd } = ctx.res 11 | 12 | const runNext = await new Promise((resolve) => { 13 | ctx.res.end = function () { 14 | originalEnd.apply(this, arguments) 15 | resolve(false) 16 | } 17 | 18 | // call express-style middleware 19 | middleware(ctx.req, ctx.res, () => { 20 | resolve(true) 21 | }) 22 | }) 23 | 24 | if (runNext) { 25 | await next() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/Player/selectors/getRoomPrefs.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store/store' 2 | import { createSelector } from '@reduxjs/toolkit' 3 | import type { IRoomPrefs } from 'shared/types' 4 | 5 | const DEFAULT_ROOM_PREFS: IRoomPrefs = { 6 | qr: { 7 | isEnabled: false, 8 | opacity: 0.625, 9 | password: '', 10 | size: 0.5, 11 | }, 12 | } 13 | 14 | const getRoomId = (state: RootState) => state.user.roomId 15 | const getRooms = (state: RootState) => state.rooms.entities 16 | 17 | const getRoomPrefs = createSelector( 18 | [getRoomId, getRooms], 19 | (roomId, rooms) => { 20 | if (typeof roomId !== 'number' 21 | || !rooms[roomId] 22 | || !rooms[roomId].prefs 23 | ) { 24 | return DEFAULT_ROOM_PREFS 25 | } 26 | 27 | return rooms[roomId]?.prefs 28 | }) 29 | 30 | export default getRoomPrefs 31 | -------------------------------------------------------------------------------- /server/Rooms/socket.js: -------------------------------------------------------------------------------- 1 | import Rooms from './Rooms.js' 2 | import { 3 | ROOM_PREFS_PUSH_REQUEST, 4 | ROOM_PREFS_PUSH, 5 | _ERROR, 6 | } from '../../shared/actionTypes.ts' 7 | 8 | const ACTION_HANDLERS = { 9 | [ROOM_PREFS_PUSH_REQUEST]: async (sock, { payload }, acknowledge) => { 10 | const { roomId } = payload 11 | 12 | if (!sock.user.isAdmin || !roomId) { 13 | acknowledge({ 14 | type: ROOM_PREFS_PUSH_REQUEST + _ERROR, 15 | error: 'Unauthorized', 16 | }) 17 | } 18 | 19 | const sockets = await sock.server.in(Rooms.prefix(roomId)).fetchSockets() 20 | 21 | for (const s of sockets) { 22 | if (s?.user.isAdmin) { 23 | sock.server.to(s.id).emit('action', { 24 | type: ROOM_PREFS_PUSH, 25 | payload, 26 | }) 27 | } 28 | } 29 | }, 30 | } 31 | 32 | export default ACTION_HANDLERS 33 | -------------------------------------------------------------------------------- /src/components/InputImage/InputImage.css: -------------------------------------------------------------------------------- 1 | .container { 2 | overflow: hidden; 3 | position: relative; 4 | min-width: 96px; 5 | height: 72px; 6 | border-radius: var(--border-radius); 7 | text-align: center; 8 | cursor: pointer; 9 | background-color: var(--btn-bg-color); 10 | 11 | svg { 12 | color: #000; 13 | } 14 | } 15 | 16 | .fileInput { 17 | cursor: inherit; 18 | display: block; 19 | font-size: 999px; 20 | filter: alpha(opacity=0); 21 | min-height: 100%; 22 | min-width: 100%; 23 | opacity: 0; 24 | position: absolute; 25 | right: 0; 26 | text-align: right; 27 | top: 0; 28 | } 29 | 30 | .btnClear { 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | color: hsl(var(--hue-pink), 100%, 70%); 35 | z-index: 99; 36 | } 37 | 38 | .placeholder { 39 | color: var(--btn-bg-color); 40 | } 41 | -------------------------------------------------------------------------------- /src/routes/Library/modules/artists.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | import { Artist } from 'shared/types' 3 | import { 4 | LIBRARY_PUSH, 5 | } from 'shared/actionTypes' 6 | 7 | const libraryPush = createAction<{ 8 | artists: ArtistsState 9 | }>(LIBRARY_PUSH) 10 | 11 | // ------------------------------------ 12 | // Reducer 13 | // ------------------------------------ 14 | interface ArtistsState { 15 | result: number[] 16 | entities: Record 17 | } 18 | 19 | const initialState: ArtistsState = { 20 | result: [], 21 | entities: {}, 22 | } 23 | 24 | const artistsReducer = createReducer(initialState, (builder) => { 25 | builder 26 | .addCase(libraryPush, (_, { payload }) => ({ 27 | result: payload.artists.result, 28 | entities: payload.artists.entities, 29 | })) 30 | }) 31 | 32 | export default artistsReducer 33 | -------------------------------------------------------------------------------- /src/components/InputRadio/InputRadio.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './InputRadio.css' 4 | 5 | interface InputRadioProps extends Omit, 'onChange'> { 6 | onChange?: (value: string) => void 7 | label?: string 8 | className?: string 9 | } 10 | 11 | const InputRadio = ({ 12 | onChange, 13 | label, 14 | className, 15 | ...rest 16 | }: InputRadioProps) => { 17 | const handleChange = (e: React.ChangeEvent) => { 18 | if (onChange) { 19 | onChange(e.target.value) 20 | } 21 | } 22 | 23 | return ( 24 | 32 | ) 33 | } 34 | 35 | export default InputRadio 36 | -------------------------------------------------------------------------------- /server/lib/pushQueuesAndLibrary.js: -------------------------------------------------------------------------------- 1 | import Library from '../Library/Library.js' 2 | import Queue from '../Queue/Queue.js' 3 | import Rooms from '../Rooms/Rooms.js' 4 | import { LIBRARY_PUSH, QUEUE_PUSH } from '../../shared/actionTypes.ts' 5 | 6 | async function pushQueuesAndLibrary (io) { 7 | // emit (potentially) updated queues to each room 8 | // it's important that this happens before the library is pushed, 9 | // otherwise queue items might reference newly non-existent songs 10 | for (const { room, roomId } of Rooms.getActive(io)) { 11 | io.to(room).emit('action', { 12 | type: QUEUE_PUSH, 13 | payload: await Queue.get(roomId), 14 | }) 15 | } 16 | 17 | // invalidate cache 18 | Library.cache.version = null 19 | 20 | io.emit('action', { 21 | type: LIBRARY_PUSH, 22 | payload: await Library.get(), 23 | }) 24 | } 25 | 26 | export default pushQueuesAndLibrary 27 | -------------------------------------------------------------------------------- /src/components/Spinner/Spinner.css: -------------------------------------------------------------------------------- 1 | /* adapted from https://github.com/tobiasahlin/SpinKit */ 2 | .container { 3 | display: flex; 4 | height: 100%; 5 | align-items: center; 6 | justify-content: center; 7 | } 8 | 9 | .spinner { 10 | background-color: var(--btn-bg-color); 11 | height: 40px; 12 | width: 6px; 13 | animation: sk-stretchdelay 0.8s infinite ease-in-out; 14 | margin: 0 5px; 15 | } 16 | 17 | .rect2 { 18 | animation-delay: -0.7s; 19 | } 20 | 21 | .rect3 { 22 | animation-delay: -0.6s; 23 | } 24 | 25 | .rect4 { 26 | animation-delay: -0.5s; 27 | } 28 | 29 | .rect5 { 30 | animation-delay: -0.4s; 31 | } 32 | 33 | @-webkit-keyframes sk-stretchdelay { 34 | 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } 35 | 20% { -webkit-transform: scaleY(1.0) } 36 | } 37 | 38 | @keyframes sk-stretchdelay { 39 | 0%, 40%, 100% { 40 | transform: scaleY(0.4); 41 | } 20% { 42 | transform: scaleY(1.0); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/routes/Account/selectors/getUsers.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store/store' 2 | import { createSelector } from '@reduxjs/toolkit' 3 | 4 | const getResult = (state: RootState) => state.users.result 5 | const getEntities = (state: RootState) => state.users.entities 6 | const getFilterOnline = (state: RootState) => state.users.filterOnline 7 | const getFilterRoomId = (state: RootState) => state.users.filterRoomId 8 | 9 | const getUsers = createSelector( 10 | [getResult, getEntities, getFilterOnline, getFilterRoomId], 11 | (result, entities, filterOnline, filterRoomId) => { 12 | if (filterOnline) { 13 | result = result.filter(userId => entities[userId].rooms.length) 14 | } else if (typeof filterRoomId === 'number') { 15 | result = result.filter(userId => entities[userId].rooms.includes(filterRoomId)) 16 | } 17 | 18 | return { 19 | result, 20 | entities, 21 | } 22 | }) 23 | 24 | export default getUsers 25 | -------------------------------------------------------------------------------- /server/Scanner/FileScanner/getConfig.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import getLogger from '../../lib/Log.js' 3 | import fs from 'fs' 4 | import JSON5 from 'json5' 5 | const log = getLogger('FileScanner') 6 | const CONFIG = '_kes.v2.json' 7 | 8 | // search each folder from dir up to baseDir 9 | function getConfig (dir, baseDir) { 10 | dir = path.normalize(dir) 11 | baseDir = path.normalize(baseDir) 12 | const cfgPath = path.resolve(dir, CONFIG) 13 | 14 | try { 15 | const userScript = fs.readFileSync(cfgPath, 'utf-8') 16 | log.info('Using custom parser config: %s', cfgPath) 17 | 18 | try { 19 | return JSON5.parse(userScript) 20 | } catch (err) { 21 | log.error(err) 22 | } 23 | } catch { 24 | log.verbose('No parser config found: %s', dir) 25 | } 26 | 27 | if (dir === baseDir) { 28 | log.info('Using default parser config') 29 | return 30 | } 31 | 32 | // try parent dir 33 | return getConfig(path.resolve(dir, '..'), baseDir) 34 | } 35 | 36 | export default getConfig 37 | -------------------------------------------------------------------------------- /src/components/InputCheckbox/InputCheckbox.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: inline-flex; 3 | align-items: center; 4 | gap: var(--space-s); 5 | cursor: pointer; 6 | 7 | input[type="checkbox"] { 8 | -webkit-appearance: none; 9 | appearance: none; 10 | margin: 0; 11 | background: var(--btn-bg-color); 12 | color: var(--btn-bg-color); 13 | width: 1.25em; 14 | height: 1.25em; 15 | border-radius: 0.15em; 16 | display: grid; 17 | place-content: center; 18 | cursor: pointer; 19 | 20 | &:focus-visible { 21 | outline: var(--focus-outline); 22 | } 23 | 24 | &::before { 25 | content: ""; 26 | width: 0.65em; 27 | height: 0.65em; 28 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 29 | transform: scale(0); 30 | background: #000; 31 | } 32 | 33 | &:checked { 34 | background: var(--btn-active-bg-color); 35 | } 36 | 37 | &:checked::before { 38 | transform: scale(1); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { RouterProvider } from 'react-router-dom' 4 | import store from './store/store' 5 | import socket from 'lib/socket' 6 | import AppRouter from 'lib/AppRouter' 7 | import { connectSocket } from './store/modules/user' 8 | import Persistor from 'store/Persistor' 9 | 10 | Persistor.init(store, () => { 11 | // rehydration complete; open socket connection 12 | // if it looks like we have a valid session 13 | if (store.getState().user.userId !== null) { 14 | store.dispatch(connectSocket()) 15 | socket.open() 16 | } 17 | }) 18 | 19 | socket.on('reconnect_attempt', () => { 20 | store.dispatch(connectSocket()) 21 | }) 22 | 23 | // ======================================================== 24 | // Go! 25 | // ======================================================== 26 | createRoot(document.getElementById('root')) 27 | .render( 28 | 29 | 30 | , 31 | ) 32 | -------------------------------------------------------------------------------- /server/lib/Log.js: -------------------------------------------------------------------------------- 1 | import log from 'electron-log/node.js' 2 | const LEVELS = [false, 'error', 'warn', 'info', 'verbose', 'debug'] 3 | 4 | class Logger { 5 | static #instance 6 | 7 | static init (logId, cfg) { 8 | // defaults 9 | log.transports.console.level = 'debug' 10 | log.transports.file.level = false 11 | log.transports.file.fileName = logId + '.log' 12 | 13 | for (const transport in cfg) { 14 | for (const key in cfg[transport]) { 15 | if (key === 'level') log.transports[transport].level = LEVELS[cfg[transport].level] 16 | else log.transports[transport][key] = cfg[transport][key] 17 | } 18 | } 19 | 20 | Logger.#instance = log 21 | return log 22 | } 23 | 24 | static getLogger (scope = '') { 25 | if (!Logger.#instance) throw new Error('logger not initialized') 26 | return Logger.#instance.scope(scope) 27 | } 28 | } 29 | 30 | // for each process/worker to initialize their logger 31 | export const initLogger = Logger.init 32 | 33 | // default export 34 | export default Logger.getLogger 35 | -------------------------------------------------------------------------------- /src/components/Header/UpNext/UpNext.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-size: 16px; 3 | color: var(--text-color); 4 | animation-duration: 1.5s; 5 | animation-direction: alternate; 6 | animation-iteration-count: infinite; 7 | animation-timing-function: ease-out; 8 | } 9 | 10 | .upNext { 11 | composes: container; 12 | animation-name: up-next-bg-animation; 13 | filter: var(--up-next-filter); 14 | } 15 | 16 | @keyframes up-next-bg-animation { 17 | 0% { background-color: var(--up-next-bg-color-to); } 18 | 100% { background-color: var(--up-next-bg-color-from); } 19 | } 20 | 21 | .upNow { 22 | composes: container; 23 | animation-name: up-now-bg-animation; 24 | filter: var(--up-now-filter); 25 | } 26 | 27 | @keyframes up-now-bg-animation { 28 | 0% { background-color: var(--up-now-bg-color-to); } 29 | 100% { background-color: var(--up-now-bg-color-from); } 30 | } 31 | 32 | .inQueue { 33 | composes: container; 34 | background-color: var(--up-next-bg-color-from); 35 | } 36 | 37 | .msg { 38 | margin: 0; 39 | padding: 5px; 40 | text-align: center; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Buttons/Buttons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './Buttons.css' 4 | 5 | interface ButtonsProps { 6 | btnWidth: number 7 | className?: string 8 | children?: React.ReactNode 9 | isExpanded: boolean 10 | } 11 | 12 | export default class Buttons extends React.Component { 13 | render () { 14 | let visible = 0 15 | 16 | const children = React.Children.map(this.props.children, (c) => { 17 | if (React.isValidElement<{ 'className': string, 'data-hide'?: boolean }>(c)) { 18 | if (c.props['data-hide'] && !this.props.isExpanded) { 19 | return React.cloneElement(c, { 20 | className: clsx(c.props.className, styles.btnHide), 21 | }) 22 | } 23 | 24 | visible++ 25 | return c 26 | } 27 | }) 28 | 29 | return ( 30 |
34 | {children} 35 |
36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/types/butterchurn.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'butterchurn' { 2 | type Preset = Record 3 | type ImageData = { data: string, width: number, height: number } 4 | class Visualizer { 5 | loadExtraImages (imageData: Record): void 6 | loadPreset (preset: Preset, blendTime: number): void 7 | setRendererSize (width: number, height: number): void 8 | connectAudio (node: AudioNode): void 9 | render (): void 10 | } 11 | 12 | export default class Butterchurn { 13 | static createVisualizer (context: BaseAudioContext, canvas: HTMLCanvasElement, options?: { 14 | width?: number 15 | height?: number 16 | onlyUseWASM?: boolean 17 | }): Visualizer 18 | } 19 | } 20 | 21 | declare module 'butterchurn-presets/all' { 22 | import { type Preset } from 'butterchurn' 23 | const presets: Record 24 | export default presets 25 | } 26 | 27 | declare module 'butterchurn-presets/imageData' { 28 | import { type ImageData } from 'butterchurn' 29 | const imageData: Record 30 | export default imageData 31 | } 32 | -------------------------------------------------------------------------------- /docs/layouts/_default/baseof.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ partial "head.html" . }} 4 | 5 | {{ partial "sidebar.html" . }} 6 |
7 | {{ block "main" . }} 8 | {{ end }} 9 |
10 | 11 | 12 | 30 | {{ partial "footer.html" . }} 31 | 32 | -------------------------------------------------------------------------------- /src/components/Accordion/Accordion.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: hsl(var(--hue-blue), 20%, 15%); 3 | border-radius: var(--border-radius); 4 | 5 | &[data-expanded] { 6 | .icon { 7 | transform: rotate(90deg); 8 | } 9 | 10 | .headingContainer { 11 | border-radius: var(--border-radius) var(--border-radius) 0 0; 12 | } 13 | } 14 | } 15 | 16 | .icon { 17 | flex-shrink: 0; 18 | } 19 | 20 | .headingContainer { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | background-color: hsl(var(--hue-blue), 20%, 25%); 25 | background-image: linear-gradient(135deg, hsl(var(--hue-blue), 20%, 25%) 0%, hsl(var(--hue-blue), 20%, 15%) 100%); 26 | border-radius: var(--border-radius); 27 | cursor: pointer; 28 | 29 | button { 30 | color: inherit; 31 | background: none; 32 | border: none; 33 | cursor: pointer; 34 | outline: none; 35 | 36 | &:focus-visible { 37 | outline: var(--focus-outline); 38 | } 39 | } 40 | 41 | svg { 42 | display: block; 43 | height: var(--icon-size-l); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/lib/schemas/005-roles.sql: -------------------------------------------------------------------------------- 1 | -- Up 2 | CREATE TABLE IF NOT EXISTS "roles" ( 3 | "roleId" integer PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | "name" text NOT NULL COLLATE NOCASE, 5 | "data" text NOT NULL DEFAULT('{}') 6 | ); 7 | 8 | CREATE UNIQUE INDEX IF NOT EXISTS idxName ON "roles" ("name" ASC); 9 | 10 | INSERT INTO roles (name) VALUES ('admin'), ('player'), ('standard'), ('guest'); 11 | 12 | ALTER TABLE users ADD COLUMN "roleId" integer NOT NULL DEFAULT 0 REFERENCES roles(roleId) DEFERRABLE INITIALLY DEFERRED; 13 | 14 | UPDATE users SET roleId = CASE 15 | WHEN isAdmin = 1 THEN (SELECT roleId FROM roles WHERE name = 'admin') 16 | WHEN isAdmin = 0 THEN (SELECT roleId FROM roles WHERE name = 'standard') 17 | ELSE (SELECT roleId FROM roles WHERE name = 'standard') 18 | END; 19 | 20 | ALTER TABLE users DROP COLUMN isAdmin; 21 | 22 | -- Down 23 | ALTER TABLE users ADD COLUMN isAdmin integer DEFAULT 0; 24 | 25 | UPDATE users SET isAdmin = CASE 26 | WHEN roleId = (SELECT roleId FROM roles WHERE name = 'admin') THEN 1 27 | ELSE 0 28 | END; 29 | 30 | ALTER TABLE users DROP COLUMN roleId; 31 | 32 | DROP TABLE roles; 33 | -------------------------------------------------------------------------------- /src/store/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineSlices } from '@reduxjs/toolkit' 2 | import { optimistic } from 'redux-optimistic-ui' 3 | 4 | import artists from 'routes/Library/modules/artists' 5 | import library from 'routes/Library/modules/library' 6 | import prefs from './modules/prefs' 7 | import queue from 'routes/Queue/modules/queue' 8 | import rooms from './modules/rooms' 9 | import songs from 'routes/Library/modules/songs' 10 | import songInfo from './modules/songInfo' 11 | import starCounts from 'routes/Library/modules/starCounts' 12 | import status from './modules/status' 13 | import ui from './modules/ui' 14 | import user from './modules/user' 15 | import userStars from './modules/userStars' 16 | 17 | export interface LazyLoadedSlices {} // eslint-disable-line @typescript-eslint/no-empty-object-type 18 | 19 | const combinedReducer = combineSlices({ 20 | artists, 21 | library, 22 | prefs, 23 | queue: optimistic(queue), 24 | rooms, 25 | songs, 26 | songInfo, 27 | starCounts, 28 | status, 29 | ui, 30 | user, 31 | userStars: optimistic(userStars), 32 | }).withLazyLoadedSlices() 33 | 34 | export default combinedReducer 35 | -------------------------------------------------------------------------------- /src/routes/Library/modules/songs.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | import { Song } from 'shared/types' 3 | import { 4 | LIBRARY_PUSH, 5 | LIBRARY_PUSH_SONG, 6 | } from 'shared/actionTypes' 7 | 8 | const libraryPushSong = createAction(LIBRARY_PUSH_SONG) 9 | const libraryPush = createAction<{ 10 | songs: SongsState 11 | }>(LIBRARY_PUSH) 12 | 13 | // ------------------------------------ 14 | // Reducer 15 | // ------------------------------------ 16 | interface SongsState { 17 | result: number[] 18 | entities: Record 19 | } 20 | 21 | const initialState: SongsState = { 22 | result: [], 23 | entities: {}, 24 | } 25 | 26 | const songsReducer = createReducer(initialState, (builder) => { 27 | builder 28 | .addCase(libraryPush, (_, { payload }) => ({ 29 | result: payload.songs.result, 30 | entities: payload.songs.entities, 31 | })) 32 | .addCase(libraryPushSong, (state, { payload }) => ({ 33 | ...state, 34 | entities: { 35 | ...state.entities, 36 | ...payload, 37 | }, 38 | })) 39 | }) 40 | 41 | export default songsReducer 42 | -------------------------------------------------------------------------------- /src/routes/Account/views/FirstRun/FirstRun.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { useAppDispatch, useAppSelector } from 'store/hooks' 3 | import { createAccount } from 'store/modules/user' 4 | import Button from 'components/Button/Button' 5 | import AccountForm from '../../components/AccountForm/AccountForm' 6 | import styles from './FirstRun.css' 7 | 8 | const FirstRun = () => { 9 | const user = useAppSelector(state => state.user) 10 | 11 | const dispatch = useAppDispatch() 12 | const handleCreate = useCallback((data: FormData) => { 13 | dispatch(createAccount(data)) 14 | }, [dispatch]) 15 | 16 | return ( 17 | <> 18 |
19 |

Welcome

20 |

21 | Create your 22 | admin 23 | {' '} 24 | account to get started. All data is locally stored and never shared. 25 |

26 |
27 | 28 | 29 | 32 | 33 | 34 | ) 35 | } 36 | 37 | export default FirstRun 38 | -------------------------------------------------------------------------------- /src/components/ToggleAnimation/ToggleAnimation.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import clsx from 'clsx' 3 | 4 | interface ToggleAnimationProps { 5 | toggle: boolean 6 | className: string 7 | children?: React.ReactNode 8 | } 9 | 10 | export default class ToggleAnimation extends Component { 11 | state = { 12 | animate: false, 13 | } 14 | 15 | componentDidUpdate (prevProps: ToggleAnimationProps) { 16 | if (this.props.toggle !== prevProps.toggle) { 17 | this.setState({ animate: true }) 18 | } 19 | } 20 | 21 | render () { 22 | if (this.state.animate) { 23 | return React.Children.map(this.props.children, (c) => { 24 | if (React.isValidElement<{ className: string, onAnimationEnd: () => void }>(c)) { 25 | return React.cloneElement(c, { 26 | className: clsx(c.props.className, this.props.className), 27 | onAnimationEnd: this.handleAnimationEnd, 28 | }) 29 | } 30 | }) 31 | } 32 | 33 | // don't attach event handler until we really need to 34 | return this.props.children 35 | } 36 | 37 | handleAnimationEnd = () => this.setState({ animate: false }) 38 | } 39 | -------------------------------------------------------------------------------- /src/routes/Account/components/About/About.css: -------------------------------------------------------------------------------- 1 | .content { 2 | text-align: center; 3 | } 4 | 5 | .logo { 6 | font-size: 24px; 7 | cursor: pointer; 8 | } 9 | 10 | .sm { 11 | color: #aaa; 12 | font-size: 75%; 13 | margin-top: 1em; 14 | } 15 | 16 | .pseudolink { 17 | text-decoration: underline; 18 | } 19 | 20 | .changelog { 21 | text-align: left; 22 | width: 90%; 23 | height: 90%; 24 | } 25 | 26 | .ghButtonContainer { 27 | display: flex; 28 | justify-content: center; 29 | gap: 12px; 30 | } 31 | 32 | .ghButton { 33 | display: inline-block; 34 | white-space: nowrap; 35 | border: 1px solid; 36 | border-color: rgba(240,246,252,.1); 37 | background-image: linear-gradient(180deg, #21262d, #1a1e23 90%); 38 | border-radius: 0.25em; 39 | } 40 | 41 | .ghButton:hover { 42 | border-color: #8b949e; 43 | background-image: linear-gradient(180deg, #30363d, #292e33 90%); 44 | } 45 | 46 | .ghButton a { 47 | display: inline-block; 48 | color: #c9d1d9; 49 | padding: 8px 10px; 50 | font-size: 12px; 51 | line-height: 16px; 52 | text-decoration: none; 53 | } 54 | 55 | .ghButton svg { 56 | margin-right: 5px; 57 | } 58 | 59 | .ghButton.sponsor svg { 60 | color: #db61a2; 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/dateTime.ts: -------------------------------------------------------------------------------- 1 | // formats a javascript Date object into a 12h AM/PM time string 2 | // based on https://gist.github.com/hjst/1326755 3 | export function formatTime (dateObj: Date) { 4 | let hour: number | string = dateObj.getHours() 5 | let minute: number | string = dateObj.getMinutes() 6 | const ap = (hour > 11) ? 'p' : 'a' 7 | 8 | if (hour > 12) { 9 | hour -= 12 10 | } else if (hour === 0) { 11 | hour = '12' 12 | } 13 | 14 | if (minute < 10) { 15 | minute = '0' + minute 16 | } 17 | 18 | return hour + ':' + minute + ap 19 | } 20 | 21 | export function formatDate (dateObj: Date) { 22 | return dateObj.toISOString().substring(0, 10) 23 | } 24 | 25 | export function formatDateTime (dateObj: Date) { 26 | return (formatDate(dateObj) + ' ' + formatTime(dateObj)) 27 | } 28 | 29 | export function formatDuration (sec: number) { 30 | const m = Math.floor(sec / 60) 31 | const s = sec % 60 32 | 33 | return `${m}:${s < 10 ? '0' + s : s}` 34 | } 35 | 36 | export function formatSeconds (sec: number, fuzzy = false) { 37 | if (sec >= 60 && fuzzy) return Math.round(sec / 60) + 'm' 38 | 39 | const m = Math.floor(sec / 60) 40 | const s = sec % 60 41 | 42 | return m ? `${m}m ${s}s` : `${s}s` 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import { NavLink } from 'react-router-dom' 4 | import Button from 'components/Button/Button' 5 | import styles from './Navigation.css' 6 | 7 | const Navigation = React.forwardRef((_, ref) => ( 8 |
9 | clsx(isActive && styles.active)}> 10 |
31 | )) 32 | 33 | Navigation.displayName = 'Navigation' 34 | 35 | export default Navigation 36 | -------------------------------------------------------------------------------- /docs/layouts/partials/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ if .Params.seoTitle }}{{ .Params.seoTitle }}{{ else }}{{ .Site.Title }} | {{ .Title }}{{ end }} 8 | 24 | {{ $style := resources.Get "css/style.scss" | toCSS | minify | fingerprint }} 25 | 26 | -------------------------------------------------------------------------------- /server/lib/util.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import crypto from 'crypto' 3 | 4 | /** 5 | * Gets the normalized file extension, in lowercase and including the period. 6 | * 7 | * @param {string} filename The filename to extract the extension from. 8 | * @returns {string} The extension in lowercase with a period, or an empty string. 9 | */ 10 | export const getExt = filename => path.extname(filename).toLowerCase() 11 | 12 | export const parsePathIds = (str) => { 13 | const nums = [] 14 | 15 | // multiple ids? 16 | if (str && str.includes(',')) { 17 | const parts = str.split(',') 18 | 19 | for (const part of parts) { 20 | const n = parseInt(part.trim(), 10) 21 | if (!isNaN(n)) nums.push(n) 22 | } 23 | } else { 24 | // single id? 25 | const n = parseInt(str, 10) 26 | 27 | if (!isNaN(n)) nums.push(n) 28 | } 29 | 30 | if (nums.length) return nums 31 | 32 | return !!str 33 | } 34 | 35 | export const randomChars = (length) => { 36 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 37 | const bytes = crypto.randomBytes(length) 38 | let result = '' 39 | 40 | for (let i = 0; i < length; i++) { 41 | result += chars[bytes[i] % chars.length] 42 | } 43 | 44 | return result 45 | } 46 | -------------------------------------------------------------------------------- /src/components/Header/UpNext/UpNext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { formatSeconds } from 'lib/dateTime' 3 | import styles from './UpNext.css' 4 | 5 | interface UpNextProps { 6 | isUpNow: boolean 7 | isUpNext: boolean 8 | wait?: number 9 | } 10 | 11 | const UpNext = (props: UpNextProps) => { 12 | if (props.isUpNow) { 13 | return ( 14 |
15 |

16 | You’re up 17 | {' '} 18 | now 19 |

20 |
21 | ) 22 | } 23 | 24 | if (props.isUpNext) { 25 | return ( 26 |
27 |

28 | You’re up 29 | {' '} 30 | next 31 | {props.wait ? ` in ${formatSeconds(props.wait, true)}` : ''} 32 |

33 |
34 | ) 35 | } 36 | 37 | if (props.wait) { 38 | return ( 39 |
40 |

41 | You’re up in 42 | {' '} 43 | {formatSeconds(props.wait, true)} 44 |

45 |
46 | ) 47 | } 48 | 49 | return null 50 | } 51 | 52 | export default UpNext 53 | -------------------------------------------------------------------------------- /src/routes/Library/components/ArtistItem/ArtistItem.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | height: var(--artist-item-height); 4 | align-items: center; 5 | overflow: hidden; 6 | } 7 | 8 | .name { 9 | color: var(--artist-name-color); 10 | flex: 1; 11 | font-size: 20px; 12 | text-overflow: ellipsis; 13 | overflow: hidden; 14 | white-space: nowrap; 15 | padding-left: 3px; 16 | cursor: pointer; 17 | } 18 | 19 | .folderContainer { 20 | height: var(--artist-item-height); 21 | color: var(--artist-folder-bg-color); 22 | } 23 | 24 | .count { 25 | position: relative; 26 | top: -30px; 27 | text-align: center; 28 | font-size: 16px; 29 | color: var(--artist-name-color); 30 | } 31 | 32 | .iconChevron { 33 | position: relative; 34 | top: -34px; 35 | text-align: center; 36 | color: var(--text-color); 37 | } 38 | 39 | .hasStarred .folderContainer { 40 | color: var(--artist-folder-starred-bg-color); 41 | filter: var(--artist-folder-starred-filter); 42 | } 43 | 44 | .hasStarred .iconChevron, 45 | .hasStarred .count { 46 | color: #000; 47 | } 48 | 49 | .animateGlow { 50 | animation: var(--animation-text-shadow-glow); 51 | } 52 | 53 | .isChildQueued { 54 | color: var(--queued-item-color); 55 | text-shadow: var(--queued-item-text-shadow); 56 | } 57 | -------------------------------------------------------------------------------- /src/routes/Player/components/PlayerTextOverlay/ColorCycle/ColorCycle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import styles from './ColorCycle.css' 4 | 5 | const framerate = 33 // ms 6 | 7 | interface ColorCycleProps { 8 | className?: string 9 | text: string 10 | offset?: number 11 | } 12 | 13 | class ColorCycle extends React.Component { 14 | shouldComponentUpdate (prevProps: ColorCycleProps) { 15 | return prevProps.text !== this.props.text 16 | } 17 | 18 | render () { 19 | // randomly offset the starting color 20 | // 10,000ms animation @ 33ms per frame is about 300 total frames 21 | const offset = this.props.offset || Math.random() * -300 22 | 23 | const text = this.props.text.split('').map((char, i) => { 24 | const delay = (i - this.props.text.length + offset) * framerate 25 | 26 | return ( 27 | 32 | {char} 33 | 34 | ) 35 | }) 36 | 37 | return ( 38 |
39 | {text} 40 |
41 | ) 42 | } 43 | } 44 | 45 | export default ColorCycle 46 | -------------------------------------------------------------------------------- /src/routes/Library/modules/starCounts.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | import { 3 | SONG_STARRED, 4 | SONG_UNSTARRED, 5 | STAR_COUNTS_PUSH, 6 | } from 'shared/actionTypes' 7 | 8 | const songStarred = createAction<{ songId: number }>(SONG_STARRED) 9 | const songUnstarred = createAction<{ songId: number }>(SONG_UNSTARRED) 10 | const starCountsPush = createAction(STAR_COUNTS_PUSH) 11 | 12 | // ------------------------------------ 13 | // Reducer 14 | // ------------------------------------ 15 | interface StarCountsState { 16 | artists: Record 17 | songs: Record 18 | version: number 19 | } 20 | 21 | const initialState: StarCountsState = { 22 | artists: {}, 23 | songs: {}, 24 | version: 0, 25 | } 26 | 27 | const starCountsReducer = createReducer(initialState, (builder) => { 28 | builder 29 | .addCase(songStarred, (state, { payload }) => { 30 | state.songs[payload.songId] = state.songs[payload.songId] + 1 || 1 31 | }) 32 | .addCase(songUnstarred, (state, { payload }) => { 33 | state.songs[payload.songId] = Math.max(state.songs[payload.songId] - 1, 0) 34 | }) 35 | .addCase(starCountsPush, (_, { payload }) => ({ 36 | ...payload, 37 | })) 38 | }) 39 | 40 | export default starCountsReducer 41 | -------------------------------------------------------------------------------- /server/Library/socket.js: -------------------------------------------------------------------------------- 1 | import Library from './Library.js' 2 | import { STAR_SONG, SONG_STARRED, UNSTAR_SONG, SONG_UNSTARRED, _SUCCESS } from '../../shared/actionTypes.ts' 3 | 4 | const ACTION_HANDLERS = { 5 | [STAR_SONG]: async (sock, { payload }, acknowledge) => { 6 | const changes = await Library.starSong(payload.songId, sock.user.userId) 7 | 8 | // success 9 | acknowledge({ type: STAR_SONG + _SUCCESS }) 10 | 11 | // tell all clients (some users may be in multiple rooms) 12 | if (changes) { 13 | sock.server.emit('action', { 14 | type: SONG_STARRED, 15 | payload: { 16 | userId: sock.user.userId, 17 | songId: payload.songId, 18 | }, 19 | }) 20 | } 21 | }, 22 | [UNSTAR_SONG]: async (sock, { payload }, acknowledge) => { 23 | const changes = await Library.unstarSong(payload.songId, sock.user.userId) 24 | 25 | // success 26 | acknowledge({ type: UNSTAR_SONG + _SUCCESS }) 27 | 28 | if (changes) { 29 | // tell all clients (some users may be in multiple rooms) 30 | sock.server.emit('action', { 31 | type: SONG_UNSTARRED, 32 | payload: { 33 | userId: sock.user.userId, 34 | songId: payload.songId, 35 | }, 36 | }) 37 | } 38 | }, 39 | } 40 | 41 | export default ACTION_HANDLERS 42 | -------------------------------------------------------------------------------- /src/components/Button/Button.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: transparent; 3 | border: none; 4 | padding: 0; 5 | outline: none; 6 | cursor: pointer; 7 | 8 | &:focus-visible { 9 | outline: var(--focus-outline); 10 | } 11 | 12 | svg { 13 | display: block; 14 | } 15 | } 16 | 17 | .default, 18 | .primary, 19 | .danger { 20 | padding: var(--space-m); 21 | width: 100%; 22 | color: var(--btn-default-color); 23 | font-weight: var(--font-weight-bold); 24 | box-shadow: var(--box-shadow); 25 | border-radius: var(--border-radius); 26 | } 27 | 28 | .default { 29 | color: var(--text-color); 30 | text-shadow: var(--text-shadow); 31 | background-color: hsl(var(--hue-blue), 10%, 25%); 32 | background-image: linear-gradient(180deg, hsl(var(--hue-blue),10%,30%) 0%, hsl(var(--hue-blue),10%,20%) 100%); 33 | } 34 | 35 | .primary { 36 | color: hsl(var(--hue-blue), 10%, 15%); 37 | background-color: var(--btn-primary-bg-color); 38 | background-image: linear-gradient(180deg, hsl(var(--hue-blue),92%,60%) 0%, hsl(var(--hue-blue),92%,50%) 100%); 39 | } 40 | 41 | .danger { 42 | color: var(--text-color); 43 | text-shadow: var(--text-shadow); 44 | background-color: var(--btn-danger-bg-color); 45 | background-image: linear-gradient(180deg, hsl(var(--hue-pink), 62%, 50%) 0%, hsl(var(--hue-pink),62%,40%) 100%); 46 | } 47 | -------------------------------------------------------------------------------- /src/routes/Account/views/SignedOutView/SignIn/SignIn.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Button from 'components/Button/Button' 3 | import styles from './SignIn.css' 4 | 5 | interface SignInProps { 6 | username: string 7 | password: string 8 | onUsernameChange: (username: string) => void 9 | onPasswordChange: (password: string) => void 10 | onSubmit: (e: React.FormEvent) => void 11 | onFirstFieldRef: (el: HTMLInputElement | null) => void 12 | } 13 | 14 | const SignIn = ({ 15 | username, 16 | password, 17 | onUsernameChange, 18 | onPasswordChange, 19 | onSubmit, 20 | onFirstFieldRef, 21 | }: SignInProps) => { 22 | return ( 23 |
24 | onUsernameChange(e.target.value)} 30 | ref={onFirstFieldRef} 31 | /> 32 | onPasswordChange(e.target.value)} 38 | /> 39 | 42 |
43 | ) 44 | } 45 | 46 | export default SignIn 47 | -------------------------------------------------------------------------------- /src/components/Header/PlaybackCtrl/VolumeSlider/VolumeSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Slider from 'components/Slider/Slider' 3 | import Icon from 'components/Icon/Icon' 4 | import styles from './VolumeSlider.css' 5 | 6 | interface VolumeSliderProps { 7 | volume: number 8 | onVolumeChange: (value: number) => void 9 | } 10 | 11 | interface HandleProps { 12 | 'className'?: string 13 | 'aria-label'?: string 14 | } 15 | 16 | // volume slider handle/grabber 17 | const handle = (node: React.ReactElement, { value }: { value: number }) => { 18 | const icon = (() => { 19 | if (value === 0) return 'VOLUME_OFF' 20 | if (value < 0.4) return 'VOLUME_MUTE' 21 | if (value < 0.7) return 'VOLUME_DOWN' 22 | return 'VOLUME_UP' 23 | })() 24 | 25 | // rc-slider passes a node (div) to which we add style and children 26 | return React.cloneElement(node, { 27 | 'aria-label': 'Volume', 28 | 'className': styles.handle, 29 | }, ( 30 | 31 | )) 32 | } 33 | 34 | const VolumeSlider = ({ volume, onVolumeChange }: VolumeSliderProps) => { 35 | return ( 36 | 45 | ) 46 | } 47 | 48 | export default VolumeSlider 49 | -------------------------------------------------------------------------------- /src/routes/Account/views/AccountView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useAppDispatch, useAppSelector } from 'store/hooks' 3 | import { fetchPrefs } from 'store/modules/prefs' 4 | import FirstRun from './FirstRun/FirstRun' 5 | import SignedInView from './SignedInView/SignedInView' 6 | import SignedOutView from './SignedOutView/SignedOutView' 7 | import styles from './AccountView.css' 8 | 9 | const AccountView = () => { 10 | const isSignedIn = useAppSelector(state => state.user.userId !== null) 11 | const isFirstRun = useAppSelector(state => state.prefs.isFirstRun === true) 12 | const ui = useAppSelector(state => state.ui) 13 | const dispatch = useAppDispatch() 14 | 15 | // once per mount 16 | // (do this here instead of Prefs component to detect firstRun) 17 | useEffect(() => { 18 | dispatch(fetchPrefs()) 19 | }, [dispatch]) 20 | 21 | return ( 22 |
31 | {isSignedIn 32 | && } 33 | 34 | {!isFirstRun && !isSignedIn 35 | && } 36 | 37 | {isFirstRun 38 | && } 39 |
40 | ) 41 | } 42 | 43 | export default AccountView 44 | -------------------------------------------------------------------------------- /server/lib/Database.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fse from 'fs-extra' 3 | import sqlite3 from 'sqlite3' 4 | import { open as sqliteOpen } from 'sqlite' 5 | import getLogger from './Log.js' 6 | 7 | const log = getLogger('db') 8 | 9 | class Database { 10 | static refs = {} 11 | 12 | static async close () { 13 | if (Database.refs.db) { 14 | log.info('Closing database file %s', Database.refs.db.config.filename) 15 | await Database.refs.db.close() 16 | } 17 | } 18 | 19 | static async open ({ file, ro = true } = {}) { 20 | if (Database.refs.db) throw new Error('Database already open') 21 | log.info('Opening database file %s %s', ro ? '(read-only)' : '(writeable)', file) 22 | 23 | // create path if it doesn't exist 24 | fse.ensureDirSync(path.dirname(file)) 25 | 26 | const db = await sqliteOpen({ 27 | filename: file, 28 | driver: sqlite3.Database, 29 | mode: ro ? sqlite3.OPEN_READONLY : null, 30 | }) 31 | 32 | if (!ro) { 33 | await db.migrate({ 34 | migrationsPath: path.join(import.meta.dirname, 'schemas'), 35 | }) 36 | 37 | await db.run('PRAGMA journal_mode = WAL;') 38 | await db.run('PRAGMA foreign_keys = ON;') 39 | } 40 | 41 | Database.refs.db = db 42 | return db 43 | } 44 | } 45 | 46 | export const open = Database.open 47 | export const close = Database.close 48 | 49 | export default Database.refs 50 | -------------------------------------------------------------------------------- /src/routes/Account/views/SignedInView/SignedInView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import combinedReducer from 'store/reducers' 3 | import { useAppDispatch, useAppSelector } from 'store/hooks' 4 | import { fetchAccount } from 'store/modules/user' 5 | import usersReducer, { sliceInjectNoOp } from '../../modules/users' 6 | import About from '../../components/About/About' 7 | import Account from '../../components/Account/Account' 8 | import Prefs from '../../components/Prefs/Prefs' 9 | import Rooms from '../../components/Rooms/Rooms' 10 | import Users from '../../components/Users/Users' 11 | 12 | const SignedInView = () => { 13 | const { isAdmin } = useAppSelector(state => state.user) 14 | const sliceExists = !!useAppSelector(state => state.users) 15 | const dispatch = useAppDispatch() 16 | 17 | if (isAdmin && !sliceExists) { 18 | combinedReducer.inject({ reducerPath: 'users', reducer: usersReducer }) 19 | dispatch(sliceInjectNoOp()) // update store with new slice 20 | } 21 | 22 | // once per mount 23 | useEffect(() => { 24 | (async () => dispatch(fetchAccount()))() 25 | }, [dispatch]) 26 | 27 | return ( 28 | <> 29 | {isAdmin 30 | && } 31 | 32 | {isAdmin 33 | && } 34 | 35 | {isAdmin 36 | && } 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default SignedInView 46 | -------------------------------------------------------------------------------- /src/components/Slider/Slider.css: -------------------------------------------------------------------------------- 1 | .container { 2 | --track-height: 0.5rem; 3 | --handle-height: 3rem; 4 | 5 | height: var(--handle-height); 6 | 7 | &:global(.rc-slider) { 8 | position: relative; 9 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 10 | } 11 | 12 | &:global(.rc-slider) * { 13 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 14 | } 15 | 16 | & :global(.rc-slider-rail) { 17 | position: absolute; 18 | height: var(--track-height); 19 | top: calc(50% - (var(--track-height) / 2)); 20 | width: 100%; 21 | background-color: var(--transport-volume-track-color); 22 | border-radius: var(--border-radius); 23 | cursor: pointer; 24 | } 25 | 26 | & :global(.rc-slider-track) { 27 | position: absolute; 28 | height: var(--track-height); 29 | top: calc(50% - (var(--track-height) / 2)); 30 | left: 0; 31 | border-radius: var(--border-radius); 32 | background-color: var(--transport-volume-track-active-color); 33 | filter: var(--transport-volume-track-active-filter); 34 | cursor: pointer; 35 | } 36 | } 37 | 38 | .handle { 39 | cursor: pointer; 40 | color: var(--btn-active-bg-color); 41 | position: absolute; 42 | touch-action: pan-x; 43 | outline: none; 44 | 45 | &:focus-visible { 46 | outline: var(--focus-outline); 47 | } 48 | 49 | svg { 50 | display: block; 51 | height: var(--handle-height); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/UserImage/UserImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import Icon from 'components/Icon/Icon' 4 | import styles from './UserImage.css' 5 | 6 | interface UserImageProps { 7 | className?: string 8 | dateUpdated: number 9 | height?: number 10 | userId: number 11 | } 12 | 13 | class UserImage extends React.Component { 14 | state = { 15 | isLoading: true, 16 | isErrored: false, 17 | } 18 | 19 | render () { 20 | const { props, state } = this 21 | 22 | return ( 23 | <> 24 | {(state.isLoading || state.isErrored) && props.height 25 | && } 26 | 27 | {!state.isErrored && ( 28 | 38 | )} 39 | 40 | ) 41 | } 42 | 43 | handleImgLoad = () => { 44 | this.setState({ isLoading: false }) 45 | } 46 | 47 | handleImgError = () => { 48 | this.setState({ isErrored: true, isLoading: false }) 49 | } 50 | } 51 | 52 | export default UserImage 53 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PlayerPrefs/PlayerPrefs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { useAppDispatch, useAppSelector } from 'store/hooks' 3 | import Accordion from 'components/Accordion/Accordion' 4 | import Icon from 'components/Icon/Icon' 5 | import { setPref } from 'store/modules/prefs' 6 | import styles from './PlayerPrefs.css' 7 | 8 | const PlayerPrefs = () => { 9 | const isReplayGainEnabled = useAppSelector(state => state.prefs.isReplayGainEnabled) 10 | const dispatch = useAppDispatch() 11 | 12 | const toggleCheckbox = useCallback((e: React.ChangeEvent) => { 13 | dispatch(setPref({ key: e.currentTarget.name, data: e.currentTarget.checked })) 14 | }, [dispatch]) 15 | 16 | return ( 17 | 21 | 22 |
Player
23 | 24 | )} 25 | > 26 |
27 | 37 |
38 |
39 | ) 40 | } 41 | 42 | export default PlayerPrefs 43 | -------------------------------------------------------------------------------- /src/routes/Queue/selectors/getWaits.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'store/store' 2 | import { createSelector } from '@reduxjs/toolkit' 3 | import getPlayerHistory from './getPlayerHistory' 4 | import getRoundRobinQueue from './getRoundRobinQueue' 5 | 6 | const getPosition = (state: RootState) => state.status.position 7 | const getQueue = (state: RootState) => getRoundRobinQueue(state) 8 | const getQueueId = (state: RootState) => state.status.queueId 9 | const getSongs = (state: RootState) => state.songs 10 | 11 | const getWaits = createSelector( 12 | [getQueue, getQueueId, getPlayerHistory, getPosition, getSongs], 13 | (queue, queueId, history, position, songs) => { 14 | const curIdx = queue.result.indexOf(queueId) 15 | const waits: Record = {} 16 | let curWait = 0 17 | let nextWait = 0 18 | 19 | queue.result.forEach((queueId, i) => { 20 | const songId = queue.entities[queueId].songId 21 | if (!songs.entities[songId]) return 22 | 23 | if (i === curIdx) { 24 | // if history includes the current item it's already been played 25 | if (history.lastIndexOf(queueId) === -1) { 26 | nextWait = Math.round(songs.entities[songId].duration - position) 27 | } 28 | } else if (i > curIdx) { 29 | // upcoming 30 | curWait += nextWait 31 | nextWait = songs.entities[songId].duration 32 | } 33 | 34 | waits[queueId] = curWait 35 | }) 36 | 37 | return waits 38 | }, 39 | ) 40 | 41 | export default getWaits 42 | -------------------------------------------------------------------------------- /src/routes/Player/components/PlayerTextOverlay/UpNow/UpNow.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | left: 100vw; 4 | top: 0; 5 | } 6 | 7 | .innerContainer { 8 | margin-top: 2vh; 9 | margin-right: 2vh; 10 | padding: 2vh; 11 | background-color: var(--chrome-bg-color); 12 | border: 1px solid var(--chrome-border-color); 13 | border-radius: var(--border-radius); 14 | text-align: center; 15 | } 16 | 17 | @supports (backdrop-filter: blur(20px) brightness(50%) saturate(50%)) or (-webkit-backdrop-filter: blur(20px) brightness(50%) saturate(50%)) { 18 | .innerContainer { 19 | background-color: transparent !important; 20 | -webkit-backdrop-filter: blur(20px) brightness(50%) saturate(50%); 21 | backdrop-filter: blur(20px) brightness(50%) saturate(50%); 22 | } 23 | } 24 | 25 | .enterActive { 26 | animation: slide-in .25s ease-out; 27 | animation-fill-mode: forwards; 28 | animation-delay: .25s; 29 | } 30 | 31 | .enterDone { 32 | transform: translateX(-100%); 33 | } 34 | 35 | .exitActive { 36 | animation: slide-out .25s ease-in; 37 | animation-fill-mode: forwards; 38 | } 39 | 40 | .user { 41 | font-size: 6vh; 42 | text-rendering: optimizeLegibility; 43 | text-shadow: 1px 1px 5px #000; 44 | } 45 | 46 | .userImage { 47 | height: 20vh; 48 | } 49 | 50 | @keyframes slide-in { 51 | 0% { transform: translateX(0%); } 52 | 100% { transform: translateX(-100%); } 53 | } 54 | 55 | @keyframes slide-out { 56 | 0% { transform: translateX(-100%); } 57 | 100% { transform: translateX(0%); } 58 | } 59 | -------------------------------------------------------------------------------- /src/routes/Player/components/PlayerTextOverlay/PlayerTextOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ColorCycle from './ColorCycle/ColorCycle' 3 | import UpNow from './UpNow/UpNow' 4 | import type { QueueItem } from 'shared/types' 5 | import styles from './PlayerTextOverlay.css' 6 | 7 | interface PlayerTextOverlayProps { 8 | queueItem?: QueueItem 9 | nextQueueItem?: QueueItem 10 | isAtQueueEnd: boolean 11 | isQueueEmpty: boolean 12 | isErrored: boolean 13 | width: number 14 | height: number 15 | } 16 | 17 | const PlayerTextOverlay = ({ 18 | isQueueEmpty, 19 | isAtQueueEnd, 20 | isErrored, 21 | nextQueueItem, 22 | queueItem, 23 | width, 24 | height, 25 | }: PlayerTextOverlayProps) => { 26 | let Component 27 | 28 | if (isQueueEmpty || (isAtQueueEnd && !nextQueueItem)) { 29 | Component = 30 | } else if (!queueItem || (isAtQueueEnd && nextQueueItem)) { 31 | Component = 32 | } else if (isErrored) { 33 | const offset = Math.random() * -300 34 | Component = ( 35 | <> 36 | 37 | 38 | 39 | ) 40 | } else { 41 | Component = 42 | } 43 | 44 | return ( 45 |
46 | {Component} 47 |
48 | ) 49 | } 50 | 51 | export default React.memo(PlayerTextOverlay) 52 | -------------------------------------------------------------------------------- /src/lib/HttpApi.ts: -------------------------------------------------------------------------------- 1 | export default class HttpApi { 2 | prefix: string 3 | options: RequestInit 4 | 5 | constructor (prefix = '') { 6 | this.prefix = prefix 7 | this.options = { 8 | credentials: 'same-origin', 9 | } 10 | } 11 | 12 | put = >(url: string, options = {}) => this.request('PUT', url, options) 13 | post = >(url: string, options = {}) => this.request('POST', url, options) 14 | get = >(url: string, options = {}) => this.request('GET', url, options) 15 | delete = >(url: string, options = {}) => this.request('DELETE', url, options) 16 | 17 | request = async | Response>( 18 | method: string, 19 | url: string, 20 | options = {}, 21 | ): Promise => { 22 | const opts = { 23 | ...this.options, 24 | ...options, 25 | method, 26 | } 27 | 28 | // assume we're sending JSON if not multipart form data 29 | if (typeof opts.body === 'object' && !(opts.body instanceof FormData)) { 30 | opts.headers = new Headers({ 31 | 'Content-Type': 'application/json', 32 | }) 33 | 34 | opts.body = JSON.stringify(opts.body) 35 | } 36 | 37 | const res = await fetch(`${document.baseURI}api/${this.prefix}${url}`, opts) 38 | 39 | if (res.ok) { 40 | const type = res.headers.get('Content-Type') 41 | return (type && type.includes('application/json')) 42 | ? res.json() 43 | : res as unknown as T 44 | } 45 | 46 | const txt = await res.text() 47 | throw new Error(txt) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Header/ProgressBar/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Button from 'components/Button/Button' 3 | import styles from './ProgressBar.css' 4 | 5 | interface ProgressBarProps { 6 | isActive: boolean 7 | onCancel: () => void 8 | pct: number 9 | text: string 10 | } 11 | 12 | export default class ProgressBar extends React.Component { 13 | state = { 14 | isCanceling: false, 15 | isVisible: false, 16 | } 17 | 18 | handleCancelClick = () => { 19 | if (this.props.isActive && !this.state.isCanceling) { 20 | this.setState({ isCanceling: true }) 21 | this.props.onCancel() 22 | } else { 23 | this.setState({ isVisible: false }) 24 | } 25 | } 26 | 27 | componentDidUpdate (prevProps: ProgressBarProps) { 28 | if (this.props.isActive && !prevProps.isActive) { 29 | this.setState({ isVisible: true, isCanceling: false }) 30 | } else if (this.state.isCanceling && this.props.text !== prevProps.text) { 31 | // only show 'Stopping...' until the next update 32 | this.setState({ isCanceling: false }) 33 | } 34 | } 35 | 36 | render () { 37 | const { state, props } = this 38 | if (!state.isVisible) return null 39 | 40 | return ( 41 |
42 |

{state.isCanceling ? 'Stopping...' : props.text}

43 |
50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Logo/Logo.css: -------------------------------------------------------------------------------- 1 | .container { 2 | font-family: Beon; 3 | white-space: nowrap; 4 | flex: 1; 5 | } 6 | 7 | .title { 8 | border: none; 9 | color: hsl(var(--hue-blue), 10%, 80%); 10 | display: inline-block; 11 | font-size: 200%; 12 | font-weight: normal; 13 | text-transform: uppercase; 14 | margin: 0; 15 | text-shadow: 16 | 0 0 .1em hsl(var(--hue-pink), 100%, 100%), 17 | 0 0 .5em hsl(var(--hue-pink), 100%, 100%), 18 | 0 0 .25em hsl(var(--hue-pink), 100%, 50%), 19 | 0 0 .5em hsl(var(--hue-pink), 100%, 50%), 20 | 0 0 .75em hsl(var(--hue-pink), 100%, 50%), 21 | 0 0 1em hsl(var(--hue-pink), 100%, 50%), 22 | 0 0 1.5em hsl(var(--hue-pink), 100%, 50%), 23 | 0 0 3em hsl(var(--hue-pink), 100%, 50%), 24 | 0 0 6em hsl(var(--hue-pink), 100%, 50%), 25 | 0 0 9em hsl(var(--hue-pink), 100%, 50%); 26 | } 27 | 28 | .eternal { 29 | font-size: 40%; 30 | text-transform: uppercase; 31 | display: block; 32 | letter-spacing: .9em; 33 | border: .1em solid hsl(var(--hue-blue), 100%, 90%); 34 | border-radius: .25em; 35 | padding: .25em; 36 | text-shadow: 37 | 0 0 .1em hsl(var(--hue-blue), 100%, 90%), 38 | 0 0 .25em hsl(var(--hue-blue), 100%, 90%), 39 | 0 0 .25em hsl(var(--hue-blue), 100%, 50%), 40 | 0 0 .5em hsl(var(--hue-blue), 100%, 50%); 41 | box-shadow: 42 | 0 0 .1em hsl(var(--hue-blue), 100%, 90%), 43 | 0 0 .25em hsl(var(--hue-blue), 100%, 90%), 44 | 0 0 .5em hsl(var(--hue-blue), 100%, 50%), 45 | 0 0 .75em hsl(var(--hue-blue), 100%, 50%), 46 | inset 0 0 .5em hsl(var(--hue-blue), 100%, 50%), 47 | inset 0 0 .75em hsl(var(--hue-blue), 100%, 50%); 48 | } 49 | 50 | .lastChar { 51 | letter-spacing: normal; 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Header/PlaybackCtrl/DisplayCtrl/DisplayCtrl.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | width: 90vw; 3 | max-width: 480px; 4 | } 5 | 6 | .container { 7 | display: flex; 8 | flex-direction: column; 9 | gap: var(--space-m); 10 | } 11 | 12 | .section { 13 | /* Safari REALLY doesn't like fieldsets to be flex *items*, hence the .section wrapper */ 14 | fieldset { 15 | display: flex; 16 | flex-direction: column; 17 | gap: var(--space-m); 18 | margin: 0; 19 | padding: var(--container-padding); 20 | border: 1px solid var(--btn-bg-color); 21 | border-radius: var(--border-radius); 22 | } 23 | 24 | legend { 25 | font-size: var(--font-size-m); 26 | width: fit-content; 27 | } 28 | } 29 | 30 | .visualizer legend { 31 | label { 32 | display: flex; 33 | gap: var(--space-s); 34 | align-items: center; 35 | cursor: pointer; 36 | } 37 | } 38 | 39 | .presetContainer { 40 | display: flex; 41 | flex-direction: column; 42 | gap: var(--space-s); 43 | } 44 | 45 | .presetButtons { 46 | display: flex; 47 | gap: var(--space-s); 48 | 49 | button { 50 | display: block; 51 | background-color: var(--btn-active-bg-color); 52 | border-radius: var(--border-radius); 53 | width: 100%; 54 | 55 | svg { 56 | height: var(--icon-size-xl); 57 | color: #000; 58 | } 59 | } 60 | } 61 | 62 | .presetName { 63 | margin: 0; 64 | height: 2lh; 65 | display: -webkit-box; 66 | -webkit-line-clamp: 2; 67 | -webkit-box-orient: vertical; 68 | line-clamp: 2; 69 | overflow: hidden; 70 | text-overflow: ellipsis; 71 | } 72 | 73 | .unsupported { 74 | text-align: center; 75 | margin: var(--space-m) 0; 76 | color: grey; 77 | } 78 | 79 | .slider { 80 | margin: 0 1.25rem; 81 | } 82 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | opacity: 0; 5 | color: var(--text-color); 6 | background: transparent; 7 | padding: var(--space-m); 8 | border: 1.5px solid hsl(var(--hue-blue), 20%, 30%); 9 | border-radius: 16px; 10 | max-width: min(800px, 90vw); 11 | box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0); 12 | backdrop-filter: blur(30px) brightness(50%) saturate(100%); 13 | -webkit-backdrop-filter: blur(30px) brightness(50%) saturate(100%); 14 | 15 | &[open] { 16 | animation: fadeIn 0.15s forwards; 17 | box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.5); 18 | } 19 | } 20 | 21 | .titleContainer { 22 | display: flex; 23 | font-family: var(--font-family-custom); 24 | margin: 0 0 var(--space-s) 0; 25 | 26 | h1 { 27 | flex: 1; 28 | margin: 0; 29 | font-size: var(--font-size-xl); 30 | line-height: 1em; 31 | } 32 | 33 | } 34 | 35 | .btnClose { 36 | position: relative; 37 | top: calc(var(--space-s) * -1); 38 | right: calc(var(--space-s) * -1); 39 | align-self: flex-start; 40 | color: var(--btn-active-bg-color); 41 | padding: var(--space-s); 42 | line-height: 1em; 43 | 44 | &:focus-visible { 45 | outline: none; 46 | } 47 | 48 | svg { 49 | height: var(--icon-size-l); 50 | line-height: 1em; 51 | } 52 | } 53 | 54 | .content { 55 | flex: 1; 56 | 57 | &.scrollable { 58 | overflow: auto; 59 | -webkit-overflow-scrolling: touch; 60 | } 61 | } 62 | 63 | .buttons { 64 | display: flex; 65 | flex-direction: column; 66 | row-gap: var(--space-s); 67 | column-gap: var(--space-m); 68 | margin-top: var(--container-padding); 69 | } 70 | 71 | 72 | @keyframes fadeIn { 73 | from { opacity: 0; } 74 | to { opacity: 1; } 75 | } 76 | -------------------------------------------------------------------------------- /server/Scanner/FileScanner/getFiles.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import getLogger from '../../lib/Log.js' 4 | const log = getLogger('FileScanner:getFiles') 5 | 6 | /** 7 | * Silly promise wrapper for synchronous walker 8 | * 9 | * We want a synchronous walker for performance, but FileScanner runs 10 | * the walker in a loop, which will block the (async) socket.io status 11 | * emissions unless we use setTimeout here. @todo is there a better way? 12 | * 13 | * @param {string} dir path to recursively list 14 | * @param {function} filterFn filter function applied to each file 15 | * @return {array} array of objects with path and stat properties 16 | */ 17 | function getFiles (dir, filterFn) { 18 | return new Promise((resolve, reject) => { 19 | setTimeout(() => { 20 | try { 21 | resolve(walkSync(dir, filterFn)) 22 | } catch (err) { 23 | reject(err) 24 | } 25 | }, 0) 26 | }) 27 | } 28 | 29 | /** 30 | * Directory walker that only throws if parent directory 31 | * can't be read. Errors stat-ing children are only logged. 32 | */ 33 | function walkSync (dir, filterFn) { 34 | let results = [] 35 | const list = fs.readdirSync(dir) 36 | 37 | list.forEach((file) => { 38 | let stats 39 | file = path.join(dir, file) 40 | 41 | try { 42 | stats = fs.statSync(file) 43 | } catch (err) { 44 | log.warn(err.message) 45 | return 46 | } 47 | 48 | if (stats && stats.isDirectory()) { 49 | try { 50 | results = results.concat(walkSync(file, filterFn)) 51 | } catch (err) { 52 | log.warn(err.message) 53 | } 54 | } else { 55 | if (!filterFn || filterFn(file)) { 56 | results.push({ file, stats }) 57 | } 58 | } 59 | }) 60 | 61 | return results 62 | } 63 | 64 | export default getFiles 65 | -------------------------------------------------------------------------------- /server/Prefs/socket.js: -------------------------------------------------------------------------------- 1 | import getLogger from '../lib/Log.js' 2 | import Library from '../Library/Library.js' 3 | import Prefs from './Prefs.js' 4 | import { LIBRARY_PUSH, PREFS_PATH_SET_PRIORITY, PREFS_PUSH, PREFS_SET, _ERROR } from '../../shared/actionTypes.ts' 5 | const log = getLogger(`server[${process.pid}]`) 6 | 7 | const ACTION_HANDLERS = { 8 | [PREFS_SET]: async (sock, { payload }, acknowledge) => { 9 | if (!sock.user.isAdmin) { 10 | acknowledge({ 11 | type: PREFS_SET + _ERROR, 12 | error: 'Unauthorized', 13 | }) 14 | } 15 | 16 | await Prefs.set(payload.key, payload.data) 17 | log.info('%s (%s) set pref %s = %s', sock.user.name, sock.id, payload.key, payload.data) 18 | 19 | await pushPrefs(sock) 20 | }, 21 | [PREFS_PATH_SET_PRIORITY]: async (sock, { payload }, acknowledge) => { 22 | if (!sock.user.isAdmin) { 23 | acknowledge({ 24 | type: PREFS_PATH_SET_PRIORITY + _ERROR, 25 | error: 'Unauthorized', 26 | }) 27 | } 28 | 29 | await Prefs.setPathPriority(payload) 30 | log.info('%s re-prioritized media folders; pushing library to all', sock.user.name) 31 | 32 | await pushPrefs(sock) 33 | 34 | // invalidate cache 35 | Library.cache.version = null 36 | 37 | sock.server.emit('action', { 38 | type: LIBRARY_PUSH, 39 | payload: await Library.get(), 40 | }) 41 | }, 42 | } 43 | 44 | // helper to push prefs to admins 45 | const pushPrefs = async (sock) => { 46 | const admins = [] 47 | 48 | for (const s of sock.server.sockets.sockets.values()) { 49 | if (s.user && s.user.isAdmin) { 50 | admins.push(s.id) 51 | sock.server.to(s.id) 52 | } 53 | } 54 | 55 | if (admins.length) { 56 | sock.server.emit('action', { 57 | type: PREFS_PUSH, 58 | payload: await Prefs.get(), 59 | }) 60 | } 61 | } 62 | 63 | export default ACTION_HANDLERS 64 | -------------------------------------------------------------------------------- /src/routes/Player/views/PlayerView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import screenfull from 'screenfull' 3 | import combinedReducer from 'store/reducers' 4 | import { useAppSelector, useAppDispatch } from 'store/hooks' 5 | import playerReducer, { sliceInjectNoOp } from '../modules/player' 6 | import playerVisualizerReducer from '../modules/playerVisualizer' 7 | import PlayerController from '../components/PlayerController/PlayerController' 8 | import { fetchCurrentRoom } from 'store/modules/rooms' 9 | import styles from './PlayerView.css' 10 | 11 | const PlayerView = () => { 12 | const { innerWidth, innerHeight, headerHeight, footerHeight } = useAppSelector(state => state.ui) 13 | const viewportHeight = innerHeight - headerHeight - footerHeight 14 | const dispatch = useAppDispatch() 15 | 16 | // @todo: find better place for this? 17 | if (!useAppSelector(state => state.player)) { 18 | combinedReducer.inject({ reducerPath: 'player', reducer: playerReducer }) 19 | combinedReducer.inject({ reducerPath: 'playerVisualizer', reducer: playerVisualizerReducer }) 20 | dispatch(sliceInjectNoOp()) // update store with new slices 21 | } 22 | 23 | // once per mount 24 | useEffect(() => { 25 | dispatch(fetchCurrentRoom()) 26 | }, [dispatch]) 27 | 28 | return ( 29 |
30 |
39 | 43 |
44 |
45 | ) 46 | } 47 | 48 | export default PlayerView 49 | -------------------------------------------------------------------------------- /src/routes/Library/components/SongItem/SongItem.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | 5 | &.queued { 6 | text-shadow: var(--queued-item-text-shadow); 7 | 8 | .title { 9 | color: var(--queued-item-color); 10 | } 11 | } 12 | 13 | &.starred { 14 | .star { 15 | color: var(--song-item-btn-active-bg-color); 16 | filter: var(--song-item-btn-active-filter); 17 | } 18 | 19 | .starCount { 20 | color: var(--song-item-btn-active-color); 21 | } 22 | } 23 | } 24 | 25 | .animateGlow { 26 | animation: var(--animation-text-shadow-glow); 27 | } 28 | 29 | .duration { 30 | color: var(--song-duration-color); 31 | font-size: 12px; 32 | text-align: right; 33 | width: 40px; 34 | } 35 | 36 | .primary { 37 | cursor: pointer; 38 | flex: 1; 39 | overflow: hidden; 40 | white-space: nowrap; 41 | margin-left: 14px; 42 | } 43 | 44 | .title { 45 | color: var(--song-title-color); 46 | font-size: 18px; 47 | text-overflow: ellipsis; 48 | overflow: hidden; 49 | } 50 | 51 | .artist { 52 | color: #aaa; 53 | font-size: 15px; 54 | text-overflow: ellipsis; 55 | overflow: hidden; 56 | white-space: nowrap; 57 | } 58 | 59 | /* Buttons */ 60 | .btn { 61 | cursor: pointer; 62 | width: var(--song-item-btn-width); 63 | height: var(--song-item-btn-height); 64 | margin-right: var(--btn-margin); 65 | transition: opacity .3s; 66 | } 67 | 68 | .star { 69 | color: var(--song-item-btn-bg-color); 70 | height: var(--song-item-btn-height); 71 | } 72 | 73 | .starCount { 74 | position: relative; 75 | font-size: 13px; 76 | top: -28px; 77 | text-align: center; 78 | text-shadow: none; 79 | color: var(--text-color); 80 | } 81 | 82 | .animateStar { 83 | animation: var(--animation-bounce); 84 | } 85 | 86 | .info { 87 | color: var(--song-item-btn-active-bg-color); 88 | filter: var(--song-item-btn-active-filter); 89 | } 90 | -------------------------------------------------------------------------------- /src/routes/Queue/components/QueueListAnimator/QueueListAnimator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Flipper, Flipped } from 'react-flip-toolkit' 3 | import { useAppSelector } from 'store/hooks' 4 | import styles from './QueueListAnimator.css' 5 | 6 | const handleEnter = (el: HTMLDivElement) => { 7 | el.addEventListener('animationend', e => (e.currentTarget as HTMLDivElement).classList.remove(styles.itemEnter)) 8 | el.classList.add(styles.itemEnter) 9 | el.style.removeProperty('opacity') 10 | } 11 | 12 | const handleExit = (el: HTMLDivElement, _i: number, removeEl: () => void) => { 13 | el.addEventListener('animationend', removeEl) 14 | el.classList.add(styles.itemExit) 15 | } 16 | 17 | const handleShouldFlip = (prev: number, cur: number) => cur === prev 18 | 19 | interface QueueListAnimatorProps { 20 | queueItems: React.ReactElement[] 21 | } 22 | 23 | const QueueListAnimator = ({ 24 | queueItems, 25 | }: QueueListAnimatorProps) => { 26 | const headerHeight = useAppSelector(state => state.ui.headerHeight) 27 | 28 | // Flipped applies data-* props to its child; using a div wrapper 29 | // here so QueueItems need not be concerned with rendering them 30 | // https://github.com/aholachek/react-flip-toolkit#wrapping-a-react-component 31 | const items = React.Children.map(queueItems, (child) => { 32 | return ( 33 | 41 |
42 | {child} 43 |
44 |
45 | ) 46 | }) 47 | 48 | return ( 49 | 54 | {items} 55 | 56 | ) 57 | } 58 | 59 | export default QueueListAnimator 60 | -------------------------------------------------------------------------------- /src/components/Accordion/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import React, { cloneElement, useId, useState } from 'react' 2 | import clsx from 'clsx' 3 | import Icon from 'components/Icon/Icon' 4 | import styles from './Accordion.module.css' 5 | 6 | export type AccordionProps = { 7 | className?: string 8 | children: React.ReactNode 9 | contentClassName?: string 10 | headingComponent: React.ReactElement<{ children?: React.ReactNode }> 11 | iconClassName?: string 12 | initialExpanded?: boolean 13 | } 14 | 15 | const Accordion = ({ 16 | className, 17 | children, 18 | contentClassName, 19 | headingComponent, 20 | iconClassName, 21 | initialExpanded = false, 22 | }: AccordionProps) => { 23 | const [isExpanded, setIsExpanded] = useState(initialExpanded) 24 | const id = useId() 25 | 26 | const handleOnClick = () => { 27 | setIsExpanded(!isExpanded) 28 | } 29 | 30 | const a11yHeadingComponent = cloneElement(headingComponent, { 31 | children: ( 32 | 40 | ), 41 | }) 42 | 43 | return ( 44 |
45 |
46 | {a11yHeadingComponent} 47 | 48 |
49 | 57 |
58 | ) 59 | } 60 | 61 | export default Accordion 62 | -------------------------------------------------------------------------------- /server/Scanner/ScannerQueue.js: -------------------------------------------------------------------------------- 1 | import FileScanner from './FileScanner/FileScanner.js' 2 | import Prefs from '../Prefs/Prefs.js' 3 | import getLogger from '../lib/Log.js' 4 | 5 | const log = getLogger('queue') 6 | 7 | class ScannerQueue { 8 | #instance 9 | #isCanceling = false 10 | #q = [] 11 | 12 | constructor (onIteration, onDone) { 13 | this.onIteration = onIteration 14 | this.onDone = onDone 15 | } 16 | 17 | async queue (pathIds) { 18 | const prefs = await Prefs.get() 19 | 20 | if (pathIds === true) { 21 | pathIds = prefs.paths.result // queueing all paths 22 | } else if (Number.isInteger(pathIds)) { 23 | pathIds = [pathIds] 24 | } 25 | 26 | if (!Array.isArray(pathIds)) { 27 | log.warn('invalid pathIds: %s', pathIds) 28 | return 29 | } 30 | 31 | pathIds.forEach((id) => { 32 | const dir = prefs.paths.entities[id]?.path 33 | 34 | if (!dir) { 35 | log.warn('ignoring (invalid pathId): %s', id) 36 | } else if (this.#q.includes(id)) { 37 | log.info('ignoring (path already queued): %s', dir) 38 | } else { 39 | log.info('path queued for scan: %s', dir) 40 | this.#q.push(id) 41 | } 42 | }) 43 | 44 | if (this.#q.length && !this.#instance) { 45 | this.start() 46 | } 47 | } 48 | 49 | async start () { 50 | log.info('Starting media scan') 51 | 52 | while (this.#q.length && !this.#isCanceling) { 53 | const prefs = await Prefs.get() 54 | this.#instance = new FileScanner(prefs, { length: this.#q.length }) 55 | 56 | const stats = await this.#instance.scan(this.#q.shift()) 57 | this.onIteration(stats) 58 | } 59 | 60 | this.onDone() 61 | } 62 | 63 | stop () { 64 | log.info('Stopping media scan (user requested)') 65 | this.#isCanceling = true 66 | 67 | if (this.#instance) { 68 | this.#instance.cancel() 69 | } 70 | } 71 | } 72 | 73 | export default ScannerQueue 74 | -------------------------------------------------------------------------------- /src/components/App/CoreLayout/CoreLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from 'react' 2 | import { useAppDispatch, useAppSelector } from 'store/hooks' 3 | import useResizeObserver from 'use-resize-observer' 4 | // global stylesheets should be imported before any 5 | // components that will import their own modular css 6 | import '../../../styles/global.css' 7 | import Button from 'components/Button/Button' 8 | import Header from 'components/Header/Header' 9 | import Navigation from 'components/Navigation/Navigation' 10 | import Modal from 'components/Modal/Modal' 11 | import SongInfo from 'components/SongInfo/SongInfo' 12 | import Routes from '../Routes/Routes' 13 | import { clearErrorMessage, setFooterHeight, setHeaderHeight } from 'store/modules/ui' 14 | 15 | const CoreLayout = () => { 16 | const dispatch = useAppDispatch() 17 | const headerRef = useRef(null) 18 | const navRef = useRef(null) 19 | 20 | useResizeObserver({ 21 | onResize: ({ height }) => { dispatch(setHeaderHeight(height)) }, 22 | ref: headerRef, 23 | }) 24 | 25 | useResizeObserver({ 26 | onResize: ({ height }) => { dispatch(setFooterHeight(height)) }, 27 | ref: navRef, 28 | }) 29 | 30 | const ui = useAppSelector(state => state.ui) 31 | const closeError = useCallback(() => dispatch(clearErrorMessage()), [dispatch]) 32 | 33 | return ( 34 | <> 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {ui.isErrored && ( 44 | OK} 48 | > 49 |

50 | {ui.errorMessage} 51 |

52 |
53 | )} 54 | 55 | ) 56 | } 57 | 58 | export default CoreLayout 59 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathItem/PathItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { Draggable } from '@hello-pangea/dnd' 3 | import Button from 'components/Button/Button' 4 | import Icon from 'components/Icon/Icon' 5 | import { Path } from 'shared/types' 6 | import styles from './PathItem.css' 7 | 8 | interface PathItemProps { 9 | index: number 10 | onInfo: (pathId: number) => void 11 | onRefresh: (pathId: number) => void 12 | path: Path 13 | } 14 | 15 | const PathItem = ({ index, onInfo, onRefresh, path }: PathItemProps) => { 16 | const handleInfo = useCallback((e: React.SyntheticEvent) => onInfo(parseInt(e.currentTarget.dataset.pathId)), [onInfo]) 17 | const handleRefresh = useCallback((e: React.SyntheticEvent) => onRefresh(parseInt(e.currentTarget.dataset.pathId)), [onRefresh]) 18 | 19 | return ( 20 | 21 | {provided => ( 22 |
29 |
30 | 31 |
32 |
33 | {path.path} 34 |
35 |
48 | )} 49 |
50 | ) 51 | } 52 | 53 | export default PathItem 54 | -------------------------------------------------------------------------------- /src/routes/Library/components/SongList/SongList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { useAppDispatch, useAppSelector } from 'store/hooks' 3 | import { ensureState } from 'redux-optimistic-ui' 4 | import SongItem from '../SongItem/SongItem' 5 | 6 | import { queueSong } from 'routes/Queue/modules/queue' 7 | import { showSongInfo } from 'store/modules/songInfo' 8 | import { toggleSongStarred } from 'store/modules/userStars' 9 | 10 | interface SongListProps { 11 | filterKeywords: string[] 12 | queuedSongs: number[] 13 | showArtist: boolean 14 | songIds: number[] 15 | } 16 | 17 | const SongList = (props: SongListProps) => { 18 | const artists = useAppSelector(state => state.artists.entities) 19 | const songs = useAppSelector(state => state.songs.entities) 20 | const starredSongs = useAppSelector(state => ensureState(state.userStars).starredSongs) 21 | const starredSongCounts = useAppSelector(state => state.starCounts.songs) 22 | const isAdmin = useAppSelector(state => state.user.isAdmin) 23 | 24 | const dispatch = useAppDispatch() 25 | const handleSongQueue = useCallback((songId: number) => dispatch(queueSong(songId)), [dispatch]) 26 | const handleSongInfo = useCallback((songId: number) => dispatch(showSongInfo(songId)), [dispatch]) 27 | const handleSongStar = useCallback((songId: number) => dispatch(toggleSongStarred(songId)), [dispatch]) 28 | 29 | return props.songIds.map(songId => ( 30 | 43 | )) 44 | } 45 | 46 | export default SongList 47 | -------------------------------------------------------------------------------- /src/routes/Queue/modules/queue.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createReducer } from '@reduxjs/toolkit' 2 | import type { QueueItem, OptimisticQueueItem } from 'shared/types' 3 | import { 4 | QUEUE_ADD, 5 | QUEUE_MOVE, 6 | QUEUE_PUSH, 7 | QUEUE_REMOVE, 8 | LOGOUT, 9 | } from 'shared/actionTypes' 10 | 11 | // ------------------------------------ 12 | // Actions 13 | // ------------------------------------ 14 | const logout = createAction(LOGOUT) 15 | export const moveItem = createAction<{ queueId: number, prevQueueId: number }>(QUEUE_MOVE) 16 | export const removeItem = createAction<{ queueId: number }>(QUEUE_REMOVE) 17 | export const queuePush = createAction(QUEUE_PUSH) 18 | 19 | export const queueSong = createAction(QUEUE_ADD, (songId: number) => ({ 20 | payload: { songId }, 21 | meta: { isOptimistic: true }, 22 | })) 23 | 24 | // ------------------------------------ 25 | // Reducer 26 | // ------------------------------------ 27 | interface QueueState { 28 | isLoading: boolean 29 | result: number[] // queueIds 30 | entities: Record 31 | } 32 | 33 | const initialState: QueueState = { 34 | isLoading: true, 35 | result: [], 36 | entities: {}, 37 | } 38 | 39 | const queueReducer = createReducer(initialState, (builder) => { 40 | builder 41 | .addCase(queueSong, (state, { payload }) => { 42 | // optimistic 43 | const nextQueueId = state.result.length ? (state.result[state.result.length - 1] as number) + 1 : 1 44 | 45 | state.result.push(nextQueueId) 46 | state.entities[nextQueueId] = { 47 | ...payload, 48 | queueId: nextQueueId, 49 | prevQueueId: nextQueueId - 1 || null, 50 | isOptimistic: true, 51 | } 52 | }) 53 | .addCase(queuePush, (state, { payload }) => ({ 54 | isLoading: false, 55 | result: payload.result, 56 | entities: payload.entities, 57 | })) 58 | .addCase(logout, (state) => { 59 | state.result = [] 60 | state.entities = {} 61 | }) 62 | }) 63 | 64 | export default queueReducer 65 | -------------------------------------------------------------------------------- /src/store/socketMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Action, Middleware, UnknownAction } from '@reduxjs/toolkit' 2 | import { BEGIN, COMMIT, REVERT } from 'redux-optimistic-ui' 3 | import { Socket } from 'socket.io-client' 4 | import { OptimisticAction } from './store' 5 | 6 | // optimistic actions need a transaction id to match BEGIN to COMMIT/REVERT 7 | let transactionID = 0 8 | 9 | export default function createSocketMiddleware (socket: Socket, prefix: string): Middleware { 10 | return (store) => { 11 | // attach handler for incoming actions (from server) 12 | socket.on('action', action => store.dispatch(action)) 13 | 14 | return next => (action: Action | OptimisticAction) => { 15 | // dispatch normally if it's not a socket.io request 16 | if (!action.type || !action.type.startsWith(prefix)) { 17 | return next(action) 18 | } 19 | 20 | const hasMeta = 'meta' in action 21 | const isOptimistic = hasMeta && (action.meta?.isOptimistic ?? false) 22 | 23 | socket.emit('action', action, (cbAction: UnknownAction) => { 24 | // make sure callback response is an action 25 | if (typeof cbAction !== 'object' || typeof cbAction.type !== 'string') { 26 | return 27 | } 28 | 29 | if (isOptimistic) { 30 | cbAction.meta = { 31 | ...('meta' in cbAction && typeof cbAction.meta === 'object' ? cbAction.meta : {}), 32 | optimistic: cbAction.error ? { type: REVERT, id: transactionID } : { type: COMMIT, id: transactionID }, 33 | } 34 | } 35 | 36 | next(cbAction) 37 | }) 38 | 39 | if (!isOptimistic) { 40 | return next(action) 41 | } 42 | 43 | // dispatch optimistically? 44 | transactionID++ 45 | 46 | // don't mutate action because we don't need to 47 | // emit this meta info to the server 48 | next({ 49 | ...action, 50 | meta: { 51 | ...action.meta, 52 | optimistic: { type: BEGIN, id: transactionID }, 53 | }, 54 | }) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/routes/Account/components/Prefs/PathPrefs/PathInfo/PathInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import Modal from 'components/Modal/Modal' 3 | import InputCheckbox from 'components/InputCheckbox/InputCheckbox' 4 | import Button from 'components/Button/Button' 5 | import styles from './PathInfo.css' 6 | import type { Path } from 'shared/types' 7 | 8 | interface PathInfoProps { 9 | onClose: () => void 10 | onRemove: (pathId: number) => void 11 | onUpdate: (pathId: number, data: object) => void 12 | path: Path 13 | } 14 | 15 | const PathInfo = ({ onClose, onRemove, onUpdate, path }: PathInfoProps) => { 16 | const handleChange = useCallback((data: Record) => { 17 | onUpdate(path.pathId, data) 18 | }, [onUpdate, path.pathId]) 19 | 20 | const handleRemove = useCallback(() => onRemove(path.pathId), [onRemove, path.pathId]) 21 | 22 | return ( 23 | 28 | 29 | 30 | 31 | )} 32 | > 33 |
34 |

35 | {path?.path} 36 |
37 | pathId: 38 | {path?.pathId} 39 |

40 | 41 |
42 | handleChange({ isWatchingEnabled: event.currentTarget.checked })} 46 | /> 47 | handleChange({ isVideoKeyingEnabled: event.currentTarget.checked })} 51 | /> 52 | 53 |
54 |
55 | ) 56 | } 57 | 58 | export default PathInfo 59 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useCallback } from 'react' 2 | import clsx from 'clsx' 3 | import Button from 'components/Button/Button' 4 | import styles from './Modal.css' 5 | 6 | export type ModalProps = { 7 | buttons?: React.ReactNode 8 | className?: string 9 | children?: React.ReactNode 10 | onClose: () => void 11 | scrollable?: boolean 12 | title: string 13 | visible?: boolean 14 | } 15 | 16 | const Modal = ({ buttons, className, children, visible = true, onClose, scrollable, title }: ModalProps) => { 17 | const dialogRef = useRef(null) 18 | const isOutsideClick = useRef(false) 19 | 20 | useEffect(() => { 21 | if (visible && dialogRef.current) { 22 | dialogRef.current.showModal() 23 | } 24 | }, [visible]) 25 | 26 | const handleMouseDown = useCallback((event: React.MouseEvent) => { 27 | isOutsideClick.current = event.target === dialogRef.current 28 | }, []) 29 | 30 | const handleMouseUp = useCallback((event: React.MouseEvent) => { 31 | if (isOutsideClick.current && event.target === dialogRef.current) { 32 | onClose() 33 | } 34 | isOutsideClick.current = false 35 | }, [onClose]) 36 | 37 | const handleCancel = useCallback((e: React.SyntheticEvent) => { 38 | e.preventDefault() 39 | onClose() 40 | }, [onClose]) 41 | 42 | if (!visible) return null 43 | 44 | return ( 45 | 52 |
53 |

{title}

54 |
56 |
{children}
57 | {buttons &&
{buttons}
} 58 |
59 | ) 60 | } 61 | 62 | export default Modal 63 | -------------------------------------------------------------------------------- /server/scannerWorker.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { parsePathIds } from './lib/util.js' 3 | import { initLogger } from './lib/Log.js' 4 | import { 5 | REQUEST_SCAN, 6 | REQUEST_SCAN_STOP, 7 | SCANNER_WORKER_STATUS, 8 | } from '../shared/actionTypes.ts' 9 | 10 | const env = JSON.parse(process.env.KES_ENV_JSON) 11 | const log = initLogger('scanner', { 12 | console: { 13 | level: env.KES_SCANNER_CONSOLE_LEVEL ?? (env.NODE_ENV === 'development' ? 5 : 4), 14 | useStyles: env.KES_CONSOLE_COLORS ?? undefined, 15 | }, 16 | file: { 17 | level: env.KES_SCANNER_LOG_LEVEL ?? (env.NODE_ENV === 'development' ? 0 : 3), 18 | }, 19 | }).scope(`scanner[${process.pid}]`) 20 | 21 | let IPC 22 | 23 | ;(async function () { 24 | // init database 25 | const { open } = await import('./lib/Database.js') 26 | 27 | await open({ 28 | file: path.join(env.KES_PATH_DATA, 'database.sqlite3'), 29 | ro: true, 30 | }) 31 | 32 | // init IPC listener 33 | const IPCBridge = await import('./lib/IPCBridge.js') 34 | IPC = IPCBridge.default 35 | 36 | IPC.use({ 37 | [REQUEST_SCAN]: ({ payload }) => { 38 | q.queue(payload.pathIds) // no need to await; fire and forget 39 | }, 40 | [REQUEST_SCAN_STOP]: () => { 41 | q.stop() 42 | }, 43 | }) 44 | 45 | const { default: ScannerQueue } = await import('./Scanner/ScannerQueue.js') 46 | const q = new ScannerQueue(onIteration, onDone) 47 | const args = process.argv.slice(2) 48 | log.debug('received arguments: %s', args) 49 | 50 | if (!args.length) { 51 | process.exit(1) // eslint-disable-line n/no-process-exit 52 | } 53 | 54 | const pathIds = parsePathIds(args[0]) 55 | log.debug('parsed pathIds: %s', pathIds) 56 | 57 | q.queue(pathIds) 58 | })() 59 | 60 | // @todo 61 | function onIteration (stats) { 62 | return stats 63 | } 64 | 65 | function onDone () { 66 | IPC.send({ 67 | type: SCANNER_WORKER_STATUS, 68 | payload: { 69 | isScanning: false, 70 | pct: 100, 71 | text: 'Finished', 72 | }, 73 | }) 74 | 75 | process.exit(0) // eslint-disable-line n/no-process-exit 76 | } 77 | -------------------------------------------------------------------------------- /src/routes/Player/components/PlayerTextOverlay/UpNow/UpNow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import { CSSTransition } from 'react-transition-group' 3 | import UserImage from 'components/UserImage/UserImage' 4 | import type { QueueItem } from 'shared/types' 5 | import styles from './UpNow.css' 6 | 7 | interface UpNowProps { 8 | queueItem: QueueItem 9 | } 10 | 11 | const UpNow = ({ queueItem }: UpNowProps) => { 12 | const [show, setShow] = useState(false) 13 | const timeoutID = useRef | null>(null) 14 | 15 | const animate = () => { 16 | if (timeoutID.current) { 17 | clearTimeout(timeoutID.current) 18 | } 19 | 20 | setShow(true) 21 | 22 | timeoutID.current = setTimeout(() => { 23 | setShow(false) 24 | }, 5000) 25 | } 26 | 27 | useEffect(() => { 28 | animate() 29 | 30 | // Cleanup when component unmounts 31 | return () => { 32 | if (timeoutID.current) { 33 | clearTimeout(timeoutID.current) 34 | } 35 | } 36 | }, []) 37 | 38 | useEffect(() => { 39 | animate() 40 | 41 | // Cleanup the timeout when queueItem changes 42 | return () => { 43 | if (timeoutID.current) { 44 | clearTimeout(timeoutID.current) 45 | } 46 | } 47 | }, [queueItem.queueId]) 48 | 49 | return ( 50 | 60 |
61 |
62 | 67 |
68 | {queueItem.userDisplayName} 69 |
70 |
71 |
72 |
73 | ) 74 | } 75 | 76 | export default UpNow 77 | -------------------------------------------------------------------------------- /src/routes/Library/views/LibraryView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import ArtistList from '../components/ArtistList/ArtistList' 4 | import SearchResults from '../components/SearchResults/SearchResults' 5 | import TextOverlay from 'components/TextOverlay/TextOverlay' 6 | import Spinner from 'components/Spinner/Spinner' 7 | import styles from './LibraryView.css' 8 | import { Artist, Song } from 'shared/types' 9 | import { type UIState } from 'store/modules/ui' 10 | 11 | interface LibraryViewProps { 12 | isAdmin: boolean 13 | isLoading: boolean 14 | isSearching: boolean 15 | isEmpty: boolean 16 | artists: Record 17 | songs: Record 18 | starredArtistCounts: Record 19 | queuedSongs: number[] 20 | starredSongs: number[] 21 | expandedArtists: number[] 22 | alphaPickerMap: Record 23 | scrollTop: number 24 | ui: UIState 25 | // SearchResults view 26 | songsResult: number[] 27 | artistsResult: number[] 28 | filterKeywords: string[] 29 | filterStarred: boolean 30 | expandedArtistResults: number[] 31 | // Actions 32 | toggleArtistExpanded: (artistId: number) => void 33 | toggleArtistResultExpanded: (artistId: number) => void 34 | scrollArtists: (scrollTop: number) => void 35 | showSongInfo: (songId: number) => void 36 | closeSongInfo: () => void 37 | } 38 | 39 | const LibraryView = (props: LibraryViewProps) => { 40 | const { isAdmin, isEmpty, isLoading, isSearching } = props 41 | 42 | return ( 43 | <> 44 | {!isSearching && } 45 | 46 | {isSearching && } 47 | 48 | {isLoading && } 49 | 50 | {!isLoading && isEmpty && ( 51 | 52 |

Library Empty

53 | {isAdmin && ( 54 |

55 | Add media folders 56 | {' '} 57 | to get started. 58 |

59 | )} 60 |
61 | )} 62 | 63 | ) 64 | } 65 | 66 | export default LibraryView 67 | -------------------------------------------------------------------------------- /docs/assets/css/logo.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | font-size: 2rem; 3 | line-height: 1.15; // from app's style 4 | text-align: center; 5 | white-space: nowrap; 6 | padding-top: .5rem; 7 | } 8 | 9 | .logo a { 10 | font-family: 'Beon', sans-serif; 11 | border: none; 12 | color: hsl(var(--hue-blue), 10%, 80%) !important; 13 | display: inline-block; 14 | font-size: 200%; 15 | text-transform: uppercase; 16 | text-decoration: none; 17 | margin: 1.75rem 0; 18 | text-shadow: 19 | 0 0 .1em hsl(var(--hue-pink), 100%, 100%), 20 | 0 0 .5em hsl(var(--hue-pink), 100%, 100%), 21 | 0 0 .25em hsl(var(--hue-pink), 100%, 50%), 22 | 0 0 .5em hsl(var(--hue-pink), 100%, 50%), 23 | 0 0 .75em hsl(var(--hue-pink), 100%, 50%), 24 | 0 0 1em hsl(var(--hue-pink), 100%, 50%), 25 | 0 0 1.5em hsl(var(--hue-pink), 100%, 50%), 26 | 0 0 3em hsl(var(--hue-pink), 100%, 50%), 27 | 0 0 6em hsl(var(--hue-pink), 100%, 50%), 28 | 0 0 9em hsl(var(--hue-pink), 100%, 50%); 29 | } 30 | 31 | .logo-eternal { 32 | font-size: 40%; 33 | text-transform: uppercase; 34 | display: block; 35 | letter-spacing: .9em; 36 | border: .1em solid hsl(var(--hue-blue), 100%, 90%); 37 | border-radius: .25em; 38 | padding: .25em; 39 | text-shadow: 40 | 0 0 .1em hsl(var(--hue-blue), 100%, 90%), 41 | 0 0 .25em hsl(var(--hue-blue), 100%, 90%), 42 | 0 0 .25em hsl(var(--hue-blue), 100%, 50%), 43 | 0 0 .5em hsl(var(--hue-blue), 100%, 50%); 44 | box-shadow: 45 | 0 0 .1em hsl(var(--hue-blue), 100%, 90%), 46 | 0 0 .25em hsl(var(--hue-blue), 100%, 90%), 47 | 0 0 .5em hsl(var(--hue-blue), 100%, 50%), 48 | 0 0 .75em hsl(var(--hue-blue), 100%, 50%), 49 | inset 0 0 .5em hsl(var(--hue-blue), 100%, 50%), 50 | inset 0 0 .75em hsl(var(--hue-blue), 100%, 50%); 51 | } 52 | 53 | .logo-lastChar { 54 | letter-spacing: normal; 55 | } 56 | 57 | .logo .tagline { 58 | font-family: 'Raleway', sans-serif; 59 | font-size: 1rem; 60 | font-weight: 200; 61 | text-align: center; 62 | text-shadow: var(--text-shadow-glow); 63 | } 64 | 65 | @media (min-width: 60rem) { 66 | .logo { 67 | font-size: 36px; 68 | } 69 | 70 | .logo a { 71 | margin-top: 0; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js' 4 | import tseslint from 'typescript-eslint' 5 | import stylistic from '@stylistic/eslint-plugin' 6 | import pluginReact from 'eslint-plugin-react' 7 | import pluginReactHooks from 'eslint-plugin-react-hooks' 8 | import pluginRefresh from 'eslint-plugin-react-refresh' 9 | import pluginPromise from 'eslint-plugin-promise' 10 | import pluginNode from 'eslint-plugin-n' 11 | import globals from 'globals' 12 | 13 | export default tseslint.config( 14 | // global config 15 | eslint.configs.recommended, 16 | ...tseslint.configs.recommended, 17 | stylistic.configs.recommended, 18 | pluginPromise.configs['flat/recommended'], 19 | { 20 | plugins: { 21 | '@stylistic': stylistic, 22 | '@typescript-eslint': tseslint.plugin, 23 | }, 24 | rules: { 25 | '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }], 26 | '@stylistic/space-before-function-paren': ['error', 'always'], 27 | }, 28 | }, 29 | { 30 | ignores: ['build/**', 'docs/**', 'dist/**', 'node_modules/**'], 31 | }, 32 | // client-only config 33 | { 34 | files: ['src/**/*.{js,jsx,ts,tsx}', 'shared/**/*.{js,ts}'], 35 | plugins: { 36 | 'react': pluginReact, 37 | 'react-hooks': pluginReactHooks, 38 | 'react-refresh': pluginRefresh, 39 | }, 40 | rules: { 41 | ...pluginReact.configs.flat.recommended.rules, 42 | ...pluginReact.configs.flat['jsx-runtime'].rules, 43 | ...pluginReactHooks.configs.recommended.rules, 44 | 'react-refresh/only-export-components': 'error', 45 | '@stylistic/jsx-quotes': ['error', 'prefer-single'], 46 | }, 47 | languageOptions: { 48 | parser: tseslint.parser, 49 | globals: globals.browser, 50 | }, 51 | settings: { 52 | react: { 53 | version: 'detect', 54 | }, 55 | }, 56 | }, 57 | // server-only config 58 | { 59 | files: ['server/**/*.js', 'config/webpack.config.js'], 60 | ...pluginNode.configs['flat/recommended-script'], 61 | languageOptions: { 62 | globals: globals.node, 63 | parserOptions: { 64 | ecmaVersion: 2022, 65 | }, 66 | }, 67 | }, 68 | ) 69 | -------------------------------------------------------------------------------- /src/routes/Account/components/Users/EditUser/EditUser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { useAppDispatch } from 'store/hooks' 3 | import { createUser, removeUser, updateUser } from '../../../modules/users' 4 | import Button from 'components/Button/Button' 5 | import Modal from 'components/Modal/Modal' 6 | import AccountForm from '../../AccountForm/AccountForm' 7 | import { User } from 'shared/types' 8 | import styles from './EditUser.css' 9 | 10 | interface EditUserProps { 11 | user?: User 12 | onClose: () => void 13 | } 14 | 15 | const EditUser = (props: EditUserProps) => { 16 | const dispatch = useAppDispatch() 17 | const handleSubmit = useCallback((data: FormData) => { 18 | if (props.user) dispatch(updateUser({ userId: props.user.userId, data })) 19 | else dispatch(createUser(data)) 20 | }, [dispatch, props.user]) 21 | 22 | const handleRemoveClick = useCallback(() => { 23 | if (confirm(`Remove user "${props.user.username}"?\n\nTheir queued songs will also be removed.`)) { 24 | dispatch(removeUser(props.user.userId)) 25 | } 26 | }, [dispatch, props.user]) 27 | 28 | return ( 29 | 34 | 35 |
36 | {!props.user && ( 37 | 40 | )} 41 | 42 | {props.user && ( 43 | 46 | )} 47 | 48 | {props.user && ( 49 | 52 | )} 53 | 54 | 57 |
58 |
59 |
60 | ) 61 | } 62 | 63 | export default EditUser 64 | -------------------------------------------------------------------------------- /src/store/modules/songInfo.ts: -------------------------------------------------------------------------------- 1 | import { createAction, createAsyncThunk, createReducer } from '@reduxjs/toolkit' 2 | import HttpApi from 'lib/HttpApi' 3 | import { 4 | SONG_INFO_REQUEST, 5 | SONG_INFO_SET_PREFERRED, 6 | SONG_INFO_CLOSE, 7 | } from 'shared/actionTypes' 8 | import { Media } from 'shared/types' 9 | 10 | const api = new HttpApi() 11 | 12 | // ------------------------------------ 13 | // Actions 14 | // ------------------------------------ 15 | export const showSongInfo = createAsyncThunk( 16 | SONG_INFO_REQUEST, 17 | async (songId: number) => await api.get<{ result: number[], entities: Media[] }>(`song/${songId}`), 18 | ) 19 | 20 | export const closeSongInfo = createAction(SONG_INFO_CLOSE) 21 | 22 | export const setPreferredSong = createAsyncThunk( 23 | SONG_INFO_SET_PREFERRED, 24 | async ({ 25 | songId, 26 | mediaId, 27 | isPreferred, 28 | }: Pick, thunkAPI) => { 29 | await api.request(isPreferred ? 'PUT' : 'DELETE', `media/${mediaId}/prefer`) 30 | thunkAPI.dispatch(showSongInfo(songId)) 31 | }, 32 | ) 33 | 34 | // ------------------------------------ 35 | // Reducer 36 | // ------------------------------------ 37 | interface SongInfoState { 38 | isLoading: boolean 39 | isVisible: boolean 40 | songId: number | null 41 | media: { result: number[], entities: Record } 42 | } 43 | 44 | const initialState: SongInfoState = { 45 | isLoading: false, 46 | isVisible: false, 47 | songId: null, 48 | media: { result: [], entities: {} }, 49 | } 50 | 51 | const songInfoReducer = createReducer(initialState, (builder) => { 52 | builder 53 | .addCase(showSongInfo.pending, (state, { meta }) => { 54 | state.isLoading = true 55 | state.isVisible = true 56 | state.songId = meta.arg 57 | }) 58 | .addCase(showSongInfo.fulfilled, (state, { payload }) => { 59 | state.isLoading = false 60 | state.media = payload 61 | }) 62 | .addCase(showSongInfo.rejected, (state) => { 63 | state.isLoading = false 64 | state.isVisible = false 65 | }) 66 | .addCase(closeSongInfo, (state) => { 67 | state.isVisible = false 68 | }) 69 | }) 70 | 71 | export default songInfoReducer 72 | -------------------------------------------------------------------------------- /src/components/App/Routes/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Routes, Route, Navigate, useLocation } from 'react-router-dom' 3 | import { useAppSelector } from 'store/hooks' 4 | 5 | import AccountView from 'routes/Account/views/AccountView' 6 | import LibraryView from 'routes/Library/views/LibraryViewContainer' // @todo 7 | import QueueView from 'routes/Queue/views/QueueView' 8 | const PlayerView = React.lazy(() => import('routes/Player/views/PlayerView')) 9 | 10 | const AppRoutes = () => ( 11 | 12 | } /> 13 | 17 | 18 | 19 | )} 20 | /> 21 | 25 | 26 | 27 | )} 28 | /> 29 | 33 | 34 | 35 | )} 36 | /> 37 | 47 | )} 48 | /> 49 | 50 | ) 51 | 52 | export default AppRoutes 53 | 54 | interface RequireAuthProps { 55 | children: React.ReactNode 56 | path: string 57 | redirectTo: string 58 | } 59 | 60 | const RequireAuth = ({ 61 | children, 62 | path, 63 | redirectTo, 64 | }: RequireAuthProps) => { 65 | const isAuthenticated = useAppSelector(state => state.user.userId !== null) 66 | const location = useLocation() 67 | 68 | if (!isAuthenticated) { 69 | // set their originally-desired location in query parameter 70 | const params = new URLSearchParams(location.search) 71 | params.set('redirect', path) 72 | 73 | return 74 | } 75 | 76 | return children 77 | } 78 | -------------------------------------------------------------------------------- /src/routes/Library/components/LibraryHeader/LibraryHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Button from 'components/Button/Button' 3 | import styles from './LibraryHeader.css' 4 | 5 | interface LibraryHeaderProps { 6 | filterStr: string 7 | filterStarred: boolean 8 | // actions 9 | setFilterStr(search: string): void 10 | resetFilterStr(): void 11 | toggleFilterStarred(): void 12 | } 13 | 14 | class LibraryHeader extends React.Component { 15 | searchInput = React.createRef() 16 | state = { 17 | value: this.props.filterStr, 18 | } 19 | 20 | handleChange = (event: React.ChangeEvent) => { 21 | this.setState({ value: event.target.value }) 22 | this.props.setFilterStr(event.target.value) 23 | } 24 | 25 | handleMagnifierClick = () => { 26 | if (this.state.value.trim()) this.clearSearch() 27 | else this.searchInput.current.focus() 28 | } 29 | 30 | clearSearch = () => { 31 | this.setState({ value: '' }) 32 | this.props.resetFilterStr() 33 | } 34 | 35 | render () { 36 | const { filterStr, filterStarred } = this.props 37 | 38 | return ( 39 |
40 |
70 | ) 71 | } 72 | } 73 | 74 | export default LibraryHeader 75 | -------------------------------------------------------------------------------- /server/watcherWorker.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import pathLib from 'path' 3 | import { initLogger } from './lib/Log.js' 4 | import accumulatedThrottle from './lib/accumulatedThrottle.js' 5 | import fileTypes from './Media/fileTypes.js' 6 | import { 7 | WATCHER_WORKER_EVENT, 8 | WATCHER_WORKER_WATCH, 9 | } from '../shared/actionTypes.ts' 10 | 11 | const env = JSON.parse(process.env.KES_ENV_JSON) 12 | const log = initLogger('scanner', { 13 | console: { 14 | level: env.KES_SCANNER_CONSOLE_LEVEL ?? (env.NODE_ENV === 'development' ? 5 : 4), 15 | useStyles: env.KES_CONSOLE_COLORS ?? undefined, 16 | }, 17 | file: { 18 | level: env.KES_SCANNER_LOG_LEVEL ?? (env.NODE_ENV === 'development' ? 0 : 3), 19 | }, 20 | }).scope(`watcher[${process.pid}]`) 21 | 22 | const refs = [] 23 | const searchExts = Object.keys(fileTypes).filter(ext => fileTypes[ext].scan !== false) 24 | 25 | ;(async function () { 26 | const { default: IPC } = await import('./lib/IPCBridge.js') 27 | 28 | IPC.use({ 29 | [WATCHER_WORKER_WATCH]: ({ payload }) => { 30 | while (refs.length) { 31 | const ref = refs.shift() 32 | ref.close() 33 | } 34 | 35 | const { result, entities } = payload.paths 36 | const pathIds = result.filter(pathId => entities[pathId]?.prefs?.isWatchingEnabled) 37 | 38 | if (!pathIds.length) { 39 | log.info('no paths with watching enabled; exiting') 40 | process.exit(0) // eslint-disable-line n/no-process-exit 41 | } 42 | 43 | log.info('watching %s path(s):', pathIds.length) 44 | 45 | pathIds.forEach((pathId) => { 46 | log.info(' => %s', entities[pathId].path) 47 | 48 | const cb = accumulatedThrottle((events) => { 49 | const event = events.find(([, filename]) => searchExts.includes(pathLib.extname(filename).toLowerCase())) 50 | if (!event) return 51 | 52 | log.info('event in path: %s (filename=%s) (type=%s)', entities[pathId].path, event[1], event[0]) 53 | 54 | IPC.send({ 55 | type: WATCHER_WORKER_EVENT, 56 | payload: { pathId }, 57 | }) 58 | }, 1000) 59 | 60 | const ref = fs.watch(entities[pathId].path, { recursive: true }, cb) 61 | refs.push(ref) 62 | }) 63 | }, 64 | }) 65 | })() 66 | -------------------------------------------------------------------------------- /src/routes/Queue/views/QueueView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react' 2 | import { useAppSelector } from 'store/hooks' 3 | import { ensureState } from 'redux-optimistic-ui' 4 | import { Link } from 'react-router-dom' 5 | import getRoundRobinQueue from '../selectors/getRoundRobinQueue' 6 | import QueueList from '../components/QueueList/QueueList' 7 | import Spinner from 'components/Spinner/Spinner' 8 | import TextOverlay from 'components/TextOverlay/TextOverlay' 9 | import styles from './QueueView.css' 10 | 11 | const QUEUE_ITEM_HEIGHT = 92 12 | 13 | const QueueView = () => { 14 | const { innerWidth, innerHeight, headerHeight, footerHeight } = useAppSelector(state => state.ui) 15 | const isInRoom = useAppSelector(state => !!state.user.roomId) 16 | const isLoading = useAppSelector(state => ensureState(state.queue).isLoading) 17 | const queue = useAppSelector(getRoundRobinQueue) 18 | const queueId = useAppSelector(state => state.status.queueId) 19 | const containerRef = useRef(null) 20 | 21 | // ensure current song is in view on first mount only 22 | useEffect(() => { 23 | if (containerRef.current) { 24 | const i = queue.result.indexOf(queueId) 25 | containerRef.current.scrollTop = QUEUE_ITEM_HEIGHT * i 26 | } 27 | }, [queue.result, queueId]) 28 | 29 | return ( 30 |
40 | {!isInRoom && ( 41 | 42 |

Get a Room!

43 |

44 | Sign in to a room 45 | {' '} 46 | to start queueing songs. 47 |

48 |
49 | )} 50 | 51 | {isLoading && } 52 | 53 | {!isLoading && queue.result.length === 0 && ( 54 | 55 |

Queue Empty

56 |

57 | Tap a song in the 58 | {' '} 59 | library 60 | {' '} 61 | to queue it. 62 |

63 |
64 | )} 65 | 66 | 67 |
68 | ) 69 | } 70 | 71 | export default QueueView 72 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { Action, configureStore, ThunkAction, UnknownAction } from '@reduxjs/toolkit' 2 | import rootReducer from './reducers' 3 | import socket from 'lib/socket' 4 | import createSocketMiddleware from './socketMiddleware' 5 | import createThrottle from 'redux-throttle' 6 | import { 7 | FLUSH, 8 | REHYDRATE, 9 | PAUSE, 10 | PERSIST, 11 | PURGE, 12 | REGISTER, 13 | } from 'redux-persist' 14 | import { windowResize } from './modules/ui' 15 | 16 | // resize action 17 | window.addEventListener('resize', () => store.dispatch(windowResize({ 18 | innerWidth: window.innerWidth, 19 | innerHeight: window.innerHeight, 20 | }))) 21 | 22 | export interface OptimisticAction extends Action { 23 | meta: { 24 | isOptimistic?: boolean 25 | } 26 | } 27 | 28 | // ====================================================== 29 | // Middleware Configuration 30 | // ====================================================== 31 | const throttle = createThrottle(1000, { 32 | // https://lodash.com/docs#throttle 33 | leading: true, 34 | trailing: true, 35 | }) 36 | 37 | const socketMiddleware = createSocketMiddleware(socket, 'server/') 38 | 39 | // ====================================================== 40 | // Store Instantiation and HMR Setup 41 | // ====================================================== 42 | const store = configureStore({ 43 | reducer: rootReducer, 44 | middleware: getDefaultMiddleware => getDefaultMiddleware({ 45 | // https://redux-toolkit.js.org/usage/usage-guide#use-with-redux-persist 46 | serializableCheck: { 47 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], 48 | }, 49 | }).concat(throttle, socketMiddleware), 50 | }) 51 | 52 | // @todo: this doesn't handle dynamically injected (lazy-loaded) reducers 53 | if (module.hot) { 54 | module.hot.accept('./reducers', async () => { 55 | const { default: combinedReducer } = await import('./reducers') 56 | store.replaceReducer(combinedReducer) 57 | }) 58 | } 59 | 60 | // Infer the `RootState` and `AppDispatch` types from the store itself 61 | export type RootState = ReturnType 62 | export type AppDispatch = typeof store.dispatch 63 | 64 | // generic type for non-async thunks 65 | export type AppThunk = ThunkAction< 66 | ReturnType, 67 | RootState, 68 | unknown, 69 | UnknownAction 70 | > 71 | 72 | export default store 73 | -------------------------------------------------------------------------------- /src/routes/Account/components/Rooms/EditRoom/UserPrefs/UserPrefs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import { useAppSelector } from 'store/hooks' 3 | import Accordion from 'components/Accordion/Accordion' 4 | import InputCheckbox from 'components/InputCheckbox/InputCheckbox' 5 | import Icon from 'components/Icon/Icon' 6 | import type { IRoomPrefs } from 'shared/types' 7 | import styles from './UserPrefs.css' 8 | 9 | interface UserPrefsProps { 10 | prefs: Partial 11 | onChange: (prefs: Partial) => void 12 | } 13 | 14 | const UserPrefs = ({ onChange, prefs = {} }: UserPrefsProps) => { 15 | const roles = useAppSelector(state => state.prefs.roles) 16 | 17 | const getRoleId = useCallback((roleName: string) => { 18 | return roles.result.find(roleId => roles.entities[roleId].name === roleName) 19 | }, [roles.entities, roles.result]) 20 | 21 | const handleChange = useCallback((e: React.ChangeEvent) => { 22 | const { name, checked } = e.target 23 | const roleId = getRoleId(name) 24 | if (!roleId) return 25 | 26 | onChange({ 27 | ...prefs, 28 | roles: { 29 | ...prefs.roles, 30 | [roleId]: { 31 | ...prefs.roles?.[roleId], 32 | allowNew: checked, 33 | }, 34 | }, 35 | }) 36 | }, [getRoleId, onChange, prefs]) 37 | 38 | const allowNewGuest = prefs.roles?.[getRoleId('guest')]?.allowNew ?? false 39 | const allowNewStandard = prefs.roles?.[getRoleId('standard')]?.allowNew ?? false 40 | 41 | return ( 42 | 45 | 46 |
Users
47 | 48 | )} 49 | > 50 |
51 |
52 | 58 |
59 |
60 | 66 |
67 |
68 |
69 | ) 70 | } 71 | 72 | export default UserPrefs 73 | -------------------------------------------------------------------------------- /src/routes/Library/components/ArtistItem/ArtistItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import clsx from 'clsx' 3 | import SongList from '../SongList/SongList' 4 | import Icon from 'components/Icon/Icon' 5 | import Highlighter from 'react-highlight-words' 6 | import ToggleAnimation from 'components/ToggleAnimation/ToggleAnimation' 7 | import styles from './ArtistItem.css' 8 | 9 | interface ArtistItemProps { 10 | artistId: number 11 | artistSongIds: number[] 12 | filterKeywords: string[] 13 | isExpanded: boolean 14 | name: string 15 | numStars: number 16 | queuedSongs: number[] 17 | starredSongs: number[] 18 | style?: object 19 | // actions 20 | onArtistClick(artistId: number): void 21 | } 22 | 23 | class ArtistItem extends React.Component { 24 | render () { 25 | const { props } = this 26 | const isChildQueued = props.artistSongIds.some(songId => props.queuedSongs.includes(songId)) 27 | const isChildStarred = props.artistSongIds.some(songId => props.starredSongs.includes(songId)) 28 | 29 | return ( 30 |
31 |
32 |
33 | 34 | {props.isExpanded && ( 35 |
36 | 37 |
38 | )} 39 | {!props.isExpanded &&
{props.artistSongIds.length}
} 40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 | 48 | {props.isExpanded && ( 49 | 55 | )} 56 |
57 | ) 58 | } 59 | 60 | handleArtistClick = () => { 61 | this.props.onArtistClick(this.props.artistId) 62 | } 63 | } 64 | 65 | export default ArtistItem 66 | -------------------------------------------------------------------------------- /src/components/PaddedList/PaddedList.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react' 2 | import { VariableSizeList as List, ListOnScrollProps } from 'react-window' 3 | import styles from './PaddedList.css' 4 | 5 | interface PaddedListProps { 6 | numRows: number 7 | onScroll?(props: ListOnScrollProps): void 8 | onRef?(ref: List): void 9 | paddingTop: number 10 | paddingRight?: number 11 | paddingBottom: number 12 | rowRenderer(props: { index: number, style: CSSProperties }): React.ReactNode 13 | rowHeight(index: number): number 14 | width: number 15 | height: number 16 | } 17 | 18 | class PaddedList extends React.Component { 19 | list: List | null = null 20 | 21 | componentDidMount () { 22 | if (this.props.onRef) { 23 | this.props.onRef(this.list) 24 | } 25 | } 26 | 27 | render () { 28 | return ( 29 | 39 | {this.rowRenderer} 40 | 41 | ) 42 | } 43 | 44 | componentDidUpdate (prevProps: PaddedListProps) { 45 | const { paddingTop, paddingBottom } = this.props 46 | if (paddingTop !== prevProps.paddingTop || paddingBottom !== prevProps.paddingBottom) { 47 | this.list.resetAfterIndex(0) 48 | } 49 | } 50 | 51 | rowRenderer = ({ index, style }: { index: number, style: CSSProperties }) => { 52 | // top & bottom spacer 53 | if (index === 0 || index === this.props.numRows + 1) { 54 | return ( 55 |
56 | ) 57 | } 58 | 59 | return this.props.rowRenderer({ 60 | index: --index, 61 | style: { ...style, paddingRight: this.props.paddingRight }, 62 | }) 63 | } 64 | 65 | getItemSize = (index: number) => { 66 | // top & bottom spacer 67 | if (index === 0) { 68 | return this.props.paddingTop 69 | } else if (index === this.props.numRows + 1) { 70 | return this.props.paddingBottom 71 | } else { 72 | index-- 73 | } 74 | 75 | return this.props.rowHeight(index) 76 | } 77 | 78 | setRef = (ref: List) => { 79 | this.list = ref 80 | } 81 | } 82 | 83 | export default PaddedList 84 | -------------------------------------------------------------------------------- /src/routes/Player/components/Player/MP4Player/MP4Player.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './MP4Player.css' 3 | 4 | interface MP4PlayerProps { 5 | isPlaying: boolean 6 | mediaId: number 7 | mediaKey: number 8 | width: number 9 | height: number 10 | onAudioElement(video: HTMLVideoElement): void 11 | // media events 12 | onEnd(): void 13 | onError(error: string): void 14 | onLoad(): void 15 | onPlay(): void 16 | onStatus(status: { position: number }): void 17 | } 18 | 19 | class MP4Player extends React.Component { 20 | video = React.createRef() 21 | 22 | componentDidMount () { 23 | this.props.onAudioElement(this.video.current) 24 | this.updateSources() 25 | } 26 | 27 | componentDidUpdate (prevProps: MP4PlayerProps) { 28 | if (prevProps.mediaKey !== this.props.mediaKey) { 29 | this.updateSources() 30 | } 31 | 32 | if (prevProps.isPlaying !== this.props.isPlaying) { 33 | this.updateIsPlaying() 34 | } 35 | } 36 | 37 | render () { 38 | const { width, height } = this.props 39 | 40 | return ( 41 |