├── .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 | ©{{ now.Format "2006" }} RadRoot LLC
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
4 | {{ range .Pages }}
5 | -
6 | {{ .Title }}
7 |
8 | {{ end }}
9 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
11 |
12 |
13 |
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 |
8 |
9 |
10 |
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 |
15 |
16 | clsx(isActive && styles.active)}>
17 |
22 |
23 | clsx(isActive && styles.active)}>
24 |
29 |
30 |
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 |
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 |
49 |
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 |
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 |
41 |
47 |
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 |
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 |
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 |
46 |
54 | {filterStr && (
55 |
61 | )}
62 |
69 |
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 |
54 | )
55 | }
56 |
57 | updateSources = () => {
58 | this.video.current.src = `${document.baseURI}api/media/${this.props.mediaId}?type=video`
59 | this.video.current.load()
60 | }
61 |
62 | updateIsPlaying = () => {
63 | if (this.props.isPlaying) {
64 | this.video.current.play()
65 | .catch(err => this.props.onError(err.message))
66 | } else {
67 | this.video.current.pause()
68 | }
69 | }
70 |
71 | /*
72 | *