Compare commits

...

372 Commits

Author SHA1 Message Date
jeffvli d51ca37e1b Bump to v0.5.0 2023-10-31 03:10:51 -07:00
jeffvli 9d8dcc7ade Add weblate notice 2023-10-31 00:24:29 -07:00
Jeff 8430b1ec95 Add localization support (#333)
* Add updated i18n config and en locale
2023-10-30 19:22:45 -07:00
Martin Pauli 11863fd4c1 Fix clear rating hotkey (#338) 2023-10-30 18:50:48 -07:00
Nicholas Malcolm cf9c7e2640 Build docker container for arm (#336)
* Build all supported container platforms

* Remove less popular platforms
2023-10-30 18:50:21 -07:00
Kendall Garner 9d780e0342 [bugfix]: prevent default (#334)
* [bugfix]: prevent default on rating
2023-10-28 21:10:52 -07:00
Kendall Garner 4ec981df83 [bugfix/feature]: Improve ratings (#332)
* [bugfix/feature]: Improve ratings

Fix: add preventDefault/stopPropagation to prevent scrolling to top in queue
Feat: instead of double click for clear, click on same value
2023-10-28 20:00:01 -07:00
jeffvli e5564c2ac2 Add additional dependencies to linux build (#320) 2023-10-28 16:51:07 -07:00
Martin Pauli 7a580c2c65 Add favorite hotkey options (#326)
* Add favorite hotkey options

* Update wording

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-10-27 18:22:16 -07:00
jeffvli ac84088c69 Set owner field edit to Navidrome only (#327) 2023-10-26 16:42:21 -07:00
jeffvli 3c2e4d40ec Update play button for dynamic theme 2023-10-23 15:45:47 -07:00
jeffvli fdff79496a Set pause status on last track end (#291) 2023-10-23 09:02:48 -07:00
jeffvli ccfadda729 Add play count to jellyfin album sort (#324) 2023-10-23 08:37:04 -07:00
jeffvli f21b8d6bbd Update base button styles
- Use brightness filter for hover/focus styles
- Re-add default active style
2023-10-23 08:24:23 -07:00
jeffvli 244c00c4c6 Add discord rich presence (#72) 2023-10-23 06:58:39 -07:00
Kendall Garner 2664a80851 Support changing playback rate (#275)
* initial idea for playback rate

* Add transparency to dropdown

* Move playback speed component to right controls

* Set mpv speed on startup

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 17:47:44 -07:00
Kendall Garner 742b13d65e [Feature] Support changing accent/primary color (#282)
* [Feature] Support changing accent/primary color

- adds color picker to settings with five swatches (blue default, yellow green and red imported from sonixd, purple new)
- changing color will change the appropriate css variable

* Remove hover styles that use an alternate primary

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 17:46:28 -07:00
jeffvli 8dcd49d574 Fix add to playlist from artist page (#296) 2023-10-22 16:18:55 -07:00
jeffvli 02c8cbcad6 Revert jellyfin getPlaylistList implementation (#272) 2023-10-22 16:00:41 -07:00
jeffvli 86fb52f6d4 Fix current song row when queue is empty 2023-10-22 15:57:15 -07:00
Kendall Garner 452ef783f2 [bugfix/feat]: always fetch artist image for Navidrome (#317)
* [bugfix/feat]: always fetch artist image for Navidrome

* Add error fallback to library header image

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 15:46:48 -07:00
Kendall Garner 74cab01013 [feature]: Support using system fonts (#304)
* [feature]: Support using system fonts

Uses the **experimental** queryLocalFonts API, when prompted, to get the fonts and do CSS.
Resolves #270 and #288 (by proxy)

Caveats/notes:
- This is experimental, and is only supported by Chrome/Chromium/Edgeium (see https://caniuse.com/?search=querylocalfonts)
- As far as I can tell, the only way to dynamically change the font (shown in https://wicg.github.io/local-font-access/#example-style-with-local-fonts) was by DOM manipulation; css variables did not seem to work
- This shows **all** fonts, including their variants (bold/italic/etc); given that the style names could be localized, not sure of a way to parse this (on my system, for instance, I had 859 different combinations)
- I made fonts a separate top-level setting because it was easier to manipulate without causing as many rerenders; feel free to put that back

* add permission chec

* add electron magic to support custom font

* restrict content types
2023-10-22 15:25:17 -07:00
Kendall Garner e6ed9229c2 [bugfix]: fix queue offset when removing tracks (#301)
* [bugfix]: fix queue offset when removing tracks

* Fix song index numbers when removing songs

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-22 15:21:31 -07:00
jeffvli 3a144ab821 Fix query editor not scrolling (#322) 2023-10-22 15:09:48 -07:00
jeffvli 913e89b01b Set column defs on play queue to use correct row index 2023-10-19 04:33:09 -07:00
jeffvli 768a88de8f Fix row refresh on status change for current song 2023-10-19 03:37:17 -07:00
jeffvli 8e2a107d4a Fix className clash on current song 2023-10-19 03:36:16 -07:00
jeffvli e77efcf836 Add artist name to window title 2023-10-18 20:49:50 -07:00
jeffvli 818f155993 Hide playing icon if player is paused 2023-10-18 19:55:57 -07:00
jeffvli b28fe4cbc9 Convert play icon from base64 to svg 2023-10-18 19:51:55 -07:00
Kendall Garner 8a53fab751 add more emphasis to current song (#283)
* add more emphasis to current song

* add css indicator (rivolumelineup)

* don't use absolute position, support album track number

* Respect order of set-queue function (fix race condition)

* Fix table row actions button on album detail and play queue

* Fix album detail table customizations

* Bump to v0.4.1

* Fix opacity mask for unsynced lyrics container

* Separate sidebar icons to new component

- Fixes react render issue

* Add app focus hook

* Remove css play image

* Add player status as cell refresh condition for queue

* Add current song images

* Add current song styles for all song tables

* Revert row index cell width

* Remove animated svg on browser

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-10-18 18:32:11 -07:00
Kendall Garner 9964f95d5d [Remote] Full PWA support, misc bugfixes (#280)
- Fix setting remote port properly
- Add web worker support (so it can be installed as an "app")
- build fixes/removing stray console.log
2023-10-18 10:49:09 -07:00
Kendall Garner fe298d3232 Reset Carousel position on data refresh (#303)
* Reset Carousel position on data refresh

* add refresh for all carousels
2023-10-18 10:47:55 -07:00
Kendall Garner 03e582f301 [feature]: support running feishin on custom path (#307)
* [feature]: support running feishin on custom path

* add details in readme
2023-10-17 22:10:53 -07:00
Kendall Garner d7b3d5c0bd [bugfix]: do not duplicate tracks when adding to multiple playlists (#300) 2023-10-17 16:11:14 -07:00
Kendall Garner 5fdf4c06f9 properly implement Jellyfin getSongDetail (#298) 2023-10-17 16:05:44 -07:00
TacoCake c7aa5d09c9 In the fullscreen player use dynamic resolution for the main image (#290)
* In the fullscreen player use dynamic resolution for the main image

* Use ceil instead of round

* Add types and lint

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-10-17 06:47:50 -07:00
Kendall Garner f4f73289c9 [bugfix]: remove ignore CORS/SSL switches from web version (#305) 2023-10-17 06:21:36 -07:00
Lily Rose ac7ec133db Replace hardcoded Jellyfin authentication DeviceId to include hostname and username (#312) 2023-10-17 06:21:15 -07:00
Kendall Garner 1a948ab86b fix artist discography year filter (#299) 2023-10-17 06:05:36 -07:00
Kendall Garner f6667a39a0 fix toggle replay (#292) 2023-10-17 05:49:29 -07:00
jeffvli cbeb4ab7d8 Separate sidebar icons to new component
- Fixes react render issue
2023-10-17 05:46:42 -07:00
jeffvli 3675146f1f Fix opacity mask for unsynced lyrics container 2023-10-07 19:58:04 -07:00
jeffvli 946f4ff306 Bump to v0.4.1 2023-10-07 19:06:30 -07:00
jeffvli 277669c413 Fix album detail table customizations 2023-10-07 18:11:02 -07:00
jeffvli 49b6478b72 Fix table row actions button on album detail and play queue 2023-10-07 17:32:59 -07:00
jeffvli ca39409cc3 Respect order of set-queue function (fix race condition) 2023-10-07 16:46:23 -07:00
jeffvli cca6fa21db Adjust scrobble duration to check in ms 2023-10-05 22:11:48 -07:00
jeffvli 5e1059870c Fix second song on startup not playing 2023-10-05 21:54:11 -07:00
Kendall Garner 6bac172bbe fix scrobble durations (#269)
* fix scrobble durations

* Fix scrobble condition on last song in queue, normalize ms

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-10-05 21:45:47 -07:00
Kendall Garner 118a9f73d1 fix unsynced lyrics (#279) 2023-10-04 22:02:42 -07:00
jeffvli c464be8cea Fix quit functionality (#184) 2023-09-27 02:37:03 -07:00
jeffvli 3bbe696f4c Update react-router and add useTransition support 2023-09-25 16:13:27 -07:00
jeffvli f7cacd2b73 Remove page fade in transition 2023-09-25 16:12:51 -07:00
jeffvli 62794623a3 Fix tracks list refresh on search 2023-09-25 15:57:48 -07:00
Kendall Garner 9e3e038d42 [Remote] Actually fix auth (#260)
* fix favicon, basic auth

* actual fix......
2023-09-24 17:31:33 -07:00
jeffvli b375238baf Bump to v0.4.0 2023-09-24 17:23:39 -07:00
Kendall Garner 02b06a07be fix favicon, basic auth (#259) 2023-09-24 17:02:25 -07:00
Kendall Garner d7f21b3c6b special socket for dev; defer to default otherwise (#258)
* special socket for dev; defer to default otherwise

* Add write-all permissions to docker push

* special socket for dev; defer to default otherwise

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-24 16:56:45 -07:00
jeffvli 1113ef972f Remove docker push on pr 2023-09-24 16:54:43 -07:00
jeffvli 46a2c29b22 Add write-all permissions to docker push 2023-09-23 22:36:56 -07:00
jeffvli ebcb7bc4d1 Add path param for ND song list api 2023-09-23 15:37:16 -07:00
jeffvli 0b62bee3a6 Add grid view for tracks (#128) 2023-09-23 15:36:57 -07:00
jeffvli d3503af12c Add song count column to albums list 2023-09-23 04:05:15 -07:00
jeffvli 571ea3c653 Add rating hotkeys (#208) 2023-09-23 03:20:04 -07:00
jeffvli f0e518d3c8 Add quit button to menu (#184) 2023-09-22 18:04:15 -07:00
jeffvli 47dc83f360 Make collapsed sidebar navigation configurable 2023-09-22 17:55:03 -07:00
jeffvli 8cbc25a932 Add browser forward/back hotkeys (#155) 2023-09-22 17:52:00 -07:00
jeffvli 0cba405b45 Add navigation buttons to the collapsed sidebar (#203) 2023-09-22 15:33:28 -07:00
jeffvli 45b80ac395 Add discsubtitle for navidrome (#217) 2023-09-22 15:12:23 -07:00
jeffvli 8b0fe69e1c Fix alignment of button leftIcon 2023-09-22 15:11:27 -07:00
jeffvli 25e621372c Parse URLs from note field (#154) 2023-09-22 04:25:16 -07:00
jeffvli 14f4649b93 Move drag container to scrollarea component 2023-09-22 02:40:27 -07:00
jeffvli 1a87adb728 Fix transient props for styled-components v6 2023-09-22 02:34:57 -07:00
jeffvli bb9bf7ba6a Add docker run instructions 2023-09-22 00:31:56 -07:00
jeffvli fb7e7bfa3e Add latest tag 2023-09-21 21:44:03 -07:00
jeffvli a8a14a62c0 Separate auto and manual docker pushes 2023-09-21 21:13:49 -07:00
jeffvli cd836d54db Add docker publish workflow 2023-09-21 20:46:48 -07:00
jeffvli c90c43944d Fix logo path 2023-09-21 20:31:39 -07:00
jeffvli fd7468a4fe Add drag container for web library headers (#206) 2023-09-21 18:46:47 -07:00
jeffvli c4f9868a6b Revert library header line clamp to 2 lines (#215) 2023-09-21 17:52:14 -07:00
jeffvli fbb0907a70 Fix lyrics mask 2023-09-21 17:41:27 -07:00
jeffvli 201ee895f9 Allow css vendor-prefix 2023-09-21 17:41:19 -07:00
jeffvli 51be0153d3 Adjust fullscreen player styles
- Remove opacity on metadata section
- Add text shadow to metadata text
- Add padding under title
- Uppercase artists and album name
2023-09-21 17:35:22 -07:00
jeffvli 29a9a11085 Fix subsonic song duration 2023-09-21 17:35:22 -07:00
Kendall Garner 65f28bb9dc Replaygain support for Web Player (#243)
* replaygain!

* resume context

* don't fire both players

* replaygain for jellyfin

* actually remove console.log

---------

Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-21 17:06:13 -07:00
jeffvli fd264daffc Add new app icon (#232) 2023-09-21 11:24:20 -07:00
Alberto Rodríguez 18e35f2ba9 Added docker image build script (#245)
* Added docker image build script

* Changed to alpine docker and expose port 9180

* Use multi-stage build

---------

Co-authored-by: = <=>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-09-20 18:01:47 -07:00
Kendall Garner 487e9be8ec Invalidate playlist song list on update (#248) 2023-09-20 16:28:59 -07:00
Benjamin d9049ed066 Prevent MPV from loading user config/scripts (#247) 2023-09-20 16:27:36 -07:00
Kendall Garner 6e62448b88 fix other places of duration display (and other minor fixes) (#249)
* fix other places of duration display

* add back comma

* add max-width for image
2023-09-20 16:07:40 -07:00
jeffvli ec457d5125 Lint files based on updated rules 2023-09-15 20:42:38 -07:00
jeffvli d45b01625b Re-add linting for styled-components
- Update styled-components to v6
- Update stylelint to v15
- Add styled-components css plugin
2023-09-15 20:42:03 -07:00
jeffvli 2defa5cc13 Fix seek slider from duration normalizations 2023-09-15 19:31:34 -07:00
jeffvli 9cc9c3a87f Bump electron to v25.8.1 2023-09-15 16:54:17 -07:00
jeffvli 153d8ce6ce Fix nd/jf duration normalizations 2023-09-15 16:52:14 -07:00
jeffvli 5e33212112 Add dedicated refresh button to list views (#235) 2023-09-15 13:47:39 -07:00
jeffvli 7d6990eb90 Add notice regarding broken MPV version 2023-09-15 12:52:03 -07:00
jeffvli d75ea94161 Fix first launch mpv playback (#210) 2023-09-15 03:08:17 -07:00
Kendall Garner 1badecc20a always call autoNext, even if not used (#241) 2023-09-10 15:08:48 -07:00
Kendall Garner c90a56811d [bugfix]: support final lyric with no newline (#240) 2023-09-10 15:07:21 -07:00
nate contino 4e5e3bc9a1 Adjust quarantine bit warning wording to include all Macs running 12+ (#236) 2023-09-10 15:04:24 -07:00
Kendall Garner c8397bb5ef Add transparency/opacity for queue sidebar (#231)
* add opacity

* add background for song metadata

* Add padding and border radius to opacity elements

* Remove font-weight transition on active lyrics (#233)

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
2023-09-10 15:03:46 -07:00
Alberto Rodríguez 0ae53b023c improved client detection (#229)
Co-authored-by: = <=>
2023-09-10 13:01:32 -07:00
Kendall Garner 1acfa93f1a Improve MPV initialization and restore (#222)
- set mpv settings only after it has successfully started (at least on linux, settings were not taken)
- change timing of restore queue to behave properly
2023-08-24 18:28:50 -07:00
jeffvli b60ba27892 Allow reuathentication for jellyfin (#214) 2023-08-24 18:17:20 -07:00
jeffvli 7ddba8ede7 Fix JF song filter import (#223) 2023-08-24 18:10:58 -07:00
jeffvli a8bd53b757 Adjust jellyfin playlist fetch 2023-08-24 18:04:01 -07:00
jeffvli 877b2e9f3b Fix normalized album duration values (#205) 2023-08-11 21:08:13 -07:00
jeffvli 663893dccb Fix missing related artist images 2023-08-09 21:30:27 -07:00
jeffvli 96ace40fc3 Upgrade electron-builder to latest 2023-08-08 10:01:08 -07:00
jeffvli e9de9f5b65 Fix electron build target 2023-08-08 09:52:58 -07:00
jeffvli c92c94cf1a Bump to v0.3.0 2023-08-08 09:44:14 -07:00
jeffvli 1d664bbbd7 Home screen improvements
- Only show spinner on load rather than fetch
- Add refresh button to explore
- Adjust stale times
2023-08-08 09:26:53 -07:00
jeffvli 7c59722f0a Improve genres for jellyfin
- Support music folder
- Add images
- Fix genre filters on album/track filters
2023-08-08 09:26:53 -07:00
jeffvli 3f813b1a26 Make album detail columns customizable 2023-08-08 09:26:53 -07:00
jeffvli 13d6758500 Center playerbar time values 2023-08-08 09:26:53 -07:00
jeffvli 2b6323c396 Fix min-size header image 2023-08-08 09:26:53 -07:00
jeffvli 8338aaf18d Add related genre albums to album detail 2023-08-08 09:26:48 -07:00
jeffvli 5f5c3bbb11 Fix filter on id pages 2023-08-08 01:30:05 -07:00
jeffvli 2a9d30e43d Fix genre grid route 2023-08-08 01:29:12 -07:00
jeffvli d1e5571163 Add initial fetch for all grid views 2023-08-08 01:26:02 -07:00
jeffvli e542fcb8aa Memoize context values 2023-08-08 00:38:32 -07:00
jeffvli 1111fd00a1 Remove web build from electron build 2023-08-08 00:35:29 -07:00
jeffvli 6ae8046781 Fix search params being overriden on table view 2023-08-08 00:33:47 -07:00
jeffvli 689b40eb91 Add web prod build 2023-08-08 00:23:46 -07:00
jeffvli b3bdff446d Add vercel rewrite 2023-08-08 00:23:38 -07:00
jeffvli 8686a7c592 Various lyrics improvements
- Update wording on provider fetcher
- Invalidate query on reset instead of resetQuery
2023-08-07 21:59:55 -07:00
jeffvli 59a851f8c8 Add new version dialog 2023-08-07 21:44:39 -07:00
jeffvli fedef48411 Add dialog component 2023-08-07 21:42:52 -07:00
jeffvli e3fc99cf82 Additional adjustments on mpv play 2023-08-07 14:48:02 -07:00
jeffvli f09ad1da89 Add dynamic grid sizing 2023-08-07 14:42:47 -07:00
Gelaechter 1ab75f7187 Fix sidebar layout to show full album cover (#193) 2023-08-06 12:03:45 -07:00
jeffvli 3d1f36e85a Replace header with regular image 2023-08-06 12:02:13 -07:00
jeffvli 23e791c829 Attempt additional cleanup on mpv start 2023-08-06 11:41:40 -07:00
jeffvli aaaaee7043 Format themes 2023-08-06 10:48:51 -07:00
jeffvli fca135ce2b Add additional lyrics customizability options (#146) 2023-08-04 19:32:41 -07:00
jeffvli 72b4a60c7b Adjust folder filter on song list 2023-08-04 13:45:10 -07:00
jeffvli ff68de8c09 Fix jellyfin album song order (#192) 2023-08-04 13:26:33 -07:00
jeffvli c8663db4ba Clean up 2023-08-04 13:08:40 -07:00
jeffvli 95780f1969 Add optional chain to jf genres 2023-08-04 13:07:39 -07:00
jeffvli 1327d58b23 Fix clear, clean up 2023-08-04 12:29:55 -07:00
jeffvli f6d239d87c Improve lyrics persistence
- Search overrides existing lyrics query
- Separate reset / clear commands
2023-08-04 11:51:01 -07:00
jeffvli 80fb844d3c Adjust subsonic genre normalization 2023-08-04 10:32:35 -07:00
jeffvli b35d3c3256 Set scrollbar autohide to leave 2023-08-04 03:31:36 -07:00
jeffvli 14453a8524 Change dropdown transitions back to fade 2023-08-04 03:28:42 -07:00
jeffvli d0aba6e16e Support genres in context menu 2023-08-04 02:27:04 -07:00
jeffvli 0b207c78e7 Add genre link to table cells 2023-08-04 01:53:23 -07:00
jeffvli ee83fdba71 Persist lyrics queries in indexeddb 2023-08-04 01:41:45 -07:00
jeffvli adfa748bfb Adjust genre links on detail pages 2023-08-03 19:04:12 -07:00
jeffvli 505974320f Fix custom filter implementation 2023-08-03 19:03:23 -07:00
jeffvli 5896d886d7 Add genre list route and implementation 2023-08-03 16:58:35 -07:00
jeffvli f6d74ce9c3 Add params to genre list query key, adjust cache/staletime 2023-07-31 17:53:21 -07:00
jeffvli f443c466b0 Fix song list filter genres not loading 2023-07-31 17:40:55 -07:00
jeffvli 8029712b55 Add initial genre list support 2023-07-31 17:17:26 -07:00
jeffvli 4d5085f230 Fix linter rules 2023-07-31 17:17:25 -07:00
jeffvli 9f60769b65 Fix missing autosizer types 2023-07-31 17:17:25 -07:00
jeffvli e618ac7590 Re-add limits to list item queries 2023-07-31 17:17:25 -07:00
jeffvli 9f55238b74 Add simpleimg to title cell 2023-07-31 17:17:25 -07:00
Gelaechter 93e00e7afb Fix Jellyfin album duration normalization (#191) 2023-07-29 10:35:30 -07:00
jeffvli 8e83beffcc Adjust title font sizes, fix placeholder sizing 2023-07-24 15:07:56 -07:00
jeffvli 230fa33525 Adjust various elements on album artist page 2023-07-24 14:51:37 -07:00
jeffvli ed070850a4 Remove current album from "more from this artist" 2023-07-24 14:51:37 -07:00
jeffvli 2072f9554e Memoize context menu provider 2023-07-24 14:51:37 -07:00
jeffvli 2aaf3c34c8 Refactor library header 2023-07-24 14:51:33 -07:00
jeffvli b57f601e1b Add css modules extensions 2023-07-24 14:37:42 -07:00
jeffvli 51f8415025 Update lockfile 2023-07-23 05:36:42 -07:00
jeffvli e6bcb4e237 Fix ag-grid styles 2023-07-23 05:31:10 -07:00
Kendall Garner c9dbf9b5be Add remote control (#164)
* draft add remotes

* add favorite, rating

* add basic auth
2023-07-23 05:23:18 -07:00
jeffvli 0a13d047bb Begin support for container queries with css modules 2023-07-23 05:18:08 -07:00
jeffvli 84bec824f2 Add css modules, remove styled components linters and utils 2023-07-23 05:16:29 -07:00
jeffvli 03a4a1da55 Fix color by id if no image found 2023-07-23 03:50:55 -07:00
jeffvli 2c9509b58d Add recommended css module extension 2023-07-23 03:49:45 -07:00
jeffvli 42ea5af2eb Improve css module support
- Add readable identifiers
- Export as camel case
2023-07-23 03:49:32 -07:00
jeffvli ebf0d3b47f Add new rules 2023-07-23 01:51:08 -07:00
jeffvli e44b8592e5 Bump packages 2023-07-23 01:50:20 -07:00
jeffvli f9338aafcd Fix original search params from being replaced 2023-07-22 05:26:15 -07:00
jeffvli 3aec139f58 Use search params for artist-specific lists 2023-07-22 05:05:33 -07:00
jeffvli 8a367b00a3 Remove paginated table display type 2023-07-22 04:42:46 -07:00
jeffvli 46374ef2b5 Fix delete on full playlist view 2023-07-22 01:52:55 -07:00
jeffvli febe1a703c Revert library header height change 2023-07-21 19:11:15 -07:00
jeffvli 853770ea8e Prevent wrong initial color on navigation on the same route 2023-07-21 18:51:37 -07:00
jeffvli 48eaddbeda Reduce z-index on page headers
- Potentially causes tooltips to be hidden
2023-07-21 18:40:52 -07:00
jeffvli 0a26c489b6 Change page animation easing transition 2023-07-21 18:32:47 -07:00
jeffvli bbee3fc655 Update scrollArea for relevant pages 2023-07-21 18:04:05 -07:00
jeffvli a8dfc7bcd6 Use memoized carousel component for pages 2023-07-21 18:03:19 -07:00
jeffvli 74384639de Add memoized version of grid carousel 2023-07-21 18:00:51 -07:00
jeffvli 20524452ae Fix noHeader implementation for scrollArea 2023-07-21 18:00:13 -07:00
jeffvli f274801be6 Remove table header x margins 2023-07-21 17:58:04 -07:00
jeffvli 9d18384b2d Add stickyHeader prop to table component 2023-07-21 17:53:54 -07:00
jeffvli 92d7560362 Fix custom header text positioning to match originals 2023-07-21 17:30:37 -07:00
jeffvli 47d84fae2d Add missing peer package 2023-07-21 05:38:16 -07:00
jeffvli c3d8791455 Refactor scrollarea component for overlayscrollbars 2023-07-21 05:20:40 -07:00
jeffvli 3d6f5a2748 Add overlayscrollbars package 2023-07-21 04:02:27 -07:00
jeffvli 61403510d4 Increase height of detail header 2023-07-21 03:35:59 -07:00
jeffvli e796b031ea Clean up various queries 2023-07-21 00:18:53 -07:00
jeffvli 2d62b9d72d Set full height on disc number cell 2023-07-21 00:18:53 -07:00
jeffvli f5cbcace64 Remove autofit on gridReady
- Potentially causes horizontal scrollbar to flash on render
2023-07-21 00:18:53 -07:00
jeffvli e7c15ef5f1 Tweak average color algorithm 2023-07-21 00:18:53 -07:00
jeffvli 31eb22f968 Improve header color styles on detail pages 2023-07-21 00:18:44 -07:00
jeffvli 713260bfc9 Add rgb to rgba util 2023-07-20 17:09:10 -07:00
jeffvli ba00538cc3 Brighten sidebar items 2023-07-20 17:08:58 -07:00
jeffvli dd2dd797a1 Add check for undefined list query 2023-07-20 17:00:01 -07:00
jeffvli eec556d34a Additional fix to query key list values 2023-07-20 03:38:50 -07:00
jeffvli 7378fd1f20 Fix broken pagination split logic 2023-07-20 02:08:23 -07:00
jeffvli 6821735f65 Split key pagination in central handler 2023-07-20 01:55:49 -07:00
jeffvli 1cb0a1d72a Add initial data fetch function to grid 2023-07-20 00:41:18 -07:00
jeffvli 287f1dc0e1 Update search list implementation 2023-07-20 00:41:18 -07:00
jeffvli 6dd9333dbb Update album list implementation 2023-07-20 00:41:13 -07:00
jeffvli 55937e71db Allow play button click handler to have args 2023-07-20 00:41:13 -07:00
jeffvli c0e3174d09 Fix artist image placeholders for Navidrome 2023-07-20 00:41:13 -07:00
jeffvli 440cc04fbc Update album artist list implementation 2023-07-20 00:41:13 -07:00
jeffvli 6cd27c3e88 Update song list implementation 2023-07-20 00:41:09 -07:00
jeffvli 85964bfded Update playlist list implementation 2023-07-20 00:41:05 -07:00
jeffvli 8b4a2d1ac0 Simplify list store and table implementation 2023-07-20 00:41:04 -07:00
jeffvli 9bcefb3105 Add generic list context 2023-07-20 00:41:04 -07:00
jeffvli 4029127018 Add fallback to line clamp in case of artist overflow 2023-07-20 00:41:04 -07:00
jeffvli f9ddd3140a Add search to playlist list 2023-07-20 00:41:04 -07:00
jeffvli 651af8539a Add reusable list filter refresh handler 2023-07-20 00:41:00 -07:00
jeffvli 4e4eca14ec Add change action to search clear button (#176) 2023-07-19 01:32:55 -07:00
jeffvli 1ec70bfa78 Add search to playlist api 2023-07-19 01:32:09 -07:00
jeffvli c3f97dfa4c Split pagination from filter in query keys 2023-07-19 01:29:42 -07:00
jeffvli bba27c5ddb Migrate playlist list to use list store 2023-07-19 00:28:46 -07:00
jeffvli 78860db537 Bump react-icons version 2023-07-18 18:40:07 -07:00
jeffvli ece7fecc76 Highlight currently playing song on all song tables (#178) 2023-07-18 18:39:39 -07:00
jeffvli 919016ca5a Add table context 2023-07-18 18:34:51 -07:00
jeffvli b8dfbf9d49 Add Inter font style 2023-07-18 17:38:41 -07:00
jeffvli 179129b7cb Add actions table column 2023-07-18 17:37:32 -07:00
jeffvli 817675ee0e Update playlist headers 2023-07-18 09:54:51 -07:00
jeffvli 57cdb0eb69 Reduce size of sidebar items 2023-07-18 09:51:47 -07:00
jeffvli 8233a56def Fix smart playlist sort order on creation (#163) 2023-07-16 23:59:51 -07:00
jeffvli 0c54b79c09 Clean up 2023-07-16 23:57:31 -07:00
jeffvli 3fb9853eb6 Fix select label positioning 2023-07-16 23:57:01 -07:00
jeffvli 1de89071e8 Fix hidden scrollbar for electron 25
- Newer chromium versions do not support scrollbar overlay css
2023-07-16 23:48:44 -07:00
jeffvli be37dada13 Bump to electron v25 2023-07-16 23:33:38 -07:00
jeffvli c27a9a8ffb Remove base animation of smart playlist query builder display 2023-07-16 23:25:00 -07:00
jeffvli be0792a5c7 Refresh playlist list on delete (#134) 2023-07-16 23:23:07 -07:00
jeffvli 37e4940c2e Set full playlist view as default 2023-07-16 13:40:50 -07:00
jeffvli e965bd2663 Update album artist list views 2023-07-16 13:35:40 -07:00
jeffvli b9caa73405 Fix list response types 2023-07-16 13:35:40 -07:00
jeffvli 0ba8d5bf70 Add extra item types to card row 2023-07-16 13:35:32 -07:00
jeffvli 1fc5e9a0e8 Update song list table view 2023-07-16 11:44:33 -07:00
jeffvli f09227d963 Update album table view 2023-07-16 11:15:47 -07:00
jeffvli 47ecbf0601 Fix column width declarations 2023-07-16 11:06:09 -07:00
jeffvli 481258484c Add reusable virtual table hook 2023-07-15 20:27:35 -07:00
jeffvli 3dcb0dc4ed Allow navigating directly to playlist song view 2023-07-15 15:57:40 -07:00
jeffvli d64040f3f0 Enable delete button in playlist song list 2023-07-15 15:53:36 -07:00
jeffvli 63a77ae68c Add playlist grid views 2023-07-15 11:22:09 -07:00
jeffvli e980e31bd2 Handle playlist card types 2023-07-15 11:22:09 -07:00
jeffvli 3b5dff795f Change default popup transition 2023-07-15 11:22:09 -07:00
jeffvli 8129a3994b Add order toggle button component 2023-07-15 11:22:09 -07:00
jeffvli 734b632c6c Fix item size slider for grid on album list 2023-07-15 11:22:09 -07:00
jeffvli 34f05fa2a5 Add undefined check to error message
- In some cases you can get an irrecoverable UI due to this being undefined
2023-07-15 11:22:09 -07:00
Kyan f74e02eb09 Add fix to hide the "More from this artist" carousel when it contains no albums (#173)
* Add fix to hide carousel with other albums
2023-07-15 11:13:56 -07:00
jeffvli 287fbab29a Set initial count on playlist list (#180)
- Allows for easier infinite scrolling to end of list
2023-07-14 19:13:03 -07:00
jeffvli e9d1e4a597 Set stylelint indentation to 4 2023-07-04 17:19:29 -07:00
jeffvli 70f893e5e9 Create vercel rewrite (#168) 2023-07-01 19:24:06 -07:00
jeffvli 30e52ebb54 Lint all files 2023-07-01 19:14:12 -07:00
jeffvli 22af76b4d6 Update prettier for 4 space tab 2023-07-01 19:06:57 -07:00
jeffvli cb7bf438e9 Add new app logo 2023-07-01 14:11:15 -07:00
zackslash a1b5c21a84 use platform window bar style as default (#150) 2023-07-01 13:50:11 -07:00
zackslash 4c5fa0750b fix label-font-family typo (#157) 2023-07-01 12:45:55 -07:00
Gelaechter 22160ba59f Show first instance when starting a second one (#149) 2023-07-01 12:45:08 -07:00
jeffvli ba8e23e8d4 Add missing sidebar configuration 2023-06-14 00:45:10 -07:00
jeffvli 7fa4641dfe Bump to v0.2.0 2023-06-14 00:13:29 -07:00
jeffvli 4167af098f Various cleanup 2023-06-14 00:12:10 -07:00
jeffvli c5f551e963 Use flex gap instead of line height for lyrics 2023-06-13 18:47:47 -07:00
jeffvli fbd0e5b27b Fix image position when scaling 2023-06-13 18:47:43 -07:00
jeffvli 5877b8cc6f Fix feature carousel dynamic styles 2023-06-13 18:38:55 -07:00
jeffvli 23f4bfde99 Add additional outline button styles 2023-06-13 18:38:07 -07:00
jeffvli 4898fa7dcf Add responsive styling for min-width sidebar 2023-06-13 18:08:42 -07:00
jeffvli a6990fd732 Fix various queue behavior
- Fix add next behavior when shuffle is enabled
- Fix shuffled queue when songs are removed from queue
- Fix queue indices when currently playing song is removed
- Re-shuffle queue after queue is finished when shuffle is enabled
2023-06-13 17:47:40 -07:00
Kendall Garner 2fac9efc1b initial implementation for password saving (#132)
* initial implementation for password saving

* support restoring password in interceptor

* Fix modal overflow and position styles

* warn about 429, better error handling

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
2023-06-13 10:52:51 -07:00
jeffvli a3a84766e4 Fix artist/album artist cells (#117) 2023-06-11 23:27:50 -07:00
jeffvli 0e9a77ffe0 Remove unneeded link condition 2023-06-11 20:40:27 -07:00
jeffvli 8f7e6a5222 Disable link from invalid artist on table cells (#117) 2023-06-11 20:38:32 -07:00
jeffvli 736945d6ef Set explicit window bar width 2023-06-11 20:18:28 -07:00
jeffvli f97e855f51 Support local navidrome album artist image (#116) 2023-06-11 20:02:14 -07:00
Kendall Garner d6e628099c Add LrcLib Fetcher (#136)
* lrclib, do not show search/clear buttons if no fetchers configured
2023-06-11 12:45:50 -07:00
Gelaechter d7ca25525c Add zooming functionality (#140) 2023-06-11 12:45:13 -07:00
Jeff 72099cb1fe Add configuration for player image aspect ratio (#138) 2023-06-10 19:14:49 -07:00
jeffvli eeefe9d9dc Add updated scroll container to unsync lyrics 2023-06-09 17:24:34 -07:00
jeffvli 86c3e54119 Add tooltip to offset input 2023-06-09 17:24:34 -07:00
jeffvli ea0737cf1f Fix overlay scrollbar 2023-06-09 17:24:34 -07:00
jeffvli f4eaacc64a Adjust vertical padding on lyrics display 2023-06-09 17:24:34 -07:00
jeffvli 7f6efbe6dc Hide search / clear actions on web 2023-06-09 17:24:34 -07:00
jeffvli 72811dbedb Fix broken layout on lyrics scroll
- App layout would break when transitioning into the full-screen due to scrollIntoView
- Replace scroll action with scrollTop implementation
2023-06-09 17:24:34 -07:00
jeffvli 493e13ebc0 Fix id return type on netease fetcher 2023-06-09 17:24:34 -07:00
jeffvli 14aeee888f Adjust header, input, and modal styles 2023-06-09 17:24:34 -07:00
jeffvli cbc08d6f03 Improve lyrics match with scored searches 2023-06-09 17:24:34 -07:00
jeffvli 77703b904f Allow override on songs without default found lyrics 2023-06-09 17:24:34 -07:00
jeffvli 762644d23d Fix provider text 2023-06-09 17:24:34 -07:00
jeffvli 75403078d2 Disable action buttons when no song selected 2023-06-09 17:24:34 -07:00
jeffvli 255a131f3b Require only single query for search 2023-06-09 17:24:34 -07:00
jeffvli e56350c1c2 Add remaining lyric actions functionality 2023-06-09 17:24:34 -07:00
jeffvli aaa1b5f63a Fix override on song change 2023-06-09 17:24:34 -07:00
jeffvli 3d409bb6f1 Fix queue container layout 2023-06-09 17:24:34 -07:00
jeffvli 7ab532be07 Improve accessibility of actions bar 2023-06-09 17:24:34 -07:00
jeffvli 946b73d215 Miscellanous fixes 2023-06-09 17:24:34 -07:00
jeffvli 2f0634dc03 Add lyric search selection and override 2023-06-09 17:24:34 -07:00
jeffvli f8ecb3fc53 Update fetchers to support search 2023-06-09 17:24:34 -07:00
jeffvli 01608fa875 Fix ref issue 2023-06-09 17:24:34 -07:00
jeffvli 14a6766072 Add initial lyrics search UI 2023-06-09 17:24:34 -07:00
jeffvli 0fa5b6496f Add lyric search functions and query 2023-06-09 17:24:34 -07:00
jeffvli 43c11ab6e3 Add alternative lyrics format parser
- Many synced lyrics on NetEase are broken due to not being the standard lrc format
2023-06-09 17:24:34 -07:00
jeffvli 41a901f3c4 Catch error on jellyfin query fail 2023-06-09 17:24:34 -07:00
jeffvli 2bdc664619 Fix animation transition on fullscreen player open 2023-06-09 17:24:34 -07:00
Kendall Garner 8835fc640a Include lyric simplification, restore clear button
- merges lyric simplification
- restores metadata/clear
2023-06-09 17:24:34 -07:00
jeffvli f92cd89c46 Add fade in animation for lyrics container 2023-06-09 17:24:34 -07:00
jeffvli a1a113d3c6 Move scroll container to wrap lyrics only 2023-06-09 17:24:34 -07:00
jeffvli 3f78c3f420 Move all lyrics fetching logic into query 2023-06-09 17:24:34 -07:00
jeffvli f10912d930 Set lyrics scrollarea to max height by default 2023-06-09 17:24:34 -07:00
jeffvli 98fa47348c Replace main lyrics listener with promise handler 2023-06-09 17:24:34 -07:00
jeffvli d38c846e80 Update lyric fetcher return types 2023-06-09 17:24:34 -07:00
Kendall Garner 007a099951 Lyrics Improvements
- Make the settings text actually consistent with behavior
- Add metadata (artist/track name) for fetched tracks
- Add ability to remove incorrectly fetched lyric
- Add lyric fetch cache; save the last 10 fetches
- Add ability to change offset in full screen, add more comments
2023-06-09 17:24:34 -07:00
jeffvli 9622cd346c Force play on queue row double click 2023-06-06 19:45:47 -07:00
jeffvli c3c1f4cc5f Refactor mpv initialization/cleanup
- Don't re-initialize the player on re-render
- Fixes the player potentially crashing on hot reload
2023-06-06 10:48:47 -07:00
jeffvli d97fe4c621 Replace node-mpv with fork version 2023-06-06 00:57:05 -07:00
jeffvli 7e5733db34 Add missing key on sidebar 2023-06-06 00:55:49 -07:00
jeffvli d1dde2428f Minor fixes on main component 2023-06-04 16:46:05 -07:00
jeffvli 190dd71b3c Fix styling on unsync lyrics 2023-06-04 16:46:05 -07:00
jeffvli feb61c28d7 Change padding on vertical player 2023-06-04 16:46:05 -07:00
jeffvli f380eccc68 Improve responsive styles for playerbar 2023-06-04 16:46:05 -07:00
jeffvli cf43bf360e Use scale instead of font-size for active lyric 2023-06-04 16:46:05 -07:00
jeffvli 48dfd469ed Additional styling changes 2023-06-04 16:46:05 -07:00
jeffvli 5dd860735d Adjust lyrics styling / animations 2023-06-04 16:46:05 -07:00
jeffvli 7cd2077dcd Refactor layout to grid 2023-06-04 16:46:05 -07:00
jeffvli 7430bba853 Update minimum app dimensions 2023-06-04 16:46:05 -07:00
jeffvli 782c351ca6 Disable query if no song present 2023-06-04 16:46:05 -07:00
Kendall Garner 3aef2a80a7 rename listener function 2023-06-04 16:46:05 -07:00
Kendall Garner 85a10c799a address comments 2023-06-04 16:46:05 -07:00
Kendall Garner 9eef570740 support .txt 2023-06-04 16:46:05 -07:00
Kendall Garner 58f38b2655 add jellyfin, improvements 2023-06-04 16:46:05 -07:00
Kendall Garner 85d2576bdc Improved lyric syncing, fetch
- uses a somewhat more sane way to parse lyrics and teardown timeouts
- adds 'seeked' to setCurrentTime to make detecting seeks in lyric much easier
- adds ability to fetch lyrics from genius/netease (desktop only)
2023-06-04 16:46:05 -07:00
Kendall Garner 23f9bd4e9f initial implementation for lyrics 2023-06-04 16:46:05 -07:00
jeffvli 8eb0029bb8 Add undefined check 2023-06-03 05:46:20 -07:00
jeffvli c8a0df4759 Add configurable sidebar 2023-06-03 05:46:20 -07:00
jeffvli e7bc29a8f1 Remove unneeded hook 2023-06-03 05:46:20 -07:00
jeffvli 5295c69f46 Bump ts-rest 2023-06-03 05:46:15 -07:00
jeffvli f58552be84 Remove unneeded console logs 2023-06-03 00:40:57 -07:00
jeffvli cd57142caf Fix duplicate import 2023-06-03 00:40:13 -07:00
jeffvli 86ad2d0383 Fix invalid spinner props 2023-06-03 00:39:52 -07:00
jeffvli 7d5aa6fd13 Add sidebar customization settings 2023-06-03 00:39:33 -07:00
jeffvli f2ef630921 Fix global button styling on settings page 2023-06-02 22:38:49 -07:00
jeffvli 9250b30249 Downgrade framer-motion from v10 -> v9
- Issues are present in v10 that prevent drag/drop reorder from working properly
2023-06-02 18:35:36 -07:00
jeffvli 2b16cce0aa Move global search to default result 2023-06-02 17:25:15 -07:00
jeffvli 34870556b4 Add auto-focus to search input 2023-06-02 13:13:33 -07:00
jeffvli 7e2d9bd585 Split album list views 2023-06-02 13:09:28 -07:00
jeffvli 691bc8f1ef Add full container spinner/loader 2023-06-02 13:07:30 -07:00
jeffvli 5dbc0c61c5 Clean up from mantine upgrade 2023-06-02 11:48:43 -07:00
jeffvli 0bc1ee3492 Downgrade auto-sizer package
- issue with types with ts v4
2023-06-02 11:48:22 -07:00
jeffvli 7403a46f91 Remove initial animation on page header 2023-06-02 11:38:33 -07:00
jeffvli 8ffb81093d Improve feature carousel component
- Add play button
- Clamp title to 1 line
- Restrict to 1 genre and 1 artist
- Infinite loop pagination
2023-06-02 01:30:08 -07:00
jeffvli d312c3c70a Handle initial render item count for carousel 2023-06-02 01:21:52 -07:00
jeffvli cd66a9dccb Clean up 2023-06-02 01:21:02 -07:00
jeffvli f2690b262f Remove container query requirement for carousel sizing 2023-06-02 01:01:50 -07:00
jeffvli 63c5a83911 Bump packages 2023-06-01 20:19:07 -07:00
jeffvli 17b1acad9d Optimize various pages 2023-06-01 20:08:43 -07:00
jeffvli e7c7eb3ec0 Split up main content route component 2023-05-31 01:13:54 -07:00
jeffvli fa0a21a021 Optimize app outlet 2023-05-31 00:27:16 -07:00
jeffvli 791088deb6 Persist scroll offset on table-view album list 2023-05-30 20:05:52 -07:00
jeffvli 9c1a2a4a8d Fix playlist form
- Invalid initial state and definition for public playlist
2023-05-30 19:35:22 -07:00
jeffvli 6d092d9ebc Add native frame styles per OS 2023-05-27 14:02:10 -07:00
jeffvli 73997cf6c7 Add clarity to conditional restart 2023-05-27 14:02:10 -07:00
jeffvli 1d074dae2e Adjust conditionals in a few places 2023-05-27 14:02:10 -07:00
Gelaechter a878875f83 Add native titlebar & fix app restart for AppImages 2023-05-27 14:02:10 -07:00
Kendall Garner d055ae89e0 media session for windows/mac 2023-05-26 18:20:27 -07:00
Kendall Garner f83639d5f8 round volume, update all clients 2023-05-26 18:20:27 -07:00
Kendall Garner 97ccf3bc6d add media session/mpris 2023-05-26 18:20:27 -07:00
jeffvli 76805a0b19 Fix ND potentially setting undefined undefined credential (#60) 2023-05-24 00:33:35 -07:00
jeffvli 0103a84358 Add clear buttons to search input 2023-05-21 21:01:23 -07:00
jeffvli 611cbc6dd9 Fix search results being capped to window height 2023-05-21 20:51:52 -07:00
448 changed files with 86595 additions and 77498 deletions
+45 -40
View File
@@ -7,53 +7,58 @@ import { dependencies as externals } from '../../release/app/package.json';
import webpackPaths from './webpack.paths';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
const createStyledComponentsTransformer = require('typescript-plugin-styled-components').default;
const styledComponentsTransformer = createStyledComponentsTransformer();
const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})],
externals: [...Object.keys(externals || {})],
module: {
rules: [
{
exclude: /node_modules/,
test: /\.[jt]sx?$/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
},
module: {
rules: [
{
exclude: /node_modules/,
test: /\.[jt]sx?$/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
getCustomTransformers: () => ({ before: [styledComponentsTransformer] }),
},
},
},
],
},
output: {
// https://github.com/webpack/webpack/issues/1114
library: {
type: 'commonjs2',
},
},
path: webpackPaths.srcPath,
},
plugins: [
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
}),
],
},
output: {
// https://github.com/webpack/webpack/issues/1114
library: {
type: 'commonjs2',
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
fallback: {
child_process: false,
},
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
modules: [webpackPaths.srcPath, 'node_modules'],
},
path: webpackPaths.srcPath,
},
plugins: [
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
}),
],
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
fallback: {
child_process: false,
},
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
modules: [webpackPaths.srcPath, 'node_modules'],
},
stats: 'errors-only',
stats: 'errors-only',
};
export default configuration;
+127
View File
@@ -0,0 +1,127 @@
import 'webpack-dev-server';
import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
const { version } = require('../../package.json');
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web'],
entry: {
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
},
output: {
path: webpackPaths.dllPath,
publicPath: '/',
filename: '[name].js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
templateParameters: {
version,
prod: false,
},
}),
],
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);
+142
View File
@@ -0,0 +1,142 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
const { version } = require('../../package.json');
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
mode: 'production',
target: ['web'],
entry: {
remote: path.join(webpackPaths.srcRemotePath, 'index.tsx'),
worker: path.join(webpackPaths.srcRemotePath, 'service-worker.ts'),
},
output: {
path: webpackPaths.distRemotePath,
publicPath: './',
filename: '[name].js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'remote.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRemotePath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: true,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
templateParameters: {
version,
prod: true,
},
}),
],
};
export default merge(baseConfig, configuration);
+13 -1
View File
@@ -67,7 +67,10 @@ const configuration: webpack.Configuration = {
{
loader: 'css-loader',
options: {
modules: true,
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
@@ -168,6 +171,14 @@ const configuration: webpack.Configuration = {
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting remote.js builder...');
const remoteProcess = spawn('npm', ['run', 'start:remote'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting Main Process...');
spawn('npm', ['run', 'start:main'], {
shell: true,
@@ -175,6 +186,7 @@ const configuration: webpack.Configuration = {
})
.on('close', (code: number) => {
preloadProcess.kill();
remoteProcess.kill();
process.exit(code!);
})
.on('error', (spawnError) => console.error(spawnError));
+4 -1
View File
@@ -54,7 +54,10 @@ const configuration: webpack.Configuration = {
{
loader: 'css-loader',
options: {
modules: true,
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
+113 -109
View File
@@ -13,128 +13,132 @@ import webpackPaths from './webpack.paths';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
checkNodeEnv('development');
}
const port = process.env.PORT || 4343;
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
devtool: 'inline-source-map',
mode: 'development',
mode: 'development',
target: ['web', 'electron-renderer'],
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
historyApiFallback: {
verbose: true,
module: {
rules: [
{
test: /\.s?css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
setupMiddlewares(middlewares) {
return middlewares;
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
return middlewares;
},
},
},
};
export default merge(baseConfig, configuration);
+135
View File
@@ -0,0 +1,135 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
checkNodeEnv('production');
deleteSourceMaps();
const devtoolsConfig =
process.env.DEBUG_PROD === 'true'
? {
devtool: 'source-map',
}
: {};
const configuration: webpack.Configuration = {
...devtoolsConfig,
mode: 'production',
target: ['web'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: {
path: webpackPaths.distWebPath,
publicPath: 'auto',
filename: 'renderer.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]--[hash:base64:5]',
exportLocalsConvention: 'camelCaseOnly',
},
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
new CssMinimizerPlugin(),
],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'style.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
favicon: path.join(webpackPaths.assetsPath, 'icons', 'favicon.ico'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: process.env.NODE_ENV !== 'production',
}),
],
};
export default merge(baseConfig, configuration);
+22 -14
View File
@@ -5,7 +5,9 @@ const rootPath = path.join(__dirname, '../..');
const dllPath = path.join(__dirname, '../dll');
const srcPath = path.join(rootPath, 'src');
const assetsPath = path.join(rootPath, 'assets');
const srcMainPath = path.join(srcPath, 'main');
const srcRemotePath = path.join(srcPath, 'remote');
const srcRendererPath = path.join(srcPath, 'renderer');
const releasePath = path.join(rootPath, 'release');
@@ -16,23 +18,29 @@ const srcNodeModulesPath = path.join(srcPath, 'node_modules');
const distPath = path.join(appPath, 'dist');
const distMainPath = path.join(distPath, 'main');
const distRemotePath = path.join(distPath, 'remote');
const distRendererPath = path.join(distPath, 'renderer');
const distWebPath = path.join(distPath, 'web');
const buildPath = path.join(releasePath, 'build');
export default {
rootPath,
dllPath,
srcPath,
srcMainPath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRendererPath,
buildPath,
assetsPath,
rootPath,
dllPath,
srcPath,
srcMainPath,
srcRemotePath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRemotePath,
distRendererPath,
distWebPath,
buildPath,
};
+13 -4
View File
@@ -5,20 +5,29 @@ import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
const remotePath = path.join(webpackPaths.distMainPath, 'remote.js');
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
if (!fs.existsSync(mainPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The main process is not built yet. Build it by running "npm run build:main"'
)
'The main process is not built yet. Build it by running "npm run build:main"',
),
);
}
if (!fs.existsSync(remotePath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The remote process is not built yet. Build it by running "npm run build:remote"',
),
);
}
if (!fs.existsSync(rendererPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The renderer process is not built yet. Build it by running "npm run build:renderer"'
)
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
),
);
}
+1
View File
@@ -4,5 +4,6 @@ import webpackPaths from '../configs/webpack.paths';
export default function deleteSourceMaps() {
rimraf.sync(path.join(webpackPaths.distMainPath, '*.js.map'));
rimraf.sync(path.join(webpackPaths.distRemotePath, '*.js.map'));
rimraf.sync(path.join(webpackPaths.distRendererPath, '*.js.map'));
}
+92 -85
View File
@@ -1,90 +1,97 @@
module.exports = {
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 12,
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
ignorePatterns: ['.erb/*', 'server'],
parser: '@typescript-eslint/parser',
project: './tsconfig.json',
sourceType: 'module',
tsconfigRootDir: './',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'default-case': 'off',
'import/extensions': 'off',
'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
'newlines-between': 'never',
pathGroups: [
{
group: 'external',
pattern: 'react',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
'import/prefer-default-export': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/interactive-supports-focus': 'off',
'jsx-a11y/media-has-caption': 'off',
'no-await-in-loop': 'off',
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-underscore-dangle': 'off',
'prefer-destructuring': 'off',
'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
ignoreCase: false,
noSortAlphabetically: false,
reservedFirst: true,
shorthandFirst: true,
shorthandLast: false,
},
],
'react/no-array-index-key': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-default-props': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
parserOptions: {
createDefaultProgram: true,
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
project: './tsconfig.json',
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
sourceType: 'module',
tsconfigRootDir: './',
},
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-shadow': ['off'],
'@typescript-eslint/no-unused-vars': ['error'],
'@typescript-eslint/no-use-before-define': ['error'],
'default-case': 'off',
'import/extensions': 'off',
'import/no-absolute-path': 'off',
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'import/no-unresolved': 'error',
'import/order': [
'error',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
'newlines-between': 'never',
pathGroups: [
{
group: 'external',
pattern: 'react',
position: 'before',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
'import/prefer-default-export': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/interactive-supports-focus': 'off',
'jsx-a11y/media-has-caption': 'off',
'no-await-in-loop': 'off',
'no-console': 'off',
'no-nested-ternary': 'off',
'no-restricted-syntax': 'off',
'no-shadow': 'off',
'no-underscore-dangle': 'off',
'no-unused-vars': 'off',
'no-use-before-define': 'off',
'prefer-destructuring': 'off',
'react/function-component-definition': 'off',
'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': 'off',
'react/jsx-sort-props': [
'error',
{
callbacksLast: true,
ignoreCase: false,
noSortAlphabetically: false,
reservedFirst: true,
shorthandFirst: true,
shorthandLast: false,
},
],
'react/no-array-index-key': 'off',
'react/react-in-jsx-scope': 'off',
'react/require-default-props': 'off',
'sort-keys-fix/sort-keys-fix': 'warn',
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
},
},
},
};
+4 -4
View File
@@ -39,7 +39,7 @@ labels: 'bug'
<!--- Include as many relevant details about the environment you experienced the bug in -->
- Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) :
- Application version (e.g. v0.1.0) :
- Operating System and version (e.g. Windows 10) :
- Server and version (e.g. Navidrome v0.48.0) :
- Node version (if developing locally) :
+5 -5
View File
@@ -1,6 +1,6 @@
requiredHeaders:
- Prerequisites
- Expected Behavior
- Current Behavior
- Possible Solution
- Your Environment
- Prerequisites
- Expected Behavior
- Current Behavior
- Possible Solution
- Your Environment
+5 -5
View File
@@ -4,14 +4,14 @@ daysUntilStale: 60
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- discussion
- security
- discussion
- security
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
+54
View File
@@ -0,0 +1,54 @@
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
name: Publish Docker to GHCR
permissions: write-all
on:
push:
tags:
- 'v*.*.*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+46
View File
@@ -0,0 +1,46 @@
# Referenced from: https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#introduction
name: Publish Docker to GHCR (Manual)
on: workflow_dispatch
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: |
linux/amd64
linux/arm/v7
linux/arm64/v8
+29 -29
View File
@@ -3,37 +3,37 @@ name: Publish Linux (Manual)
on: workflow_dispatch
jobs:
publish:
runs-on: ${{ matrix.os }}
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
strategy:
matrix:
os: [ubuntu-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --linux
on_retry_command: npm cache clean --force
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --linux
on_retry_command: npm cache clean --force
+29 -29
View File
@@ -3,37 +3,37 @@ name: Publish Windows and macOS (Manual)
on: workflow_dispatch
jobs:
publish:
runs-on: ${{ matrix.os }}
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
strategy:
matrix:
os: [macos-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v1
steps:
- name: Checkout git repo
uses: actions/checkout@v1
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install Node and NPM
uses: actions/setup-node@v1
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win --mac
on_retry_command: npm cache clean --force
- name: Publish releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm exec electron-builder -- --publish always --win --mac
on_retry_command: npm cache clean --force
+51 -51
View File
@@ -1,54 +1,54 @@
name: Comment on pull request
on:
workflow_run:
workflows: ['Publish (PR)']
types: [completed]
workflow_run:
workflows: ['Publish (PR)']
types: [completed]
jobs:
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}
pr_comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
script: |
async function upsertComment(owner, repo, issue_number, purpose, body) {
const {data: comments} = await github.rest.issues.listComments(
{owner, repo, issue_number});
const marker = `<!-- bot: ${purpose} -->`;
body = marker + "\n" + body;
const existing = comments.filter((c) => c.body.includes(marker));
if (existing.length > 0) {
const last = existing[existing.length - 1];
core.info(`Updating comment ${last.id}`);
await github.rest.issues.updateComment({
owner, repo,
body,
comment_id: last.id,
});
} else {
core.info(`Creating a comment in issue / PR #${issue_number}`);
await github.rest.issues.createComment({issue_number, body, owner, repo});
}
}
const {owner, repo} = context.repo;
const run_id = ${{github.event.workflow_run.id}};
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
if (!pull_requests.length) {
return core.error("This workflow doesn't match any pull requests!");
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
core.info("Review thread message body:", body);
for (const pr of pull_requests) {
await upsertComment(owner, repo, pr.number,
"nightly-link", body);
}
+47 -47
View File
@@ -1,60 +1,60 @@
name: Publish (PR)
on:
pull_request:
branches:
- development
pull_request:
branches:
- development
jobs:
publish:
runs-on: ${{ matrix.os }}
publish:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest]
strategy:
matrix:
os: [macos-latest]
steps:
- name: Checkout git repo
uses: actions/checkout@v3
steps:
- name: Checkout git repo
uses: actions/checkout@v3
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install Node and NPM
uses: actions/setup-node@v3
with:
node-version: 16
cache: npm
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Install dependencies
run: |
npm install --legacy-peer-deps
- name: Build releases
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- name: Build releases
uses: nick-invision/retry@v2.8.2
with:
timeout_minutes: 30
max_attempts: 3
retry_on: error
command: |
npm run postinstall
npm run build
npm run package:pr
on_retry_command: npm cache clean --force
- uses: actions/upload-artifact@v3
with:
name: windows-binaries
path: |
release/build/*.exe
- uses: actions/upload-artifact@v3
with:
name: windows-binaries
path: |
release/build/*.exe
- uses: actions/upload-artifact@v3
with:
name: linux-binaries
path: |
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3
with:
name: linux-binaries
path: |
release/build/*.AppImage
release/build/*.deb
release/build/*.rpm
- uses: actions/upload-artifact@v3
with:
name: macos-binaries
path: |
release/build/*.dmg
- uses: actions/upload-artifact@v3
with:
name: macos-binaries
path: |
release/build/*.dmg
+24 -24
View File
@@ -3,32 +3,32 @@ name: Test
on: [push, pull_request]
jobs:
release:
runs-on: ${{ matrix.os }}
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v1
steps:
- name: Check out Git repository
uses: actions/checkout@v1
- name: Install Node.js and NPM
uses: actions/setup-node@v2
with:
node-version: 16
cache: npm
- name: Install Node.js and NPM
uses: actions/setup-node@v2
with:
node-version: 16
cache: npm
- name: npm install
run: |
npm install --legacy-peer-deps
- name: npm install
run: |
npm install --legacy-peer-deps
- name: npm test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run package
npm run lint
npm exec tsc
npm test
- name: npm test
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm run lint
npm run package
npm exec tsc
npm test
+20 -18
View File
@@ -1,20 +1,22 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": true,
"overrides": [
{
"files": ["**/*.css", "**/*.scss", "**/*.html"],
"options": {
"singleQuote": false
}
}
],
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "lf",
"singleAttributePerLine": true
"printWidth": 100,
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"overrides": [
{
"files": ["**/*.css", "**/*.scss", "**/*.html"],
"options": {
"singleQuote": true
}
}
],
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "lf",
"singleAttributePerLine": true
}
+14 -28
View File
@@ -1,31 +1,17 @@
{
"processors": ["stylelint-processor-styled-components"],
"customSyntax": "postcss-scss",
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-styled-components",
"stylelint-config-rational-order"
],
"rules": {
"color-function-notation": ["legacy"],
"declaration-empty-line-before": null,
"order/properties-order": [],
"plugin/rational-order": [
true,
{
"border-in-box-model": false,
"empty-line-between-groups": false
}
"customSyntax": "postcss-styled-syntax",
"extends": [
"stylelint-config-standard",
"stylelint-config-styled-components",
"stylelint-config-recess-order"
],
"string-quotes": "single",
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [
true,
{ "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }
],
"value-keyword-case": ["lower", { "ignoreKeywords": ["dummyValue"] }],
"declaration-colon-newline-after": null
}
"rules": {
"declaration-empty-line-before": null,
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
"selector-type-no-unknown": [true, { "ignoreTypes": ["/-styled-mixin/", "/^\\$\\w+/"] }],
"declaration-colon-newline-after": null,
"property-no-vendor-prefix": null
}
}
+8 -6
View File
@@ -1,8 +1,10 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"stylelint.vscode-stylelint",
"esbenp.prettier-vscode"
]
"recommendations": [
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"stylelint.vscode-stylelint",
"esbenp.prettier-vscode",
"clinyong.vscode-css-modules",
"Huuums.vscode-fast-folder-structure"
]
}
+26 -28
View File
@@ -1,30 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"protocol": "inspector",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run start:main --inspect=5858 --remote-debugging-port=9223"
],
"preLaunchTask": "Start Webpack Dev"
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 15000
}
],
"compounds": [
{
"name": "Electron: All",
"configurations": ["Electron: Main", "Electron: Renderer"]
}
]
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"protocol": "inspector",
"runtimeExecutable": "npm",
"runtimeArgs": ["run start:main --inspect=5858 --remote-debugging-port=9223"],
"preLaunchTask": "Start Webpack Dev"
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 15000
}
],
"compounds": [
{
"name": "Electron: All",
"configurations": ["Electron: Main", "Electron: Renderer"]
}
]
}
+70 -41
View File
@@ -1,43 +1,72 @@
{
"files.associations": {
".eslintrc": "jsonc",
".prettierrc": "jsonc",
".eslintignore": "ignore"
},
"eslint.validate": ["typescript"],
"eslint.workingDirectories": [
{ "directory": "./", "changeProcessCWD": true },
{ "directory": "./server", "changeProcessCWD": true }
],
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": false
},
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
"search.exclude": {
".git": true,
".eslintcache": true,
".erb/dll": true,
"release/{build,app/dist}": true,
"node_modules": true,
"npm-debug.log.*": true,
"test/**/__snapshots__": true,
"package-lock.json": true,
"*.{css,sass,scss}.d.ts": true
},
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
"typescript.tsdk": "node_modules\\typescript\\lib",
"typescript.preferences.importModuleSpecifier": "non-relative",
"stylelint.validate": ["css", "less", "postcss", "typescript", "typescriptreact", "scss"],
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true
"files.associations": {
".eslintrc": "jsonc",
".prettierrc": "jsonc",
".eslintignore": "ignore"
},
"eslint.validate": ["typescript"],
"eslint.workingDirectories": [
{ "directory": "./", "changeProcessCWD": true },
{ "directory": "./server", "changeProcessCWD": true }
],
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true,
"source.organizeImports": false,
"source.formatDocument": true
},
"css.validate": true,
"less.validate": false,
"scss.validate": true,
"scss.lint.unknownAtRules": "warning",
"scss.lint.unknownProperties": "warning",
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
"search.exclude": {
".git": true,
".eslintcache": true,
".erb/dll": true,
"release/{build,app/dist}": true,
"node_modules": true,
"npm-debug.log.*": true,
"test/**/__snapshots__": true,
"package-lock.json": true,
"*.{css,sass,scss}.d.ts": true
},
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
"typescript.tsdk": "node_modules\\typescript\\lib",
"typescript.preferences.importModuleSpecifier": "non-relative",
"stylelint.validate": ["css", "scss", "typescript", "typescriptreact"],
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
"folderTemplates.structures": [
{
"name": "TypeScript Feature Component With CSS Modules",
"omitParentDirectory": true,
"structure": [
{
"fileName": "<FTName | kebabcase>.tsx",
"template": "Functional Component with CSS Modules"
},
{
"fileName": "<FTName | kebabcase>.module.scss"
}
]
}
],
"folderTemplates.fileTemplates": {
"Functional Component with CSS Modules": [
"import styles from './<FTName | kebabcase>.module.scss';",
"",
"interface <FTName | pascalcase>Props {}",
"",
"export const <FTName | pascalcase> = ({}: <FTName | pascalcase>Props) => {",
" return <div></div>;",
"};"
]
}
}
+22 -22
View File
@@ -1,25 +1,25 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"label": "Start Webpack Dev",
"script": "start:renderer",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "____________"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Compiling\\.\\.\\.$",
"endsPattern": "(Compiled successfully|Failed to compile)\\.$"
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"label": "Start Webpack Dev",
"script": "start:renderer",
"options": {
"cwd": "${workspaceFolder}"
},
"isBackground": true,
"problemMatcher": {
"owner": "custom",
"pattern": {
"regexp": "____________"
},
"background": {
"activeOnStart": true,
"beginsPattern": "Compiling\\.\\.\\.$",
"endsPattern": "(Compiled successfully|Failed to compile)\\.$"
}
}
}
}
}
]
]
}
+18
View File
@@ -0,0 +1,18 @@
# --- Builder stage
FROM node:18-alpine as builder
WORKDIR /app
COPY . /app
# Scripts include electron-specific dependencies, which we don't need
RUN npm install --legacy-peer-deps --ignore-scripts
RUN npm run build:web
# --- Production stage
FROM nginx:alpine-slim
COPY --chown=nginx:nginx --from=builder /app/release/app/dist/web /usr/share/nginx/html
COPY ng.conf.template /etc/nginx/templates/default.conf.template
ENV PUBLIC_PATH="/"
EXPOSE 9180
CMD ["nginx", "-g", "daemon off;"]
+52 -13
View File
@@ -1,3 +1,5 @@
<img src="assets/icons/icon.png" alt="logo" title="feishin" align="right" height="60px" />
# Feishin
<p align="center">
@@ -29,32 +31,65 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
## Features
- [x] MPV player backend
- [x] Web player backend
- [x] Modern UI
- [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome)
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
- [x] MPV player backend
- [x] Web player backend
- [x] Modern UI
- [x] Scrobble playback to your server
- [x] Smart playlist editor (Navidrome)
- [x] Synchronized and unsynchronized lyrics support
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
## Screenshots
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
## Getting Started
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
### Desktop (recommended)
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases). The desktop client is the recommended way to use Feishin. It supports both the MPV and web player backends, as well as includes built-in fetching for lyrics.
If you're using a device running macOS 12 (Monterey) or higher, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
### Web and Docker
Visit [https://feishin.vercel.app](https://feishin.vercel.app) to use the hosted web version of Feishin. The web client only supports the web player backend.
Feishin is also available as a Docker image. The images are hosted via `ghcr.io` and are available to view [here](https://github.com/jeffvli/feishin/pkgs/container/feishin). You can run the container using the following commands:
```bash
# Run the latest version
docker run --name feishin --port 9180:9180 ghcr.io/jeffvli/feishin:latest
# Build the image locally
docker build -t feishin .
docker run --name feishin --port 9180:9180 feishin
```
### Configuration
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
3. _Optional_ - If you want to host Feishin on a subpath (not `/`), then pass in the following environment variable: `PUBLIC_PATH=PATH`. For example, to host on `/feishin`, pass in `PUBLIC_PATH=/feishin`.
## FAQ
### MPV is either not working or is rapidly switching between pause/play states
First thing to do is check that your MPV binary path is correct. Navigate to the settings page and re-set the path and restart the app. If your issue still isn't resolved, try reinstalling MPV. Known working versions include `v0.35.x` and `v0.36.x`. `v0.34.x` is a known broken version.
### What music servers does Feishin support?
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
- [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- ~~[Gonic](https://github.com/sentriz/gonic)~~
- ~~[Astiga](https://asti.ga/)~~
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
- [Navidrome](https://github.com/navidrome/navidrome)
- [Jellyfin](https://github.com/jellyfin/jellyfin)
- [Funkwhale](https://funkwhale.audio/) - TBD
- Subsonic-compatible servers - TBD
## Development
@@ -62,6 +97,10 @@ Built and tested using Node `v16.15.0`.
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v4.6.0.
## Translation
This project uses [Weblate](https://hosted.weblate.org/projects/feishin/) for translations. If you would like to contribute, please visit the link and submit a translation.
## License
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)
+12 -12
View File
@@ -1,31 +1,31 @@
type Styles = Record<string, string>;
declare module '*.svg' {
const content: string;
export default content;
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
const content: string;
export default content;
}
declare module '*.scss' {
const content: Styles;
export default content;
const content: Styles;
export default content;
}
declare module '*.sass' {
const content: Styles;
export default content;
const content: Styles;
export default content;
}
declare module '*.css' {
const content: Styles;
export default content;
const content: Styles;
export default content;
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

+19
View File
@@ -0,0 +1,19 @@
server {
listen 9180;
sendfile on;
default_type application/octet-stream;
gzip on;
gzip_http_version 1.1;
gzip_disable "MSIE [1-6]\.";
gzip_min_length 256;
gzip_vary on;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_comp_level 9;
location ${PUBLIC_PATH} {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html =404;
}
}
+37073 -41289
View File
File diff suppressed because it is too large Load Diff
+354 -306
View File
@@ -1,319 +1,367 @@
{
"name": "feishin",
"productName": "Feishin",
"description": "Feishin music server",
"version": "0.1.1",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:styles": "npx stylelint **/*.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"cross-env NODE_ENV=development eslint --cache"
],
"*.json,.{eslintrc,prettierrc}": [
"prettier --ignore-path .eslintignore --parser json --write"
],
"*.{css,scss}": [
"prettier --ignore-path .eslintignore --single-quote --write"
],
"*.{html,md,yml}": [
"prettier --ignore-path .eslintignore --single-quote --write"
]
},
"build": {
"name": "feishin",
"productName": "Feishin",
"appId": "org.jeffvli.feishin",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "22.3.1",
"mac": {
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
"description": "Feishin music server",
"version": "0.5.0",
"scripts": {
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\" \"npm run build:remote\"",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:remote": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"build:web": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.web.prod.ts",
"build:docker": "npm run build:web && docker build -t jeffvli/feishin .",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"lint": "concurrently \"npm run lint:code\" \"npm run lint:styles\"",
"lint:code": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"lint:styles": "npx stylelint **/*.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:remote": "cross-env NODE_ENV=developemnt TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.remote.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
"test": "jest",
"prepare": "husky install",
"i18next": "i18next -c src/i18n/i18next-parser.config.js",
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"cross-env NODE_ENV=development eslint --cache"
],
"*.json,.{eslintrc,prettierrc}": [
"prettier --ignore-path .eslintignore --parser json --write"
],
"*.{css,scss}": [
"prettier --ignore-path .eslintignore --single-quote --write"
],
"*.{html,md,yml}": [
"prettier --ignore-path .eslintignore --single-quote --write"
]
},
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
"build": {
"productName": "Feishin",
"appId": "org.jeffvli.feishin",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"asar": true,
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist",
"node_modules",
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"electronVersion": "25.8.1",
"mac": {
"target": {
"target": "default",
"arch": [
"arm64",
"x64"
]
},
"icon": "assets/icons/icon.icns",
"type": "distribution",
"hardenedRuntime": true,
"entitlements": "assets/entitlements.mac.plist",
"entitlementsInherit": "assets/entitlements.mac.plist",
"gatekeeperAssess": false
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
"nsis",
"zip"
],
"icon": "assets/icons/icon.ico"
},
"deb": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"rpm": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"freebsd": {
"depends": [
"libgssapi_krb5.so.2",
"libavahi-common.so.3",
"libavahi-client.so.3",
"libkrb5.so.3",
"libkrb5support.so.0",
"libkeyutils.so.1",
"libcups.so.2"
]
},
"linux": {
"target": [
"AppImage",
"tar.xz"
],
"icon": "assets/icons/icon.png",
"category": "Development"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"extraResources": [
"./assets/**"
],
"publish": {
"provider": "github",
"owner": "jeffvli",
"repo": "feishin"
}
]
},
"win": {
"target": [
"nsis",
"zip"
]
"repository": {
"type": "git",
"url": "git+https://github.com/jeffvli/feishin.git"
},
"linux": {
"target": [
"AppImage",
"tar.xz"
],
"icon": "assets/icons/placeholder.png",
"category": "Development"
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
"contributors": [],
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/jeffvli/feishin/issues"
},
"extraResources": [
"./assets/**"
"keywords": [
"subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
],
"publish": {
"provider": "github",
"owner": "jeffvli",
"repo": "feishin"
"homepage": "https://github.com/jeffvli/feishin",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleDirectories": [
"node_modules",
"release/app/node_modules"
],
"testPathIgnorePatterns": [
"release/app/dist"
],
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
"core-js": "^3.21.1",
"cross-env": "^7.0.3",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^25.8.1",
"electron-builder": "^24.6.3",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electronmon": "^2.0.2",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.6.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-styled-syntax": "^0.5.0",
"postcss-syntax": "^0.36.2",
"prettier": "^2.6.2",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.11",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^15.10.3",
"stylelint-config-css-modules": "^4.3.0",
"stylelint-config-recess-order": "^4.3.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^5.2.2",
"typescript-plugin-styled-components": "^3.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@ag-grid-community/client-side-row-model": "^28.2.1",
"@ag-grid-community/core": "^28.2.1",
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.17",
"@mantine/dates": "^6.0.17",
"@mantine/form": "^6.0.17",
"@mantine/hooks": "^6.0.17",
"@mantine/modals": "^6.0.17",
"@mantine/notifications": "^6.0.17",
"@mantine/utils": "^6.0.17",
"@tanstack/react-query": "^4.32.1",
"@tanstack/react-query-devtools": "^4.32.1",
"@tanstack/react-query-persist-client": "^4.32.1",
"@ts-rest/core": "^3.23.0",
"@xhayper/discord-rpc": "^1.0.24",
"axios": "^1.4.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.3.0",
"format-duration": "^2.0.0",
"framer-motion": "^10.13.0",
"fuse.js": "^6.6.2",
"history": "^5.3.0",
"i18next": "^21.10.0",
"idb-keyval": "^6.2.1",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "github:jeffvli/Node-MPV",
"overlayscrollbars": "^2.2.1",
"overlayscrollbars-react": "^0.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.18.6",
"react-icons": "^4.10.1",
"react-player": "^2.11.0",
"react-router": "^6.16.0",
"react-router-dom": "^6.16.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.17",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"styled-components": "^6.0.8",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.9"
},
"resolutions": {
"styled-components": "^6"
},
"devEngines": {
"node": ">=14.x",
"npm": ">=7.x"
},
"browserslist": [],
"electronmon": {
"patterns": [
"!server",
"!src/renderer"
]
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/jeffvli/feishin.git"
},
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"contributors": [],
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/jeffvli/feishin/issues"
},
"keywords": [
"subsonic",
"navidrome",
"airsonic",
"jellyfin",
"react",
"electron"
],
"homepage": "https://github.com/jeffvli/feishin",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleDirectories": [
"node_modules",
"release/app/node_modules"
],
"testPathIgnorePatterns": [
"release/app/dist"
],
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@stylelint/postcss-css-in-js": "^0.38.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.0.0",
"@types/electron-localshortcut": "^3.1.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.188",
"@types/md5": "^2.3.2",
"@types/node": "^17.0.23",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react-test-renderer": "^17.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.6",
"@types/styled-components": "^5.1.26",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.4.1",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.47.0",
"@typescript-eslint/parser": "^5.47.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^7.1.0",
"core-js": "^3.21.1",
"cross-env": "^7.0.3",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"detect-port": "^1.3.0",
"electron": "^22.3.1",
"electron-builder": "^24.0.0-alpha.13",
"electron-devtools-installer": "^3.2.0",
"electron-notarize": "^1.2.1",
"electronmon": "^2.0.2",
"eslint": "^8.30.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.0.3",
"eslint-import-resolver-typescript": "^2.7.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^26.1.3",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-typescript-sort-keys": "^2.1.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^7.0.4",
"i18next-parser": "^6.3.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.7",
"mini-css-extract-plugin": "^2.6.0",
"postcss-scss": "^4.0.4",
"postcss-syntax": "^0.36.2",
"prettier": "^2.6.2",
"react-refresh": "^0.12.0",
"react-refresh-typescript": "^2.0.4",
"react-test-renderer": "^18.0.0",
"rimraf": "^3.0.2",
"sass": "^1.49.11",
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"stylelint": "^14.9.1",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-standard-scss": "^4.0.0",
"stylelint-config-styled-components": "^0.1.1",
"stylelint-order": "^5.0.0",
"stylelint-processor-styled-components": "^1.10.0",
"terser-webpack-plugin": "^5.3.1",
"ts-jest": "^27.1.4",
"ts-loader": "^9.2.8",
"ts-node": "^10.7.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typescript": "^4.8.4",
"typescript-plugin-styled-components": "^2.0.0",
"url-loader": "^4.1.1",
"webpack": "^5.71.0",
"webpack-bundle-analyzer": "^4.5.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.8.0",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@ag-grid-community/client-side-row-model": "^28.2.1",
"@ag-grid-community/core": "^28.2.1",
"@ag-grid-community/infinite-row-model": "^28.2.1",
"@ag-grid-community/react": "^28.2.1",
"@ag-grid-community/styles": "^28.2.1",
"@emotion/react": "^11.10.4",
"@mantine/core": "^6.0.10",
"@mantine/dates": "^6.0.10",
"@mantine/form": "^6.0.10",
"@mantine/hooks": "^6.0.10",
"@mantine/modals": "^6.0.10",
"@mantine/notifications": "^6.0.10",
"@mantine/utils": "^6.0.10",
"@tanstack/react-query": "^4.29.5",
"@tanstack/react-query-devtools": "^4.29.6",
"@ts-rest/core": "^3.19.4",
"axios": "^1.4.0",
"cmdk": "^0.2.0",
"dayjs": "^1.11.6",
"electron-debug": "^3.2.0",
"electron-localshortcut": "^3.2.1",
"electron-log": "^4.4.6",
"electron-store": "^8.1.0",
"electron-updater": "^4.6.5",
"fast-average-color": "^9.2.0",
"format-duration": "^2.0.0",
"framer-motion": "^8.1.3",
"history": "^5.3.0",
"i18next": "^21.6.16",
"immer": "^9.0.21",
"is-electron": "^2.2.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"memoize-one": "^6.0.0",
"nanoid": "^3.3.3",
"net": "^1.0.2",
"node-mpv": "^2.0.0-beta.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-i18next": "^11.16.7",
"react-icons": "^4.8.0",
"react-player": "^2.11.0",
"react-router": "^6.5.0",
"react-router-dom": "^6.5.0",
"react-simple-img": "^3.0.0",
"react-virtualized-auto-sizer": "^1.0.15",
"react-window": "^1.8.9",
"react-window-infinite-loader": "^1.0.9",
"styled-components": "^5.3.10",
"swiper": "^9.3.1",
"zod": "^3.21.4",
"zustand": "^4.3.8"
},
"resolutions": {
"styled-components": "^5"
},
"devEngines": {
"node": ">=14.x",
"npm": ">=7.x"
},
"browserslist": [],
"electronmon": {
"patterns": [
"!server",
"!src/renderer"
]
}
}
+2321 -1989
View File
File diff suppressed because it is too large Load Diff
+22 -20
View File
@@ -1,22 +1,24 @@
{
"name": "feishin",
"version": "0.1.1",
"description": "",
"main": "./dist/main/main.js",
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"scripts": {
"electron-rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run electron-rebuild && npm run link-modules"
},
"dependencies": {
"mpris-service": "^2.1.2"
},
"devDependencies": {
"electron": "22.3.1"
},
"license": "GPL-3.0"
"name": "feishin",
"version": "0.5.0",
"description": "",
"main": "./dist/main/main.js",
"author": {
"name": "jeffvli",
"url": "https://github.com/jeffvli/"
},
"scripts": {
"electron-rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run electron-rebuild && npm run link-modules"
},
"dependencies": {
"cheerio": "^1.0.0-rc.12",
"mpris-service": "^2.1.2",
"ws": "^8.13.0"
},
"devDependencies": {
"electron": "25.3.0"
},
"license": "GPL-3.0"
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { render } from '@testing-library/react';
import { App } from '../renderer/app';
describe('App', () => {
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
});
-32
View File
@@ -1,32 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const en = require('./locales/en.json');
const resources = {
en: { translation: en },
};
export const Languages = [
{
label: 'English',
value: 'en',
},
];
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
lng: 'en',
resources,
});
export default i18n;
+72
View File
@@ -0,0 +1,72 @@
import { PostProcessorModule } from 'i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
const resources = {
en: { translation: en },
};
export const languages = [
{
label: 'English',
value: 'en',
},
];
const lowerCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'lowerCase',
process: (value: string) => {
return value.toLocaleLowerCase();
},
};
const upperCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'upperCase',
process: (value: string) => {
return value.toLocaleUpperCase();
},
};
const titleCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'titleCase',
process: (value: string) => {
return value.replace(/\w\S*/g, (txt) => {
return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
});
},
};
const sentenceCasePostProcessor: PostProcessorModule = {
type: 'postProcessor',
name: 'sentenceCase',
process: (value: string) => {
const sentences = value.split('. ');
return sentences
.map((sentence) => {
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLocaleLowerCase();
})
.join('. ');
},
};
i18n.use(lowerCasePostProcessor)
.use(upperCasePostProcessor)
.use(titleCasePostProcessor)
.use(sentenceCasePostProcessor)
.use(initReactI18next) // passes i18n down to react-i18next
.init({
fallbackLng: 'en',
// language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
resources,
});
export default i18n;
+41 -114
View File
@@ -1,117 +1,44 @@
// i18next-parser.config.js
// Reference: https://github.com/i18next/i18next-parser#options
module.exports = {
contextSeparator: '_',
// Key separator used in your translation keys
createOldCatalogs: true,
// Exit with an exit code of 1 when translations are updated (for CI purpose)
customValueTemplate: null,
// Save the \_old files
defaultNamespace: 'translation',
// Default namespace used in your i18next config
defaultValue: '',
// Exit with an exit code of 1 on warnings
failOnUpdate: false,
// Display info about the parsing including some stats
failOnWarnings: false,
// The locale to compare with default values to determine whether a default value has been changed.
// If this is set and a default value differs from a translation in the specified locale, all entries
// for that key across locales are reset to the default value, and existing translations are moved to
// the `_old` file.
i18nextOptions: null,
// Default value to give to empty keys
// You may also specify a function accepting the locale, namespace, and key as arguments
indentation: 2,
// Plural separator used in your translation keys
// If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
input: [
'../components/**/*.{js,jsx,ts,tsx}',
'../features/**/*.{js,jsx,ts,tsx}',
'../layouts/**/*.{js,jsx,ts,tsx}',
'!../../src/node_modules/**',
'!../../src/**/*.prod.js',
],
// Indentation of the catalog files
keepRemoved: false,
// Keep keys from the catalog that are no longer in code
keySeparator: '.',
// Key separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
// see below for more details
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
// if you're writing jsx inside .js files, change this to JsxLexer
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
// Control the line ending. See options at https://github.com/ryanve/eol
locales: ['en'],
// An array of the locales in your applications
namespaceSeparator: false,
// Namespace separator used in your translation keys
// If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.
output: 'src/renderer/i18n/locales/$LOCALE.json',
// Supports $LOCALE and $NAMESPACE injection
// Supports JSON (.json) and YAML (.yml) file formats
// Where to write the locale files relative to process.cwd()
pluralSeparator: '_',
// If you wish to customize the value output the value as an object, you can set your own format.
// ${defaultValue} is the default value you set in your translation function.
// Any other custom property will be automatically extracted.
//
// Example:
// {
// message: "${defaultValue}",
// description: "${maxLength}", //
// }
resetDefaultValueLocale: 'en',
// Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)
skipDefaultValues: false,
// An array of globs that describe where to look for source files
// relative to the location of the configuration file
sort: true,
// Whether to ignore default values
// You may also specify a function accepting the locale and namespace as arguments
useKeysAsDefaultValue: true,
// Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World"
// This option takes precedence over the `defaultValue` and `skipDefaultValues` options
// You may also specify a function accepting the locale and namespace as arguments
verbose: false,
// If you wish to customize options in internally used i18next instance, you can define an object with any
// configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
// { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.
contextSeparator: '_',
createOldCatalogs: true,
customValueTemplate: null,
defaultNamespace: 'translation',
defaultValue: '',
failOnUpdate: false,
failOnWarnings: false,
i18nextOptions: null,
indentation: 4,
input: [
'../renderer/components/**/*.{js,jsx,ts,tsx}',
'../renderer/features/**/*.{js,jsx,ts,tsx}',
'../renderer/layouts/**/*.{js,jsx,ts,tsx}',
'!../src/node_modules/**',
'!../src/**/*.prod.js',
],
keepRemoved: false,
keySeparator: '.',
lexers: {
default: ['JavascriptLexer'],
handlebars: ['HandlebarsLexer'],
hbs: ['HandlebarsLexer'],
htm: ['HTMLLexer'],
html: ['HTMLLexer'],
js: ['JavascriptLexer'],
jsx: ['JsxLexer'],
mjs: ['JavascriptLexer'],
ts: ['JavascriptLexer'],
tsx: ['JsxLexer'],
},
lineEnding: 'auto',
locales: ['en'],
namespaceSeparator: false,
output: 'src/renderer/i18n/locales/$LOCALE.json',
pluralSeparator: '_',
resetDefaultValueLocale: 'en',
skipDefaultValues: false,
sort: true,
useKeysAsDefaultValue: true,
verbose: false,
};
+604 -7
View File
@@ -1,9 +1,606 @@
{
"player": {
"next": "player.next",
"play": "player.play",
"prev": "player.prev",
"seekBack": "player.seekBack",
"seekForward": "player.seekForward"
}
"action": {
"addToFavorites": "add to $t(entity.favorite_other)",
"addToPlaylist": "add to $t(entity.playlist_one)",
"clearQueue": "clear queue",
"createPlaylist": "create $t(entity.playlist_one)",
"deletePlaylist": "delete $t(entity.playlist_one)",
"deselectAll": "deselect all",
"editPlaylist": "edit $t(entity.playlist_one)",
"goToPage": "go to page",
"moveToBottom": "move to bottom",
"moveToTop": "move to top",
"refresh": "$t(common.refresh)",
"removeFromFavorites": "remove from $t(entity.favorite_other)",
"removeFromPlaylist": "remove from $t(entity.playlist_one)",
"removeFromQueue": "remove from queue",
"setRating": "set rating",
"toggleSmartPlaylistEditor": "toggle $t(entity.smartPlaylist) editor",
"viewPlaylists": "view $t(entity.playlist_other)"
},
"common": {
"action_one": "action",
"action_other": "actions",
"add": "add",
"areYouSure": "are you sure?",
"ascending": "ascending",
"backward": "backward",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"cancel": "cancel",
"center": "center",
"channel_one": "channel",
"channel_other": "channels",
"clear": "clear",
"collapse": "collapse",
"comingSoon": "coming soon...",
"configure": "configure",
"confirm": "confirm",
"create": "create",
"currentSong": "current $t(entity.track_one)",
"decrease": "decrease",
"delete": "delete",
"descending": "descending",
"description": "description",
"disable": "disable",
"disc": "disc",
"dismiss": "dismiss",
"duration": "duration",
"edit": "edit",
"enable": "enable",
"expand": "expand",
"favorite": "favorite",
"filter_one": "filter",
"filter_other": "filters",
"filters": "filters",
"forceRestartRequired": "restart to apply changes... close the notification to restart",
"forward": "forward",
"gap": "gap",
"home": "home",
"increase": "increase",
"left": "left",
"limit": "limit",
"manage": "manage",
"maximize": "maximize",
"menu": "menu",
"minimize": "minimize",
"modified": "modified",
"name": "name",
"no": "no",
"none": "none",
"noResultsFromQuery": "the query returned no results",
"note": "note",
"ok": "ok",
"owner": "owner",
"path": "path",
"playerMustBePaused": "player must be paused",
"previousSong": "previous $t(entity.track_one)",
"quit": "quit",
"random": "random",
"rating": "rating",
"refresh": "refresh",
"reset": "reset",
"resetToDefault": "reset to default",
"restartRequired": "restart required",
"right": "right",
"save": "save",
"saveAndReplace": "save and replace",
"saveAs": "save as",
"search": "search",
"setting": "setting",
"setting_other": "settings",
"size": "size",
"sortOrder": "order",
"title": "title",
"trackNumber": "track",
"unknown": "unknown",
"version": "version",
"year": "year",
"yes": "yes"
},
"entity": {
"album_one": "album",
"album_other": "albums",
"albumArtist_one": "album artist",
"albumArtist_other": "album artists",
"albumArtistCount_one": "{{count}} album artist",
"albumArtistCount_other": "{{count}} album artists",
"albumWithCount_one": "{{count}} album",
"albumWithCount_other": "{{count}} albums",
"artist_one": "artist",
"artist_other": "artists",
"artistWithCount_one": "{{count}} artist",
"artistWithCount_other": "{{count}} artists",
"favorite_one": "favorite",
"favorite_other": "favorites",
"folder_one": "folder",
"folder_other": "folders",
"folderWithCount_one": "{{count}} folder",
"folderWithCount_other": "{{count}} folders",
"genre_one": "genre",
"genre_other": "genres",
"genreWithCount_one": "{{count}} genre",
"genreWithCount_other": "{{count}} genres",
"playlist_one": "playlist",
"playlist_other": "playlists",
"playlistWithCount_one": "{{count}} playlist",
"playlistWithCount_other": "{{count}} playlists",
"smartPlaylist": "smart $t(entity.playlist_one)",
"track_one": "track",
"track_other": "tracks",
"trackWithCount_one": "{{count}} track",
"trackWithCount_other": "{{count}} tracks"
},
"error": {
"apiRouteError": "unable to route request",
"audioDeviceFetchError": "an error occurred when trying to get audio devices",
"authenticationFailed": "authentication failed",
"credentialsRequired": "credentials required",
"endpointNotImplementedError": "endpoint {{endpoint} is not implemented for {{serverType}}",
"genericError": "an error occurred",
"invalidServer": "invalid server",
"localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds",
"mpvRequired": "MPV required",
"playbackError": "an error occurred when trying to play the media",
"remoteDisableError": "an error occurred when trying to $t(common.disable) the remote server",
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
"remotePortError": "an error occurred when trying to set the remote server port",
"remotePortWarning": "restart the server to apply the new port",
"serverNotSelectedError": "no server selected",
"serverRequired": "server required",
"sessionExpiredError": "your session has expired",
"systemFontError": "an error occurred when trying to get system fonts"
},
"filter": {
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"communityRating": "community rating",
"criticRating": "critic rating",
"dateAdded": "date added",
"disc": "disc",
"duration": "duration",
"favorited": "favorited",
"fromYear": "from year",
"isCompilation": "is compilation",
"isFavorited": "is favorited",
"isRated": "is rated",
"isRecentlyPlayed": "is recently played",
"lastPlayed": "last played",
"mostPlayed": "most played",
"name": "name",
"note": "note",
"path": "path",
"playCount": "play count",
"random": "random",
"rating": "rating",
"recentlyAdded": "recently added",
"recentlyPlayed": "recently played",
"releaseDate": "release date",
"releaseYear": "release year",
"search": "search",
"songCount": "song count",
"title": "title",
"toYear": "to year",
"trackNumber": "track"
},
"form": {
"addServer": {
"error_savePassword": "an error occurred when trying to save the password",
"ignoreCors": "ignore cors ($t(common.restartRequired))",
"ignoreSsl": "ignore ssl ($t(common.restartRequired))",
"input_legacyAuthentication": "enable legacy authentication",
"input_name": "server name",
"input_password": "password",
"input_savePassword": "save password",
"input_url": "url",
"input_username": "username",
"success": "server added successfully",
"title": "add server"
},
"addToPlaylist": {
"input_playlists": "$t(entity.playlist_other)",
"input_skipDuplicates": "skip duplicates",
"success": "added {{message}} $t(entity.song_other) to {{numOfPlaylists}} $t(entity.playlist_other)",
"title": "add to $t(entity.playlist_one)"
},
"createPlaylist": {
"input_description": "$t(common.description)",
"input_name": "$t(common.name)",
"input_owner": "$t(common.owner)",
"input_public": "public",
"success": "$t(entity.playlist_one) created successfully",
"title": "create $t(entity.playlist_one)"
},
"deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist_one) to confirm",
"success": "$t(entity.playlist_one) deleted successfully",
"title": "delete $t(entity.playlist_one)"
},
"editPlaylist": {
"title": "edit $t(entity.playlist_one)"
},
"lyricSearch": {
"input_artist": "$t(entity.artist_one)",
"input_name": "$t(common.name)",
"title": "lyric search"
},
"queryEditor": {
"input_optionMatchAll": "match all",
"input_optionMatchAny": "match any"
},
"updateServer": {
"success": "server updated successfully",
"title": "update server"
}
},
"page": {
"albumArtistList": {
"title": "$t(entity.albumArtist_other)"
},
"albumDetail": {
"moreFromArtist": "more from this $t(entity.genre_one)",
"moreFromGeneric": "more from {{item}}"
},
"albumList": {
"title": "$t(entity.album_other)"
},
"appMenu": {
"collapseSidebar": "collapse sidebar",
"expandSidebar": "expand sidebar",
"goBack": "go back",
"goForward": "go forward",
"manageServers": "manage servers",
"openBrowserDevtools": "open browser devtools",
"quit": "$t(common.quit)",
"selectServer": "select server",
"settings": "$t(common.setting_other)",
"version": "version {{version}}"
},
"contextMenu": {
"addFavorite": "$t(action.addToFavorites)",
"addLast": "$t(player.addLast)",
"addNext": "$t(player.addNext)",
"addToFavorites": "$t(action.addToFavorites)",
"addToPlaylist": "$t(action.addToPlaylist)",
"createPlaylist": "$t(action.createPlaylist)",
"deletePlaylist": "$t(action.deletePlaylist)",
"deselectAll": "$t(action.deselectAll)",
"moveToBottom": "$t(action.moveToBottom)",
"moveToTop": "$t(action.moveToTop)",
"numberSelected": "{{count}} selected",
"play": "$t(player.play)",
"removeFromFavorites": "$t(action.removeFromFavorites)",
"removeFromPlaylist": "$t(action.removeFromPlaylist)",
"removeFromQueue": "$t(action.removeFromQueue)",
"setRating": "$t(action.setRating)"
},
"fullscreenPlayer": {
"config": {
"dynamicBackground": "dynamic background",
"followCurrentLyric": "follow current lyric",
"lyricAlignment": "lyric alignment",
"lyricGap": "lyric gap",
"lyricSize": "lyric size",
"opacity": "opacity",
"showLyricMatch": "show lyric match",
"showLyricProvider": "show lyric provider",
"synchronized": "synchronized",
"unsynchronized": "unsynchronized",
"useImageAspectRatio": "use image aspect ratio"
},
"lyrics": "lyrics",
"related": "related",
"upNext": "up next"
},
"genreList": {
"title": "$t(entity.genre_other)"
},
"globalSearch": {
"commands": {
"goToPage": "go to page",
"searchFor": "search for {{query}}",
"serverCommands": "server commands"
},
"title": "commands"
},
"home": {
"explore": "explore from your library",
"mostPlayed": "most played",
"newlyAdded": "newly added releases",
"recentlyPlayed": "recently played",
"title": "$t(common.home)"
},
"playlistList": {
"title": "$t(entity.playlist_other)"
},
"setting": {
"generalTab": "general",
"hotkeysTab": "hotkeys",
"playbackTab": "playback",
"windowTab": "window"
},
"sidebar": {
"albumArtists": "$t(entity.albumArtist_other)",
"albums": "$t(entity.album_other)",
"artists": "$t(entity.artist_other)",
"folders": "$t(entity.folder_other)",
"genres": "$t(entity.genre_other)",
"home": "$t(common.home)",
"nowPlaying": "now playing",
"playlists": "$t(entity.playlist_other)",
"search": "$t(common.search)",
"settings": "$t(entity.setting_other)",
"tracks": "$t(entity.track_other)"
},
"trackList": {
"title": "$t(entity.track_other)"
}
},
"player": {
"addLast": "add last",
"addNext": "add next",
"favorite": "favorite",
"mute": "mute",
"muted": "muted",
"next": "next",
"play": "play",
"playbackFetchCancel": "this is taking a while... close the notification to cancel",
"playbackFetchInProgress": "loading songs...",
"playbackFetchNoResults": "no songs found",
"playbackSpeed": "playback speed",
"playRandom": "play random",
"previous": "previous",
"queue_clear": "clear queue",
"queue_moveToBottom": "move selected to top",
"queue_moveToTop": "move selected to bottom",
"queue_remove": "remove selected",
"repeat": "repeat",
"repeat_all": "repeat all",
"repeat_off": "repeat disabled",
"repeat_one": "repeat one",
"shuffle": "shuffle",
"shuffle_off": "shuffle disabled",
"skip": "skip",
"skip_back": "skip backwards",
"skip_forward": "skip forwards",
"stop": "stop",
"toggleFullscreenPlayer": "toggle fullscreen player",
"unfavorite": "unfavorite"
},
"setting": {
"accentColor": "accent color",
"accentColor_description": "sets the accent color for the application",
"applicationHotkeys": "application hotkeys",
"applicationHotkeys_description": "configure application hotkeys. toggle the checkbox to set as a global hotkey (desktop only)",
"audioDevice": "audio device",
"audioDevice_description": "select the audio device to use for playback (web player only)",
"audioExclusiveMode": "audio exclusive mode",
"audioExclusiveMode_description": "enable exclusive output mode. In this mode, the system is usually locked out, and only mpv will be able to output audio",
"audioPlayer": "audio player",
"audioPlayer_description": "select the audio player to use for playback",
"crossfadeDuration": "crossfade duration",
"crossfadeDuration_description": "sets the duration of the crossfade effect",
"crossfadeStyle": "crossfade style",
"crossfadeStyle_description": "select the crossfade style to use for the audio player",
"customFontPath": "custom font path",
"customFontPath_description": "sets the path to the custom font to use for the application",
"disableAutomaticUpdates": "disable automatic updates",
"disableLibraryUpdateOnStartup": "disable checking for new versions on startup",
"discordApplicationId": "{{discord}} application id",
"discordApplicationId_description": "the application id for {{discord}} rich presence (defaults to {{defaultId}}",
"discordIdleStatus": "show rich presence idle status",
"discordIdleStatus_description": "when enabled, update status while player is idle",
"discordRichPresence": "{{discord}} rich presence",
"discordRichPresence_description": "enable playback status in {{discord}} rich presence. Image keys are: {{icon}}, {{playing}}, and {{paused}} ",
"discordUpdateInterval": "{{discord}} rich presence update interval",
"discordUpdateInterval_description": "the time in seconds between each update (minimum 15 seconds)",
"enableRemote": "enable remote control server",
"enableRemote_description": "enables the remote control server to allow other devices to control the application",
"exitToTray": "exit to tray",
"exitToTray_description": "exit the application to the system tray",
"floatingQueueArea": "show floating queue hover area",
"floatingQueueArea_description": "display a hover icon on the right side of the screen to view the play queue",
"followLyric": "follow current lyric",
"followLyric_description": "scroll the lyric to the current playing position",
"font": "font",
"font_description": "sets the font to use for the application",
"fontType": "font type",
"fontType_description": "built-in font selects one of the fonts provided by Feishin. system font allows you to select any font provided by your operating system. custom allows you to provide your own font",
"fontType_optionBuiltIn": "built-in font",
"fontType_optionCustom": "custom font",
"fontType_optionSystem": "system font",
"gaplessAudio": "gapless audio",
"gaplessAudio_description": "sets the gapless audio setting for mpv",
"gaplessAudio_optionWeak": "weak (recommended)",
"globalMediaHotkeys": "global media hotkeys",
"globalMediaHotkeys_description": "enable or disable the usage of your system media hotkeys to control playback",
"hotkey_browserBack": "browser back",
"hotkey_browserForward": "browser forward",
"hotkey_favoriteCurrentSong": "favorite $t(common.currentSong)",
"hotkey_favoritePreviousSong": "favorite $t(common.previousSong)",
"hotkey_globalSearch": "global search",
"hotkey_localSearch": "in-page search",
"hotkey_playbackNext": "next track",
"hotkey_playbackPause": "pause",
"hotkey_playbackPlay": "play",
"hotkey_playbackPlayPause": "play / pause",
"hotkey_playbackPrevious": "previous track",
"hotkey_playbackStop": "stop",
"hotkey_rate0": "rating clear",
"hotkey_rate1": "rating 1 star",
"hotkey_rate2": "rating 2 stars",
"hotkey_rate3": "rating 3 stars",
"hotkey_rate4": "rating 4 stars",
"hotkey_rate5": "rating 5 stars",
"hotkey_skipBackward": "skip backward",
"hotkey_skipForward": "skip forward",
"hotkey_toggleCurrentSongFavorite": "toggle $t(common.currentSong) favorite",
"hotkey_toggleFullScreenPlayer": "toggle full screen player",
"hotkey_togglePreviousSongFavorite": "toggle $t(common.previousSong) favorite",
"hotkey_toggleQueue": "toggle queue",
"hotkey_toggleRepeat": "toggle repeat",
"hotkey_toggleShuffle": "toggle shuffle",
"hotkey_unfavoriteCurrentSong": "unfavorite $t(common.currentSong)",
"hotkey_unfavoritePreviousSong": "unfavorite $t(common.previousSong)",
"hotkey_volumeDown": "volume down",
"hotkey_volumeMute": "volume mute",
"hotkey_volumeUp": "volume up",
"hotkey_zoomIn": "zoom in",
"hotkey_zoomOut": "zoom out",
"language": "language",
"language_description": "sets the language for the application ($t(common.restartRequired))",
"lyricFetch": "fetch lyrics from the internet",
"lyricFetch_description": "fetch lyrics from various internet sources",
"lyricFetchProvider": "providers to fetch lyrics from",
"lyricFetchProvider_description": "select the providers to fetch lyrics from. the order of the providers is the order in which they will be queried",
"lyricOffset": "lyric offset (ms)",
"lyricOffset_description": "offset the lyric by the specified amount of milliseconds",
"minimizeToTray": "minimize to tray",
"minimizeToTray_description": "minimize the application to the system tray",
"minimumScrobblePercentage": "minimum scrobble duration (percentage)",
"minimumScrobblePercentage_description": "the minimum percentage of the song that must be played before it is scrobbled",
"minimumScrobbleSeconds": "minimum scrobble (seconds)",
"minimumScrobbleSeconds_description": "the minimum duration in seconds of the song that must be played before it is scrobbled",
"mpvExecutablePath": "mpv executable path",
"mpvExecutablePath_description": "sets the path to the mpv executable",
"mpvExecutablePath_help": "one per line",
"mpvExtraParameters": "mpv parameters",
"playbackStyle": "playback style",
"playbackStyle_description": "select the playback style to use for the audio player",
"playbackStyle_optionCrossFade": "crossfade",
"playbackStyle_optionNormal": "normal",
"playButtonBehavior": "play button behavior",
"playButtonBehavior_description": "sets the default behavior of the play button when adding songs to the queue",
"playButtonBehavior_optionAddLast": "$t(player.addLast)",
"playButtonBehavior_optionAddNext": "$t(player.addNext)",
"playButtonBehavior_optionPlay": "$t(player.play)",
"remotePassword": "remote control server password",
"remotePassword_description": "sets the password for the remote control server. These credentials are by default transferred insecurely, so you should use a unique password that you do not care about",
"remotePort": "remote control server port",
"remotePort_description": "sets the port for the remote control server",
"remoteUsername": "remote control server username",
"remoteUsername_description": "sets the username for the remote control server. if both username and password are empty, authentication will be disabled",
"replayGainClipping": "{{ReplayGain}} clipping",
"replayGainClipping_description": "Prevent clipping caused by {{ReplayGain}} by automatically lowering the gain",
"replayGainFallback": "{{ReplayGain}} fallback",
"replayGainFallback_description": "gain in db to apply if the file has no {{ReplayGain}} tags",
"replayGainMode": "{{ReplayGain}} mode",
"replayGainMode_description": "adjust volume gain according to {{ReplayGain}} values stored in the file metadata",
"replayGainMode_optionAlbum": "$t(entity.album_one)",
"replayGainMode_optionNone": "$t(common.none)",
"replayGainMode_optionTrack": "$t(entity.track_one)",
"replayGainPreamp": "{{ReplayGain}} preamp (dB)",
"replayGainPreamp_description": "adjust the preamp gain applied to the {{ReplayGain}} values",
"sampleRate": "sample rate",
"sampleRate_description": "select the output sample rate to be used if the sample frequency selected is different from that of the current media",
"savePlayQueue": "save play queue",
"savePlayQueue_description": "save the play queue when the application is closed and restore it when the application is opened",
"scrobble": "scrobble",
"scrobble_description": "scrobble plays to your media server",
"showSkipButton": "show skip buttons",
"showSkipButton_description": "show or hide the skip buttons on the player bar",
"showSkipButtons": "show skip buttons",
"showSkipButtons_description": "show or hide the skip buttons on the player bar",
"sidebarCollapsedNavigation": "sidebar (collapsed) navigation",
"sidebarCollapsedNavigation_description": "show or hide the navigation in the collapsed sidebar",
"sidebarConfiguration": "sidebar configuration",
"sidebarConfiguration_description": "select the items and order in which they appear in the sidebar",
"sidebarPlaylistList": "sidebar playlist list",
"sidebarPlaylistList_description": "show or hide the playlist list in the sidebar",
"sidePlayQueueStyle": "side play queue style",
"sidePlayQueueStyle_description": "sets the style of the side play queue",
"sidePlayQueueStyle_optionAttached": "attached",
"sidePlayQueueStyle_optionDetached": "detached",
"skipDuration": "skip duration",
"skipDuration_description": "sets the duration to skip when using the skip buttons on the player bar",
"skipPlaylistPage": "skip playlist page",
"skipPlaylistPage_description": "when navigating to a playlist, go to the playlist song list page instead of the default page",
"theme": "theme",
"theme_description": "sets the theme to use for the application",
"themeDark": "theme (dark)",
"themeDark_description": "sets the dark theme to use for the application",
"themeLight": "theme (light)",
"themeLight_description": "sets the light theme to use for the application",
"useSystemTheme": "use system theme",
"useSystemTheme_description": "follow the system-defined light or dark preference",
"volumeWheelStep": "volume wheel step",
"volumeWheelStep_description": "the amount of volume to change when scrolling the mouse wheel on the volume slider",
"windowBarStyle": "window bar style",
"windowBarStyle_description": "select the style of the window bar",
"zoom": "zoom percentage",
"zoom_description": "sets the zoom percentage for the application"
},
"table": {
"column": {
"album": "album",
"albumArtist": "album artist",
"albumCount": "$t(entity.album_other)",
"artist": "$t(entity.artist_one)",
"biography": "biography",
"bitrate": "bitrate",
"bpm": "bpm",
"channels": "$t(common.channel_other)",
"comment": "comment",
"dateAdded": "date added",
"discNumber": "disc",
"favorite": "favorite",
"genre": "$t(entity.genre_one)",
"lastPlayed": "last played",
"path": "path",
"playCount": "plays",
"rating": "rating",
"releaseDate": "release date",
"releaseYear": "year",
"songCount": "$t(entity.track_other)",
"title": "title",
"trackNumber": "track"
},
"config": {
"general": {
"autoFitColumns": "auto fit columns",
"displayType": "display type",
"gap": "$t(common.gap)",
"size": "$t(common.size)",
"tableColumns": "table columns"
},
"label": {
"actions": "$t(common.action_other)",
"album": "$t(entity.album_one)",
"albumArtist": "$t(entity.albumArtist_one)",
"artist": "$t(entity.artist_one)",
"biography": "$t(common.biography)",
"bitrate": "$t(common.bitrate)",
"bpm": "$t(common.bpm)",
"channels": "$t(common.channel_other)",
"dateAdded": "date added",
"discNumber": "disc number",
"duration": "$t(common.duration)",
"favorite": "$t(common.favorite)",
"genre": "$t(entity.genre_one)",
"lastPlayed": "last played",
"note": "$t(common.note)",
"owner": "$t(common.owner)",
"path": "$t(common.path)",
"playCount": "play count",
"rating": "$t(common.rating)",
"releaseDate": "release date",
"rowIndex": "row index",
"size": "$t(common.size)",
"title": "$t(common.title)",
"titleCombined": "$t(common.title) (combined)",
"trackNumber": "track number",
"year": "$t(common.year)"
},
"view": {
"card": "card",
"poster": "poster",
"table": "table"
}
}
}
}
@@ -0,0 +1,63 @@
import { Client, SetActivity } from '@xhayper/discord-rpc';
import { ipcMain } from 'electron';
const FEISHIN_DISCORD_APPLICATION_ID = '1165957668758900787';
let client: Client | null = null;
const createClient = (clientId?: string) => {
client = new Client({
clientId: clientId || FEISHIN_DISCORD_APPLICATION_ID,
});
client.login();
return client;
};
const setActivity = (activity: SetActivity) => {
if (client) {
client.user?.setActivity({
...activity,
});
}
};
const clearActivity = () => {
if (client) {
client.user?.clearActivity();
}
};
const quit = () => {
if (client) {
client?.destroy();
}
};
ipcMain.handle('discord-rpc-initialize', (_event, clientId?: string) => {
createClient(clientId);
});
ipcMain.handle('discord-rpc-set-activity', (_event, activity: SetActivity) => {
if (client) {
setActivity(activity);
}
});
ipcMain.handle('discord-rpc-clear-activity', () => {
if (client) {
clearActivity();
}
});
ipcMain.handle('discord-rpc-quit', () => {
quit();
});
export const discordRpc = {
clearActivity,
createClient,
quit,
setActivity,
};
+3
View File
@@ -1,2 +1,5 @@
import './lyrics';
import './player';
import './remote';
import './settings';
import './discord-rpc';
+209
View File
@@ -0,0 +1,209 @@
import axios, { AxiosResponse } from 'axios';
import { load } from 'cheerio';
import { orderSearchResults } from './shared';
import {
LyricSource,
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
const SEARCH_URL = 'https://genius.com/api/search/song';
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts
export interface GeniusResponse {
meta: Meta;
response: Response;
}
export interface Meta {
status: number;
}
export interface Response {
next_page: number;
sections: Section[];
}
export interface Section {
hits: Hit[];
type: string;
}
export interface Hit {
highlights: any[];
index: string;
result: Result;
type: string;
}
export interface Result {
_type: string;
annotation_count: number;
api_path: string;
artist_names: string;
featured_artists: any[];
full_title: string;
header_image_thumbnail_url: string;
header_image_url: string;
id: number;
instrumental: boolean;
language: string;
lyrics_owner_id: number;
lyrics_state: string;
lyrics_updated_at: number;
path: string;
primary_artist: PrimaryArtist;
pyongs_count: null;
relationships_index_url: string;
release_date_components: ReleaseDateComponents;
release_date_for_display: string;
release_date_with_abbreviated_month_for_display: string;
song_art_image_thumbnail_url: string;
song_art_image_url: string;
stats: Stats;
title: string;
title_with_featured: string;
updated_by_human_at: number;
url: string;
}
export interface PrimaryArtist {
_type: string;
api_path: string;
header_image_url: string;
id: number;
image_url: string;
index_character: string;
is_meme_verified: boolean;
is_verified: boolean;
name: string;
slug: string;
url: string;
}
export interface ReleaseDateComponents {
day: number;
month: number;
year: number;
}
export interface Stats {
hot: boolean;
unreviewed_annotations: number;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<GeniusResponse>;
const searchQuery = [params.artist, params.name].join(' ');
if (!searchQuery) {
return null;
}
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '5',
q: searchQuery,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
return {
artist: song.artist_names,
id: song.url,
name: song.full_title,
source: LyricSource.GENIUS,
};
});
return orderSearchResults({ params, results: songResults });
}
async function getSongId(
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
let result: AxiosResponse<GeniusResponse>;
try {
result = await axios.get(SEARCH_URL, {
params: {
per_page: '1',
q: `${params.artist} ${params.name}`,
},
});
} catch (e) {
console.error('Genius search request got an error!', e);
return null;
}
const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;
if (!hit) {
return null;
}
return {
artist: hit.artist_names,
id: hit.url,
name: hit.full_title,
source: LyricSource.GENIUS,
};
}
export async function getLyricsBySongId(url: string): Promise<string | null> {
let result: AxiosResponse<string, any>;
try {
result = await axios.get<string>(url, { responseType: 'text' });
} catch (e) {
console.error('Genius lyrics request got an error!', e);
return null;
}
const $ = load(result.data.split('<br/>').join('\n'));
const lyricsDiv = $('div.lyrics');
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
const lyricSections = $('div[class^=Lyrics__Container]')
.map((_, e) => $(e).text())
.toArray()
.join('\n');
return lyricSections;
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
const response = await getSongId(params);
if (!response) {
console.error('Could not find the song on Genius!');
return null;
}
const lyrics = await getLyricsBySongId(response.id);
if (!lyrics) {
console.error('Could not get lyrics on Genius!');
return null;
}
return {
artist: response.artist,
id: response.id,
lyrics,
name: response.name,
source: LyricSource.GENIUS,
};
}
+149
View File
@@ -0,0 +1,149 @@
import { ipcMain } from 'electron';
import {
query as queryGenius,
getSearchResults as searchGenius,
getLyricsBySongId as getGenius,
} from './genius';
import {
query as queryLrclib,
getSearchResults as searchLrcLib,
getLyricsBySongId as getLrcLib,
} from './lrclib';
import {
query as queryNetease,
getSearchResults as searchNetease,
getLyricsBySongId as getNetease,
} from './netease';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
QueueSong,
LyricGetQuery,
LyricSource,
} from '../../../../renderer/api/types';
import { store } from '../settings/index';
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
type SearchFetcher = (
params: LyricSearchQuery,
) => Promise<InternetProviderLyricSearchResponse[] | null>;
type GetFetcher = (id: string) => Promise<string | null>;
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
const FETCHERS: Record<LyricSource, SongFetcher> = {
[LyricSource.GENIUS]: queryGenius,
[LyricSource.LRCLIB]: queryLrclib,
[LyricSource.NETEASE]: queryNetease,
};
const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
[LyricSource.GENIUS]: searchGenius,
[LyricSource.LRCLIB]: searchLrcLib,
[LyricSource.NETEASE]: searchNetease,
};
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
[LyricSource.GENIUS]: getGenius,
[LyricSource.LRCLIB]: getLrcLib,
[LyricSource.NETEASE]: getNetease,
};
const MAX_CACHED_ITEMS = 10;
const lyricCache = new Map<string, CachedLyrics>();
const getRemoteLyrics = async (song: QueueSong) => {
const sources = store.get('lyrics', []) as LyricSource[];
const cached = lyricCache.get(song.id);
if (cached) {
for (const source of sources) {
const data = cached[source];
if (data) return data;
}
}
let lyricsFromSource = null;
for (const source of sources) {
const params = {
album: song.album || song.name,
artist: song.artistName,
duration: song.duration,
name: song.name,
};
const response = await FETCHERS[source](params);
if (response) {
const newResult = cached
? {
...cached,
[source]: response,
}
: ({ [source]: response } as CachedLyrics);
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
const toRemove = lyricCache.keys().next().value;
lyricCache.delete(toRemove);
}
lyricCache.set(song.id, newResult);
lyricsFromSource = response;
break;
}
}
return lyricsFromSource;
};
const searchRemoteLyrics = async (params: LyricSearchQuery) => {
const sources = store.get('lyrics', []) as LyricSource[];
const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {
[LyricSource.GENIUS]: [],
[LyricSource.LRCLIB]: [],
[LyricSource.NETEASE]: [],
};
for (const source of sources) {
const response = await SEARCH_FETCHERS[source](params);
if (response) {
response.forEach((result) => {
results[source].push(result);
});
}
}
return results;
};
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
const { remoteSongId, remoteSource } = params;
const response = await GET_FETCHERS[remoteSource](remoteSongId);
if (!response) {
return null;
}
return response;
};
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
const lyric = await getRemoteLyrics(song);
return lyric;
});
ipcMain.handle('lyric-search', async (_event, params: LyricSearchQuery) => {
const lyricResults = await searchRemoteLyrics(params);
return lyricResults;
});
ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
const lyricResults = await getRemoteLyricsById(params);
return lyricResults;
});
+119
View File
@@ -0,0 +1,119 @@
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
import axios, { AxiosResponse } from 'axios';
import { orderSearchResults } from './shared';
import {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
LyricSource,
} from '../../../../renderer/api/types';
const FETCH_URL = 'https://lrclib.net/api/get';
const SEEARCH_URL = 'https://lrclib.net/api/search';
const TIMEOUT_MS = 5000;
export interface LrcLibSearchResponse {
albumName: string;
artistName: string;
id: number;
name: string;
}
export interface LrcLibTrackResponse {
albumName: string;
artistName: string;
duration: number;
id: number;
instrumental: boolean;
isrc: string;
lang: string;
name: string;
plainLyrics: string | null;
releaseDate: string;
spotifyId: string;
syncedLyrics: string | null;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<LrcLibSearchResponse[]>;
if (!params.name) {
return null;
}
try {
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
params: {
q: params.name,
},
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
if (!result.data) return null;
const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {
return {
artist: song.artistName,
id: String(song.id),
name: song.name,
source: LyricSource.LRCLIB,
};
});
return orderSearchResults({ params, results: songResults });
}
export async function getLyricsBySongId(songId: string): Promise<string | null> {
let result: AxiosResponse<LrcLibTrackResponse, any>;
try {
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
} catch (e) {
console.error('LrcLib lyrics request got an error!', e);
return null;
}
return result.data.syncedLyrics || result.data.plainLyrics || null;
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
let result: AxiosResponse<LrcLibTrackResponse, any>;
try {
result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {
params: {
album_name: params.album,
artist_name: params.artist,
duration: params.duration,
track_name: params.name,
},
timeout: TIMEOUT_MS,
});
} catch (e) {
console.error('LrcLib search request got an error!', e);
return null;
}
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
if (!lyrics) {
console.error(`Could not get lyrics on LrcLib!`);
return null;
}
return {
artist: result.data.artistName,
id: String(result.data.id),
lyrics,
name: result.data.name,
source: LyricSource.LRCLIB,
};
}
+167
View File
@@ -0,0 +1,167 @@
import axios, { AxiosResponse } from 'axios';
import { LyricSource } from '../../../../renderer/api/types';
import { orderSearchResults } from './shared';
import type {
InternetProviderLyricResponse,
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '/@/renderer/api/types';
const SEARCH_URL = 'https://music.163.com/api/search/get';
const LYRICS_URL = 'https://music.163.com/api/song/lyric';
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts
export interface NetEaseResponse {
code: number;
result: Result;
}
export interface Result {
hasMore: boolean;
songCount: number;
songs: Song[];
}
export interface Song {
album: Album;
alias: string[];
artists: Artist[];
copyrightId: number;
duration: number;
fee: number;
ftype: number;
id: number;
mark: number;
mvid: number;
name: string;
rUrl: null;
rtype: number;
status: number;
transNames?: string[];
}
export interface Album {
artist: Artist;
copyrightId: number;
id: number;
mark: number;
name: string;
picId: number;
publishTime: number;
size: number;
status: number;
transNames?: string[];
}
export interface Artist {
albumSize: number;
alias: any[];
fansGroup: null;
id: number;
img1v1: number;
img1v1Url: string;
name: string;
picId: number;
picUrl: null;
trans: null;
}
export async function getSearchResults(
params: LyricSearchQuery,
): Promise<InternetProviderLyricSearchResponse[] | null> {
let result: AxiosResponse<NetEaseResponse>;
const searchQuery = [params.artist, params.name].join(' ');
if (!searchQuery) {
return null;
}
try {
result = await axios.get(SEARCH_URL, {
params: {
limit: 5,
offset: 0,
s: searchQuery,
type: '1',
},
});
} catch (e) {
console.error('NetEase search request got an error!', e);
return null;
}
const rawSongsResult = result?.data.result?.songs;
if (!rawSongsResult) return null;
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';
return {
artist,
id: String(song.id),
name: song.name,
source: LyricSource.NETEASE,
};
});
return orderSearchResults({ params, results: songResults });
}
async function getMatchedLyrics(
params: LyricSearchQuery,
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
const results = await getSearchResults(params);
const firstMatch = results?.[0];
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
return null;
}
return firstMatch;
}
export async function getLyricsBySongId(songId: string): Promise<string | null> {
let result: AxiosResponse<any, any>;
try {
result = await axios.get(LYRICS_URL, {
params: {
id: songId,
kv: '-1',
lv: '-1',
},
});
} catch (e) {
console.error('NetEase lyrics request got an error!', e);
return null;
}
return result.data.klyric?.lyric || result.data.lrc?.lyric;
}
export async function query(
params: LyricSearchQuery,
): Promise<InternetProviderLyricResponse | null> {
const lyricsMatch = await getMatchedLyrics(params);
if (!lyricsMatch) {
console.error('Could not find the song on NetEase!');
return null;
}
const lyrics = await getLyricsBySongId(lyricsMatch.id);
if (!lyrics) {
console.error('Could not get lyrics on NetEase!');
return null;
}
return {
artist: lyricsMatch.artist,
id: lyricsMatch.id,
lyrics,
name: lyricsMatch.name,
source: LyricSource.NETEASE,
};
}
+34
View File
@@ -0,0 +1,34 @@
import Fuse from 'fuse.js';
import {
InternetProviderLyricSearchResponse,
LyricSearchQuery,
} from '../../../../renderer/api/types';
export const orderSearchResults = (args: {
params: LyricSearchQuery;
results: InternetProviderLyricSearchResponse[];
}) => {
const { params, results } = args;
const options: Fuse.IFuseOptions<InternetProviderLyricSearchResponse> = {
fieldNormWeight: 1,
includeScore: true,
keys: [
{ getFn: (song) => song.name, name: 'name', weight: 3 },
{ getFn: (song) => song.artist, name: 'artist' },
],
threshold: 1.0,
};
const fuse = new Fuse(results, options);
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
...(params.artist && { artist: params.artist }),
...(params.name && { name: params.name }),
});
return searchResults.map((result) => ({
...result.item,
score: result.score,
}));
};
+143 -68
View File
@@ -1,130 +1,205 @@
import console from 'console';
import { ipcMain } from 'electron';
import { getMainWindow, getMpvInstance } from '../../../main';
import { getMpvInstance } from '../../../main';
import { PlayerData } from '/@/renderer/store';
declare module 'node-mpv';
function wait(timeout: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('resolved');
}, timeout);
});
}
// function wait(timeout: number) {
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve('resolved');
// }, timeout);
// });
// }
ipcMain.handle('player-is-running', async () => {
return getMpvInstance()?.isRunning();
});
ipcMain.handle('player-clean-up', async () => {
getMpvInstance()?.stop();
getMpvInstance()?.clearPlaylist();
});
ipcMain.on('player-start', async () => {
await getMpvInstance()?.play();
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
});
// Starts the player
ipcMain.on('player-play', async () => {
await getMpvInstance()?.play();
await getMpvInstance()
?.play()
.catch((err) => {
console.log('MPV failed to play', err);
});
});
// Pauses the player
ipcMain.on('player-pause', async () => {
await getMpvInstance()?.pause();
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
});
// Stops the player
ipcMain.on('player-stop', async () => {
await getMpvInstance()?.stop();
await getMpvInstance()
?.stop()
.catch((err) => {
console.log('MPV failed to stop', err);
});
});
// Goes to the next track in the playlist
ipcMain.on('player-next', async () => {
await getMpvInstance()?.next();
await getMpvInstance()
?.next()
.catch((err) => {
console.log('MPV failed to go to next', err);
});
});
// Goes to the previous track in the playlist
ipcMain.on('player-previous', async () => {
await getMpvInstance()?.prev();
await getMpvInstance()
?.prev()
.catch((err) => {
console.log('MPV failed to go to previous', err);
});
});
// Seeks forward or backward by the given amount of seconds
ipcMain.on('player-seek', async (_event, time: number) => {
await getMpvInstance()?.seek(time);
await getMpvInstance()
?.seek(time)
.catch((err) => {
console.log('MPV failed to seek', err);
});
});
// Seeks to the given time in seconds
ipcMain.on('player-seek-to', async (_event, time: number) => {
await getMpvInstance()?.goToPosition(time);
await getMpvInstance()
?.goToPosition(time)
.catch((err) => {
console.log(`MPV failed to seek to ${time}`, err);
});
});
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()?.clearPlaylist();
await getMpvInstance()?.pause();
return;
}
if (!data.queue.current && !data.queue.next) {
await getMpvInstance()
?.clearPlaylist()
.catch((err) => {
console.log('MPV failed to clear playlist', err);
});
let complete = false;
let tryAttempts = 0;
while (!complete) {
if (tryAttempts > 3) {
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
complete = true;
} else {
try {
if (data.queue.current) {
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
}
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
complete = true;
} catch (err) {
console.error(err);
tryAttempts += 1;
await wait(500);
}
await getMpvInstance()
?.pause()
.catch((err) => {
console.log('MPV failed to pause', err);
});
return;
}
}
if (pause) {
await getMpvInstance()?.pause();
}
try {
if (data.queue.current) {
await getMpvInstance()
?.load(data.queue.current.streamUrl, 'replace')
.catch((err) => {
console.log('MPV failed to load song', err);
getMpvInstance()?.play();
});
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
}
} catch (err) {
console.error(err);
}
if (pause) {
getMpvInstance()?.pause();
}
});
// Replaces the queue in position 1 to the given data
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
const size = await getMpvInstance()?.getPlaylistSize();
const size = await getMpvInstance()
?.getPlaylistSize()
.catch((err) => {
console.log('MPV failed to get playlist size', err);
});
if (!size) {
return;
}
if (!size) {
return;
}
if (size > 1) {
await getMpvInstance()?.playlistRemove(1);
}
if (size > 1) {
await getMpvInstance()
?.playlistRemove(1)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
});
}
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
});
// Sets the next song in the queue when reaching the end of the queue
ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await getMpvInstance()?.playlistRemove(0);
// Always keep the current song as position 0 in the mpv queue
// This allows us to easily set update the next song in the queue without
// disturbing the currently playing song
await getMpvInstance()
?.playlistRemove(0)
.catch((err) => {
console.log('MPV failed to remove song from playlist', err);
getMpvInstance()?.pause();
});
if (data.queue.next) {
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
}
if (data.queue.next) {
await getMpvInstance()
?.load(data.queue.next.streamUrl, 'append')
.catch((err) => {
console.log('MPV failed to load next song', err);
});
}
});
// Sets the volume to the given value (0-100)
ipcMain.on('player-volume', async (_event, value: number) => {
await getMpvInstance()?.volume(value);
await getMpvInstance()
?.volume(value)
.catch((err) => {
console.log('MPV failed to set volume', err);
});
});
// Toggles the mute status
ipcMain.on('player-mute', async () => {
await getMpvInstance()?.mute();
ipcMain.on('player-mute', async (_event, mute: boolean) => {
await getMpvInstance()
?.mute(mute)
.catch((err) => {
console.log('MPV failed to toggle mute', err);
});
});
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
return getMpvInstance()?.getTimePosition();
});
+16 -16
View File
@@ -2,26 +2,26 @@
import { BrowserWindow, globalShortcut } from 'electron';
export const enableMediaKeys = (window: BrowserWindow | null) => {
globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop');
});
globalShortcut.register('MediaStop', () => {
window?.webContents.send('renderer-player-stop');
});
globalShortcut.register('MediaPlayPause', () => {
window?.webContents.send('renderer-player-play-pause');
});
globalShortcut.register('MediaPlayPause', () => {
window?.webContents.send('renderer-player-play-pause');
});
globalShortcut.register('MediaNextTrack', () => {
window?.webContents.send('renderer-player-next');
});
globalShortcut.register('MediaNextTrack', () => {
window?.webContents.send('renderer-player-next');
});
globalShortcut.register('MediaPreviousTrack', () => {
window?.webContents.send('renderer-player-previous');
});
globalShortcut.register('MediaPreviousTrack', () => {
window?.webContents.send('renderer-player-previous');
});
};
export const disableMediaKeys = () => {
globalShortcut.unregister('MediaStop');
globalShortcut.unregister('MediaPlayPause');
globalShortcut.unregister('MediaNextTrack');
globalShortcut.unregister('MediaPreviousTrack');
globalShortcut.unregister('MediaStop');
globalShortcut.unregister('MediaPlayPause');
globalShortcut.unregister('MediaNextTrack');
globalShortcut.unregister('MediaPreviousTrack');
};
+662
View File
@@ -0,0 +1,662 @@
import { Stats, promises } from 'fs';
import { readFile } from 'fs/promises';
import { IncomingMessage, Server, ServerResponse, createServer } from 'http';
import { join } from 'path';
import { deflate, gzip } from 'zlib';
import axios from 'axios';
import { app, ipcMain } from 'electron';
import { Server as WsServer, WebSocketServer, WebSocket } from 'ws';
import manifest from './manifest.json';
import { ClientEvent, ServerEvent } from '../../../../remote/types';
import { PlayerRepeat, SongUpdate } from '../../../../renderer/types';
import { getMainWindow } from '../../../main';
import { isLinux } from '../../../utils';
let mprisPlayer: any | undefined;
if (isLinux()) {
// eslint-disable-next-line global-require
mprisPlayer = require('../../linux/mpris').mprisPlayer;
}
interface RemoteConfig {
enabled: boolean;
password: string;
port: number;
username: string;
}
interface MimeType {
css: string;
html: string;
ico: string;
js: string;
}
interface StatefulWebSocket extends WebSocket {
alive: boolean;
auth: boolean;
}
let server: Server | undefined;
let wsServer: WsServer<StatefulWebSocket> | undefined;
const settings: RemoteConfig = {
enabled: false,
password: '',
port: 4333,
username: '',
};
type SendData = ServerEvent & {
client: StatefulWebSocket;
};
function send({ client, event, data }: SendData): void {
if (client.readyState === WebSocket.OPEN) {
if (client.alive && client.auth) {
client.send(JSON.stringify({ data, event }));
}
}
}
function broadcast(message: ServerEvent): void {
if (wsServer) {
for (const client of wsServer.clients) {
send({ client, ...message });
}
}
}
const shutdownServer = () => {
if (wsServer) {
wsServer.clients.forEach((client) => client.close(4000));
wsServer.close();
wsServer = undefined;
}
if (server) {
server.close();
server = undefined;
}
};
const MIME_TYPES: MimeType = {
css: 'text/css',
html: 'text/html; charset=UTF-8',
ico: 'image/x-icon',
js: 'application/javascript',
};
const PING_TIMEOUT_MS = 10000;
const UP_TIMEOUT_MS = 5000;
enum Encoding {
GZIP = 'gzip',
NONE = 'none',
ZLIB = 'deflate',
}
const GZIP_REGEX = /\bgzip\b/;
const ZLIB_REGEX = /bdeflate\b/;
let currentSong: SongUpdate = {
currentTime: 0,
};
const getEncoding = (encoding: string | string[]): Encoding => {
const encodingArray = Array.isArray(encoding) ? encoding : [encoding];
for (const code of encodingArray) {
if (code.match(GZIP_REGEX)) {
return Encoding.GZIP;
}
if (code.match(ZLIB_REGEX)) {
return Encoding.ZLIB;
}
}
return Encoding.NONE;
};
const cache = new Map<string, Map<Encoding, [number, Buffer]>>();
function setOk(
res: ServerResponse,
mtimeMs: number,
extension: keyof MimeType,
encoding: Encoding,
data?: Buffer,
) {
res.statusCode = data ? 200 : 304;
res.setHeader('Content-Type', MIME_TYPES[extension]);
res.setHeader('ETag', `"${mtimeMs}"`);
res.setHeader('Cache-Control', 'public');
if (encoding !== 'none') res.setHeader('Content-Encoding', encoding);
res.end(data);
}
async function serveFile(
req: IncomingMessage,
file: string,
extension: keyof MimeType,
res: ServerResponse,
): Promise<void> {
const fileName = `${file}.${extension}`;
const path = app.isPackaged
? join(__dirname, '../remote', fileName)
: join(__dirname, '../../../../../.erb/dll', fileName);
let stats: Stats;
try {
stats = await promises.stat(path);
} catch (error) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end((error as Error).message);
// This is a resolve, even though it is an error, because we want specific (non 500) status
return Promise.resolve();
}
const encodings = req.headers['accept-encoding'] ?? '';
const selectedEncoding = getEncoding(encodings);
const ifMatch = req.headers['if-none-match'];
const fileInfo = cache.get(fileName);
let cached = fileInfo?.get(selectedEncoding);
if (cached && cached[0] !== stats.mtimeMs) {
cache.get(fileName)!.delete(selectedEncoding);
cached = undefined;
}
if (ifMatch && cached) {
const options = ifMatch.split(',');
for (const option of options) {
const mTime = Number(option.replaceAll('"', '').trim());
if (cached[0] === mTime) {
setOk(res, cached[0], extension, selectedEncoding);
return Promise.resolve();
}
}
}
if (!cached || cached[0] !== stats.mtimeMs) {
const content = await readFile(path);
switch (selectedEncoding) {
case Encoding.GZIP:
return new Promise((resolve, reject) => {
gzip(content, (error, result) => {
if (error) {
reject(error);
return;
}
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
resolve();
});
});
case Encoding.ZLIB:
return new Promise((resolve, reject) => {
deflate(content, (error, result) => {
if (error) {
reject(error);
return;
}
const newEntry: [number, Buffer] = [stats.mtimeMs, result];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, result);
resolve();
});
});
default: {
const newEntry: [number, Buffer] = [stats.mtimeMs, content];
if (fileInfo) {
fileInfo.set(selectedEncoding, newEntry);
} else {
cache.set(fileName, new Map([[selectedEncoding, newEntry]]));
}
setOk(res, stats.mtimeMs, extension, selectedEncoding, content);
return Promise.resolve();
}
}
}
setOk(res, cached[0], extension, selectedEncoding, cached[1]);
return Promise.resolve();
}
function authorize(req: IncomingMessage): boolean {
if (settings.username || settings.password) {
// https://stackoverflow.com/questions/23616371/basic-http-authentication-with-node-and-express-4
const authorization = req.headers.authorization?.split(' ')[1] || '';
const [login, password] = Buffer.from(authorization, 'base64').toString().split(':');
return login === settings.username && password === settings.password;
}
return true;
}
const enableServer = (config: RemoteConfig): Promise<void> => {
return new Promise<void>((resolve, reject) => {
try {
if (server) {
server.close();
}
server = createServer({}, async (req, res) => {
if (!authorize(req)) {
res.statusCode = 401;
res.setHeader('WWW-Authenticate', 'Basic realm="401"');
res.end('Authorization required');
return;
}
try {
switch (req.url) {
case '/': {
await serveFile(req, 'index', 'html', res);
break;
}
case '/favicon.ico': {
await serveFile(req, 'favicon', 'ico', res);
break;
}
case '/remote.css': {
await serveFile(req, 'remote', 'css', res);
break;
}
case '/remote.js': {
await serveFile(req, 'remote', 'js', res);
break;
}
case '/manifest.json': {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(manifest));
break;
}
case '/credentials': {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(req.headers.authorization);
break;
}
default: {
if (req.url?.startsWith('/worker.js')) {
await serveFile(req, 'worker', 'js', res);
} else {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not Found');
}
}
}
} catch (error) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/plain');
res.end((error as Error).message);
}
});
server.listen(config.port, resolve);
wsServer = new WebSocketServer({ server });
wsServer.on('connection', (ws) => {
let authFail: number | undefined;
ws.alive = true;
if (!settings.username && !settings.password) {
ws.auth = true;
} else {
authFail = setTimeout(() => {
if (!ws.auth) {
ws.close();
}
}, 10000) as unknown as number;
}
ws.on('error', console.error);
ws.on('message', (data) => {
try {
const json = JSON.parse(data.toString()) as ClientEvent;
const event = json.event;
if (!ws.auth) {
if (event === 'authenticate') {
const auth = json.header.split(' ')[1];
const [login, password] = Buffer.from(auth, 'base64')
.toString()
.split(':');
if (login === settings.username && password === settings.password) {
ws.auth = true;
} else {
ws.close();
}
clearTimeout(authFail);
} else {
return;
}
}
switch (event) {
case 'pause': {
getMainWindow()?.webContents.send('renderer-player-pause');
break;
}
case 'play': {
getMainWindow()?.webContents.send('renderer-player-play');
break;
}
case 'next': {
getMainWindow()?.webContents.send('renderer-player-next');
break;
}
case 'previous': {
getMainWindow()?.webContents.send('renderer-player-previous');
break;
}
case 'proxy': {
const toFetch = currentSong.song?.imageUrl?.replaceAll(
/&(size|width|height=\d+)/g,
'',
);
if (!toFetch) return;
axios
.get(toFetch, { responseType: 'arraybuffer' })
.then((resp) => {
if (ws.readyState === WebSocket.OPEN) {
send({
client: ws,
data: Buffer.from(resp.data, 'binary').toString(
'base64',
),
event: 'proxy',
});
}
return null;
})
.catch((error) => {
if (ws.readyState === WebSocket.OPEN) {
send({
client: ws,
data: error.message,
event: 'error',
});
}
});
break;
}
case 'repeat': {
getMainWindow()?.webContents.send('renderer-player-toggle-repeat');
break;
}
case 'shuffle': {
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle');
break;
}
case 'volume': {
let volume = Number(json.volume);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
getMainWindow()?.webContents.send('request-volume', {
volume,
});
if (mprisPlayer) {
mprisPlayer.volume = volume / 100;
}
break;
}
case 'favorite': {
const { favorite, id } = json;
if (id && id === currentSong.song?.id) {
getMainWindow()?.webContents.send('request-favorite', {
favorite,
id,
serverId: currentSong.song.serverId,
});
}
break;
}
case 'rating': {
const { rating, id } = json;
if (id && id === currentSong.song?.id) {
getMainWindow()?.webContents.send('request-rating', {
id,
rating,
serverId: currentSong.song.serverId,
});
}
break;
}
}
} catch (error) {
console.error(error);
}
});
ws.on('pong', () => {
ws.alive = true;
});
ws.send(JSON.stringify({ data: currentSong, event: 'song' }));
});
const heartBeat = setInterval(() => {
wsServer?.clients.forEach((ws) => {
if (!ws.alive) {
ws.terminate();
return;
}
ws.alive = false;
ws.ping();
});
}, PING_TIMEOUT_MS);
wsServer.on('close', () => {
clearInterval(heartBeat);
});
setTimeout(() => {
reject(new Error('Server did not come up'));
}, UP_TIMEOUT_MS);
} catch (error) {
reject(error);
shutdownServer();
}
});
};
ipcMain.handle('remote-enable', async (_event, enabled: boolean) => {
settings.enabled = enabled;
if (enabled) {
try {
await enableServer(settings);
} catch (error) {
return (error as Error).message;
}
} else {
shutdownServer();
}
return null;
});
ipcMain.handle('remote-port', async (_event, port: number) => {
settings.port = port;
});
ipcMain.on('remote-password', (_event, password: string) => {
settings.password = password;
wsServer?.clients.forEach((client) => client.close(4002));
});
ipcMain.handle(
'remote-settings',
async (_event, enabled: boolean, port: number, username: string, password: string) => {
settings.enabled = enabled;
settings.password = password;
settings.port = port;
settings.username = username;
if (enabled) {
try {
await enableServer(settings);
} catch (error) {
return (error as Error).message;
}
} else {
shutdownServer();
}
return null;
},
);
ipcMain.on('remote-username', (_event, username: string) => {
settings.username = username;
wsServer?.clients.forEach((client) => client.close(4002));
});
ipcMain.on('update-favorite', (_event, favorite: boolean, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
const id = currentSong.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userFavorite = favorite;
broadcast({ data: { favorite, id: songId }, event: 'favorite' });
return;
}
}
});
ipcMain.on('update-rating', (_event, rating: number, serverId: string, ids: string[]) => {
if (currentSong.song?.serverId !== serverId) return;
const id = currentSong.song.id;
for (const songId of ids) {
if (songId === id) {
currentSong.song.userRating = rating;
broadcast({ data: { id: songId, rating }, event: 'rating' });
return;
}
}
});
ipcMain.on('update-repeat', (_event, repeat: PlayerRepeat) => {
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
});
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
});
ipcMain.on('update-song', (_event, data: SongUpdate) => {
const { song, ...rest } = data;
const songChanged = song?.id !== currentSong.song?.id;
if (!song?.id) {
currentSong = {
...currentSong,
...data,
song: undefined,
};
} else {
currentSong = {
...currentSong,
...data,
};
}
if (songChanged) {
broadcast({ data: { ...rest, song: song || null }, event: 'song' });
} else {
broadcast({ data: rest, event: 'song' });
}
});
ipcMain.on('update-volume', (_event, volume: number) => {
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
});
if (mprisPlayer) {
mprisPlayer.on('loopStatus', (event: string) => {
const repeat =
event === 'Playlist'
? PlayerRepeat.ALL
: event === 'Track'
? PlayerRepeat.ONE
: PlayerRepeat.NONE;
currentSong.repeat = repeat;
broadcast({ data: { repeat }, event: 'song' });
});
mprisPlayer.on('shuffle', (shuffle: boolean) => {
currentSong.shuffle = shuffle;
broadcast({ data: { shuffle }, event: 'song' });
});
mprisPlayer.on('volume', (vol: number) => {
let volume = Math.round(vol * 100);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
currentSong.volume = volume;
broadcast({ data: { volume }, event: 'song' });
});
}
@@ -0,0 +1,17 @@
{
"name": "Feishin Remote",
"short_name": "Feishin Remote",
"start_url": "/",
"background_color": "#000100",
"theme_color": "#E7E7E7",
"icons": [
{
"src": "favicon.ico",
"sizes": "32x32",
"type": "image/png",
"purpose": "maskable any"
}
],
"display": "standalone",
"orientation": "portrait"
}
+41 -3
View File
@@ -1,12 +1,50 @@
import { ipcMain } from 'electron';
import { ipcMain, safeStorage } from 'electron';
import Store from 'electron-store';
export const store = new Store();
ipcMain.handle('settings-get', (_event, data: { property: string }) => {
return store.get(`${data.property}`);
return store.get(`${data.property}`);
});
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
store.set(`${data.property}`, data.value);
store.set(`${data.property}`, data.value);
});
ipcMain.handle('password-get', (_event, server: string): string | null => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
if (!servers) {
return null;
}
const encrypted = servers[server];
if (!encrypted) return null;
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
return decrypted;
}
return null;
});
ipcMain.on('password-remove', (_event, server: string) => {
const passwords = store.get('server', {}) as Record<string, string>;
if (server in passwords) {
delete passwords[server];
}
store.set({ server: passwords });
});
ipcMain.handle('password-set', (_event, password: string, server: string) => {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(password);
const passwords = store.get('server', {}) as Record<string, string>;
passwords[server] = encrypted.toString('hex');
store.set({ server: passwords });
return true;
}
return false;
});
+103 -102
View File
@@ -1,168 +1,169 @@
import { ipcMain } from 'electron';
import Player from 'mpris-service';
import { QueueSong, RelatedArtist } from '../../../renderer/api/types';
import { PlayerRepeat, PlayerStatus, SongUpdate } from '../../../renderer/types';
import { getMainWindow } from '../../main';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
const mprisPlayer = Player({
identity: 'Feishin',
maximumRate: 1.0,
minimumRate: 1.0,
name: 'Feishin',
rate: 1.0,
supportedInterfaces: ['player'],
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
supportedUriSchemes: ['file'],
identity: 'Feishin',
maximumRate: 1.0,
minimumRate: 1.0,
name: 'Feishin',
rate: 1.0,
supportedInterfaces: ['player'],
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
supportedUriSchemes: ['file'],
});
mprisPlayer.on('quit', () => {
process.exit();
process.exit();
});
mprisPlayer.on('stop', () => {
getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused';
getMainWindow()?.webContents.send('renderer-player-stop');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('pause', () => {
getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused';
getMainWindow()?.webContents.send('renderer-player-pause');
mprisPlayer.playbackStatus = 'Paused';
});
mprisPlayer.on('play', () => {
getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing';
getMainWindow()?.webContents.send('renderer-player-play');
mprisPlayer.playbackStatus = 'Playing';
});
mprisPlayer.on('playpause', () => {
getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
} else {
mprisPlayer.playbackStatus = 'Paused';
}
getMainWindow()?.webContents.send('renderer-player-play-pause');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
} else {
mprisPlayer.playbackStatus = 'Paused';
}
});
mprisPlayer.on('next', () => {
getMainWindow()?.webContents.send('renderer-player-next');
getMainWindow()?.webContents.send('renderer-player-next');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
}
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = 'Playing';
}
});
mprisPlayer.on('previous', () => {
getMainWindow()?.webContents.send('renderer-player-previous');
getMainWindow()?.webContents.send('renderer-player-previous');
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
}
if (mprisPlayer.playbackStatus !== 'Playing') {
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
}
});
mprisPlayer.on('volume', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-volume', {
volume: event,
});
mprisPlayer.on('volume', (vol: number) => {
let volume = Math.round(vol * 100);
if (volume > 100) {
volume = 100;
} else if (volume < 0) {
volume = 0;
}
getMainWindow()?.webContents.send('request-volume', {
volume,
});
});
mprisPlayer.on('shuffle', (event: boolean) => {
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
mprisPlayer.shuffle = event;
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
mprisPlayer.shuffle = event;
});
mprisPlayer.on('loopStatus', (event: string) => {
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
mprisPlayer.loopStatus = event;
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
mprisPlayer.loopStatus = event;
});
mprisPlayer.on('position', (event: any) => {
getMainWindow()?.webContents.send('mpris-request-position', {
position: event.position / 1e6,
});
getMainWindow()?.webContents.send('request-position', {
position: event.position / 1e6,
});
});
mprisPlayer.on('seek', (event: number) => {
getMainWindow()?.webContents.send('mpris-request-seek', {
offset: event / 1e6,
});
getMainWindow()?.webContents.send('request-seek', {
offset: event / 1e6,
});
});
ipcMain.on('mpris-update-position', (_event, arg) => {
mprisPlayer.getPosition = () => arg * 1e6;
mprisPlayer.getPosition = () => arg * 1e6;
});
ipcMain.on('mpris-update-seek', (_event, arg) => {
mprisPlayer.seeked(arg * 1e6);
mprisPlayer.seeked(arg * 1e6);
});
ipcMain.on('mpris-update-volume', (_event, arg) => {
mprisPlayer.volume = Number(arg);
ipcMain.on('update-volume', (_event, volume) => {
mprisPlayer.volume = Number(volume) / 100;
});
ipcMain.on('mpris-update-repeat', (_event, arg) => {
mprisPlayer.loopStatus = arg;
const REPEAT_TO_MPRIS: Record<PlayerRepeat, string> = {
[PlayerRepeat.ALL]: 'Playlist',
[PlayerRepeat.ONE]: 'Track',
[PlayerRepeat.NONE]: 'None',
};
ipcMain.on('update-repeat', (_event, arg: PlayerRepeat) => {
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[arg];
});
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
mprisPlayer.shuffle = arg;
ipcMain.on('update-shuffle', (_event, shuffle: boolean) => {
mprisPlayer.shuffle = shuffle;
});
ipcMain.on(
'mpris-update-song',
(
_event,
args: {
currentTime: number;
repeat: PlayerRepeat;
shuffle: PlayerShuffle;
song: QueueSong;
status: PlayerStatus;
},
) => {
ipcMain.on('update-song', (_event, args: SongUpdate) => {
const { song, status, repeat, shuffle } = args || {};
try {
mprisPlayer.playbackStatus = status;
mprisPlayer.playbackStatus = status === PlayerStatus.PLAYING ? 'Playing' : 'Paused';
if (repeat) {
mprisPlayer.loopStatus =
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
}
if (repeat) {
mprisPlayer.loopStatus = REPEAT_TO_MPRIS[repeat];
}
if (shuffle) {
mprisPlayer.shuffle = shuffle;
}
if (shuffle) {
mprisPlayer.shuffle = shuffle;
}
if (!song) return;
if (!song) return;
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
const upsizedImageUrl = song.imageUrl
? song.imageUrl
?.replace(/&size=\d+/, '&size=300')
.replace(/\?width=\d+/, '?width=300')
.replace(/&height=\d+/, '&height=300')
: null;
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
'mpris:trackid': song?.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
'xesam:artist':
song.artists?.length !== 0
? song.artists?.map((artist: RelatedArtist) => artist.name)
: null,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
};
mprisPlayer.metadata = {
'mpris:artUrl': upsizedImageUrl,
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e3) : null,
'mpris:trackid': song.id
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
: '',
'xesam:album': song.album || null,
'xesam:albumArtist': song.albumArtists?.length
? song.albumArtists.map((artist) => artist.name)
: null,
'xesam:artist': song.artists?.length ? song.artists.map((artist) => artist.name) : null,
'xesam:discNumber': song.discNumber ? song.discNumber : null,
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
'xesam:title': song.name || null,
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
'xesam:useCount':
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
};
} catch (err) {
console.log(err);
console.log(err);
}
},
);
});
export { mprisPlayer };
+543 -454
View File
File diff suppressed because it is too large Load Diff
+255 -245
View File
@@ -1,269 +1,279 @@
import { app, Menu, shell, BrowserWindow, MenuItemConstructorOptions } from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
mainWindow: BrowserWindow;
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
const template =
process.platform === 'darwin' ? this.buildDarwinTemplate() : this.buildDefaultTemplate();
buildMenu(): Menu {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true') {
this.setupDevelopmentEnvironment();
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
return menu;
}
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
return menu;
}
Menu.buildFromTemplate([
{
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
accelerator: 'Command+H',
label: 'Hide ElectronReact',
selector: 'hide:',
},
{
accelerator: 'Command+Shift+H',
label: 'Hide Others',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
accelerator: 'Command+Q',
click: () => {
app.quit();
},
label: 'Quit',
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' },
{ accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' },
{ type: 'separator' },
{ accelerator: 'Command+X', label: 'Cut', selector: 'cut:' },
{ accelerator: 'Command+C', label: 'Copy', selector: 'copy:' },
{ accelerator: 'Command+V', label: 'Paste', selector: 'paste:' },
{
accelerator: 'Command+A',
label: 'Select All',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: 'Reload',
},
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
{
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle Developer Tools',
},
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
accelerator: 'Command+M',
label: 'Minimize',
selector: 'performMiniaturize:',
},
{ accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
};
const subMenuView =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
accelerator: 'Ctrl+O',
label: '&Open',
},
{
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
label: '&Close',
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? [
Menu.buildFromTemplate([
{
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: '&Reload',
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
label: 'Inspect element',
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
accelerator: 'Command+H',
label: 'Hide ElectronReact',
selector: 'hide:',
},
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle &Full Screen',
accelerator: 'Command+Shift+H',
label: 'Hide Others',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
accelerator: 'Command+Q',
click: () => {
app.quit();
},
label: 'Quit',
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ accelerator: 'Command+Z', label: 'Undo', selector: 'undo:' },
{ accelerator: 'Shift+Command+Z', label: 'Redo', selector: 'redo:' },
{ type: 'separator' },
{ accelerator: 'Command+X', label: 'Cut', selector: 'cut:' },
{ accelerator: 'Command+C', label: 'Copy', selector: 'copy:' },
{ accelerator: 'Command+V', label: 'Paste', selector: 'paste:' },
{
accelerator: 'Command+A',
label: 'Select All',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: 'Reload',
},
{
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle &Developer Tools',
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
]
: [
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle &Full Screen',
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle Developer Tools',
},
],
},
{
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/tree/main/docs#readme');
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
},
];
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
label: 'Toggle Full Screen',
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
accelerator: 'Command+M',
label: 'Minimize',
selector: 'performMiniaturize:',
},
{ accelerator: 'Command+W', label: 'Close', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
};
return templateDefault;
}
const subMenuView =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
accelerator: 'Ctrl+O',
label: '&Open',
},
{
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
label: '&Close',
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'
? [
{
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
label: '&Reload',
},
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
label: 'Toggle &Full Screen',
},
{
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
label: 'Toggle &Developer Tools',
},
]
: [
{
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
label: 'Toggle &Full Screen',
},
],
},
{
label: 'Help',
submenu: [
{
click() {
shell.openExternal('https://electronjs.org');
},
label: 'Learn More',
},
{
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
label: 'Documentation',
},
{
click() {
shell.openExternal('https://www.electronjs.org/community');
},
label: 'Community Discussions',
},
{
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
label: 'Search Issues',
},
],
},
];
return templateDefault;
}
}
+13 -7
View File
@@ -1,17 +1,23 @@
import { contextBridge } from 'electron';
import { browser } from './preload/browser';
import { discordRpc } from './preload/discord-rpc';
import { ipc } from './preload/ipc';
import { localSettings } from './preload/local-settings';
import { lyrics } from './preload/lyrics';
import { mpris } from './preload/mpris';
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
import { remote } from './preload/remote';
import { utils } from './preload/utils';
contextBridge.exposeInMainWorld('electron', {
browser,
ipc,
localSettings,
mpris,
mpvPlayer,
mpvPlayerListener,
utils,
browser,
discordRpc,
ipc,
localSettings,
lyrics,
mpris,
mpvPlayer,
mpvPlayerListener,
remote,
utils,
});
+18 -10
View File
@@ -1,26 +1,34 @@
import { ipcRenderer } from 'electron';
const exit = () => {
ipcRenderer.send('window-close');
ipcRenderer.send('window-close');
};
const maximize = () => {
ipcRenderer.send('window-maximize');
ipcRenderer.send('window-maximize');
};
const minimize = () => {
ipcRenderer.send('window-minimize');
ipcRenderer.send('window-minimize');
};
const unmaximize = () => {
ipcRenderer.send('window-unmaximize');
ipcRenderer.send('window-unmaximize');
};
const quit = () => {
ipcRenderer.send('window-quit');
};
const devtools = () => {
ipcRenderer.send('window-dev-tools');
ipcRenderer.send('window-dev-tools');
};
export const browser = {
devtools,
exit,
maximize,
minimize,
unmaximize,
devtools,
exit,
maximize,
minimize,
quit,
unmaximize,
};
+28
View File
@@ -0,0 +1,28 @@
import { SetActivity } from '@xhayper/discord-rpc';
import { ipcRenderer } from 'electron';
const initialize = (clientId: string) => {
const client = ipcRenderer.invoke('discord-rpc-initialize', clientId);
return client;
};
const clearActivity = () => {
ipcRenderer.invoke('discord-rpc-clear-activity');
};
const setActivity = (activity: SetActivity) => {
ipcRenderer.invoke('discord-rpc-set-activity', activity);
};
const quit = () => {
ipcRenderer.invoke('discord-rpc-quit');
};
export const discordRpc = {
clearActivity,
initialize,
quit,
setActivity,
};
export type DiscordRpc = typeof discordRpc;
+6 -4
View File
@@ -1,14 +1,16 @@
import { ipcRenderer } from 'electron';
const removeAllListeners = (channel: string) => {
ipcRenderer.removeAllListeners(channel);
ipcRenderer.removeAllListeners(channel);
};
const send = (channel: string, ...args: any[]) => {
ipcRenderer.send(channel, ...args);
ipcRenderer.send(channel, ...args);
};
export const ipc = {
removeAllListeners,
send,
removeAllListeners,
send,
};
export type Ipc = typeof ipc;
+38 -11
View File
@@ -1,32 +1,59 @@
import { ipcRenderer } from 'electron';
import { IpcRendererEvent, ipcRenderer, webFrame } from 'electron';
import Store from 'electron-store';
const store = new Store();
const set = (property: string, value: string | Record<string, unknown> | boolean | string[]) => {
store.set(`${property}`, value);
store.set(`${property}`, value);
};
const get = (property: string) => {
return store.get(`${property}`);
return store.get(`${property}`);
};
const restart = () => {
ipcRenderer.send('app-restart');
ipcRenderer.send('app-restart');
};
const enableMediaKeys = () => {
ipcRenderer.send('global-media-keys-enable');
ipcRenderer.send('global-media-keys-enable');
};
const disableMediaKeys = () => {
ipcRenderer.send('global-media-keys-disable');
ipcRenderer.send('global-media-keys-disable');
};
const passwordGet = async (server: string): Promise<string | null> => {
return ipcRenderer.invoke('password-get', server);
};
const passwordRemove = (server: string) => {
ipcRenderer.send('password-remove', server);
};
const passwordSet = async (password: string, server: string): Promise<boolean> => {
return ipcRenderer.invoke('password-set', password, server);
};
const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100);
};
const fontError = (cb: (event: IpcRendererEvent, file: string) => void) => {
ipcRenderer.on('custom-font-error', cb);
};
export const localSettings = {
disableMediaKeys,
enableMediaKeys,
get,
restart,
set,
disableMediaKeys,
enableMediaKeys,
fontError,
get,
passwordGet,
passwordRemove,
passwordSet,
restart,
set,
setZoomFactor,
};
export type LocalSettings = typeof localSettings;
+33
View File
@@ -0,0 +1,33 @@
import { ipcRenderer } from 'electron';
import {
InternetProviderLyricSearchResponse,
LyricGetQuery,
LyricSearchQuery,
LyricSource,
QueueSong,
} from '/@/renderer/api/types';
const getRemoteLyricsBySong = (song: QueueSong) => {
const result = ipcRenderer.invoke('lyric-by-song', song);
return result;
};
const searchRemoteLyrics = (
params: LyricSearchQuery,
): Promise<Record<LyricSource, InternetProviderLyricSearchResponse[]>> => {
const result = ipcRenderer.invoke('lyric-search', params);
return result;
};
const getRemoteLyricsByRemoteId = (id: LyricGetQuery) => {
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
return result;
};
export const lyrics = {
getRemoteLyricsByRemoteId,
getRemoteLyricsBySong,
searchRemoteLyrics,
};
export type Lyrics = typeof lyrics;
+21 -50
View File
@@ -1,70 +1,41 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { QueueSong } from '/@/renderer/api/types';
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
ipcRenderer.send('mpris-update-song', args);
};
import type { PlayerRepeat } from '/@/renderer/types';
const updatePosition = (timeSec: number) => {
ipcRenderer.send('mpris-update-position', timeSec);
ipcRenderer.send('mpris-update-position', timeSec);
};
const updateSeek = (timeSec: number) => {
ipcRenderer.send('mpris-update-seek', timeSec);
};
const updateVolume = (volume: number) => {
ipcRenderer.send('mpris-update-volume', volume);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('mpris-update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('mpris-update-shuffle', shuffle);
ipcRenderer.send('mpris-update-seek', timeSec);
};
const toggleRepeat = () => {
ipcRenderer.send('mpris-toggle-repeat');
ipcRenderer.send('mpris-toggle-repeat');
};
const toggleShuffle = () => {
ipcRenderer.send('mpris-toggle-shuffle');
ipcRenderer.send('mpris-toggle-shuffle');
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('mpris-request-position', cb);
const requestToggleRepeat = (
cb: (event: IpcRendererEvent, data: { repeat: PlayerRepeat }) => void,
) => {
ipcRenderer.on('mpris-request-toggle-repeat', cb);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('mpris-request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('mpris-request-volume', cb);
};
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-repeat', cb);
};
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
const requestToggleShuffle = (
cb: (event: IpcRendererEvent, data: { shuffle: boolean }) => void,
) => {
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
};
export const mpris = {
requestPosition,
requestSeek,
requestToggleRepeat,
requestToggleShuffle,
requestVolume,
toggleRepeat,
toggleShuffle,
updatePosition,
updateRepeat,
updateSeek,
updateShuffle,
updateSong,
updateVolume,
requestToggleRepeat,
requestToggleShuffle,
toggleRepeat,
toggleShuffle,
updatePosition,
updateSeek,
};
export type Mpris = typeof mpris;
+100 -80
View File
@@ -1,199 +1,219 @@
import { ipcRenderer, IpcRendererEvent } from 'electron';
import { PlayerData } from '/@/renderer/store';
import { PlayerData, PlayerState } from '/@/renderer/store';
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-initialize', data);
ipcRenderer.send('player-initialize', data);
};
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
ipcRenderer.send('player-restart', data);
ipcRenderer.send('player-restart', data);
};
const isRunning = () => {
return ipcRenderer.invoke('player-is-running');
};
const cleanup = () => {
return ipcRenderer.invoke('player-clean-up');
};
const setProperties = (data: Record<string, any>) => {
console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data);
console.log('Setting property :>>', data);
ipcRenderer.send('player-set-properties', data);
};
const autoNext = (data: PlayerData) => {
ipcRenderer.send('player-auto-next', data);
ipcRenderer.send('player-auto-next', data);
};
const currentTime = () => {
ipcRenderer.send('player-current-time');
ipcRenderer.send('player-current-time');
};
const mute = () => {
ipcRenderer.send('player-mute');
const mute = (mute: boolean) => {
ipcRenderer.send('player-mute', mute);
};
const next = () => {
ipcRenderer.send('player-next');
ipcRenderer.send('player-next');
};
const pause = () => {
ipcRenderer.send('player-pause');
ipcRenderer.send('player-pause');
};
const play = () => {
ipcRenderer.send('player-play');
ipcRenderer.send('player-play');
};
const previous = () => {
ipcRenderer.send('player-previous');
ipcRenderer.send('player-previous');
};
const restoreQueue = () => {
ipcRenderer.send('player-restore-queue');
ipcRenderer.send('player-restore-queue');
};
const saveQueue = (data: Record<string, any>) => {
ipcRenderer.send('player-save-queue', data);
ipcRenderer.send('player-save-queue', data);
};
const seek = (seconds: number) => {
ipcRenderer.send('player-seek', seconds);
ipcRenderer.send('player-seek', seconds);
};
const seekTo = (seconds: number) => {
ipcRenderer.send('player-seek-to', seconds);
ipcRenderer.send('player-seek-to', seconds);
};
const setQueue = (data: PlayerData, pause?: boolean) => {
ipcRenderer.send('player-set-queue', data, pause);
ipcRenderer.send('player-set-queue', data, pause);
};
const setQueueNext = (data: PlayerData) => {
ipcRenderer.send('player-set-queue-next', data);
ipcRenderer.send('player-set-queue-next', data);
};
const stop = () => {
ipcRenderer.send('player-stop');
ipcRenderer.send('player-stop');
};
const volume = (value: number) => {
ipcRenderer.send('player-volume', value);
ipcRenderer.send('player-volume', value);
};
const quit = () => {
ipcRenderer.send('player-quit');
ipcRenderer.send('player-quit');
};
const getCurrentTime = async () => {
return ipcRenderer.invoke('player-get-time');
};
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-auto-next', cb);
ipcRenderer.on('renderer-player-auto-next', cb);
};
const rendererCurrentTime = (cb: (event: IpcRendererEvent, data: number) => void) => {
ipcRenderer.on('renderer-player-current-time', cb);
ipcRenderer.on('renderer-player-current-time', cb);
};
const rendererNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-next', cb);
ipcRenderer.on('renderer-player-next', cb);
};
const rendererPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-pause', cb);
ipcRenderer.on('renderer-player-pause', cb);
};
const rendererPlay = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play', cb);
ipcRenderer.on('renderer-player-play', cb);
};
const rendererPlayPause = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-play-pause', cb);
ipcRenderer.on('renderer-player-play-pause', cb);
};
const rendererPrevious = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-previous', cb);
ipcRenderer.on('renderer-player-previous', cb);
};
const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-stop', cb);
ipcRenderer.on('renderer-player-stop', cb);
};
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-forward', cb);
ipcRenderer.on('renderer-player-skip-forward', cb);
};
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-skip-backward', cb);
ipcRenderer.on('renderer-player-skip-backward', cb);
};
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-up', cb);
ipcRenderer.on('renderer-player-volume-up', cb);
};
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-down', cb);
ipcRenderer.on('renderer-player-volume-down', cb);
};
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-volume-mute', cb);
ipcRenderer.on('renderer-player-volume-mute', cb);
};
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-repeat', cb);
ipcRenderer.on('renderer-player-toggle-repeat', cb);
};
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
};
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-quit', cb);
ipcRenderer.on('renderer-player-quit', cb);
};
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-save-queue', cb);
ipcRenderer.on('renderer-player-save-queue', cb);
};
const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
const rendererRestoreQueue = (
cb: (event: IpcRendererEvent, data: Partial<PlayerState>) => void,
) => {
ipcRenderer.on('renderer-player-restore-queue', cb);
};
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
ipcRenderer.on('renderer-player-error', cb);
ipcRenderer.on('renderer-player-error', cb);
};
export const mpvPlayer = {
autoNext,
currentTime,
initialize,
mute,
next,
pause,
play,
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
setQueue,
setQueueNext,
stop,
volume,
autoNext,
cleanup,
currentTime,
getCurrentTime,
initialize,
isRunning,
mute,
next,
pause,
play,
previous,
quit,
restart,
restoreQueue,
saveQueue,
seek,
seekTo,
setProperties,
setQueue,
setQueueNext,
stop,
volume,
};
export const mpvPlayerListener = {
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
rendererAutoNext,
rendererCurrentTime,
rendererError,
rendererNext,
rendererPause,
rendererPlay,
rendererPlayPause,
rendererPrevious,
rendererQuit,
rendererRestoreQueue,
rendererSaveQueue,
rendererSkipBackward,
rendererSkipForward,
rendererStop,
rendererToggleRepeat,
rendererToggleShuffle,
rendererVolumeDown,
rendererVolumeMute,
rendererVolumeUp,
};
export type MpvPLayer = typeof mpvPlayer;
export type MpvPlayerListener = typeof mpvPlayerListener;
+101
View File
@@ -0,0 +1,101 @@
import { IpcRendererEvent, ipcRenderer } from 'electron';
import { SongUpdate } from '/@/renderer/types';
const requestFavorite = (
cb: (
event: IpcRendererEvent,
data: { favorite: boolean; id: string; serverId: string },
) => void,
) => {
ipcRenderer.on('request-favorite', cb);
};
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
ipcRenderer.on('request-position', cb);
};
const requestRating = (
cb: (event: IpcRendererEvent, data: { id: string; rating: number; serverId: string }) => void,
) => {
ipcRenderer.on('request-rating', cb);
};
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
ipcRenderer.on('request-seek', cb);
};
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
ipcRenderer.on('request-volume', cb);
};
const setRemoteEnabled = (enabled: boolean): Promise<string | null> => {
const result = ipcRenderer.invoke('remote-enable', enabled);
return result;
};
const setRemotePort = (port: number): Promise<string | null> => {
const result = ipcRenderer.invoke('remote-port', port);
return result;
};
const updateFavorite = (favorite: boolean, serverId: string, ids: string[]) => {
ipcRenderer.send('update-favorite', favorite, serverId, ids);
};
const updatePassword = (password: string) => {
ipcRenderer.send('remote-password', password);
};
const updateSetting = (
enabled: boolean,
port: number,
username: string,
password: string,
): Promise<string | null> => {
return ipcRenderer.invoke('remote-settings', enabled, port, username, password);
};
const updateRating = (rating: number, serverId: string, ids: string[]) => {
ipcRenderer.send('update-rating', rating, serverId, ids);
};
const updateRepeat = (repeat: string) => {
ipcRenderer.send('update-repeat', repeat);
};
const updateShuffle = (shuffle: boolean) => {
ipcRenderer.send('update-shuffle', shuffle);
};
const updateSong = (args: SongUpdate) => {
ipcRenderer.send('update-song', args);
};
const updateUsername = (username: string) => {
ipcRenderer.send('remote-username', username);
};
const updateVolume = (volume: number) => {
ipcRenderer.send('update-volume', volume);
};
export const remote = {
requestFavorite,
requestPosition,
requestRating,
requestSeek,
requestVolume,
setRemoteEnabled,
setRemotePort,
updateFavorite,
updatePassword,
updateRating,
updateRepeat,
updateSetting,
updateShuffle,
updateSong,
updateUsername,
updateVolume,
};
export type Remote = typeof remote;
+5 -3
View File
@@ -1,7 +1,9 @@
import { isMacOS, isWindows, isLinux } from '../utils';
export const utils = {
isLinux,
isMacOS,
isWindows,
isLinux,
isMacOS,
isWindows,
};
export type Utils = typeof utils;
+27 -27
View File
@@ -6,47 +6,47 @@ import { URL } from 'url';
export let resolveHtmlPath: (htmlFileName: string) => string;
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
};
const port = process.env.PORT || 4343;
resolveHtmlPath = (htmlFileName: string) => {
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
};
} else {
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
};
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
};
}
export const isMacOS = () => {
return process.platform === 'darwin';
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
return process.platform === 'linux';
};
export const hotkeyToElectronAccelerator = (hotkey: string) => {
let accelerator = hotkey;
let accelerator = hotkey;
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
const replacements = {
mod: 'CmdOrCtrl',
numpad: 'num',
numpadadd: 'numadd',
numpaddecimal: 'numdec',
numpaddivide: 'numdiv',
numpadenter: 'numenter',
numpadmultiply: 'nummult',
numpadsubtract: 'numsub',
};
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
Object.keys(replacements).forEach((key) => {
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
});
return accelerator;
return accelerator;
};
+84
View File
@@ -0,0 +1,84 @@
import { useEffect } from 'react';
import { MantineProvider } from '@mantine/core';
import './styles/global.scss';
import { useIsDark, useReconnect } from '/@/remote/store';
import { Shell } from '/@/remote/components/shell';
export const App = () => {
const isDark = useIsDark();
const reconnect = useReconnect();
useEffect(() => {
reconnect();
}, [reconnect]);
return (
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: isDark ? 'dark' : 'light',
components: {
AppShell: {
styles: {
body: {
height: '100vh',
overflow: 'scroll',
},
},
},
Modal: {
styles: {
body: {
background: 'var(--modal-bg)',
height: '100vh',
},
close: { marginRight: '0.5rem' },
content: { borderRadius: '5px' },
header: {
background: 'var(--modal-header-bg)',
paddingBottom: '1rem',
},
title: { fontSize: 'medium', fontWeight: 500 },
},
},
},
defaultRadius: 'xs',
dir: 'ltr',
focusRing: 'auto',
focusRingStyles: {
inputStyles: () => ({
border: '1px solid var(--primary-color)',
}),
resetStyles: () => ({ outline: 'none' }),
styles: () => ({
outline: '1px solid var(--primary-color)',
outlineOffset: '-1px',
}),
},
fontFamily: 'var(--content-font-family)',
fontSizes: {
lg: '1.1rem',
md: '1rem',
sm: '0.9rem',
xl: '1.5rem',
xs: '0.8rem',
},
headings: {
fontFamily: 'var(--content-font-family)',
fontWeight: 700,
},
other: {},
spacing: {
lg: '2rem',
md: '1rem',
sm: '0.5rem',
xl: '4rem',
xs: '0rem',
},
}}
>
<Shell />
</MantineProvider>
);
};
@@ -0,0 +1,20 @@
import { CiImageOff, CiImageOn } from 'react-icons/ci';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useShowImage, useToggleShowImage } from '/@/remote/store';
export const ImageButton = () => {
const showImage = useShowImage();
const toggleImage = useToggleShowImage();
return (
<RemoteButton
mr={5}
size="xl"
tooltip={showImage ? 'Hide Image' : 'Show Image'}
variant="default"
onClick={() => toggleImage()}
>
{showImage ? <CiImageOff size={30} /> : <CiImageOn size={30} />}
</RemoteButton>
);
};
@@ -0,0 +1,21 @@
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { useConnected, useReconnect } from '/@/remote/store';
import { RiRestartLine } from 'react-icons/ri';
export const ReconnectButton = () => {
const connected = useConnected();
const reconnect = useReconnect();
return (
<RemoteButton
$active={!connected}
mr={5}
size="xl"
tooltip={connected ? 'Reconnect' : 'Not connected. Reconnect.'}
variant="default"
onClick={() => reconnect()}
>
<RiRestartLine size={30} />
</RemoteButton>
);
};
@@ -0,0 +1,60 @@
import { MouseEvent, ReactNode, Ref, forwardRef } from 'react';
import { Button, type ButtonProps as MantineButtonProps } from '@mantine/core';
import { Tooltip } from '/@/renderer/components/tooltip';
import styled from 'styled-components';
interface StyledButtonProps extends MantineButtonProps {
$active?: boolean;
children: ReactNode;
onClick?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onMouseDown?: (e: MouseEvent<HTMLButtonElement, MouseEvent>) => void;
ref: Ref<HTMLButtonElement>;
}
export interface ButtonProps extends StyledButtonProps {
tooltip: string;
}
const StyledButton = styled(Button)<StyledButtonProps>`
svg {
display: flex;
fill: ${({ $active: active }) =>
active ? 'var(--primary-color)' : 'var(--playerbar-btn-fg)'};
stroke: var(--playerbar-btn-fg);
}
&:hover {
background: var(--playerbar-btn-bg-hover);
svg {
fill: ${({ $active: active }) =>
active
? 'var(--primary-color) !important'
: 'var(--playerbar-btn-fg-hover) !important'};
}
}
`;
export const RemoteButton = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, tooltip, ...props }: ButtonProps, ref) => {
return (
<Tooltip
withinPortal
label={tooltip}
>
<StyledButton
{...props}
ref={ref}
>
{children}
</StyledButton>
</Tooltip>
);
},
);
RemoteButton.defaultProps = {
$active: false,
onClick: undefined,
onMouseDown: undefined,
};
@@ -0,0 +1,27 @@
import { useIsDark, useToggleDark } from '/@/remote/store';
import { RiMoonLine, RiSunLine } from 'react-icons/ri';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import { AppTheme } from '/@/renderer/themes/types';
import { useEffect } from 'react';
export const ThemeButton = () => {
const isDark = useIsDark();
const toggleDark = useToggleDark();
useEffect(() => {
const targetTheme: AppTheme = isDark ? AppTheme.DEFAULT_DARK : AppTheme.DEFAULT_LIGHT;
document.body.setAttribute('data-theme', targetTheme);
}, [isDark]);
return (
<RemoteButton
mr={5}
size="xl"
tooltip="Toggle Theme"
variant="default"
onClick={() => toggleDark()}
>
{isDark ? <RiSunLine size={30} /> : <RiMoonLine size={30} />}
</RemoteButton>
);
};
+176
View File
@@ -0,0 +1,176 @@
import { useCallback } from 'react';
import { Group, Image, Text, Title } from '@mantine/core';
import { useInfo, useSend, useShowImage } from '/@/remote/store';
import { RemoteButton } from '/@/remote/components/buttons/remote-button';
import formatDuration from 'format-duration';
import debounce from 'lodash/debounce';
import {
RiHeartLine,
RiPauseFill,
RiPlayFill,
RiRepeat2Line,
RiRepeatOneLine,
RiShuffleFill,
RiSkipBackFill,
RiSkipForwardFill,
RiVolumeUpFill,
} from 'react-icons/ri';
import { PlayerRepeat, PlayerStatus } from '/@/renderer/types';
import { WrapperSlider } from '/@/remote/components/wrapped-slider';
import { Tooltip } from '/@/renderer/components/tooltip';
import { Rating } from '/@/renderer/components';
export const RemoteContainer = () => {
const { repeat, shuffle, song, status, volume } = useInfo();
const send = useSend();
const showImage = useShowImage();
const id = song?.id;
const setRating = useCallback(
(rating: number) => {
send({ event: 'rating', id: id!, rating });
},
[send, id],
);
const debouncedSetRating = debounce(setRating, 400);
return (
<>
{song && (
<>
<Title order={1}>{song.name}</Title>
<Group align="flex-end">
<Title order={2}>Album: {song.album}</Title>
<Title order={2}>Artist: {song.artistName}</Title>
</Group>
<Group position="apart">
<Title order={3}>Duration: {formatDuration(song.duration)}</Title>
{song.releaseDate && (
<Title order={3}>
Released: {new Date(song.releaseDate).toLocaleDateString()}
</Title>
)}
<Title order={3}>Plays: {song.playCount}</Title>
</Group>
</>
)}
<Group
grow
spacing={0}
>
<RemoteButton
tooltip="Previous track"
variant="default"
onClick={() => send({ event: 'previous' })}
>
<RiSkipBackFill size={25} />
</RemoteButton>
<RemoteButton
tooltip={status === PlayerStatus.PLAYING ? 'Pause' : 'Play'}
variant="default"
onClick={() => {
if (status === PlayerStatus.PLAYING) {
send({ event: 'pause' });
} else if (status === PlayerStatus.PAUSED) {
send({ event: 'play' });
}
}}
>
{status === PlayerStatus.PLAYING ? (
<RiPauseFill size={25} />
) : (
<RiPlayFill size={25} />
)}
</RemoteButton>
<RemoteButton
tooltip="Next track"
variant="default"
onClick={() => send({ event: 'next' })}
>
<RiSkipForwardFill size={25} />
</RemoteButton>
</Group>
<Group
grow
spacing={0}
>
<RemoteButton
$active={shuffle || false}
tooltip={shuffle ? 'Shuffle tracks' : 'Shuffle disabled'}
variant="default"
onClick={() => send({ event: 'shuffle' })}
>
<RiShuffleFill size={25} />
</RemoteButton>
<RemoteButton
$active={repeat !== undefined && repeat !== PlayerRepeat.NONE}
tooltip={`Repeat ${
repeat === PlayerRepeat.ONE
? 'One'
: repeat === PlayerRepeat.ALL
? 'all'
: 'none'
}`}
variant="default"
onClick={() => send({ event: 'repeat' })}
>
{repeat === undefined || repeat === PlayerRepeat.ONE ? (
<RiRepeatOneLine size={25} />
) : (
<RiRepeat2Line size={25} />
)}
</RemoteButton>
<RemoteButton
$active={song?.userFavorite}
disabled={!song}
tooltip={song?.userFavorite ? 'Unfavorite' : 'Favorite'}
variant="default"
onClick={() => {
if (!id) return;
send({ event: 'favorite', favorite: !song.userFavorite, id });
}}
>
<RiHeartLine size={25} />
</RemoteButton>
{(song?.serverType === 'navidrome' || song?.serverType === 'subsonic') && (
<div style={{ margin: 'auto' }}>
<Tooltip
label="Double click to clear"
openDelay={1000}
>
<Rating
sx={{ margin: 'auto' }}
value={song.userRating ?? 0}
onChange={debouncedSetRating}
onDoubleClick={() => debouncedSetRating(0)}
/>
</Tooltip>
</div>
)}
</Group>
<WrapperSlider
leftLabel={<RiVolumeUpFill size={20} />}
max={100}
rightLabel={
<Text
size="xs"
weight={600}
>
{volume ?? 0}
</Text>
}
value={volume ?? 0}
onChangeEnd={(e) => send({ event: 'volume', volume: e })}
/>
{showImage && (
<Image
src={song?.imageUrl?.replaceAll(/&(size|width|height=\d+)/g, '')}
onError={() => send({ event: 'proxy' })}
/>
)}
</>
);
};
+75
View File
@@ -0,0 +1,75 @@
import {
AppShell,
Container,
Flex,
Grid,
Header,
Image,
MediaQuery,
Skeleton,
Title,
} from '@mantine/core';
import { ThemeButton } from '/@/remote/components/buttons/theme-button';
import { ImageButton } from '/@/remote/components/buttons/image-button';
import { RemoteContainer } from '/@/remote/components/remote-container';
import { ReconnectButton } from '/@/remote/components/buttons/reconnect-button';
import { useConnected } from '/@/remote/store';
export const Shell = () => {
const connected = useConnected();
return (
<AppShell
header={
<Header height={60}>
<Grid>
<Grid.Col span="auto">
<div>
<Image
fit="contain"
height={60}
src="/favicon.ico"
width={60}
/>
</div>
</Grid.Col>
<MediaQuery
smallerThan="sm"
styles={{ display: 'none' }}
>
<Grid.Col
sm={6}
xs={0}
>
<Title ta="center">Feishin Remote</Title>
</Grid.Col>
</MediaQuery>
<Grid.Col span="auto">
<Flex
direction="row"
justify="right"
>
<ReconnectButton />
<ImageButton />
<ThemeButton />
</Flex>
</Grid.Col>
</Grid>
</Header>
}
padding="md"
>
<Container>
{connected ? (
<RemoteContainer />
) : (
<Skeleton
height={300}
width="100%"
/>
)}
</Container>
</AppShell>
);
};
+62
View File
@@ -0,0 +1,62 @@
import { useState, ReactNode } from 'react';
import { SliderProps } from '@mantine/core';
import styled from 'styled-components';
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
const SliderContainer = styled.div`
display: flex;
width: 95%;
height: 20px;
margin: 10px 0;
`;
const SliderValueWrapper = styled.div<{ $position: 'left' | 'right' }>`
display: flex;
flex: 1;
align-self: flex-end;
justify-content: center;
max-width: 50px;
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
align-items: center;
height: 100%;
`;
export interface WrappedProps extends Omit<SliderProps, 'onChangeEnd'> {
leftLabel?: ReactNode;
onChangeEnd: (value: number) => void;
rightLabel?: ReactNode;
value: number;
}
export const WrapperSlider = ({ leftLabel, rightLabel, value, ...props }: WrappedProps) => {
const [isSeeking, setIsSeeking] = useState(false);
const [seek, setSeek] = useState(0);
return (
<SliderContainer>
{leftLabel && <SliderValueWrapper $position="left">{leftLabel}</SliderValueWrapper>}
<SliderWrapper>
<PlayerbarSlider
{...props}
min={0}
size={6}
value={!isSeeking ? value ?? 0 : seek}
w="100%"
onChange={(e) => {
setIsSeeking(true);
setSeek(e);
}}
onChangeEnd={(e) => {
props.onChangeEnd(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
{rightLabel && <SliderValueWrapper $position="right">{rightLabel}</SliderValueWrapper>}
</SliderContainer>
);
};
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Feishin Remote</title>
<link rel="manifest" href="manifest.json">
<script>
if ('serviceWorker' in navigator) {
const version = encodeURIComponent("<%= version %>");
const prod = encodeURIComponent("<%= prod %>");
navigator.serviceWorker.register(`/worker.js?version=${version}&prod=${prod}`);
}
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
import { Notifications } from '@mantine/notifications';
import { createRoot } from 'react-dom/client';
import { App } from '/@/remote/app';
const container = document.getElementById('root')! as HTMLElement;
const root = createRoot(container);
root.render(
<>
<Notifications
containerWidth="300px"
position="bottom-center"
/>
<App />
</>,
);
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Feishin Remote",
"short_name": "Feishin Remote",
"start_url": "/",
"background_color": "#FFDCB5",
"theme_color": "#1E003D",
"icons": [
{
"src": "favicon.ico",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
}
],
"display": "standalone",
"orientation": "portrait"
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference lib="WebWorker" />
export type {};
// eslint-disable-next-line no-undef
declare const self: ServiceWorkerGlobalScope;
// eslint-disable-next-line no-restricted-globals
const url = new URL(location.toString());
const version = url.searchParams.get('version');
const prod = url.searchParams.get('prod') === 'true';
const cacheName = `Feishin-remote-${version}`;
const resourcesToCache = ['./', './remote.js', './favicon.ico'];
if (prod) {
resourcesToCache.push('./remote.css');
}
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(cacheName).then((cache) => {
return cache.addAll(resourcesToCache);
}),
);
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then((response) => {
return response || fetch(e.request);
}),
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keyList) => {
return Promise.all(
keyList.map((key) => {
if (key !== cacheName) {
return caches.delete(key);
}
return Promise.resolve();
}),
);
}),
);
});
+238
View File
@@ -0,0 +1,238 @@
import { hideNotification, showNotification } from '@mantine/notifications';
import type { NotificationProps as MantineNotificationProps } from '@mantine/notifications';
import merge from 'lodash/merge';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/remote/types';
interface StatefulWebSocket extends WebSocket {
natural: boolean;
}
interface SettingsState {
connected: boolean;
info: Omit<SongUpdateSocket, 'currentTime'>;
isDark: boolean;
showImage: boolean;
socket?: StatefulWebSocket;
}
export interface SettingsSlice extends SettingsState {
actions: {
reconnect: () => void;
send: (data: ClientEvent) => void;
toggleIsDark: () => void;
toggleShowImage: () => void;
};
}
const initialState: SettingsState = {
connected: false,
info: {},
isDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
showImage: true,
};
interface NotificationProps extends MantineNotificationProps {
type?: 'error' | 'warning';
}
const showToast = ({ type, ...props }: NotificationProps) => {
const color = type === 'warning' ? 'var(--warning-color)' : 'var(--danger-color)';
const defaultTitle = type === 'warning' ? 'Warning' : 'Error';
const defaultDuration = type === 'error' ? 2000 : 1000;
return showNotification({
autoClose: defaultDuration,
styles: () => ({
closeButton: {
'&:hover': {
background: 'transparent',
},
},
description: {
color: 'var(--toast-description-fg)',
fontSize: '1rem',
},
loader: {
margin: '1rem',
},
root: {
'&::before': { backgroundColor: color },
background: 'var(--toast-bg)',
border: '2px solid var(--generic-border-color)',
bottom: '90px',
},
title: {
color: 'var(--toast-title-fg)',
fontSize: '1.3rem',
},
}),
title: defaultTitle,
...props,
});
};
const toast = {
error: (props: NotificationProps) => showToast({ type: 'error', ...props }),
hide: hideNotification,
warn: (props: NotificationProps) => showToast({ type: 'warning', ...props }),
};
export const useRemoteStore = create<SettingsSlice>()(
persist(
devtools(
immer((set, get) => ({
actions: {
reconnect: async () => {
const existing = get().socket;
if (existing) {
if (
existing.readyState === WebSocket.OPEN ||
existing.readyState === WebSocket.CONNECTING
) {
existing.natural = true;
existing.close(4001);
}
}
let authHeader: string | undefined;
try {
const credentials = await fetch('/credentials');
authHeader = await credentials.text();
} catch (error) {
console.error('Failed to get credentials');
}
set((state) => {
const socket = new WebSocket(
// eslint-disable-next-line no-restricted-globals
location.href.replace('http', 'ws'),
) as StatefulWebSocket;
socket.natural = false;
socket.addEventListener('message', (message) => {
const { event, data } = JSON.parse(message.data) as ServerEvent;
switch (event) {
case 'error': {
toast.error({ message: data, title: 'Socket error' });
break;
}
case 'favorite': {
set((state) => {
if (state.info.song?.id === data.id) {
state.info.song.userFavorite = data.favorite;
}
});
break;
}
case 'proxy': {
set((state) => {
if (state.info.song) {
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
}
});
break;
}
case 'rating': {
set((state) => {
if (state.info.song?.id === data.id) {
state.info.song.userRating = data.rating;
}
});
break;
}
case 'song': {
set((nested) => {
nested.info = { ...nested.info, ...data };
});
}
}
});
socket.addEventListener('open', () => {
if (authHeader) {
socket.send(
JSON.stringify({
event: 'authenticate',
header: authHeader,
}),
);
}
set({ connected: true });
});
socket.addEventListener('close', (reason) => {
if (reason.code === 4002 || reason.code === 4003) {
// eslint-disable-next-line no-restricted-globals
location.reload();
} else if (reason.code === 4000) {
toast.warn({
message: 'Feishin remote server is down',
title: 'Connection closed',
});
} else if (reason.code !== 4001 && !socket.natural) {
toast.error({
message: 'Socket closed for unexpected reason',
title: 'Connection closed',
});
}
if (!socket.natural) {
set({ connected: false, info: {} });
}
});
state.socket = socket;
});
},
send: (data: ClientEvent) => {
get().socket?.send(JSON.stringify(data));
},
toggleIsDark: () => {
set((state) => {
state.isDark = !state.isDark;
});
},
toggleShowImage: () => {
set((state) => {
state.showImage = !state.showImage;
});
},
},
...initialState,
})),
{ name: 'store_settings' },
),
{
merge: (persistedState, currentState) => {
return merge(currentState, persistedState);
},
name: 'store_settings',
version: 6,
},
),
);
export const useConnected = () => useRemoteStore((state) => state.connected);
export const useInfo = () => useRemoteStore((state) => state.info);
export const useIsDark = () => useRemoteStore((state) => state.isDark);
export const useReconnect = () => useRemoteStore((state) => state.actions.reconnect);
export const useShowImage = () => useRemoteStore((state) => state.showImage);
export const useSend = () => useRemoteStore((state) => state.actions.send);
export const useToggleDark = () => useRemoteStore((state) => state.actions.toggleIsDark);
export const useToggleShowImage = () => useRemoteStore((state) => state.actions.toggleShowImage);
+127
View File
@@ -0,0 +1,127 @@
@use '../../renderer/themes/default.scss';
@use '../../renderer/themes/dark.scss';
@use '../../renderer/themes/light.scss';
@use '../../renderer/styles/ag-grid.scss';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body,
html {
position: absolute;
display: block;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
color: var(--content-text-color);
background: var(--content-bg);
font-family: var(--content-font-family);
font-size: var(--root-font-size);
user-select: none;
}
@media only screen and (max-width: 639px) {
body,
html {
overflow-x: auto;
}
}
#app {
height: inherit;
}
*,
*:before,
*:after {
box-sizing: border-box;
text-rendering: optimizeLegibility;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-text-size-adjust: none;
outline: none;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-corner {
background: var(--scrollbar-track-bg);
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-bg);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover);
}
a {
text-decoration: none;
}
button {
-webkit-app-region: no-drag;
}
.overlay-scrollbar {
overflow-y: overlay !important;
overflow-x: overlay !important;
}
.hide-scrollbar {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
&::-webkit-scrollbar {
width: 1px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.mantine-ScrollArea-thumb[data-state='visible'] {
animation: fadeIn 0.3s forwards;
}
.mantine-ScrollArea-scrollbar[data-state='hidden'] {
animation: fadeOut 0.2s forwards;
}
+66
View File
@@ -0,0 +1,66 @@
import type { QueueSong } from '/@/renderer/api/types';
import type { SongUpdate } from '/@/renderer/types';
export interface SongUpdateSocket extends Omit<SongUpdate, 'song'> {
song?: QueueSong | null;
}
export interface ServerError {
data: string;
event: 'error';
}
export interface ServerFavorite {
data: { favorite: boolean; id: string };
event: 'favorite';
}
export interface ServerProxy {
data: string;
event: 'proxy';
}
export interface ServerRating {
data: { id: string; rating: number };
event: 'rating';
}
export interface ServerSong {
data: SongUpdateSocket;
event: 'song';
}
export type ServerEvent = ServerError | ServerFavorite | ServerRating | ServerSong | ServerProxy;
export interface ClientSimpleEvent {
event: 'next' | 'pause' | 'play' | 'previous' | 'proxy' | 'repeat' | 'shuffle';
}
export interface ClientFavorite {
event: 'favorite';
favorite: boolean;
id: string;
}
export interface ClientRating {
event: 'rating';
id: string;
rating: number;
}
export interface ClientVolume {
event: 'volume';
volume: number;
}
export interface ClientAuth {
event: 'authenticate';
header: string;
}
export type ClientEvent =
| ClientAuth
| ClientSimpleEvent
| ClientFavorite
| ClientRating
| ClientVolume;
View File

Some files were not shown because too many files have changed in this diff Show More