├── .eslintrc.json ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── COMPATIBILITY.md ├── LICENSE ├── README.md ├── assets ├── config.json5 ├── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 512x512.png │ ├── 64x64.png │ ├── icon.ico │ └── icon.svg ├── kwin-widget-rule.kwinrule ├── preview_browser.png ├── preview_lyrics.png ├── preview_obs.png ├── preview_widget.png ├── preview_widget_2.png └── preview_widget_3.png ├── dist └── www │ ├── assets │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── favicon.ico │ ├── font │ │ ├── Urbanist-Black.woff2 │ │ ├── Urbanist-BlackItalic.woff2 │ │ ├── Urbanist-Bold.woff2 │ │ ├── Urbanist-BoldItalic.woff2 │ │ ├── Urbanist-ExtraBold.woff2 │ │ ├── Urbanist-ExtraBoldItalic.woff2 │ │ ├── Urbanist-Italic.woff2 │ │ ├── Urbanist-Light.woff2 │ │ ├── Urbanist-LightItalic.woff2 │ │ ├── Urbanist-Regular.woff2 │ │ ├── Urbanist-SemiBold.woff2 │ │ ├── Urbanist-SemiBoldItalic.woff2 │ │ ├── Urbanist-Thin.woff2 │ │ └── Urbanist-ThinItalic.woff2 │ ├── images │ │ ├── apps │ │ │ ├── aimp.svg │ │ │ ├── amarok.svg │ │ │ ├── chrome.svg │ │ │ ├── clementine.svg │ │ │ ├── default.svg │ │ │ ├── elisa.svg │ │ │ ├── firefox.svg │ │ │ ├── foobar2000.svg │ │ │ ├── gnome-music.svg │ │ │ ├── groove.svg │ │ │ ├── librewolf.svg │ │ │ ├── lollypop.svg │ │ │ ├── mpv.svg │ │ │ ├── msedge.svg │ │ │ ├── musicbee.svg │ │ │ ├── pithos.svg │ │ │ ├── qmmp.svg │ │ │ ├── rhythmbox.svg │ │ │ ├── sonixd.svg │ │ │ ├── spotify.svg │ │ │ ├── strawberry.svg │ │ │ ├── tauon.svg │ │ │ ├── vivaldi.svg │ │ │ └── vlc.svg │ │ ├── glyph.svg │ │ ├── no_song.png │ │ └── no_song.svg │ └── styles │ │ ├── font │ │ └── Urbanist.css │ │ ├── style.css │ │ └── ui │ │ ├── Electron.css │ │ ├── base │ │ ├── General.css │ │ └── WindowControls.css │ │ └── sunamu │ │ ├── AlbumArt.css │ │ ├── Background.css │ │ ├── Info.css │ │ ├── Lyrics.css │ │ ├── Metadata.css │ │ ├── NowPlaying.css │ │ ├── PlaybackControls.css │ │ ├── PlayingIndicator.css │ │ ├── Seekbar.css │ │ ├── Sunamu.css │ │ └── Variables.css │ ├── index.htm │ └── sunamu.webmanifest ├── electron-builder.yml ├── package.json ├── src ├── main │ ├── appStatus.ts │ ├── config.ts │ ├── electron.ts │ ├── index.ts │ ├── instance.ts │ ├── integrations │ │ ├── discordrpc.ts │ │ ├── lyrics.ts │ │ ├── lyricsOffline.ts │ │ └── tracklogger.ts │ ├── logger.ts │ ├── lyricproviders │ │ ├── genius.ts │ │ ├── local.ts │ │ ├── lrc.ts │ │ ├── metadata.ts │ │ ├── musixmatch.ts │ │ └── netease.ts │ ├── platform │ │ └── win32.ts │ ├── playbackStatus.ts │ ├── player │ │ ├── index.ts │ │ ├── mpris2.ts │ │ └── winplayer.ts │ ├── themes.ts │ ├── thirdparty │ │ ├── lastfm.ts │ │ └── spotify.ts │ ├── tsconfig.json │ ├── util.ts │ └── webserver.ts ├── types.ts └── www │ ├── index.ts │ ├── lib │ ├── appicon.ts │ ├── buttons.ts │ ├── config.ts │ ├── event.ts │ ├── lang │ │ ├── de.ts │ │ ├── en.ts │ │ ├── es.ts │ │ ├── fr.ts │ │ ├── index.ts │ │ ├── it.ts │ │ ├── nl.ts │ │ ├── zh-cn.ts │ │ └── zh-tw.ts │ ├── lyrics.ts │ ├── nowplaying.ts │ ├── npapi │ │ ├── browser-npapi.ts │ │ ├── electron-npapi.ts │ │ └── tsconfig.electron.json │ ├── screen.ts │ ├── seekbar.ts │ ├── showhide.ts │ ├── songdata.ts │ ├── thirdparty │ │ └── socket.io.esm.min.js │ └── util.ts │ └── tsconfig.json ├── tsconfig.json ├── tsconfig.settings.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2020": true, 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parser": "@typescript-eslint/parser", 9 | "plugins": [ 10 | "@typescript-eslint" 11 | ], 12 | "rules": { 13 | "arrow-body-style": [ 14 | "error", 15 | "as-needed" 16 | ], 17 | "class-methods-use-this": 1, 18 | "eqeqeq": [ 19 | "error", 20 | "smart" 21 | ], 22 | "dot-notation": [ 23 | "error", 24 | { 25 | "allowKeywords": true 26 | } 27 | ], 28 | "func-call-spacing": [ 29 | "error", 30 | "never" 31 | ], 32 | "func-names": [ 33 | "error", 34 | "as-needed" 35 | ], 36 | "prefer-arrow-callback": [ 37 | "error", 38 | { 39 | "allowNamedFunctions": true, 40 | "allowUnboundThis": true 41 | } 42 | ], 43 | "func-style": [ 44 | "error", 45 | "declaration", 46 | { 47 | "allowArrowFunctions": true 48 | } 49 | ], 50 | "indent": [ 51 | "error", 52 | "tab", 53 | { 54 | "SwitchCase": 1, 55 | "VariableDeclarator": 1, 56 | "MemberExpression": 1, 57 | "FunctionDeclaration": { 58 | "parameters": 1 59 | }, 60 | "CallExpression": { 61 | "arguments": 1 62 | }, 63 | "ArrayExpression": 1, 64 | "ObjectExpression": 1, 65 | "ImportDeclaration": 1, 66 | "flatTernaryExpressions": true, 67 | "offsetTernaryExpressions": true, 68 | "ignoreComments": false 69 | } 70 | ], 71 | "linebreak-style": [ 72 | "error", 73 | "unix" 74 | ], 75 | "quotes": [ 76 | "error", 77 | "double" 78 | ], 79 | "semi": [ 80 | "error", 81 | "always" 82 | ], 83 | "curly": [ 84 | "error", 85 | "multi-or-nest" 86 | ], 87 | "no-case-declarations": "off" 88 | } 89 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: NyaomiPic 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 21 | 22 | **Describe the bug** 23 | A clear and concise description of what the bug is. 24 | 25 | **To Reproduce** 26 | Steps to reproduce the behavior: 27 | 1. Go to '...' 28 | 2. Click on '....' 29 | 3. Scroll down to '....' 30 | 4. See error 31 | 32 | **Expected behavior** 33 | A clear and concise description of what you expected to happen. 34 | 35 | **Screenshots** 36 | If applicable, add screenshots to help explain your problem. 37 | 38 | **Running platform (please complete the following information):** 39 | - OS: [e.g. Arch Linux] 40 | - Version: [e.g. 2.2.0 - or the commit hash] 41 | - Mode: [e.g. Electron / Browser] 42 | 43 | **Additional context** 44 | Add any other context about the problem here. 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist/types* 3 | /dist/main/ 4 | /dist/www/lib/ 5 | /dist/www/index* 6 | !/dist/www/index.htm 7 | /lib/ 8 | /targets/ 9 | /yarn-*.log 10 | /.env 11 | /test.js 12 | 13 | # just in case someone runs makepkg directly 14 | /aur/sunamu*/* 15 | !/aur/sunamu*/PKGBUILD 16 | !/aur/sunamu*/.SRCINFO 17 | !/aur/sunamu*/sunamu.desktop 18 | !/aur/sunamu*/sunamu.sh 19 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.json5": "json5" 4 | }, 5 | "C_Cpp.errorSquiggles": "Enabled" 6 | } -------------------------------------------------------------------------------- /COMPATIBILITY.md: -------------------------------------------------------------------------------- 1 | # Player compatibility 2 | 3 | ### Legend 4 | - Perfect: Works flawlessly 5 | - Working: Works, but additional work is needed OR caveats apply. 6 | - Detected: Sunamu detects it, but it doesn't work. 7 | - Not working: Sunamu cannot detect it. 8 | 9 | |Name|Status|Comments| 10 | |-|-|-| 11 | |Spotify|Perfect|They fixed their shit recently! Yay!| 12 | |VLC|Perfect|| 13 | |Strawberry|Perfect|| 14 | |elementary Music|Perfect|Reported by @KorbsStudio| 15 | |Rhythmbox|Perfect|Reported by @KorbsStudio| 16 | |Spot|Perfect|Reported by @KorbsStudio| 17 | |Clementine|Perfect|Reported by @KorbsStudio| 18 | |Tauon Music Box|Perfect|Reported by @KorbsStudio| 19 | |Pithos|Perfect|Reported by @KorbsStudio| 20 | |Sonixd|Perfect|| 21 | |GNOME Music|Working|Cover arts are not shown "at first try". Possibly, the `PropertiesChanged` event is not handled well enough.| 22 | |QMMP|Working|Please enable the MPRIS plugin| 23 | |Spotifyd|Working|While the MPRIS2 implementation is kinda okay, they still need to raise the appropriate D-Bus `PropertiesChanged` event. See [their issue tracker](https://github.com/Spotifyd/spotifyd/issues/457).| 24 | |Spotify-Qt|Working|The developer has an issue ticket detailing some of the caveats [here](https://github.com/kraxarn/spotify-qt/issues/4). 25 | |MPV|Working|With the [mpv-mpris](https://github.com/hoyon/mpv-mpris) plugin| 26 | |Plasma Browser Integration|Working|It all depends on the website, really| 27 | |Lollypop|Detected|It doesn't implement the `PropertiesChanged` signal| 28 | |Amarok|Detected|Seems to not report the song information; Reported by @KorbsStudio| 29 | |Amberol|Detected|Non-compliant MPRIS2 implementation. Sunamu cannot support players not reporting `mpris:trackId`| 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Logo 2 | 3 | # Sunamu (スナム) 4 | Show your currently playing song in a stylish way! 5 | 6 | ## Screenshots 7 | 8 |
9 | Lyrics preview 10 | Browser preview 11 | Widget preview 12 | OBS source preview 13 | Sunamu will never gonna give you up 14 | Hey! 15 | 16 |
17 | 18 | ## OwO wats dis? 19 | 20 | Sunamu (pronounced as it is written) is a fancy music controller whose only purpose is to look as fancy as possible on secondary displays. 21 | 22 | _It effectively is the WAY TOO COMPLEX successor of MPRISLyrics, a project I made back when synchronized lyrics on Linux was a niche thing to have._ 23 | 24 | ## Features 25 | 26 | - Display what you are playing in your TV, secondary display, or (heck) around the entire house! 27 | - Get the Spotify link for every song you listen to!* 28 | - Get lyrics for your songs! 29 | - Get a _GOOD_ Discord Rich Presence, finally!* 30 | - Bragging rights for your particular taste in music!** 31 | 32 | *This feature, or part of it, requires a Spotify Client ID and Client Secret. 33 | 34 | **No responsibility is taken from the Sunamu devs and contributors if you have bad taste in music 35 | 36 | ## Installation 37 | 38 | **Sunamu only works on Linux! Do not spam the issue section regarding Windows or macOS, as it cannot be ported to those computers!** 39 | 40 | ### Linux 41 | 42 | #### NOTE: Sunamu is very slow on development, mostly because there's hardly anything to implement that the developer can do. Please consider this when running a tagged release, as you can easily run a months-old, or even years-old, version. Please try compiling Sunamu from this repo instead! 43 | 44 | Get the latest release from the [Releases](https://github.com/NyaomiDEV/Sunamu/releases/latest) section. 45 | 46 | ## Configuration 47 | 48 | Sunamu's configuration file is located in: 49 | - Linux: Usually `~/.config/sunamu/config.json5` (`$XDG_CONFIG_HOME/sunamu/config.json5`); 50 | - Linux Flatpak: `~/.var/app/xyz.nyaomi.sunamu/config/sunamu/config.json5` (unsupported! but you can compile it yourself); 51 | 52 | You can use it to enable or disable features, and there are a LOT of them! 53 | 54 | Do you want to give it a read? [Here it is!](assets/config.json5) 55 | 56 | ## Usage 57 | 58 | Just launch it and preferably put it in fullscreen! 59 | 60 | ## Notable observed quirks 61 | 62 | Check and contribute to the compatibility table [here](COMPATIBILITY.md). 63 | 64 | ## License 65 | 66 | See the [LICENSE](LICENSE) file. 67 | -------------------------------------------------------------------------------- /assets/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/128x128.png -------------------------------------------------------------------------------- /assets/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/16x16.png -------------------------------------------------------------------------------- /assets/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/256x256.png -------------------------------------------------------------------------------- /assets/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/32x32.png -------------------------------------------------------------------------------- /assets/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/512x512.png -------------------------------------------------------------------------------- /assets/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/64x64.png -------------------------------------------------------------------------------- /assets/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/icons/icon.ico -------------------------------------------------------------------------------- /assets/icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /assets/kwin-widget-rule.kwinrule: -------------------------------------------------------------------------------- 1 | [Sunamu Widget] 2 | Description=Sunamu Widget 3 | activity=00000000-0000-0000-0000-000000000000 4 | activityrule=2 5 | below=true 6 | belowrule=2 7 | desktops= 8 | desktopsrule=2 9 | ignoregeometry=true 10 | ignoregeometryrule=2 11 | position=0,0 12 | positionrule=2 13 | size=458,512 14 | sizerule=2 15 | skippager=true 16 | skippagerrule=2 17 | skipswitcher=true 18 | skipswitcherrule=2 19 | skiptaskbar=true 20 | skiptaskbarrule=2 21 | title=Sunamu Widget 22 | titlematch=1 23 | types=1 24 | windowrole=browser-window 25 | wmclass=sunamu 26 | wmclassmatch=1 27 | -------------------------------------------------------------------------------- /assets/preview_browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/preview_browser.png -------------------------------------------------------------------------------- /assets/preview_lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/preview_lyrics.png -------------------------------------------------------------------------------- /assets/preview_obs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/preview_obs.png -------------------------------------------------------------------------------- /assets/preview_widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/preview_widget.png -------------------------------------------------------------------------------- /assets/preview_widget_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/preview_widget_2.png -------------------------------------------------------------------------------- /assets/preview_widget_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/assets/preview_widget_3.png -------------------------------------------------------------------------------- /dist/www/assets/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /dist/www/assets/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /dist/www/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /dist/www/assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /dist/www/assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /dist/www/assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-Black.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-BlackItalic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-Bold.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-BoldItalic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-ExtraBold.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-Italic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-Light.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-LightItalic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-Regular.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-SemiBold.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-Thin.woff2 -------------------------------------------------------------------------------- /dist/www/assets/font/Urbanist-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/font/Urbanist-ThinItalic.woff2 -------------------------------------------------------------------------------- /dist/www/assets/images/apps/aimp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/amarok.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/chrome.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/clementine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/elisa.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/firefox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/gnome-music.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/groove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/librewolf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/lollypop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/mpv.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/msedge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/musicbee.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/pithos.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/qmmp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/rhythmbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/sonixd.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/spotify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/strawberry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/tauon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/vivaldi.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/apps/vlc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /dist/www/assets/images/no_song.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NyaomiDEV/Sunamu/537d2986b5df0583c6c3064cacadce8efebb0a6b/dist/www/assets/images/no_song.png -------------------------------------------------------------------------------- /dist/www/assets/images/no_song.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /dist/www/assets/styles/font/Urbanist.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Urbanist"; 3 | src: url("../../font/Urbanist-BoldItalic.woff2") format("woff2"); 4 | font-weight: bold; 5 | font-style: italic; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: "Urbanist"; 11 | src: url("../../font/Urbanist-SemiBold.woff2") format("woff2"); 12 | font-weight: 600; 13 | font-style: normal; 14 | font-display: swap; 15 | } 16 | 17 | @font-face { 18 | font-family: "Urbanist"; 19 | src: url("../../font/Urbanist-ExtraBold.woff2") format("woff2"); 20 | font-weight: bold; 21 | font-style: normal; 22 | font-display: swap; 23 | } 24 | 25 | @font-face { 26 | font-family: "Urbanist"; 27 | src: url("../../font/Urbanist-Thin.woff2") format("woff2"); 28 | font-weight: 100; 29 | font-style: normal; 30 | font-display: swap; 31 | } 32 | 33 | @font-face { 34 | font-family: "Urbanist"; 35 | src: url("../../font/Urbanist-Bold.woff2") format("woff2"); 36 | font-weight: bold; 37 | font-style: normal; 38 | font-display: swap; 39 | } 40 | 41 | @font-face { 42 | font-family: "Urbanist"; 43 | src: url("../../font/Urbanist-BlackItalic.woff2") format("woff2"); 44 | font-weight: 900; 45 | font-style: italic; 46 | font-display: swap; 47 | } 48 | 49 | @font-face { 50 | font-family: "Urbanist"; 51 | src: url("../../font/Urbanist-ThinItalic.woff2") format("woff2"); 52 | font-weight: 100; 53 | font-style: italic; 54 | font-display: swap; 55 | } 56 | 57 | @font-face { 58 | font-family: "Urbanist"; 59 | src: url("../../font/Urbanist-LightItalic.woff2") format("woff2"); 60 | font-weight: 300; 61 | font-style: italic; 62 | font-display: swap; 63 | } 64 | 65 | @font-face { 66 | font-family: "Urbanist"; 67 | src: url("../../font/Urbanist-Light.woff2") format("woff2"); 68 | font-weight: 300; 69 | font-style: normal; 70 | font-display: swap; 71 | } 72 | 73 | @font-face { 74 | font-family: "Urbanist"; 75 | src: url("../../font/Urbanist-Italic.woff2") format("woff2"); 76 | font-weight: normal; 77 | font-style: italic; 78 | font-display: swap; 79 | } 80 | 81 | @font-face { 82 | font-family: "Urbanist"; 83 | src: url("../../font/Urbanist-Black.woff2") format("woff2"); 84 | font-weight: 900; 85 | font-style: normal; 86 | font-display: swap; 87 | } 88 | 89 | @font-face { 90 | font-family: "Urbanist"; 91 | src: url("../../font/Urbanist-Regular.woff2") format("woff2"); 92 | font-weight: normal; 93 | font-style: normal; 94 | font-display: swap; 95 | } 96 | 97 | @font-face { 98 | font-family: "Urbanist"; 99 | src: url("../../font/Urbanist-SemiBoldItalic.woff2") format("woff2"); 100 | font-weight: 600; 101 | font-style: italic; 102 | font-display: swap; 103 | } 104 | 105 | @font-face { 106 | font-family: "Urbanist"; 107 | src: url("../../font/Urbanist-ExtraBoldItalic.woff2") format("woff2"); 108 | font-weight: bold; 109 | font-style: italic; 110 | font-display: swap; 111 | } 112 | 113 | -------------------------------------------------------------------------------- /dist/www/assets/styles/style.css: -------------------------------------------------------------------------------- 1 | /* Fonts */ 2 | @import url("font/Urbanist.css"); 3 | 4 | @import url("ui/base/General.css"); 5 | @import url("ui/base/WindowControls.css"); 6 | 7 | /* Sunamu rules */ 8 | @import url("ui/sunamu/Sunamu.css"); 9 | 10 | /* Electron rules */ 11 | @import url("ui/Electron.css"); 12 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/Electron.css: -------------------------------------------------------------------------------- 1 | html.electron:not(.fullscreen) .background { 2 | -webkit-app-region: drag; 3 | } 4 | 5 | html.electron #minimize, 6 | html.electron #close { 7 | display: block; 8 | } 9 | 10 | html.electron .window-controls, 11 | html.electron #now-playing, 12 | html.electron #lyrics, 13 | html.electron #lyrics-copyright { 14 | -webkit-app-region: no-drag; 15 | } 16 | 17 | html.electron.widget-mode .background { 18 | -webkit-app-region: no-drag; 19 | } 20 | 21 | html.electron.widget-mode:not(.fullscreen) #album-art { 22 | -webkit-app-region: drag; 23 | } 24 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/base/General.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow: hidden; 3 | background-color: black; 4 | margin: 0; 5 | padding: 0; 6 | user-select: none; 7 | scrollbar-color: var(--color-fg) transparent; 8 | scrollbar-width: 4px; 9 | } 10 | 11 | html.idle:not(.force-idle) { 12 | cursor: none; 13 | } 14 | 15 | html.widget-mode { 16 | background-color: transparent; 17 | } 18 | 19 | body { 20 | margin: 0; 21 | padding: 0; 22 | } 23 | 24 | html.idle:not(.force-idle) body { 25 | cursor: none; 26 | } 27 | 28 | html.widget-mode body { 29 | transition: ease .2s filter; 30 | } 31 | 32 | html.widget-mode.not-playing.idle body, 33 | html.widget-mode.not-playing.hide-when-not-playing body { 34 | filter: opacity(0); 35 | } 36 | 37 | a:-webkit-any-link { 38 | text-decoration: none !important; 39 | } 40 | 41 | ::-webkit-scrollbar { 42 | width: 4px; 43 | } 44 | 45 | ::-webkit-scrollbar-track { 46 | background-color: transparent; 47 | } 48 | 49 | ::-webkit-scrollbar-thumb { 50 | background-color: var(--color-fg); 51 | border-radius: 2px; 52 | } 53 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/base/WindowControls.css: -------------------------------------------------------------------------------- 1 | .window-controls { 2 | position: fixed; 3 | top: 56px; 4 | right: 56px; 5 | z-index: 5; 6 | display: flex; 7 | gap: 8px; 8 | opacity: 1; 9 | transition: ease .2s; 10 | } 11 | 12 | html.idle .window-controls { 13 | opacity: 0; 14 | } 15 | 16 | .window-controls * { 17 | width: 24px; 18 | height: 24px; 19 | color: var(--color-fg); 20 | cursor: default; 21 | } 22 | 23 | #fullscreen, 24 | #minimize, 25 | #close { 26 | display: none; 27 | } 28 | 29 | html.supports-fullscreen #fullscreen { 30 | display: block; 31 | } 32 | 33 | html.widget-mode .window-controls, 34 | html.non-interactive .window-controls { 35 | display: none; 36 | } 37 | 38 | html.no-show-lyrics #fullscreen { 39 | display: none; 40 | } 41 | 42 | /* iPad */ 43 | @media only screen and (max-width: 1024px) { 44 | .window-controls { 45 | top: 40px; 46 | right: 40px; 47 | } 48 | } 49 | 50 | /* iPhone */ 51 | @media only screen and (max-width: 812px) { 52 | .window-controls { 53 | top: 24px; 54 | right: 24px; 55 | } 56 | } -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/AlbumArt.css: -------------------------------------------------------------------------------- 1 | #album-art { 2 | flex-shrink: 0; 3 | display: inline-block; 4 | height: 232px; 5 | background-image: var(--cover-art-url); 6 | background-position: center; 7 | background-repeat: no-repeat; 8 | background-size: cover; 9 | border-radius: 16px; 10 | box-shadow: 11 | 0px 76px 72px rgba(0, 0, 0, 0.07), 12 | 0px 38.0371px 36.0352px rgba(0, 0, 0, 0.0532233), 13 | 0px 22.9118px 21.7059px rgba(0, 0, 0, 0.0456112), 14 | 0px 14.6831px 13.9103px rgba(0, 0, 0, 0.0399626), 15 | 0px 9.51638px 9.01552px rgba(0, 0, 0, 0.035), 16 | 0px 5.99045px 5.67517px rgba(0, 0, 0, 0.0300374), 17 | 0px 3.44189px 3.26074px rgba(0, 0, 0, 0.0243888), 18 | 0px 1.51486px 1.43513px rgba(0, 0, 0, 0.0167767); 19 | aspect-ratio: 1; 20 | transition: ease .5s background-image, 21 | ease .2s; 22 | } 23 | 24 | html.no-album-art #album-art { 25 | display: none; 26 | } 27 | 28 | html.idle #album-art { 29 | height: 88px; 30 | } 31 | 32 | /* iPad */ 33 | @media only screen and (max-width: 1024px) { 34 | #album-art { 35 | height: 160px; 36 | border-radius: 12px; 37 | } 38 | 39 | html.idle #album-art { 40 | height: 72px; 41 | } 42 | } 43 | 44 | /* iPhone */ 45 | @media only screen and (max-width: 812px) { 46 | #album-art { 47 | height: 96px; 48 | border-radius: 8px; 49 | } 50 | 51 | html.idle #album-art { 52 | height: 40px; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/Background.css: -------------------------------------------------------------------------------- 1 | .background { 2 | position: fixed; 3 | top: -12.5vh; 4 | left: -12.5vw; 5 | width: 125vw; 6 | height: 125vh; 7 | z-index: -2; 8 | 9 | background-blend-mode: multiply; 10 | background-position: center; 11 | background-repeat: no-repeat; 12 | background-size: cover; 13 | filter: none; 14 | isolation: isolate; 15 | 16 | transition: ease 2s background-image, 17 | ease 2s background-color; 18 | 19 | } 20 | 21 | html:not(.colorblock):not(.widget-mode) .background::before, 22 | html:not(.colorblock):not(.widget-mode) .background::after { 23 | content: ""; 24 | 25 | position: absolute; 26 | width: 200vmax; 27 | height: 200vmax; 28 | z-index: -1; 29 | aspect-ratio: 1; 30 | 31 | background-image: var(--cover-art-url); 32 | background-color: var(--color-bg); 33 | background-blend-mode: multiply; 34 | background-position: center; 35 | background-repeat: no-repeat; 36 | background-size: cover; 37 | 38 | filter: blur(180px) brightness(50%) saturate(150%); 39 | mix-blend-mode: exclusion; 40 | 41 | animation: bgAnim 60s ease-in-out infinite; 42 | transition: ease 2s background-image, 43 | ease 2s background-color; 44 | } 45 | 46 | html:not(.colorblock):not(.widget-mode) .background::before { 47 | top: 0; 48 | left: 0; 49 | } 50 | 51 | html:not(.colorblock):not(.widget-mode) .background::after { 52 | bottom: 0; 53 | right: 0; 54 | 55 | animation-direction: reverse; 56 | animation-delay: 10s; 57 | } 58 | 59 | html.no-bg-animation .background { 60 | background-color: var(--color-bg); 61 | background-image: var(--cover-art-url); 62 | filter: blur(64px) brightness(50%) saturate(150%); 63 | } 64 | 65 | html.colorblock .background { 66 | background-color: var(--color-bg); 67 | background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4="); 68 | background-blend-mode: overlay; 69 | background-repeat: repeat; 70 | background-size: contain; 71 | filter: none; 72 | } 73 | 74 | html.widget-mode .background { 75 | display: none; 76 | } 77 | 78 | @keyframes bgAnim { 79 | 0% { 80 | transform: rotate(0deg); 81 | } 82 | 83 | 100% { 84 | transform: rotate(360deg); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/Info.css: -------------------------------------------------------------------------------- 1 | .info-container { 2 | width: 100%; 3 | display: flex; 4 | gap: 24px; 5 | margin: 8px 0; 6 | transition: ease .2s; 7 | } 8 | 9 | html.idle .info-container { 10 | margin: 0; 11 | } 12 | 13 | html.no-info-container .info-container { 14 | display: none; 15 | } 16 | 17 | /* iPad */ 18 | @media only screen and (max-width: 1024px) { 19 | .info-container { 20 | gap: 16px; 21 | } 22 | } 23 | 24 | /* iPhone */ 25 | @media only screen and (max-width: 812px) { 26 | .info-container { 27 | gap: 8px; 28 | } 29 | } -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/Metadata.css: -------------------------------------------------------------------------------- 1 | #metadata { 2 | height: 232px; 3 | width: 100%; 4 | color: var(--color-fg); 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | overflow: hidden; 9 | transition: ease .2s; 10 | } 11 | 12 | html.idle #metadata { 13 | height: 88px; 14 | } 15 | 16 | html.idle:not(.no-lyrics) #metadata { 17 | width: 40%; 18 | } 19 | 20 | #metadata span { 21 | overflow-x: clip; 22 | overflow-y: visible; 23 | white-space: nowrap; 24 | text-overflow: ellipsis; 25 | transition: ease .05s; 26 | } 27 | 28 | #title { 29 | font-weight: 700; 30 | font-size: 64px; 31 | opacity: 1; 32 | } 33 | 34 | html.idle #title { 35 | font-size: 48px; 36 | } 37 | 38 | #artist { 39 | font-weight: 500; 40 | font-size: 48px; 41 | opacity: 0.7; 42 | } 43 | 44 | html.idle #artist { 45 | font-size: 24px; 46 | } 47 | 48 | #album { 49 | font-weight: 500; 50 | font-size: 36px; 51 | opacity: 0.7; 52 | } 53 | 54 | #play-count-info { 55 | font-weight: 500; 56 | font-size: 24px; 57 | opacity: 0.5; 58 | } 59 | 60 | html.idle :is(#album, #play-count-info) { 61 | font-size: 0; 62 | opacity: 0; 63 | } 64 | 65 | html.no-show-play-count-info #play-count-info { 66 | display: none; 67 | } 68 | 69 | #metadata .featuring { 70 | opacity: .8; 71 | } 72 | 73 | html.widget-mode #metadata > * { 74 | text-shadow: 0 2px 8px rgba(0, 0, 0, .75); 75 | } 76 | 77 | /* iPad */ 78 | @media only screen and (max-width: 1024px) { 79 | #metadata { 80 | height: 160px; 81 | } 82 | 83 | html.idle #metadata { 84 | height: 72px; 85 | } 86 | 87 | #title { 88 | font-size: 48px; 89 | } 90 | 91 | #artist { 92 | font-size: 36px; 93 | } 94 | 95 | #album { 96 | font-size: 24px; 97 | } 98 | 99 | #play-count-info { 100 | font-size: 16px; 101 | } 102 | 103 | html.idle #title { 104 | font-size: 36px; 105 | } 106 | 107 | html.idle #artist { 108 | font-size: 24px; 109 | } 110 | } 111 | 112 | /* iPhone */ 113 | @media only screen and (max-width: 812px) { 114 | #metadata { 115 | height: 96px; 116 | } 117 | 118 | html.idle #metadata { 119 | height: 40px; 120 | } 121 | 122 | #title { 123 | font-size: 24px; 124 | } 125 | 126 | #artist { 127 | font-size: 20px; 128 | } 129 | 130 | #album { 131 | font-size: 16px; 132 | } 133 | 134 | #play-count-info { 135 | font-size: 12px; 136 | } 137 | 138 | html.idle #title { 139 | font-size: 20px; 140 | } 141 | 142 | html.idle #artist { 143 | font-size: 16px; 144 | } 145 | } -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/NowPlaying.css: -------------------------------------------------------------------------------- 1 | #now-playing { 2 | box-sizing: border-box; 3 | position: absolute; 4 | bottom: 0; 5 | left: 0; 6 | z-index: 1; 7 | display: flex; 8 | flex-direction: column; 9 | align-items: flex-start; 10 | justify-content: flex-end; 11 | width: 100%; 12 | padding: 56px; 13 | } 14 | 15 | #now-playing * { 16 | z-index: 1; 17 | } 18 | 19 | #now-playing::before { 20 | content: ""; 21 | content-visibility: none; 22 | position: absolute; 23 | bottom: 0; 24 | left: 0; 25 | z-index: 0; 26 | width: 100%; 27 | height: 100%; 28 | opacity: 0; 29 | background-color: var(--color-bg); 30 | --mask-image: linear-gradient( 31 | 180deg, 32 | rgba(0,0,0,0) 0%, 33 | rgba(0,0,0,1) 75% 34 | ); 35 | mask-image: var(--mask-image); 36 | -webkit-mask-image: var(--mask-image); 37 | transition: ease .2s; 38 | } 39 | 40 | html:not(.idle):not(.widget-mode) #now-playing::before { 41 | opacity: 0.75; 42 | } 43 | 44 | /* iPad */ 45 | @media only screen and (max-width: 1024px) { 46 | #now-playing { 47 | padding: 40px; 48 | } 49 | } 50 | 51 | /* iPhone */ 52 | @media only screen and (max-width: 812px) { 53 | #now-playing { 54 | padding: 24px; 55 | } 56 | } -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/PlaybackControls.css: -------------------------------------------------------------------------------- 1 | html:is(.no-controls, .non-interactive, .not-playing) #playback-controls { 2 | display: none; 3 | } 4 | 5 | html.no-show-lyrics #lyrics-btn { 6 | display: none; 7 | } 8 | 9 | html.no-extra-buttons :is(#spotify, #lastfm) { 10 | display: none; 11 | } 12 | 13 | #playback-controls { 14 | display: flex; 15 | flex-direction: row; 16 | align-items: center; 17 | justify-content: center; 18 | width: 100%; 19 | height: 40px; 20 | margin: 8px 0; 21 | gap: 16px; 22 | transition: ease .2s; 23 | } 24 | 25 | html.idle #playback-controls { 26 | opacity: 0; 27 | margin: 0; 28 | height: 0; 29 | } 30 | 31 | #playback-controls svg { 32 | width: 24px; 33 | height: 24px; 34 | aspect-ratio: 1; 35 | padding: 4px; 36 | border-radius: 24px; 37 | color: var(--color-fg); 38 | background-color: transparent; 39 | transition: ease .2s; 40 | pointer-events: all; 41 | } 42 | 43 | #playback-controls #playpause { 44 | padding: 8px; 45 | } 46 | 47 | #playback-controls svg:hover { 48 | color: var(--color-bg); 49 | background-color: var(--color-fg); 50 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 51 | } 52 | 53 | #playback-controls svg:active { 54 | color: var(--color-bg); 55 | background-color: var(--color-fg); 56 | filter: saturate(2); 57 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 58 | } 59 | 60 | #playback-controls svg.active { 61 | filter: saturate(2); 62 | } 63 | 64 | #playback-controls svg.active:hover { 65 | filter: saturate(3); 66 | } 67 | 68 | #playback-controls svg.disabled { 69 | opacity: 0.5; 70 | filter: saturate(.5); 71 | pointer-events: none; 72 | } 73 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/PlayingIndicator.css: -------------------------------------------------------------------------------- 1 | html:is(.no-playing-indicator, .widget-mode) .playing-indicator { 2 | display: none; 3 | } 4 | 5 | html.no-player-icon #player-icon { 6 | display: none; 7 | } 8 | 9 | .playing-indicator { 10 | position: absolute; 11 | top: 56px; 12 | left: 56px; 13 | display: flex; 14 | gap: 8px; 15 | align-items: center; 16 | justify-items: center; 17 | transition: ease .2s; 18 | } 19 | 20 | html:is(.idle, .not-playing) .playing-indicator { 21 | opacity: 0; 22 | } 23 | 24 | .playing-indicator .text { 25 | display: flex; 26 | flex-direction: column; 27 | align-items: flex-start; 28 | } 29 | 30 | #player-icon { 31 | width: 72px; 32 | aspect-ratio: 1; 33 | background-color: var(--color-fg); 34 | opacity: 1; 35 | mask-size: cover; 36 | -webkit-mask-size: cover; 37 | mask-image: var(--app-icon); 38 | -webkit-mask-image: var(--app-icon); 39 | } 40 | 41 | #player-playing { 42 | color: var(--color-fg); 43 | opacity: 0.5; 44 | text-transform: uppercase; 45 | font-weight: 500; 46 | font-size: 24px; 47 | } 48 | 49 | #player-name { 50 | color: var(--color-fg); 51 | font-weight: bold; 52 | font-size: 36px; 53 | } 54 | 55 | /* iPad */ 56 | @media only screen and (max-width: 1024px) { 57 | .playing-indicator { 58 | top: 40px; 59 | left: 40px; 60 | } 61 | 62 | #player-icon { 63 | width: 48px; 64 | } 65 | 66 | #player-playing { 67 | font-size: 16px; 68 | } 69 | 70 | #player-name { 71 | font-size: 24px; 72 | } 73 | } 74 | 75 | /* iPhone */ 76 | @media only screen and (max-width: 812px) { 77 | .playing-indicator { 78 | top: 24px; 79 | left: 24px; 80 | } 81 | 82 | #player-icon { 83 | width: 24px; 84 | } 85 | 86 | #player-playing { 87 | font-size: 12px; 88 | } 89 | 90 | #player-name { 91 | font-size: 16px; 92 | } 93 | } 94 | 95 | @media only screen and (orientation: portrait) { 96 | .playing-indicator { 97 | width: 100vw; 98 | left: 0; 99 | justify-content: center; 100 | } 101 | 102 | .playing-indicator .text { 103 | align-items: center; 104 | } 105 | 106 | #player-icon { 107 | display: none; 108 | } 109 | } -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/Seekbar.css: -------------------------------------------------------------------------------- 1 | html:is(.no-progress, .not-playing, .not-reporting-position) .seekbar-container { 2 | display: none; 3 | } 4 | 5 | html.non-interactive #seekbar { 6 | pointer-events: none; 7 | } 8 | 9 | .seekbar-container { 10 | width: 100%; 11 | height: 12px; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | margin: 8px 0; 16 | gap: 8px; 17 | transition: ease .2s; 18 | } 19 | 20 | html.idle .seekbar-container { 21 | height: 0; 22 | opacity: 0; 23 | margin: 0; 24 | } 25 | 26 | #time-elapsed, #time-total { 27 | font-size: 16px; 28 | width: 48px; 29 | color: var(--color-fg); 30 | text-align: center; 31 | } 32 | 33 | #seekbar { 34 | position: relative; 35 | width: 100%; 36 | height: 8px; 37 | pointer-events: none; 38 | } 39 | 40 | #seekbar-bg { 41 | position: absolute; 42 | width: 100%; 43 | height: 100%; 44 | z-index: 0; 45 | border-radius: 4px; 46 | background-color: var(--color-fg); 47 | opacity: 0.5; 48 | box-shadow: 0 0px 0px rgba(0, 0, 0, 0.3), 0 5px 16px rgba(0, 0, 0, 0.2); 49 | } 50 | 51 | #seekbar.draggable #seekbar-bg { 52 | pointer-events: auto; 53 | } 54 | 55 | #seekbar-fg { 56 | position: absolute; 57 | border-radius: 4px; 58 | background-color: var(--color-fg); 59 | opacity: 0.5; 60 | height: 100%; 61 | transition: ease .2s; 62 | min-width: 4px; 63 | } 64 | 65 | #seekbar-ball { 66 | position: absolute; 67 | top: -4px; 68 | background-color: var(--color-fg); 69 | opacity: 0; 70 | width: 0; 71 | height: 0; 72 | border-radius: 8px; 73 | width: 16px; 74 | height: 16px; 75 | transform: translateX(-8px) scale(0); 76 | transition: ease .2s; 77 | } 78 | 79 | #seekbar:hover #seekbar-fg, 80 | #seekbar:hover #seekbar-ball { 81 | opacity: 1; 82 | } 83 | 84 | #seekbar:hover #seekbar-ball { 85 | transform: translateX(-8px) scale(1); 86 | } 87 | 88 | #seekbar-fg.dragging { 89 | transition: none; 90 | } 91 | 92 | /* iPad */ 93 | @media only screen and (max-width: 1024px) { 94 | #time-elapsed, #time-total { 95 | font-size: 12px; 96 | width: 36px; 97 | } 98 | } 99 | 100 | /* iPhone */ 101 | @media only screen and (max-width: 812px) { 102 | #time-elapsed, #time-total { 103 | font-size: 12px; 104 | width: 36px; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/Sunamu.css: -------------------------------------------------------------------------------- 1 | @import url("Variables.css"); 2 | @import url("Background.css"); 3 | @import url("AlbumArt.css"); 4 | @import url("Info.css"); 5 | @import url("Metadata.css"); 6 | @import url("Seekbar.css"); 7 | @import url("Lyrics.css"); 8 | @import url("PlayingIndicator.css"); 9 | @import url("PlaybackControls.css"); 10 | @import url("NowPlaying.css"); 11 | 12 | h1, h2, h3, h4, h5, h6, span, p { 13 | font-family: var(--font-family); 14 | } 15 | -------------------------------------------------------------------------------- /dist/www/assets/styles/ui/sunamu/Variables.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Traverse to the root and back because FUCKING WEBKIT I HATE YOU APPLE */ 3 | --cover-art-url: url("../../../../assets/images/no_song.svg"); 4 | --app-icon: url("../../../../assets/images/apps/default.svg"); 5 | 6 | --font-family: "Urbanist"; 7 | 8 | --color-muted: rgb(2, 117, 151); 9 | --color-vibrant: rgb(4, 196, 252); 10 | 11 | --color-light-muted: rgb(3, 148, 150); 12 | --color-light-vibrant: rgb(123, 251, 252); 13 | 14 | --color-dark-muted: rgb(2, 117, 151); 15 | --color-dark-vibrant: rgb(2, 102, 131); 16 | 17 | --color-fg: var(--color-light-vibrant); 18 | --color-bg: var(--color-dark-vibrant); 19 | } 20 | 21 | html:is(.no-colors, .no-palette, .not-playing) { 22 | --color-fg: white; 23 | --color-bg: rgba(0, 0, 0, 0.5); 24 | } 25 | 26 | html.inverted-default-colors:is(.no-colors, .no-palette, .not-playing) { 27 | --color-fg: black; 28 | --color-bg: rgba(255, 255, 255, 0.5); 29 | } 30 | 31 | html.colorblock:is(.not-playing) { 32 | --color-bg: var(--color-dark-vibrant); 33 | } 34 | -------------------------------------------------------------------------------- /dist/www/index.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | 34 | 35 |
36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 |
47 | 48 | 49 |
50 | 51 |
52 | 53 |
54 | 55 | 56 |
57 | 58 | 59 | 60 | 61 |
62 |
63 | 64 | 65 |
66 | 67 |
68 |
69 |
70 |
71 |
72 | 73 |
74 | 75 | 76 |
77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 |
99 | 100 |
101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /dist/www/sunamu.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#009ccc", 3 | "description": "Beautiful Now Playing widget for the desktop, the web and streaming software", 4 | "display": "fullscreen", 5 | "icons": [ 6 | { 7 | "src": "/assets/favicon/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/assets/favicon/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "name": "Sunamu", 18 | "short_name": "Sunamu", 19 | "start_url": "/index.htm" 20 | } -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: xyz.nyaomi.sunamu 2 | productName: Sunamu 3 | copyright: Copyright © 2021-2023 Naomi Calabretta 4 | directories: 5 | output: targets 6 | files: 7 | - "**/*" 8 | - "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}" 9 | - "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}" 10 | - "!**/node_modules/*.d.ts" 11 | - "!**/node_modules/.bin" 12 | - "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}" 13 | - "!.editorconfig" 14 | - "!**/._*" 15 | - "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}" 16 | - "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}" 17 | - "!**/{appveyor.yml,.travis.yml,circle.yml}" 18 | - "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}" 19 | - "!src/*" 20 | - "!.eslintrc.json" 21 | - "!test.js" 22 | - "!README.md" 23 | - "!tsconfig.json" 24 | - "!tsconfig.settings.json" 25 | - "!assets/{preview*.png,kwin-widget-rule.kwinrule}" 26 | - "!aur/*" 27 | - "!.github/*" 28 | - "!.vscode/*" 29 | - "!.env" 30 | - "!.nvmrc" 31 | asar: true 32 | asarUnpack: 33 | - "./node_modules/sharp/vendor/**/*" 34 | - "./node_modules/sharp/build/**/*" 35 | - "./node_modules/register-scheme/build/**/*" 36 | # Linux specific 37 | - "./node_modules/abstract-socket/build/**/*" 38 | # Windows specific 39 | - "./node_modules/windowtoolbox/build/**/*" 40 | - "./node_modules/winplayer-node/build/**/*" 41 | linux: 42 | target: 43 | - AppImage 44 | maintainer: Naomi Calabretta 45 | vendor: Naomi Calabretta 46 | executableName: Sunamu 47 | icon: assets/icons 48 | synopsis: Beautiful app to show which music you are playing 49 | description: Show your currently playing song in a stylish way! 50 | category: Multimedia 51 | desktop: Sunamu 52 | publish: 53 | - github 54 | deb: 55 | depends: 56 | - libgtk-3-0 57 | - libnotify4 58 | - libxss1 59 | - libxtst6 60 | - libnss3 61 | - libatspi2.0-0 62 | - libuuid1 63 | - libvips 64 | rpm: 65 | depends: 66 | - /bin/sh 67 | - gtk3 68 | - libnotify 69 | - libXScrnSaver 70 | - libXtst 71 | - nss 72 | - at-spi2-core 73 | - libuuid 74 | - vips 75 | fpm: ["--rpm-rpmbuild-define=_build_id_links none"] 76 | pacman: 77 | depends: 78 | - gtk3 79 | - nss 80 | - libvips 81 | - c-ares 82 | - libxslt 83 | - libevent 84 | - minizip 85 | - re2 86 | - snappy 87 | flatpak: 88 | license: "LICENSE" 89 | runtime: "org.freedesktop.Platform" 90 | runtimeVersion: "22.08" 91 | sdk: "org.freedesktop.Sdk" 92 | base: "org.electronjs.Electron2.BaseApp" 93 | baseVersion: "22.08" 94 | branch: "master" 95 | useWaylandFlags: false # Configurable in Sunamu Config 96 | finishArgs: 97 | # Wayland/X11 Rendering 98 | - "--socket=wayland" 99 | - "--socket=x11" 100 | - "--share=ipc" 101 | # OpenGL 102 | - "--device=dri" 103 | # Directory access 104 | # Discord IPC 105 | - "--filesystem=xdg-run/discord-ipc-0" 106 | - "--filesystem=xdg-run/discord-ipc-1" 107 | - "--filesystem=xdg-run/discord-ipc-2" 108 | - "--filesystem=xdg-run/discord-ipc-3" 109 | - "--filesystem=xdg-run/discord-ipc-4" 110 | - "--filesystem=xdg-run/discord-ipc-5" 111 | - "--filesystem=xdg-run/discord-ipc-6" 112 | - "--filesystem=xdg-run/discord-ipc-7" 113 | - "--filesystem=xdg-run/discord-ipc-8" 114 | - "--filesystem=xdg-run/discord-ipc-9" 115 | # Discord IPC but Flatpak 116 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-0" 117 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-1" 118 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-2" 119 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-3" 120 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-4" 121 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-5" 122 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-6" 123 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-7" 124 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-8" 125 | - "--filesystem=xdg-run/app/com.discordapp.Discord/discord-ipc-9" 126 | # Cover art locations for Clementine, Strawberry, etc. 127 | - "--filesystem=/tmp:ro" 128 | # Cover art locations for VLC media player, Lollypop, etc. 129 | - "--filesystem=xdg-cache:ro" 130 | # Cover art locations for Clementine, Strawberry, etc. on Flatpak 131 | - "--filesystem=xdg-run/app:ro" 132 | # Cover art locations for GNOME Music on Flatpak, and possibly more 133 | - "--filesystem=~/.var/app:ro" 134 | # Restore RW for sunamu's Flatpak folders 135 | - "--filesystem=xdg-run/app/xyz.nyaomi.sunamu" 136 | - "--filesystem=~/.var/app/xyz.nyaomi.sunamu" 137 | # Allow communication with network 138 | - "--share=network" 139 | # Talk to media players 140 | - "--talk-name=org.mpris.MediaPlayer2.*" 141 | win: 142 | target: 143 | - nsis 144 | icon: assets/icons/icon.ico 145 | legalTrademarks: Copyright © 2021-2023 Naomi Calabretta 146 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sunamu", 3 | "description": "Beautiful Now Playing widget for the desktop, the web and streaming software", 4 | "version": "2.2.0", 5 | "main": "dist/main/index.js", 6 | "license": "MPL-2.0", 7 | "author": { 8 | "email": "me@nyaomi.xyz", 9 | "name": "Naomi Calabretta" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/NyaomiDEV/Sunamu.git" 14 | }, 15 | "scripts": { 16 | "lint": "eslint src/ --ext .ts", 17 | "compile": "tsc -b", 18 | "clean": "rm dist/types*; rm -rf dist/main; rm -rf dist/www/lib; rm -rf dist/www/index.{js,d.ts,d.ts.map}; rm -rf targets", 19 | "install-deps": "electron-builder install-app-deps; yarn run build:prepare:sharp", 20 | "install-deps:node": "npm rebuild; yarn run build:prepare:sharp", 21 | "test": "yarn run compile && electron . --sunamu-debug", 22 | "test:node": "yarn run compile && node . --sunamu-debug", 23 | "dev": "yarn run test", 24 | "dev:node": "yarn run test:node", 25 | "start": "yarn run run", 26 | "start:node": "yarn run run:node", 27 | "run": "yarn run compile && electron .", 28 | "run:node": "yarn run compile && node .", 29 | "build:prepare:sharp": "cd node_modules/sharp && rm -rf vendor && rm -rf build; SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn run install", 30 | "build": "yarn run clean && yarn run compile && electron-builder" 31 | }, 32 | "devDependencies": { 33 | "@types/jsdom": "^21.1.6", 34 | "@types/mime": "^3.0.4", 35 | "@types/node": "^20.11.4", 36 | "@types/node-static": "^0.7.11", 37 | "@types/obs-studio": "^2.17.2", 38 | "@typescript-eslint/eslint-plugin": "^6.19.0", 39 | "@typescript-eslint/parser": "^6.19.0", 40 | "electron": "^28.1.3", 41 | "electron-builder": "^24.9.1", 42 | "eslint": "^8.56.0", 43 | "socket.io-client": "^4.7.4", 44 | "typescript": "^5.3.3" 45 | }, 46 | "dependencies": { 47 | "@xhayper/discord-rpc": "^1.1.2", 48 | "axios": "^1.6.5", 49 | "electron-window-state": "^5.0.3", 50 | "golden-fleece": "^1.0.9", 51 | "jsdom": "^23.2.0", 52 | "json5": "^2.2.3", 53 | "mime": "^3.0.0", 54 | "node-static": "^0.7.11", 55 | "node-vibrant": "^3.2.1-alpha.1", 56 | "sharp": "^0.33.2", 57 | "socket.io": "^4.7.4", 58 | "yargs": "^17.7.2" 59 | }, 60 | "optionalDependencies": { 61 | "mpris-for-dummies": "NyaomiDEV/mpris-for-dummies", 62 | "windowtoolbox": "NyaomiDEV/windowtoolbox", 63 | "winplayer-rs": "NyaomiDEV/WinPlayer-Node" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/main/appStatus.ts: -------------------------------------------------------------------------------- 1 | import yargs from "yargs"; 2 | import { hideBin } from "yargs/helpers"; 3 | import { get as getConfig } from "./config"; 4 | 5 | export const argv = yargs(hideBin(process.argv)).argv; 6 | 7 | export const widgetModeElectron = checkFunctionality(getConfig("scenes").electron.widgetMode, "widget-electron"); 8 | export const widgetMode = checkFunctionality(getConfig("scenes").default.widgetMode, "widget"); 9 | export const debugMode = checkFunctionality(getConfig("debugMode"), "sunamu-debug"); 10 | export const devTools = checkFunctionality(getConfig("devToolsAtStartup"), "sunamu-devtools"); 11 | export const consolidateConfig = checkFunctionality(false, "consolidate-config"); 12 | export const lyricsActive = checkFunctionality(getConfig("lyricsActive"), "lyrics"); 13 | 14 | export const useElectron = checkFunctionality(getConfig("useElectron"), "electron"); 15 | export const useWebserver = checkFunctionality(getConfig("useWebserver"), "webserver"); 16 | 17 | export function checkFunctionality(configBoolean: boolean, name: string): boolean { 18 | return checkSwitch(argv[name.toLowerCase()]) ?? checkSwitch(process.env[name.replace(/-/g, "_").toUpperCase()]) ?? configBoolean; 19 | } 20 | 21 | export function checkSwitch(str?: string | boolean): boolean | undefined { 22 | if(typeof str === "boolean") return str; 23 | 24 | if (!str) 25 | return undefined; 26 | 27 | switch (str?.toLowerCase().trim()) { 28 | case "true": 29 | case "yes": 30 | case "1": 31 | return true; 32 | case "false": 33 | case "no": 34 | case "0": 35 | case null: 36 | case undefined: 37 | return false; 38 | } 39 | 40 | return Boolean(str); 41 | } 42 | -------------------------------------------------------------------------------- /src/main/config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "../types"; 2 | import { evaluate, patch } from "golden-fleece"; 3 | import { copyFileSync, mkdirSync, readFileSync, watchFile, writeFileSync } from "fs"; 4 | import { resolve } from "path"; 5 | import { getAppData } from "./util"; 6 | import EventEmitter from "events"; 7 | 8 | const configPath = resolve(getAppData(), "sunamu", "config.json5"); 9 | const defaultConfigPath = resolve(__dirname, "..", "..", "assets", "config.json5"); 10 | 11 | const defaultConfig: Config = evaluate(readFileSync(defaultConfigPath, "utf8")); 12 | let config: Config = getUserConfig(); 13 | 14 | const emitter = new EventEmitter(); 15 | export { emitter as default }; 16 | 17 | if (compare(config, defaultConfig, true)) 18 | save(); 19 | 20 | watchFile(configPath, () => { 21 | const _config = getUserConfig(); 22 | 23 | if (compare(config, _config, false)){ 24 | config = _config; 25 | emitter.emit("configChanged"); 26 | } 27 | 28 | }); 29 | 30 | function getUserConfig() { 31 | try { 32 | return evaluate(readFileSync(configPath, "utf8")); 33 | } catch (_) { 34 | save(false, defaultConfig); 35 | return defaultConfig; 36 | } 37 | } 38 | 39 | function compare(a: any, b: any, update: boolean = false): boolean { 40 | let changed = false; 41 | for (const key in b) { 42 | if (typeof b[key] !== typeof a[key] || Array.isArray(b[key]) !== Array.isArray(a[key])) { 43 | if(update) 44 | a[key] = b[key]; 45 | changed = true; 46 | continue; 47 | } 48 | 49 | if (typeof b[key] === "object" && !Array.isArray(b[key])) 50 | changed = compare(a[key], b[key], update) || changed; 51 | } 52 | 53 | return changed; 54 | } 55 | 56 | export function save(backup: boolean = true, configToSave = config) { 57 | mkdirSync(resolve(configPath, ".."), { recursive: true }); 58 | if(backup){ 59 | const now = new Date(); 60 | const date = now.getFullYear().toString().padStart(4, "0") + 61 | now.getMonth().toString().padStart(2, "0") + 62 | now.getDay().toString().padStart(2, "0") + "-" + 63 | now.getHours().toString().padStart(2, "0") + 64 | now.getMinutes().toString().padStart(2, "0") + 65 | now.getSeconds().toString().padStart(2, "0"); 66 | copyFileSync(configPath, configPath + ".backup-" + date); 67 | } 68 | try{ 69 | writeFileSync(configPath, patch(readFileSync(configPath, "utf8"), configToSave)); 70 | }catch(_){ 71 | writeFileSync(configPath, patch(readFileSync(defaultConfigPath, "utf8"), configToSave)); 72 | } 73 | } 74 | 75 | export function consolidateToDefaultConfig(){ 76 | return writeFileSync(configPath, patch(readFileSync(defaultConfigPath, "utf8"), config)); 77 | } 78 | 79 | export function get(name: string): T { 80 | return config[name]; 81 | } 82 | 83 | export function getAll(): Config { 84 | return Object.assign({}, config); 85 | } 86 | 87 | export function set(name: string, value: T){ 88 | config[name] = value; 89 | save(false); 90 | } 91 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import getPlayer, { Player } from "./player"; 2 | import playbackStatus, { updateInfo } from "./playbackStatus"; 3 | import { consolidateConfig, lyricsActive, useElectron, useWebserver } from "./appStatus"; 4 | import { updatePresence } from "./integrations/discordrpc"; 5 | import { logTrack } from "./integrations/tracklogger"; 6 | import Instance from "./instance"; 7 | import { manageLyricsCache } from "./integrations/lyricsOffline"; 8 | import { consolidateToDefaultConfig } from "./config"; 9 | import { logToDebug } from "./logger"; 10 | export { logToDebug as debug }; 11 | 12 | process.title = "sunamu"; 13 | 14 | let player: Player; 15 | 16 | async function main(): Promise { 17 | const instance = new Instance(); 18 | const haveLock = await instance.requestLock(); 19 | 20 | if(!haveLock){ 21 | console.error("Another instance is running!"); 22 | return process.exit(1); // for some reason I can't just process.exit because thanks Node on Windows 23 | } 24 | 25 | if(consolidateConfig) 26 | consolidateToDefaultConfig(); 27 | 28 | let _useWebserver = useWebserver; 29 | player = await getPlayer(); 30 | player.init(updateInfo); 31 | 32 | if(useElectron){ 33 | logToDebug("Loading Electron"); 34 | try{ 35 | const Electron = await import("./electron"); 36 | await Electron.default(); 37 | }catch(_e){ 38 | console.error("Electron is not available. Perhaps you are using vanilla Node?\nForcing Webserver!"); 39 | _useWebserver = true; 40 | } 41 | } 42 | 43 | if(_useWebserver){ 44 | logToDebug("Loading Webserver"); 45 | const WebServer = await import("./webserver"); 46 | await WebServer.default(); 47 | } 48 | 49 | playbackStatus.on("songdata", async (_songdata?, metadataChanged?) => { 50 | updatePresence(); 51 | if(metadataChanged) 52 | logTrack(); 53 | }); 54 | 55 | // Lyrics cache management 56 | if(lyricsActive) 57 | await manageLyricsCache(); 58 | } 59 | 60 | main(); -------------------------------------------------------------------------------- /src/main/instance.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { unlink } from "fs/promises"; 3 | import { connect, createServer } from "net"; 4 | import { tmpdir } from "os"; 5 | import * as path from "path"; 6 | 7 | import yargs from "yargs"; 8 | import { hideBin } from "yargs/helpers"; 9 | 10 | export default class Instance extends EventEmitter { 11 | 12 | private server = createServer(); 13 | 14 | requestLock(): Promise { 15 | let lockPath; 16 | switch(process.platform){ 17 | case "win32": 18 | lockPath = path.join("\\\\?\\pipe", tmpdir(), "SunamuInstance.lock"); 19 | break; 20 | default: 21 | lockPath = path.resolve(tmpdir(), "SunamuInstance.lock"); 22 | break; 23 | } 24 | 25 | return new Promise(resolve => { 26 | this.server.on("error", (e: { code: string }) => { 27 | if (e.code === "EADDRINUSE") { 28 | this.server.close(); 29 | const sock = connect({ path: lockPath }, () => { 30 | sock.write(JSON.stringify(yargs(hideBin(process.argv)).argv), () => { 31 | sock.destroy(); 32 | resolve(false); 33 | }); 34 | }); 35 | sock.on("error", (e: { code: string }) => { 36 | if (e.code === "ECONNREFUSED") 37 | unlink(lockPath).then(() => resolve(this.requestLock())); 38 | }); 39 | } 40 | }); 41 | 42 | this.server.on("listening", () => resolve(true)); 43 | 44 | this.server.on("connection", _sock => { 45 | _sock.on("data", buf => { 46 | this.emit("second-instance", JSON.parse(buf.toString())); 47 | }); 48 | }); 49 | 50 | this.server.listen(lockPath); 51 | }); 52 | } 53 | 54 | releaseLock(): Promise { 55 | return new Promise(resolve => { 56 | this.server.close(e => resolve(e ? false : true)); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/integrations/discordrpc.ts: -------------------------------------------------------------------------------- 1 | import { Client, SetActivity } from "@xhayper/discord-rpc"; 2 | import { DiscordPresenceConfig } from "../../types"; 3 | import { debug } from "../"; 4 | import { checkFunctionality } from "../appStatus"; 5 | import configEmitter, { get as getConfig } from "../config"; 6 | import { songdata } from "../playbackStatus"; 7 | import { secondsToTime } from "../util"; 8 | 9 | export let discordPresenceConfig: DiscordPresenceConfig = getPresenceConfig(); 10 | 11 | const clientId = "908012408008736779"; 12 | let rpc = new Client({clientId}); 13 | 14 | rpc.on("connected", () => { 15 | debug("Discord RPC is connected"); 16 | }); 17 | 18 | rpc.on("disconnected", async () => { 19 | debug("Discord RPC was disconnected"); 20 | }); 21 | 22 | configEmitter.on("configChanged", async () => { 23 | discordPresenceConfig = getPresenceConfig(); 24 | }); 25 | 26 | function getPresenceConfig() { 27 | const settings: DiscordPresenceConfig = Object.assign({}, getConfig("discordRpc")); 28 | settings.enabled = checkFunctionality(settings.enabled, "discord-rpc"); 29 | return settings; 30 | } 31 | 32 | const connect = (() => { 33 | let isConnecting = false; 34 | 35 | async function _connect(){ 36 | if (rpc.isConnected) return; 37 | if (isConnecting) return; 38 | isConnecting = true; 39 | 40 | let error: boolean; 41 | do { 42 | error = false; 43 | try { 44 | debug("Discord RPC logging in"); 45 | await rpc.connect(); 46 | } catch (_e) { 47 | debug(_e); 48 | error = true; 49 | debug("Discord RPC errored out while logging in, waiting 5 seconds before retrying"); 50 | await new Promise(resolve => setTimeout(resolve, 5000)); 51 | } 52 | } while (error); 53 | isConnecting = false; 54 | } 55 | 56 | return _connect; 57 | })(); 58 | 59 | export async function updatePresence() { 60 | if (!discordPresenceConfig.enabled){ 61 | if(rpc.isConnected) rpc.destroy(); 62 | return; 63 | } 64 | 65 | const presence = await getPresence(); 66 | 67 | await connect(); 68 | if (!rpc.isConnected) return; // failed 69 | 70 | if(!presence) { 71 | rpc.user?.clearActivity(); 72 | return; 73 | } 74 | 75 | return rpc.user?.setActivity(presence); 76 | } 77 | 78 | async function getPresence() { 79 | if (!songdata || !songdata.metadata.id || discordPresenceConfig.blacklist.includes(songdata.appName)) 80 | return; 81 | 82 | const activity: SetActivity = { // everything must be two characters long at least 83 | details: songdata.metadata.title ? `"${songdata.metadata.title}"` : "Unknown track", 84 | state: songdata.metadata.artist ? `By ${songdata.metadata.artist}` : undefined, 85 | largeImageText: songdata.metadata.album ? `"${songdata.metadata.album}"` : undefined, 86 | smallImageKey: songdata.status.toLowerCase(), 87 | smallImageText: `${songdata.status}` + (songdata.metadata.length > 0 ? ` (${secondsToTime(songdata.metadata.length)})` : ""), 88 | instance: false 89 | }; 90 | 91 | if (songdata.status === "Playing" && songdata.elapsed.howMuch) { 92 | const now = Date.now(); 93 | const start = Math.round(now - (songdata.elapsed.howMuch * 1000)); 94 | const end = Math.round(start + (songdata.metadata.length * 1000)); 95 | activity.startTimestamp = start; 96 | activity.endTimestamp = end; 97 | } 98 | 99 | if (songdata.spotify) { 100 | if (!activity.largeImageKey){ 101 | const images = songdata.spotify.album?.images; 102 | if(images?.length) 103 | activity.largeImageKey = images[images?.length-1]?.url; 104 | } 105 | 106 | activity.buttons = [ 107 | { 108 | label: "Listen on Spotify", 109 | url: songdata.spotify.external_urls.spotify 110 | } 111 | ]; 112 | } 113 | 114 | if (songdata.lastfm) { 115 | if(!activity.largeImageKey){ 116 | const images = songdata.lastfm.album?.image; 117 | if (images?.length) 118 | activity.largeImageKey = images[images?.length - 1]?.["#text"]; 119 | } 120 | 121 | activity.buttons = [ 122 | { 123 | label: "View on Last.fm", 124 | url: songdata.lastfm.url 125 | } 126 | ]; 127 | } 128 | 129 | if (!activity.largeImageKey) 130 | activity.largeImageKey = "app_large"; 131 | 132 | return activity; 133 | } 134 | -------------------------------------------------------------------------------- /src/main/integrations/lyrics.ts: -------------------------------------------------------------------------------- 1 | import { debug } from "../"; 2 | import { get as getLyrics, save as saveLyrics } from "./lyricsOffline"; 3 | 4 | import * as Musixmatch from "../lyricproviders/musixmatch"; 5 | import * as NetEase from "../lyricproviders/netease"; 6 | import * as Genius from "../lyricproviders/genius"; 7 | import * as Metadata from "../lyricproviders/metadata"; 8 | import * as Local from "../lyricproviders/local"; 9 | import type { Lyrics, Metadata as MetadataType } from "../../types"; 10 | import { getAll as getConfig } from "../config"; 11 | 12 | const providerList: any[] = [ 13 | Musixmatch, 14 | NetEase, 15 | Genius, 16 | Metadata, 17 | Local 18 | ]; 19 | 20 | for(let i = 0; i < providerList.length; i++){ 21 | if(!providerList[i].supportedPlatforms.includes(process.platform)) 22 | providerList.splice(i, 1); 23 | } 24 | 25 | export async function getAllLyrics(metadata: MetadataType): Promise { 26 | if (!metadata.artist || !metadata.title) // there can't be lyrics without at least those two fields 27 | return []; 28 | 29 | const configProviders = getConfig().lyricsProviders; 30 | const providerPromises = Object.keys(configProviders).map(x => configProviders[x] ? providerList.find(y => y.name === x)?.query(metadata) : undefined).filter(Boolean); 31 | 32 | return (await Promise.all(providerPromises)).filter(x => x?.lines.length); 33 | } 34 | 35 | export async function saveCustomLyrics(metadata: MetadataType, lyrics: Lyrics){ 36 | const id = computeLyricsID(metadata); 37 | await saveLyrics(id, lyrics); 38 | debug("Saved custom lyrics from " + lyrics.provider); 39 | } 40 | 41 | export async function queryLyricsAutomatically(metadata: MetadataType): Promise { 42 | let lyrics: Lyrics | undefined; 43 | const id = computeLyricsID(metadata); 44 | 45 | const cached = await getLyrics(id); 46 | 47 | // This should only be executed inside the electron (main) process 48 | if (!cached || !cached.lines!.length || !cached?.synchronized) { 49 | if (!cached) debug(`Cache miss for ${metadata.artist} - ${metadata.title}`); 50 | else if (!cached?.synchronized) debug(`Cache hit but unsynced lyrics. Trying to fetch synchronized lyrics for ${metadata.artist} - ${metadata.title}`); 51 | 52 | lyrics = (await getAllLyrics(metadata)).find(x => !cached?.synchronized ? x.synchronized : true); 53 | 54 | if (lyrics){ 55 | await saveLyrics(id, lyrics); 56 | debug("Fetched from " + lyrics.provider); 57 | }else 58 | debug("Unable to fetch lyrics"); 59 | } 60 | 61 | if(cached && !lyrics) 62 | lyrics = cached; 63 | 64 | return lyrics || { unavailable: true }; 65 | } 66 | 67 | function computeLyricsID(metadata: MetadataType){ 68 | return `${metadata.artist}:${metadata.album}:${metadata.title}`; 69 | } 70 | -------------------------------------------------------------------------------- /src/main/integrations/lyricsOffline.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readdir, readFile, rm, stat, writeFile } from "fs/promises"; 2 | import { extname, resolve } from "path"; 3 | import { createHash } from "crypto"; 4 | import JSON5 from "json5"; 5 | import { Lyrics } from "../../types"; 6 | import { getAppData, gzipCompress, gzipDecompress, humanDimensionToBytes } from "../util"; 7 | import { get as getConfig } from "../config"; 8 | import { debug } from ".."; 9 | 10 | import type { Stats } from "fs"; 11 | 12 | const lyrPath = resolve(getAppData(), "sunamu", "Lyrics Cache"); 13 | 14 | function md5(data){ 15 | return createHash("md5").update(data).digest("hex"); 16 | } 17 | 18 | export async function get(id: string): Promise{ 19 | const cachePath = resolve(lyrPath, md5(id) + ".gz"); 20 | try { 21 | return JSON5.parse((await gzipDecompress(await readFile(cachePath))).toString()); 22 | }catch (_) { 23 | return undefined; 24 | } 25 | } 26 | 27 | export async function save(id: string, data: any): Promise{ 28 | const cachePath = resolve(lyrPath, md5(id) + ".gz"); 29 | // mkdir 30 | try { 31 | mkdir(lyrPath, {recursive:true}); 32 | }catch(_){ 33 | return false; 34 | } 35 | 36 | // save 37 | try { 38 | await writeFile(cachePath, await gzipCompress(JSON5.stringify(data))); 39 | return true; 40 | } catch (_) { 41 | return false; 42 | } 43 | } 44 | 45 | export async function remove(id: string): Promise{ 46 | const cachePath = resolve(lyrPath, md5(id) + ".gz"); 47 | // save 48 | try { 49 | await rm(cachePath); 50 | return true; 51 | } catch (_) { 52 | return false; 53 | } 54 | } 55 | 56 | async function convertUncompressed(): Promise{ 57 | try{ 58 | const lyrics = await readdir(lyrPath); 59 | for (const file of lyrics) { 60 | if (extname(file) === ".gz") continue; 61 | 62 | const path = resolve(lyrPath, file); 63 | await writeFile(path + ".gz", await gzipCompress(await readFile(path))); 64 | await rm(path); 65 | } 66 | } catch(_){ 67 | debug("Cannot convert uncompressed lyrics; probably the cache path does not exist."); 68 | } 69 | } 70 | 71 | async function statCachePath(): Promise | undefined>{ 72 | try{ 73 | const lyrics = await readdir(lyrPath); 74 | const stats = new Map(); 75 | 76 | stats[Symbol.iterator] = function* statsIterator() { 77 | yield* [...this.entries()].sort((a, b) => a[1].atimeMs - b[1].atimeMs); 78 | }; 79 | 80 | for (const file of lyrics) { 81 | const path = resolve(lyrPath, file); 82 | stats.set(file, await stat(path)); 83 | } 84 | 85 | return stats; 86 | }catch(_){ 87 | debug("Cannot stat lyrics cache path; probably it does not exist."); 88 | return undefined; 89 | } 90 | } 91 | 92 | async function trimPathTo(targetSize: number): Promise { 93 | debug("Target lyrics cache size in bytes:", targetSize); 94 | 95 | const currentStats = [...await statCachePath() || []]; 96 | const cacheSize = currentStats.map(x => x[1].size).reduce((_prev, _cur) => _prev + _cur, 0); 97 | 98 | let filesRemoved = 0; 99 | let bytesFreed = 0; 100 | 101 | while(cacheSize - bytesFreed > targetSize){ 102 | const pair = currentStats.shift(); 103 | if(!pair) 104 | break; 105 | 106 | await rm(resolve(lyrPath, pair[0])); 107 | bytesFreed += pair[1].size; 108 | filesRemoved++; 109 | } 110 | 111 | debug("Deleted", filesRemoved, "old lyrics for a total of", bytesFreed, "bytes"); 112 | debug("New lyrics cache size in bytes:", cacheSize - bytesFreed); 113 | 114 | return [filesRemoved, bytesFreed, cacheSize - bytesFreed]; 115 | } 116 | 117 | export async function manageLyricsCache(){ 118 | await convertUncompressed(); // just to be sure 119 | 120 | const cacheStats = await statCachePath(); 121 | 122 | if(!cacheStats) 123 | return; 124 | 125 | debug("Total cached lyrics:", cacheStats.size); 126 | 127 | const cacheSize = [...cacheStats].map(x => x[1].size).reduce((_prev, _cur) => _prev + _cur, 0); 128 | debug("Current lyrics cache size in bytes:", cacheSize); 129 | 130 | const targetSize = humanDimensionToBytes(getConfig("targetLyricsCacheSize")); 131 | 132 | if(targetSize && Number(targetSize) > 0) 133 | await trimPathTo(targetSize); 134 | } -------------------------------------------------------------------------------- /src/main/integrations/tracklogger.ts: -------------------------------------------------------------------------------- 1 | import { FileHandle, mkdir, open } from "fs/promises"; 2 | import { checkFunctionality } from "../appStatus"; 3 | import configEmitter, { get as getConfig } from "../config"; 4 | import { songdata } from "../playbackStatus"; 5 | import { resolve } from "path"; 6 | import { getAppData } from "../util"; 7 | import { debug } from "../"; 8 | import { EOL } from "os"; 9 | 10 | export let trackLogActive = getTrackLoggerConfig(); 11 | export const trackLogUTC = getTrackLoggerUTCConfig(); // not definable at runtime for consistency 12 | export const trackLogPath = getFilePath(); 13 | 14 | configEmitter.on("configChanged", async () => { 15 | trackLogActive = getTrackLoggerConfig(); 16 | await handleFile(); 17 | }); 18 | 19 | export function setTrackLogActive(value: boolean){ 20 | trackLogActive = value; 21 | handleFile(); 22 | } 23 | 24 | function getTrackLoggerConfig() { 25 | const _active = getConfig("logPlayedTracksToFile"); 26 | return checkFunctionality(_active, "log-tracks"); 27 | } 28 | 29 | function getTrackLoggerUTCConfig() { 30 | const _active = getConfig("logPlayedTracksUTCTimestamps"); 31 | return checkFunctionality(_active, "log-tracks-in-utc"); 32 | } 33 | 34 | function getFilePath(){ 35 | const sessionDate = new Date(); 36 | let sessionDateFormat: string; 37 | if (trackLogUTC) 38 | sessionDateFormat = `UTC-${sessionDate.getUTCFullYear()}${String(sessionDate.getUTCMonth() + 1).padStart(2, "0")}${String(sessionDate.getUTCDate()).padStart(2, "0")}-${String(sessionDate.getUTCHours()).padStart(2, "0")}${String(sessionDate.getUTCMinutes()).padStart(2, "0")}${String(sessionDate.getUTCSeconds()).padStart(2, "0")}`; 39 | else 40 | sessionDateFormat = `${sessionDate.getFullYear()}${String(sessionDate.getMonth() + 1).padStart(2, "0")}${String(sessionDate.getDate()).padStart(2, "0")}-${String(sessionDate.getHours()).padStart(2, "0")}${String(sessionDate.getMinutes()).padStart(2, "0")}${String(sessionDate.getSeconds()).padStart(2, "0")}`; 41 | const sessionFileName = `sunamu-tracklog-${sessionDateFormat}.txt`; 42 | 43 | return resolve(getAppData(), "sunamu", "tracklogs", sessionFileName); 44 | } 45 | 46 | 47 | let fileHandle: FileHandle | undefined; 48 | 49 | async function handleFile(){ 50 | if(!trackLogActive) { 51 | if(fileHandle){ 52 | await fileHandle.close(); 53 | fileHandle = undefined; 54 | } 55 | return; 56 | } 57 | 58 | if(!fileHandle){ 59 | try{ 60 | await mkdir(resolve(getAppData(), "sunamu", "tracklogs"), { recursive: true }); 61 | fileHandle = await open(trackLogPath, "a"); 62 | }catch(e){ 63 | debug("Cannot open file for logging"); 64 | } 65 | } 66 | 67 | } 68 | 69 | export async function logTrack() { 70 | await handleFile(); 71 | 72 | if (!trackLogActive || !songdata || !songdata.metadata.id) 73 | return; 74 | 75 | if(!fileHandle) 76 | return; // error 77 | 78 | const now = new Date(); 79 | let nowFormat: string; 80 | if(trackLogUTC) 81 | nowFormat = `${String(now.getUTCHours()).padStart(2, "0")}:${String(now.getUTCMinutes()).padStart(2, "0")}.${String(now.getUTCSeconds()).padStart(2, "0")}`; 82 | else 83 | nowFormat = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}.${String(now.getSeconds()).padStart(2, "0")}`; 84 | const line = `[${nowFormat}] ${songdata.metadata.artist ?? "Unknown artist"} - ${songdata.metadata.title ?? "Unknown track"}`; 85 | debug("Logging track", `"${songdata.metadata.artist} - ${songdata.metadata.title}"`); 86 | 87 | await fileHandle?.appendFile(line + EOL, "utf-8"); 88 | } 89 | -------------------------------------------------------------------------------- /src/main/logger.ts: -------------------------------------------------------------------------------- 1 | import { basename } from "path"; 2 | import { inspect } from "util"; 3 | import { debugMode } from "./appStatus"; 4 | 5 | function getStack(caller: Function) { 6 | const _handler = Error.prepareStackTrace; 7 | Error.prepareStackTrace = (_a, b) => b; 8 | const resultHolder: any = {}; 9 | Error.captureStackTrace(resultHolder, caller); 10 | // eslint-disable-next-line no-undef 11 | const stack: NodeJS.CallSite[] = resultHolder.stack; 12 | Error.prepareStackTrace = _handler; 13 | 14 | const parsedStack: any[] = []; 15 | for(const callSite of stack){ 16 | parsedStack.push({ 17 | function: callSite.getFunctionName(), 18 | method: callSite.getMethodName(), 19 | file: callSite.getFileName(), 20 | line: callSite.getLineNumber(), 21 | column: callSite.getColumnNumber(), 22 | // @ts-ignore this does exist, wtf typedefs 23 | async: callSite.isAsync() 24 | }); 25 | } 26 | return parsedStack; 27 | } 28 | 29 | export function logToDebug(...args: any[]) { 30 | if (debugMode){ 31 | let shiftStack = 0; 32 | if(args.length && typeof args[0] === "number") 33 | shiftStack = args.shift(); 34 | 35 | const callSite = getStack(logToDebug)[shiftStack]; 36 | let debugString = "[idkwhere]"; 37 | if(callSite) 38 | debugString = `[${callSite.file ? basename(callSite.file) : "nofile"}:${callSite.function || callSite.method || "!anonymous"}:${callSite.line}:${callSite.column}]`; 39 | 40 | for(const i in args){ 41 | if(typeof args[i] !== "string") 42 | args[i] = inspect(args[i], undefined, null, true); 43 | } 44 | 45 | console.log(debugString, ...args); 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/lyricproviders/genius.ts: -------------------------------------------------------------------------------- 1 | import type { Lyrics, Metadata } from "../../types"; 2 | 3 | import { URLSearchParams } from "url"; 4 | import axios, { AxiosResponse } from "axios"; 5 | import { JSDOM } from "jsdom"; 6 | 7 | export const name = "Genius"; 8 | export const supportedPlatforms = ["linux", "win32"]; 9 | 10 | const search_url = "https://genius.com/api/search/song"; 11 | 12 | export async function query(metadata: Metadata): Promise { 13 | const reply: Lyrics = { 14 | provider: "Genius", 15 | synchronized: false, 16 | copyright: undefined, 17 | lines: [] 18 | }; 19 | 20 | 21 | const songId = await getSongURL(metadata.artist, metadata.title); 22 | if (!songId) { 23 | console.error("Could not find the song on Genius!"); 24 | return undefined; 25 | } 26 | 27 | const lyrics = await getLyricsFromGenius(songId); 28 | if (!lyrics) { 29 | console.error("Could not get lyrics on Genius!"); 30 | return undefined; 31 | } 32 | 33 | reply.lines = lyrics.split("\n").map(x => ({text: x})); 34 | return reply; 35 | } 36 | 37 | function getSearchFields(artist: string, title: string) { 38 | const post_fields = new URLSearchParams({ 39 | q: artist + " " + title, 40 | per_page: "1" 41 | }); 42 | 43 | return post_fields.toString(); 44 | } 45 | 46 | async function getSongURL(artist: string, title: string) { 47 | let result: AxiosResponse; 48 | try { 49 | result = await axios.get(search_url + "?" + getSearchFields(artist, title)); 50 | } catch (e) { 51 | console.error("Genius search request got an error!", e); 52 | return undefined; 53 | } 54 | 55 | return result.data.response?.sections?.[0]?.hits?.[0]?.result?.url; 56 | } 57 | 58 | async function getLyricsFromGenius(url) { 59 | let result: AxiosResponse; 60 | try { 61 | result = await axios.get(url, {responseType: "text"}); 62 | } catch (e) { 63 | console.error("Genius lyrics request got an error!", e); 64 | return undefined; 65 | } 66 | 67 | const dom = new JSDOM(result.data.split("
").join("\n")); 68 | 69 | const lyricsDiv = dom.window.document.querySelector("div.lyrics"); 70 | if(lyricsDiv) 71 | return lyricsDiv.textContent?.trim(); 72 | 73 | const lyricsSections = [...dom.window.document.querySelectorAll("div[class^=Lyrics__Container]").values()].map(x => x.textContent); 74 | return lyricsSections.join("\n"); 75 | } 76 | -------------------------------------------------------------------------------- /src/main/lyricproviders/local.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "fs/promises"; 2 | import type { Lyrics, Metadata } from "../../types"; 3 | import { parseLrc } from "./lrc"; 4 | import { basename, dirname, extname, sep } from "path"; 5 | 6 | export const name = "Local"; 7 | export const supportedPlatforms = ["linux"]; 8 | 9 | export async function query(metadata: Metadata): Promise { 10 | if (metadata.location?.protocol !== "file:") { 11 | console.error("Could not get lyrics from Local: Location is not local."); 12 | return undefined; 13 | } 14 | 15 | try{ 16 | const reply: Lyrics = { 17 | provider: "Local", 18 | synchronized: false, 19 | copyright: undefined, 20 | lines: [] 21 | }; 22 | 23 | const lrcLocation = 24 | dirname(decodeURI(metadata.location.pathname)) + sep + 25 | basename(decodeURI(metadata.location.pathname), extname(decodeURI(metadata.location.pathname))) + ".lrc"; 26 | 27 | const lrcFile = await readFile(lrcLocation, "utf-8"); 28 | 29 | const parsedLrc = parseLrc(lrcFile).lines; 30 | if (parsedLrc.length) { 31 | reply.synchronized = true; 32 | reply.lines = parsedLrc; 33 | } else 34 | reply.lines = lrcFile.split("\n").map(x => ({ text: x })); 35 | 36 | return reply; 37 | }catch(_e){ 38 | console.error("Could not get lyrics from Local: LRC file is not present nor readable."); 39 | } 40 | 41 | return undefined; 42 | } 43 | -------------------------------------------------------------------------------- /src/main/lyricproviders/lrc.ts: -------------------------------------------------------------------------------- 1 | import type { LrcFile, LyricsKaraokeVerse, LyricsLine } from "../../types"; 2 | 3 | const howATimestampLooks = /\[(\d+:\d+\.?\d+)\]|\[(\d+?),(\d+)\]/g; 4 | const howALyricsLineLooks = /((?:\[\d+:\d+\.?\d+\]|\[\d+,\d+\])+)(.*)/; 5 | 6 | function convertTime(timeString: string): number { 7 | const [minutes, seconds] = timeString.split(":"); 8 | return parseInt(minutes) * 60 + parseFloat(seconds); 9 | } 10 | 11 | function extractTime(timestamps: string){ 12 | const matches = [...timestamps.matchAll(howATimestampLooks)]; 13 | const time: number[] = []; 14 | matches.forEach(m => { 15 | if(m[1]) 16 | time.push(convertTime(m[1])); 17 | else if(m[2]) 18 | time.push(Number(m[2]) / 1000); 19 | }); 20 | return time; 21 | } 22 | 23 | function extractMetadataLine(data: string): string[] { 24 | return data.trim().slice(1, -1).split(": "); 25 | } 26 | 27 | function extractKaraokeNetEase(line: string, timestamp: number): LyricsKaraokeVerse[] { 28 | // netease karaoke line 29 | const howANetEaseKaraokeLineLooks = /\(\d+,(\d+)\)/g; 30 | const components = line.split(howANetEaseKaraokeLineLooks); 31 | const karaokeVerses: LyricsKaraokeVerse[] = []; 32 | let accumulator = 0; 33 | for(let i = 1; i < components.length; i += 2){ 34 | karaokeVerses.push({ 35 | text: components[i + 1], 36 | start: timestamp + accumulator 37 | }); 38 | accumulator += Number(components[i]) / 1000; 39 | } 40 | 41 | return karaokeVerses; 42 | } 43 | 44 | export function parseLrc(data: string): LrcFile{ 45 | const result: LrcFile = { 46 | metadata: {}, 47 | lines: [] 48 | }; 49 | 50 | // Sanitize our data first and foremost 51 | // remove enhanced LRC format 52 | // TODO: Do not remove this and instead use it 53 | data = data.replace(/<\d+:\d+\.\d+>/g, "").replace(/<\d+:\d+>/g, "").replace(/<\d+>/g, ""); 54 | // extend compressed time tags (ex. [01:30] becomes [01:30.000]) 55 | data = data.replace(/\[(\d+):(\d+)\]/g, (_match: any, p1: any, p2: any) => `[${p1}:${p2}.000]`); 56 | 57 | let lines = data.trim().split("\n").map((x: string) => x.trim()); 58 | 59 | for(const line of lines){ 60 | const lyrdata = howALyricsLineLooks.exec(line); 61 | if(lyrdata){ 62 | for(const timestamp of extractTime(lyrdata[1])){ 63 | const resultLine: LyricsLine = { 64 | text: lyrdata[2].replace(/\(\d+,\d+\)/g, "").replace(/\s+/g, " "), 65 | time: timestamp, 66 | karaoke: extractKaraokeNetEase(lyrdata[2], timestamp) 67 | }; 68 | 69 | if(!resultLine.karaoke?.length) 70 | delete resultLine.karaoke; 71 | 72 | result.lines.push(resultLine); 73 | } 74 | }else{ 75 | const [ key, value ] = extractMetadataLine(line); 76 | result.metadata[key] = value; 77 | } 78 | } 79 | 80 | result.lines.sort((a, b) => a.time! - b.time!); 81 | return result; 82 | } 83 | -------------------------------------------------------------------------------- /src/main/lyricproviders/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Lyrics, Metadata } from "../../types"; 2 | import { parseLrc } from "./lrc"; 3 | 4 | export const name = "Metadata"; 5 | export const supportedPlatforms = ["linux"]; 6 | 7 | export async function query(metadata: Metadata): Promise { 8 | const reply: Lyrics = { 9 | provider: "Metadata", 10 | synchronized: false, 11 | copyright: undefined, 12 | lines: [] 13 | }; 14 | 15 | const lyrics = metadata.lyrics; 16 | if (!lyrics) { 17 | console.error("Could not get lyrics from Metadata!"); 18 | return undefined; 19 | } 20 | 21 | const parsedLrc = parseLrc(lyrics).lines; 22 | if(parsedLrc.length){ 23 | reply.synchronized = true; 24 | reply.lines = parsedLrc; 25 | } else 26 | reply.lines = lyrics.split("\n").map(x => ({ text: x })); 27 | 28 | return reply; 29 | } 30 | -------------------------------------------------------------------------------- /src/main/lyricproviders/musixmatch.ts: -------------------------------------------------------------------------------- 1 | import type { Lyrics, Metadata } from "../../types"; 2 | 3 | import { URLSearchParams } from "url"; 4 | import axios, { AxiosResponse } from "axios"; 5 | import { get as getConfig, set as setConfig } from "../config"; 6 | import { getOSLocale } from "../util"; 7 | 8 | export const name = "Musixmatch"; 9 | export const supportedPlatforms = ["linux", "win32"]; 10 | 11 | async function queryMusixmatch(method: string, params?: any, shouldUseToken = true): Promise { 12 | 13 | let token: string | undefined; 14 | if (shouldUseToken){ 15 | // Get a token from the usual place 16 | token = getConfig("mxmusertoken") || await getToken() || undefined; 17 | 18 | // If we still haven't got one, then exit if we're not actually requesting that the call has no token attached 19 | if(!token){ 20 | console.error("No Musixmatch user token found"); 21 | return undefined; 22 | } 23 | 24 | // If we don't have one in the config and we now have one, then set whatever we have in it 25 | if (!getConfig("mxmusertoken") && token) 26 | setConfig("mxmusertoken", token); 27 | } 28 | 29 | const url = "https://apic-desktop.musixmatch.com/ws/1.1/"; 30 | 31 | const _params = new URLSearchParams({ 32 | app_id: "web-desktop-app-v1.0", 33 | t: Math.random().toString(36).replace(/[^a-z]+/g, "").slice(2, 10), 34 | usertoken: token, 35 | ...params 36 | }); 37 | 38 | // If we don't have to use a token, we delete the parameter since it'll be "undefined" 39 | if(!shouldUseToken) 40 | _params.delete("usertoken"); 41 | 42 | let result: AxiosResponse; 43 | try { 44 | result = await axios.get(url + method + "?" + _params.toString(), { 45 | headers: { 46 | "Cookie": "x-mxm-user-id=", 47 | "Authority": "apic-desktop.musixmatch.com", 48 | } 49 | }); 50 | } catch (e) { 51 | console.error(`Musixmatch token request for method ${method} errored out!`, e); 52 | return undefined; 53 | } 54 | 55 | return result.data; 56 | } 57 | 58 | async function getToken(){ 59 | const result = await queryMusixmatch("token.get", {}, false); 60 | if(result) { 61 | const token = result.message.body.user_token; 62 | if (token.length && token !== "UpgradeOnlyUpgradeOnlyUpgradeOnlyUpgradeOnly") 63 | return token; 64 | } 65 | 66 | console.error("Musixmatch token request did not get us any token!"); 67 | return undefined; 68 | } 69 | 70 | export async function query(metadata: Metadata): Promise { 71 | const reply: Lyrics = { 72 | provider: "Musixmatch", 73 | synchronized: true, 74 | copyright: undefined, 75 | lines: [] 76 | }; 77 | 78 | const queryParams: any = { 79 | format: "json", 80 | namespace: "lyrics_richsynched", 81 | optional_calls: "track.richsync", 82 | subtitle_format: "mxm", 83 | q_artist: metadata.artist, 84 | q_artists: metadata.artist, 85 | q_track: metadata.title, 86 | q_album: metadata.album, 87 | q_duration: `${metadata.length}`, 88 | f_subtitle_length: `${metadata.length}`, 89 | f_subtitle_length_max_deviation: "40", 90 | }; 91 | 92 | const result = await queryMusixmatch("macro.subtitles.get", queryParams); 93 | 94 | const trackId = result.message?.body?.macro_calls?.["matcher.track.get"]?.message?.body?.track?.track_id; 95 | 96 | const richsyncMessage = result.message?.body?.macro_calls?.["track.richsync.get"]?.message; 97 | const richsync = richsyncMessage?.body?.richsync; 98 | 99 | const subtitlesMessage = result.message?.body?.macro_calls?.["track.subtitles.get"]?.message; 100 | const subtitle = subtitlesMessage?.body?.subtitle_list?.[0]?.subtitle; 101 | 102 | const lyricsMessage = result.message?.body?.macro_calls?.["track.lyrics.get"]?.message; 103 | const lyrics = lyricsMessage?.body?.lyrics; 104 | 105 | if (richsync?.richsync_body) { 106 | reply.lines = JSON.parse(richsync.richsync_body).map(v => ({ 107 | text: v.x, 108 | time: v.ts, 109 | duration: v.te - v.ts, 110 | karaoke: v.l.map(x => ({ 111 | text: x.c, 112 | start: v.ts + x.o 113 | })) 114 | })); 115 | reply.copyright = richsync.lyrics_copyright?.trim().split("\n").join(" • "); 116 | } else if (subtitle?.subtitle_body) { 117 | reply.lines = JSON.parse(subtitle.subtitle_body).map(v => ({ text: v.text, time: v.time.total })); 118 | reply.copyright = subtitle.lyrics_copyright?.trim().split("\n").join(" • "); 119 | } 120 | else if (lyrics?.lyrics_body) { 121 | reply.synchronized = false; 122 | reply.lines = lyrics.lyrics_body.split("\n").map(x => ({ text: x })); 123 | reply.copyright = lyrics.lyrics_copyright?.trim().split("\n").join(" • "); 124 | } else { 125 | console.error( 126 | "Musixmatch request didn't get us any lyrics!", 127 | result.message?.header, 128 | richsyncMessage?.header || null, 129 | richsync || null, 130 | subtitlesMessage?.header || null, 131 | subtitle || null, 132 | lyricsMessage?.header || null, 133 | lyrics || null 134 | ); 135 | return undefined; 136 | } 137 | 138 | if(trackId){ 139 | const translations = await queryTranslation(trackId); 140 | if(translations){ 141 | for (const line in reply.lines) { 142 | for (const translationLine of translations) { 143 | if ( 144 | reply.lines[line].text.toLowerCase().trim() === translationLine.translation.matched_line.toLowerCase().trim() || 145 | reply.lines[line].text.toLowerCase().trim() === translationLine.translation.subtitle_matched_line.toLowerCase().trim() 146 | ) 147 | reply.lines[line].translation = translationLine.translation.description.trim(); 148 | } 149 | } 150 | } 151 | } 152 | 153 | return reply; 154 | } 155 | 156 | async function queryTranslation(trackId: string){ 157 | const queryParams = { 158 | format: "json", 159 | comment_format: "text", 160 | part: "user", 161 | track_id: trackId, 162 | translation_fields_set: "minimal", 163 | selected_language: getConfig("mxmlanguage") || getConfig("language") || getOSLocale()[0] || "en", 164 | }; 165 | 166 | const result = await queryMusixmatch("crowd.track.translations.get", queryParams); 167 | return result.message?.body?.translations_list; 168 | } -------------------------------------------------------------------------------- /src/main/lyricproviders/netease.ts: -------------------------------------------------------------------------------- 1 | import type { Lyrics, Metadata } from "../../types"; 2 | 3 | import { URLSearchParams } from "url"; 4 | import axios, { AxiosResponse } from "axios"; 5 | import { parseLrc } from "./lrc"; 6 | 7 | export const name = "NetEase"; 8 | export const supportedPlatforms = ["linux", "win32"]; 9 | 10 | const search_url = "http://music.163.com/api/search/get"; 11 | const lyrics_url = "http://music.163.com/api/song/lyric"; 12 | 13 | export async function query(metadata: Metadata): Promise { 14 | const reply: Lyrics = { 15 | provider: "NetEase Music", 16 | synchronized: true, 17 | copyright: undefined, 18 | lines: [] 19 | }; 20 | 21 | 22 | const songId = await getSongId(metadata); 23 | if(!songId){ 24 | console.error("Could not find the song on NetEase!"); 25 | return undefined; 26 | } 27 | 28 | const lyrics = await getLyricsFromSongId(songId); 29 | if (!lyrics) { 30 | console.error("Could not get lyrics on NetEase!"); 31 | return undefined; 32 | } 33 | 34 | reply.lines = parseLrc(lyrics).lines; 35 | 36 | if(reply.lines.length) 37 | return reply; 38 | 39 | console.error("Could not get synchronized lyrics on NetEase!"); 40 | return undefined; 41 | } 42 | 43 | function getSearchFields(metadata: Metadata){ 44 | const post_fields = new URLSearchParams({ 45 | s: metadata.artist + " " + metadata.title, 46 | type: "1", 47 | limit: "10", 48 | offset: "0" 49 | }); 50 | 51 | return post_fields.toString(); 52 | } 53 | 54 | function getLyricFields(songId){ 55 | const lyric_fields = new URLSearchParams({ 56 | id: songId, 57 | lv: "-1", 58 | kv: "-1" 59 | }); 60 | 61 | return lyric_fields.toString(); 62 | } 63 | 64 | async function getSongId(metadata: Metadata){ 65 | let result: AxiosResponse; 66 | try { 67 | result = await axios.get(search_url + "?" + getSearchFields(metadata)); 68 | } catch (e) { 69 | console.error("NetEase search request got an error!", e); 70 | return undefined; 71 | } 72 | 73 | return result?.data.result?.songs?.[0].id; 74 | } 75 | 76 | async function getLyricsFromSongId(songId){ 77 | let result: AxiosResponse; 78 | try { 79 | result = await axios.get(lyrics_url + "?" + getLyricFields(songId)); 80 | } catch (e) { 81 | console.error("NetEase lyrics request got an error!", e); 82 | return undefined; 83 | } 84 | 85 | return result.data.klyric?.lyric || result.data.lrc?.lyric; 86 | } 87 | -------------------------------------------------------------------------------- /src/main/platform/win32.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "electron"; 2 | 3 | // @ts-ignore 4 | import { SetWindowPosition, HWND_BOTTOM, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE } from "windowtoolbox"; 5 | 6 | export function sendOnBottom(win: BrowserWindow){ 7 | const hWnd = win.getNativeWindowHandle(); 8 | return SetWindowPosition(hWnd, Buffer.from([HWND_BOTTOM]), 0, 0, 0, 0, SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOMOVE); 9 | } -------------------------------------------------------------------------------- /src/main/playbackStatus.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, Lyrics, Metadata, SongData, SpotifyInfo, Update } from "../types"; 2 | import { get } from "./config"; 3 | import getPlayer from "./player"; 4 | import { getSpotifySongFromId, searchSpotifySong } from "./thirdparty/spotify"; 5 | import { getLFMTrackInfo } from "./thirdparty/lastfm"; 6 | import { spotiId } from "./util"; 7 | import { queryLyricsAutomatically, saveCustomLyrics } from "./integrations/lyrics"; 8 | import { debug } from "."; 9 | import { lyricsActive } from "./appStatus"; 10 | import EventEmitter from "events"; 11 | 12 | const emitter = new EventEmitter(); 13 | export{ emitter as default }; 14 | 15 | setInterval(pollPosition, get("positionPollInterval") * 1000); 16 | 17 | const fallback: DeepPartial = { 18 | provider: undefined, 19 | metadata: { 20 | title: undefined, 21 | artist: undefined, 22 | artists: undefined, 23 | albumArtist: undefined, 24 | albumArtists: undefined, 25 | album: undefined, 26 | artUrl: undefined, 27 | artData: undefined, 28 | length: undefined, 29 | id: undefined 30 | }, 31 | capabilities: { 32 | canControl: false, 33 | canPlayPause: false, 34 | canGoNext: false, 35 | canGoPrevious: false, 36 | canSeek: false 37 | }, 38 | status: "Stopped", 39 | loop: "None", 40 | shuffle: false, 41 | volume: 0, 42 | elapsed: { 43 | howMuch: 0, 44 | when: new Date(0) 45 | }, 46 | reportsPosition: false, 47 | app: undefined, 48 | appName: undefined, 49 | lyrics: { unavailable: true }, 50 | lastfm: undefined, 51 | spotify: undefined 52 | }; 53 | 54 | export const songdata = Object.assign({}, fallback) as SongData; 55 | 56 | let updateInfoSymbol: Symbol; 57 | 58 | export async function setCustomLyrics(lyrics: Lyrics) { 59 | songdata.lyrics = lyrics; 60 | 61 | await saveCustomLyrics(songdata.metadata, lyrics); 62 | 63 | emitter.emit("songdata", songdata, false); 64 | emitter.emit("lyrics"); 65 | } 66 | 67 | export async function updateInfo(update?: Update) { 68 | // create our unique symbol 69 | const currentSymbol = Symbol(); 70 | 71 | // did the metadata change? 72 | const metadataChanged = hasMetadataChanged(songdata.metadata, update?.metadata); 73 | 74 | // incrementally update the current status 75 | Object.assign(songdata, update || fallback); 76 | 77 | if (metadataChanged) { 78 | // we set our symbol as the global one since we're tasked with extra stuff 79 | updateInfoSymbol = currentSymbol; 80 | 81 | // we need to reset our extra songdata stuff 82 | songdata.lyrics = update?.metadata.id && lyricsActive 83 | ? undefined 84 | : { unavailable: true }; 85 | 86 | songdata.lastfm = undefined; 87 | songdata.spotify = undefined; 88 | 89 | // broadcast our initial update so people won't think sunamu is laggy asf 90 | emitter.emit("songdata", songdata, true); 91 | // this also updates the lyrics to whatever screen is suitable 92 | 93 | // if we do have an update containing an ID in it, then we assume a track is playing 94 | // and therefore we can get extra information about it 95 | if (!update?.metadata.id) return; 96 | 97 | // we pre-emptively check our symbol to avoid consuming API calls for nothing 98 | // because there's already newer stuff than us 99 | if(currentSymbol !== updateInfoSymbol) return; 100 | 101 | // BEGIN OF "HUGE SUSPENSION POINT" 102 | const extraMetadata: Partial = {}; 103 | extraMetadata.spotify = await pollSpotifyDetails(update.metadata); 104 | extraMetadata.lastfm = await getLFMTrackInfo(update.metadata, get("lfmUsername")); 105 | if(lyricsActive) 106 | extraMetadata.lyrics = await queryLyricsAutomatically(update.metadata); 107 | // END OF "HUGE SUSPENSION POINT" 108 | 109 | // we now have to check our symbol to avoid updating stuff that is newer than us 110 | // also, is there a way to de-dupe this? 111 | if(currentSymbol !== updateInfoSymbol) return; 112 | 113 | // now we assign the extra metadata on songdata 114 | Object.assign(songdata, extraMetadata); 115 | 116 | } 117 | 118 | // adjust reportsPosition prop from update 119 | songdata.reportsPosition = songdata.elapsed.howMuch > 0; 120 | 121 | // we broadcast the changed status 122 | emitter.emit("songdata", songdata, false); // false means metadata didn't change (we already notified that inside the if block) 123 | 124 | // we need to broadcast an update for lyrics (unconditional) too 125 | if (metadataChanged) 126 | emitter.emit("lyrics"); 127 | } 128 | 129 | function hasMetadataChanged(oldMetadata: Metadata, newMetadata?: Metadata): boolean { 130 | if (!newMetadata) 131 | return true; 132 | 133 | let metadataChanged = false; 134 | 135 | for (let key in oldMetadata) { 136 | // skip metadata that is not worth checking because the player might report them 'asynchronously' 137 | if (["artUrl", "artData", "length"].includes(key)) continue; 138 | 139 | if ( 140 | !oldMetadata[key] && newMetadata[key] || 141 | (typeof oldMetadata[key] === "string" && oldMetadata[key] !== newMetadata[key]) || 142 | (Array.isArray(oldMetadata[key]) && oldMetadata[key] 143 | .filter(x => !newMetadata[key].includes(x)) 144 | .concat(newMetadata[key].filter(x => !oldMetadata[key].includes(x))).length !== 0) 145 | ) { 146 | metadataChanged = true; 147 | break; 148 | } 149 | } 150 | 151 | return metadataChanged; 152 | } 153 | 154 | async function pollSpotifyDetails(metadata: Metadata): Promise { 155 | if (!metadata.id) return undefined; 156 | 157 | const spotiMatch = spotiId.exec(metadata.id); 158 | 159 | if (spotiMatch){ 160 | return await getSpotifySongFromId(spotiMatch[0]) || { 161 | id: spotiMatch[0], 162 | uri: "spotify:track:" + spotiMatch[0], 163 | external_urls: { spotify: "https://open.spotify.com/track/" + spotiMatch[0] }, 164 | }; 165 | } 166 | 167 | return await searchSpotifySong() || undefined; 168 | } 169 | 170 | // ------ SONG DATA 171 | emitter.on("songdata", (_songdata, metadataChanged) => { 172 | debug(1, "broadcastSongData called with", metadataChanged); 173 | //debug(songdata); 174 | }); 175 | 176 | export async function pollPosition() { 177 | if (songdata.status === "Playing"){ 178 | songdata.elapsed = await (await getPlayer()).GetPosition(); 179 | songdata.reportsPosition = songdata.elapsed.howMuch > 0; 180 | } 181 | 182 | // calls 183 | emitter.emit("position", songdata.elapsed, songdata.reportsPosition); 184 | } 185 | -------------------------------------------------------------------------------- /src/main/player/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { Position, Update } from "../../types"; 3 | 4 | const fallback: Player = { 5 | init: async (_callback: Function, _denylist?: string[]) => undefined, 6 | getUpdate: async () => null, 7 | Play: async () => undefined, 8 | Pause: async () => undefined, 9 | PlayPause: async () => undefined, 10 | Stop: async () => undefined, 11 | Next: async () => undefined, 12 | Previous: async () => undefined, 13 | Shuffle: () => undefined, 14 | Repeat: () => undefined, 15 | Seek: async (_offset: number) => undefined, 16 | SeekPercentage: async (_percentage: number) => undefined, 17 | SetPosition: async (_position: number) => undefined, 18 | GetPosition: async () => ({howMuch: 0, when: new Date(0)}), 19 | }; 20 | 21 | let player: Player; 22 | 23 | export default async function getPlayer(){ 24 | if(!player){ 25 | switch (process.platform) { 26 | case "linux": 27 | let MPRIS2 = await import("./mpris2"); 28 | player = Object.assign({}, MPRIS2); 29 | break; 30 | case "win32": 31 | let winplayer = await import("./winplayer"); 32 | // @ts-ignore 33 | player = Object.assign({}, winplayer); 34 | break; 35 | default: 36 | console.error("Player: Unsupported platform!"); 37 | player = Object.assign({}, fallback); 38 | break; 39 | } 40 | } 41 | 42 | return player; 43 | } 44 | 45 | export interface Player { 46 | init(callback: Function, denylist?: string[]): Promise 47 | getUpdate(): Promise 48 | 49 | Play(): Promise 50 | Pause(): Promise 51 | PlayPause(): Promise 52 | Stop(): Promise 53 | 54 | Next(): Promise 55 | Previous(): Promise 56 | 57 | Shuffle(): void | Promise 58 | Repeat(): void | Promise 59 | 60 | Seek(offset: number): Promise 61 | SeekPercentage(percentage: number): Promise 62 | GetPosition(): Promise 63 | SetPosition(position: number): Promise 64 | } -------------------------------------------------------------------------------- /src/main/player/winplayer.ts: -------------------------------------------------------------------------------- 1 | import { Position, Update } from "../../types"; 2 | 3 | // @ts-ignore 4 | import { Player, PlayerManager, getFriendlyNameFor, getPlayerManager } from "winplayer-rs"; 5 | import Vibrant from "node-vibrant"; 6 | 7 | import { debug } from ".."; 8 | import sharp from "sharp"; 9 | 10 | let _player: Player | null; 11 | let _denylist: string[] | undefined; 12 | // there is no pass by reference so we will make our makeshift ref here 13 | type RevokeToken = { revoked: boolean }; 14 | let _revokeToken: RevokeToken = { revoked: false }; 15 | let updateCallback: Function; 16 | 17 | export async function init(callback: Function, denylist?: string[]): Promise{ 18 | _denylist = denylist; 19 | updateCallback = callback; 20 | const playerManager = await getPlayerManager(); 21 | if(playerManager) 22 | managerEvents(playerManager); 23 | 24 | } 25 | 26 | export async function managerEvents(playerManager: PlayerManager) { 27 | // eslint-disable-next-line no-constant-condition 28 | while(true) { 29 | if(!playerManager) break; 30 | const evt = await playerManager.pollNextEvent(); 31 | switch(evt) { 32 | case "ActiveSessionChanged": 33 | _player = null; 34 | _revokeToken.revoked = true; 35 | const player = playerManager.getActiveSession(); 36 | if(player){ 37 | _player = player; 38 | _revokeToken.revoked = false; 39 | playerEvents(); 40 | } 41 | updateCallback(await getUpdate()); 42 | break; 43 | case "SystemSessionChanged": 44 | playerManager.updateSystemSession(); 45 | break; 46 | case "SessionsChanged": 47 | playerManager.updateSessions(_denylist); 48 | break; 49 | } 50 | } 51 | } 52 | 53 | export async function playerEvents(){ 54 | // eslint-disable-next-line no-constant-condition 55 | while(true) { 56 | if(_revokeToken.revoked) break; 57 | if(!_player) break; 58 | const evt = await _player.pollNextEvent(); 59 | switch(evt) { 60 | case "PlaybackInfoChanged": 61 | updateCallback(await getUpdate()); 62 | break; 63 | case "TimelinePropertiesChanged": 64 | updateCallback(await getUpdate()); 65 | break; 66 | case "MediaPropertiesChanged": 67 | updateCallback(await getUpdate()); 68 | break; 69 | } 70 | } 71 | } 72 | 73 | export async function getUpdate(): Promise { 74 | const status = await _player?.getStatus(); 75 | 76 | if(status){ 77 | if (typeof status.metadata === "undefined"){ 78 | status.metadata = { 79 | title: "", 80 | artist: "", 81 | artists: [], 82 | albumArtists: [], 83 | length: 0 84 | }; 85 | } 86 | 87 | 88 | const update: Update = { 89 | provider: "WinPlayer", 90 | metadata: { 91 | title: status.metadata.title, 92 | album: status.metadata.album ?? "", 93 | albumArtist: status.metadata.albumArtist, 94 | albumArtists: status.metadata.albumArtists, 95 | artist: status.metadata.artist, 96 | artists: status.metadata.artists, 97 | artUrl: undefined, 98 | artData: undefined, 99 | length: status.metadata.length, 100 | count: undefined, 101 | lyrics: undefined, 102 | id: status.metadata.id ?? "", 103 | location: undefined 104 | }, 105 | capabilities: status.capabilities, 106 | status: status.status, 107 | loop: status.isLoop, 108 | shuffle: status.shuffle, 109 | volume: status.volume, 110 | elapsed: status.elapsed ?? { howMuch: 0, when: new Date(0)}, 111 | app: status.app ?? "", 112 | appName: status.app ? await getFriendlyNameFor(status.app) ?? status.app ?? "" : status.app ?? "" 113 | }; 114 | 115 | if (status.metadata?.artData) { 116 | try { 117 | const palettebuffer = await sharp(status.metadata.artData.data) 118 | .resize(512, 512, { withoutEnlargement: true }) 119 | .png() 120 | .toBuffer(); 121 | const palette = await (new Vibrant(palettebuffer, { 122 | colorCount: 16, 123 | quality: 1 124 | })).getPalette(); 125 | if (palette) { 126 | 127 | update.metadata.artData = { 128 | data: status.metadata.artData.data, 129 | type: [ 130 | status.metadata.artData.mimetype 131 | ], 132 | palette: { 133 | DarkMuted: palette.DarkMuted?.hex, 134 | DarkVibrant: palette.DarkVibrant?.hex, 135 | LightMuted: palette.LightMuted?.hex, 136 | LightVibrant: palette.LightVibrant?.hex, 137 | Muted: palette.Muted?.hex, 138 | Vibrant: palette.Vibrant?.hex, 139 | } 140 | }; 141 | } 142 | } catch (e) { 143 | debug("Couldn't compute palette for image", e); 144 | } 145 | } 146 | return update; 147 | } 148 | return null; 149 | } 150 | 151 | export async function Play() { 152 | return await _player?.play(); 153 | } 154 | 155 | export async function Pause() { 156 | return await _player?.pause(); 157 | } 158 | 159 | export async function PlayPause() { 160 | return await _player?.playPause(); 161 | } 162 | 163 | export async function Stop() { 164 | return await _player?.stop(); 165 | } 166 | 167 | export async function Next() { 168 | return await _player?.next(); 169 | } 170 | 171 | export async function Previous() { 172 | return await _player?.previous(); 173 | } 174 | 175 | export async function Shuffle() { 176 | const shuffle = await _player?.getShuffle(); 177 | return _player?.setShuffle(!shuffle); 178 | } 179 | 180 | export async function Repeat() { 181 | const repeat = await _player?.getRepeat(); 182 | switch(repeat){ 183 | case "List": 184 | default: 185 | return await _player?.setRepeat("None"); 186 | case "None": 187 | return await _player?.setRepeat("Track"); 188 | case "Track": 189 | return await _player?.setRepeat("List"); 190 | } 191 | } 192 | 193 | export async function Seek(offset: number) { 194 | return await _player?.seek(offset); 195 | } 196 | 197 | export async function SeekPercentage(percentage: number) { 198 | return await _player?.seekPercentage(percentage); 199 | } 200 | 201 | export async function SetPosition(position: number) { 202 | return await _player?.setPosition(position); 203 | } 204 | 205 | export async function GetPosition(): Promise { 206 | const pos = await _player?.getPosition(true); 207 | if(!pos) { 208 | return { 209 | howMuch: 0, 210 | when: new Date(0) 211 | }; 212 | } 213 | return pos; 214 | } 215 | -------------------------------------------------------------------------------- /src/main/themes.ts: -------------------------------------------------------------------------------- 1 | import { stat } from "fs/promises"; 2 | import { resolve } from "path"; 3 | import { getAppData } from "./util"; 4 | 5 | const themeDirectory = resolve(getAppData(), "sunamu", "themes"); 6 | 7 | export function getThemeLocation(theme: string){ 8 | const probablePath = resolve(themeDirectory, theme, "style.css"); 9 | try{ 10 | stat(probablePath); 11 | return probablePath; 12 | }catch(_){ 13 | return undefined; 14 | } 15 | } 16 | 17 | export function getThemesDirectory(){ 18 | return themeDirectory; 19 | } -------------------------------------------------------------------------------- /src/main/thirdparty/lastfm.ts: -------------------------------------------------------------------------------- 1 | // The following is actually Sunamu's own API key for Last.FM 2 | // Please do not copy it, or if you do, please do not use it 3 | // for spammy queries. We do not really want it to get rate 4 | 5 | import axios from "axios"; 6 | import { URLSearchParams } from "url"; 7 | import { LastFMInfo, Metadata } from "../../types"; 8 | 9 | import { debug } from ".."; 10 | 11 | // limited, do we? 12 | const apiKey = "fd35d621eee8c53c1130c12b2d53d7fb"; 13 | const root = "https://ws.audioscrobbler.com/2.0/"; 14 | 15 | function queryString(options){ 16 | const params = new URLSearchParams({ 17 | ...options, 18 | format: "json", 19 | api_key: apiKey 20 | }); 21 | 22 | return params.toString(); 23 | } 24 | 25 | export async function queryLastFM(methodName, options): Promise{ 26 | const allOptions = { 27 | method: methodName, 28 | ...options 29 | }; 30 | 31 | try{ 32 | const result = await axios.get(root + "?" + queryString(allOptions)); 33 | return result.data; 34 | }catch(e){ 35 | debug(`LastFM query for ${methodName} got an error`, e); 36 | } 37 | 38 | return undefined; 39 | } 40 | 41 | export async function getLFMTrackInfo(metadata: Metadata, forUsername: string): Promise{ 42 | if(!metadata.id) return; 43 | 44 | const opts: any = { 45 | track: metadata.title, 46 | artist: metadata.artist, 47 | autocorrect: 1 48 | }; 49 | 50 | if(forUsername) opts.username = forUsername; 51 | 52 | const result = await queryLastFM("track.getInfo", opts); 53 | if(result?.track) return result.track; 54 | return undefined; 55 | } 56 | -------------------------------------------------------------------------------- /src/main/thirdparty/spotify.ts: -------------------------------------------------------------------------------- 1 | import { songdata } from "../playbackStatus"; 2 | import { get as getConfig } from "../config"; 3 | import { debug } from "../"; 4 | import axios from "axios"; 5 | import { URLSearchParams } from "url"; 6 | import { SpotifyConfig, SpotifyInfo } from "../../types"; 7 | 8 | const root = "https://api.spotify.com/v1/"; 9 | let authorization = { 10 | access_token: "", 11 | token_type: "", 12 | expiration: 0 13 | }; 14 | 15 | async function checkLogin(): Promise { 16 | if (!getConfig("spotify").clientID || !getConfig("spotify").clientSecret){ 17 | debug("No Spotify app credentials in config file"); 18 | return false; 19 | } 20 | 21 | if (authorization.expiration > Math.floor(Date.now() / 1000)) return true; 22 | 23 | try{ 24 | const result = await axios({ 25 | url: "https://accounts.spotify.com/api/token", 26 | headers: { 27 | "Authorization": `Basic ${Buffer.from(`${getConfig("spotify").clientID}:${getConfig("spotify").clientSecret}`).toString("base64")}`, 28 | "Content-Type": "application/x-www-form-urlencoded" 29 | }, 30 | method: "post", 31 | data: "grant_type=client_credentials" 32 | }); 33 | 34 | if (result.status === 200) { 35 | authorization.access_token = result.data.access_token; 36 | authorization.token_type = result.data.token_type; 37 | authorization.expiration = (Math.floor(Date.now() / 1000) + result.data.expires_in); 38 | return true; 39 | } 40 | }catch(e){ 41 | debug("Spotify login request errored out", e); 42 | } 43 | 44 | console.error("Cannot log in to Spotify"); 45 | return false; 46 | } 47 | 48 | async function searchPrecise(): Promise { 49 | if (!await checkLogin()) return undefined; 50 | 51 | try{ 52 | const result = await axios({ 53 | url: root + "search?" + (new URLSearchParams({ 54 | q: `artist:"${songdata.metadata.artist?.split("\"").join("\\\"")}" album:"${songdata.metadata.album?.split("\"").join("\\\"")}" ${songdata.metadata.title}`, 55 | type: "track", 56 | limit: "1", 57 | offset: "0" 58 | })).toString(), 59 | headers: { 60 | "Authorization": `${authorization.token_type} ${authorization.access_token}` 61 | } 62 | }); 63 | 64 | if (result.status === 200) 65 | return result.data.tracks.items[0] || undefined; 66 | }catch(e){ 67 | debug("Spotify search precise errored out", e); 68 | } 69 | 70 | 71 | console.error("Cannot search song on Spotify"); 72 | return undefined; 73 | } 74 | 75 | async function searchNotSoPrecise(): Promise { 76 | if (!await checkLogin()) return undefined; 77 | 78 | try{ 79 | const result = await axios({ 80 | url: root + "search?" + (new URLSearchParams({ 81 | q: `${songdata.metadata.artist} ${songdata.metadata.album} ${songdata.metadata.title}`, 82 | type: "track", 83 | limit: "1", 84 | offset: "0" 85 | })).toString(), 86 | headers: { 87 | "Authorization": `${authorization.token_type} ${authorization.access_token}` 88 | } 89 | }); 90 | 91 | if (result.status === 200) 92 | return result.data.tracks.items[0] || undefined; 93 | }catch(e){ 94 | debug("Spotify search errored out", e); 95 | } 96 | 97 | 98 | console.error("Cannot search song on Spotify"); 99 | return undefined; 100 | } 101 | 102 | export async function getSpotifySongFromId(id: string): Promise { 103 | if (!await checkLogin()) return undefined; 104 | 105 | try { 106 | const result = await axios({ 107 | url: root + "tracks/" + id, 108 | headers: { 109 | "Authorization": `${authorization.token_type} ${authorization.access_token}` 110 | } 111 | }); 112 | 113 | if (result.status === 200) 114 | return result.data || undefined; 115 | } catch (e) { 116 | debug("Spotify track get errored out", e); 117 | } 118 | 119 | 120 | console.error("Cannot get song details on Spotify"); 121 | return undefined; 122 | } 123 | 124 | export async function searchSpotifySong(): Promise { 125 | if (!songdata.metadata.id) return undefined; 126 | 127 | return await searchPrecise() || await searchNotSoPrecise() || undefined; 128 | } 129 | -------------------------------------------------------------------------------- /src/main/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.settings.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "../../dist", /* Redirect output structure to the directory. */ 6 | } 7 | } -------------------------------------------------------------------------------- /src/main/util.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { gzip, gunzip } from "zlib"; 3 | 4 | export const spotiId = /spotify:track:(.+)/; 5 | 6 | export function getAppData() { 7 | switch (process.platform) { 8 | case "linux": 9 | if (process.env.XDG_CONFIG_HOME) return resolve(process.env.XDG_CONFIG_HOME); 10 | return resolve(process.env.HOME!, ".config"); 11 | case "win32": 12 | return resolve(process.env.APPDATA!); 13 | default: 14 | return ""; 15 | } 16 | } 17 | 18 | export function secondsToTime(duration: number) { 19 | duration = Math.floor(duration); 20 | let seconds = duration % 60, 21 | minutes = Math.floor(duration / 60) % 60; 22 | 23 | return minutes.toString().padStart(2, "0") + ":" + seconds.toString().padStart(2, "0"); 24 | } 25 | 26 | export function gzipCompress(buffer: Buffer | string): Promise { 27 | return new Promise((resolve, reject) => { 28 | gzip(buffer, (error, result) => { 29 | if (error) reject(error); 30 | else resolve(result); 31 | }); 32 | }); 33 | } 34 | 35 | export function gzipDecompress(buffer: Buffer | string): Promise { 36 | return new Promise((resolve, reject) => { 37 | gunzip(buffer, (error, result) => { 38 | if (error) reject(error); 39 | else resolve(result); 40 | }); 41 | }); 42 | } 43 | 44 | export function humanDimensionToBytes(dimension: string): number { 45 | const sizes = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"]; 46 | 47 | const match = dimension.toUpperCase().match(/(-?\d+[,.]?\d*)([BKMGTPEZY]?)/); 48 | 49 | if(!match || !match[1]) return NaN; 50 | 51 | let [ , number, weight ] = match; 52 | if(!weight) weight = "B"; 53 | 54 | return Number(number) * Math.pow(1000, sizes.indexOf(weight)); // IEC standard 55 | } 56 | 57 | export function getOSLocale(){ 58 | return Intl.DateTimeFormat().resolvedOptions().locale.split("-"); 59 | } -------------------------------------------------------------------------------- /src/main/webserver.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import getPlayer, { Player } from "./player"; 3 | import configEmitter, { get as getConfig, getAll as getAllConfig } from "./config"; 4 | import { widgetMode, debugMode, useElectron } from "./appStatus"; 5 | 6 | import { Server, Socket } from "socket.io"; 7 | import { createServer } from "http"; 8 | import { Server as StaticServer } from "node-static"; 9 | import playbackStatus, { setCustomLyrics, songdata } from "./playbackStatus"; 10 | import { getThemeLocation, getThemesDirectory } from "./themes"; 11 | 12 | import { debug } from "."; 13 | import { getAllLyrics } from "./integrations/lyrics"; 14 | 15 | let player: Player; 16 | 17 | const file = new StaticServer(path.resolve(__dirname, "..", "www"), { indexFile: "index.htm", cache: 0 }); 18 | const themes = new StaticServer(getThemesDirectory(), { cache: 0 }); 19 | const server = createServer((req, res) => { 20 | if(req.url!.startsWith("/themes/")){ 21 | req.url = req.url!.replace("/themes/", "/"); 22 | themes.serve(req, res); 23 | return; 24 | } 25 | file.serve(req, res); 26 | }); 27 | 28 | export const io = new Server(server); 29 | 30 | function registerIpc(socket: Socket) { 31 | socket.on("previous", () => player.Previous()); 32 | socket.on("playPause", () => player.PlayPause()); 33 | socket.on("next", () => player.Next()); 34 | 35 | socket.on("shuffle", () => player.Shuffle()); 36 | socket.on("repeat", () => player.Repeat()); 37 | 38 | socket.on("seek", (perc) => player.SeekPercentage(perc)); 39 | socket.on("getPosition", async (callback) => callback(await player.GetPosition())); 40 | socket.on("setPosition", (position) => player.SetPosition(position)); 41 | 42 | socket.on("getSongData", (callback) => callback(songdata)); 43 | socket.on("getConfig", (callback) => callback(getAllConfig())); 44 | 45 | socket.on("searchAllLyrics", async (metadata, callback) => callback(await getAllLyrics(metadata))); 46 | socket.on("chooseLyrics", async (lyrics) => await setCustomLyrics(lyrics)); 47 | 48 | 49 | socket.on("isWidgetMode", (callback) => callback(widgetMode)); 50 | socket.on("isDebugMode", (callback) => callback(debugMode)); 51 | socket.on("isElectronRunning", (callback) => callback(useElectron)); 52 | 53 | socket.on("getThemeLocationFor", async (theme, callback) => { 54 | const themeLocation = await getThemeLocation(theme); 55 | if(!themeLocation) 56 | return callback(); 57 | 58 | callback("/themes/" + path.relative(getThemesDirectory(), themeLocation).split("\\").join("/")); 59 | }); 60 | } 61 | 62 | function registerWindowCallbacks(socket: Socket) { 63 | const positionCallback = async (position, reportsPosition) => { socket.emit("position", position, reportsPosition); }; 64 | const songDataCallback = async (songdata, metadataChanged) => { socket.emit("update", songdata, metadataChanged); }; 65 | const lyricsUpdateCallback = async () => { socket.emit("refreshLyrics"); }; 66 | const configChangedCallback = async () => { socket.emit("configChanged"); }; 67 | 68 | playbackStatus.on("position", positionCallback); 69 | playbackStatus.on("songdata", songDataCallback); 70 | playbackStatus.on("lyrics", lyricsUpdateCallback); 71 | 72 | configEmitter.on("configChanged", configChangedCallback); 73 | 74 | socket.once("disconnect", () => { 75 | socket.removeAllListeners(); 76 | playbackStatus.off("position", positionCallback); 77 | playbackStatus.off("songdata", songDataCallback); 78 | playbackStatus.off("lyrics", lyricsUpdateCallback); 79 | 80 | configEmitter.off("configChanged", configChangedCallback); 81 | }); 82 | } 83 | 84 | export default async function webserverMain(){ 85 | player = await getPlayer(); 86 | 87 | server.listen(getConfig("webserverPort"), () => debug(`WebServer listening on port ${getConfig("webserverPort")}`)); 88 | 89 | io.on("connection", socket => { 90 | registerIpc(socket); 91 | registerWindowCallbacks(socket); 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type DeepPartial = { 2 | [P in keyof T]?: DeepPartial; 3 | }; 4 | 5 | /* eslint-disable no-unused-vars */ 6 | export type NowPlayingAPI = { 7 | previous: () => void, 8 | playPause: () => void, 9 | next: () => void, 10 | 11 | shuffle: () => void, 12 | repeat: () => void, 13 | 14 | seek: (positionToSeekbar: number) => void, 15 | setPosition: (position: number) => void, 16 | 17 | registerPositionCallback: (callback: Function) => void, 18 | registerUpdateCallback: (callback: Function) => void, 19 | registerLyricsCallback: (callback: Function) => void, 20 | registerConfigChangedCallback: (callback: Function) => void, 21 | 22 | getSongData: () => Promise, 23 | getConfig: () => Promise, 24 | 25 | searchAllLyrics: (metadata: Metadata) => Promise, 26 | chooseLyrics: (lyrics: Lyrics) => void, 27 | 28 | isWidgetMode: () => Promise, 29 | isDebugMode: () => Promise, 30 | isElectronRunning?: () => Promise, 31 | 32 | getScene: () => Promise, 33 | getThemeLocationFor: (scene: string) => Promise, 34 | 35 | minimize?: () => void, 36 | close?: () => void, 37 | openExternal: (uri: string) => void, 38 | } 39 | 40 | export type Language = { 41 | NOT_PLAYING: string 42 | PLEASE_PLAY_SONG: string 43 | PLAYING_ON_APP: string 44 | PLAY_COUNT: string 45 | SCROBBLE_COUNT: string 46 | LOADING_LYRICS: string 47 | NO_LYRICS: string 48 | NOW_PLAYING_TITLE: string 49 | UNKNOWN_ARTIST: string 50 | UNKNOWN_TITLE: string 51 | LYRICS_COPYRIGHT: string 52 | } 53 | 54 | export type LanguageData = { [x: string]: Language } 55 | 56 | export type Config = { 57 | language: string, 58 | useElectron: boolean, 59 | useWebserver: boolean, 60 | debugMode: boolean, 61 | devToolsAtStartup: boolean, 62 | positionPollInterval: number, 63 | positionUpdateInterval: number, 64 | lyricsActive: boolean, 65 | karaoke: boolean, 66 | translations: boolean, 67 | mxmlanguage: string, 68 | lfmUsername: string, 69 | mxmusertoken: string, 70 | spotify: SpotifyConfig, 71 | discordRpc: DiscordPresenceConfig, 72 | lyricsProviders: LyricsProvidersConfig, 73 | targetLyricsCacheSize?: string, 74 | logPlayedTracksToFile: boolean, 75 | logPlayedTracksUTCTimestamps: boolean, 76 | denylist?: string[], 77 | scenes: { 78 | [sceneName: string]: SceneConfig 79 | }, 80 | } 81 | 82 | export type SpotifyConfig = { 83 | clientID: string, 84 | clientSecret: string 85 | } 86 | 87 | export type LyricsProvidersConfig = { 88 | Musixmatch: boolean, 89 | NetEase: boolean, 90 | Genius: boolean, 91 | Metadata: boolean 92 | } 93 | 94 | export type DiscordPresenceConfig = { 95 | enabled: boolean, 96 | blacklist: string[] 97 | } 98 | 99 | export type SceneConfig = { 100 | type: "default" | "electron", 101 | font?: string, 102 | theme?: string, 103 | colors?: boolean, 104 | defaultColorsAreInverted?: boolean, 105 | colorblock?: boolean, 106 | bgAnimation?: boolean, 107 | widgetMode?: boolean, 108 | hideWhenNotPlaying?: boolean, 109 | showPlayingIndicator?: boolean, 110 | playerIcon?: boolean, 111 | nonInteractive?: boolean, 112 | static?: boolean, 113 | forceIdle?: boolean, 114 | showInfoContainer?: boolean, 115 | showAlbumArt?: boolean, 116 | showControls?: boolean, 117 | showExtraButtons?: boolean, 118 | showProgress?: boolean, 119 | showPlayCountInfo?: boolean, 120 | showLyrics?: boolean, 121 | lyricsBlur?: boolean, 122 | clickableLyrics?: boolean, 123 | } 124 | 125 | export type Palette = { 126 | Vibrant?: string, 127 | Muted?: string, 128 | DarkVibrant?: string, 129 | DarkMuted?: string, 130 | LightVibrant?: string, 131 | LightMuted?: string, 132 | } 133 | 134 | export type ArtData = { 135 | type: string[], 136 | data: Buffer, 137 | palette?: Palette 138 | } 139 | 140 | export type Metadata = { 141 | title: string, 142 | album: string, 143 | albumArtist?: string, 144 | albumArtists?: string[], 145 | artist: string, 146 | artists: string[], 147 | artUrl?: string, 148 | artData?: ArtData, 149 | length: number, 150 | count?: number, 151 | lyrics?: string, 152 | id: string, 153 | location?: URL 154 | } 155 | 156 | export type Capabilities = { 157 | canControl: boolean, 158 | canPlayPause: boolean, 159 | canGoNext: boolean, 160 | canGoPrevious: boolean, 161 | canSeek: boolean 162 | } 163 | 164 | export type Update = { 165 | provider: "MPRIS2" | "WinPlayer", 166 | metadata: Metadata, 167 | capabilities: Capabilities, 168 | status: string, 169 | loop: string, 170 | shuffle: boolean, 171 | volume: number, 172 | elapsed: Position, 173 | app: string, 174 | appName: string 175 | } 176 | 177 | export type Position = { 178 | howMuch: number, 179 | when: Date 180 | } 181 | 182 | export type SongData = Update & { 183 | reportsPosition: boolean, 184 | lyrics?: Lyrics, 185 | lastfm?: LastFMInfo, 186 | spotify?: SpotifyInfo 187 | } 188 | 189 | export type Lyrics = { 190 | provider?: string, 191 | synchronized?: boolean, 192 | lines?: LyricsLine[], 193 | copyright?: string, 194 | unavailable?: boolean, 195 | cached?: boolean 196 | } 197 | 198 | export type LyricsLine = { 199 | text: string, 200 | translation?: string, 201 | time?: number, 202 | duration?: number, 203 | karaoke?: LyricsKaraokeVerse[] 204 | } 205 | 206 | export type LyricsKaraokeVerse = { 207 | text: string, 208 | start: number 209 | } 210 | 211 | export type SpotifyInfo = { 212 | album?: { 213 | album_type: string, 214 | total_tracks: number, 215 | available_markets: string[], 216 | external_urls: { spotify: string }, 217 | href: string, 218 | id: string, 219 | images: { 220 | url: string, 221 | width: number, 222 | height: number 223 | }[], 224 | name: string, 225 | release_date: string, 226 | release_date_precision: string, 227 | restrictions?: { reason: string }, 228 | type: string, 229 | uri: string, 230 | album_group?: string, 231 | artists?: { 232 | external_urls: { spotify: string }, 233 | href: string, 234 | id: string, 235 | name: string, 236 | type: string, 237 | uri: string 238 | }[] 239 | }, 240 | artists?: { 241 | external_urls: { spotify: string }, 242 | followers: { 243 | href: string, 244 | total: number 245 | }, 246 | genres: string[], 247 | href: string, 248 | id: string, 249 | images: { 250 | url: string, 251 | width: number, 252 | height: number 253 | }[], 254 | name: string, 255 | popularity: number, 256 | type: string, 257 | uri: string 258 | }[], 259 | available_markets?: string[], 260 | disc_number?: number, 261 | duration_ms?: number, 262 | explicit?: boolean, 263 | external_ids?: { 264 | isrc: string, 265 | ean: string, 266 | upc: string 267 | }, 268 | external_urls: { spotify: string }, 269 | href?: string, 270 | id: string, 271 | is_playable?: boolean, 272 | restrictions?: { reason: string }, 273 | name?: string, 274 | popularity?: number, 275 | preview_url?: string, 276 | track_number?: number, 277 | type?: string, 278 | uri: string, 279 | is_local?: boolean 280 | } 281 | 282 | export type LastFMInfo = { 283 | artist: { 284 | name: string, 285 | url: string 286 | } 287 | 288 | album: { 289 | artist: string, 290 | title: string, 291 | url: string, 292 | image: { 293 | "#text": string, 294 | size: string 295 | }[], 296 | 297 | } 298 | name: string, 299 | duration: string, 300 | url: string, 301 | mbid?: string, 302 | 303 | listeners: string, 304 | playcount: string, 305 | 306 | userloved?: string, 307 | userplaycount?: string, 308 | 309 | streamable: { 310 | fulltrack: string, 311 | "#text": string 312 | }, 313 | 314 | toptags: { 315 | tags: any[] 316 | } 317 | } 318 | 319 | export type LrcFile = { 320 | lines: LyricsLine[], 321 | metadata: { 322 | [x: string]: string 323 | } 324 | } -------------------------------------------------------------------------------- /src/www/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import type { Lyrics, NowPlayingAPI, SongData } from "../types"; 3 | 4 | import "./lib/npapi/browser-npapi.js"; 5 | import "./lib/screen.js"; 6 | 7 | import { updateNowPlaying } from "./lib/nowplaying.js"; 8 | import { putLyricsInPlace } from "./lib/lyrics.js"; 9 | import songdata from "./lib/songdata.js"; 10 | 11 | import "./lib/buttons.js"; 12 | import "./lib/event.js"; 13 | import "./lib/seekbar.js"; 14 | 15 | declare global { 16 | interface Window { 17 | title: string 18 | np: NowPlayingAPI 19 | getNowPlaying?: () => SongData 20 | detectedLanguage?: string 21 | copyLyrics?: () => (string | undefined) 22 | copyLyricsTranslated?: () => (string | undefined) 23 | searchForCustomLyrics: (artist: string, title: string, album: string) => Promise 24 | chooseLyrics: (input: number) => void 25 | } 26 | } 27 | 28 | window.title = "Sunamu" + (document.documentElement.classList.contains("widget-mode") ? " Widget" : ""); 29 | 30 | // Expose debug stuff 31 | if(await window.np.isDebugMode()){ 32 | window.getNowPlaying = () => songdata; 33 | window.detectedLanguage = navigator.language.split("-")[0]; 34 | window.copyLyrics = () => songdata.lyrics?.lines?.map(x => x.text).join("\n") + `\n\n("${songdata.metadata.title}" - ${songdata.metadata.artist})`; 35 | window.copyLyricsTranslated = () => songdata.lyrics?.lines?.map(x => x.translation || x.text).join("\n") + `\n\n("${songdata.metadata.title}" - ${songdata.metadata.artist})`; 36 | } 37 | 38 | updateNowPlaying(); 39 | putLyricsInPlace(); 40 | 41 | const lrcStorage = { 42 | id: undefined as string|undefined, 43 | lyrics: [] as Lyrics[] 44 | }; 45 | 46 | async function searchForCustomLyrics(title?: string, artist?: string, album?: string) { 47 | const metadata = Object.assign({}, songdata.metadata); 48 | if(title) metadata.title = title; 49 | if(artist) metadata.artist = artist; 50 | if(album) metadata.album = album; 51 | lrcStorage.id = metadata.id; 52 | (await window.np.searchAllLyrics(metadata)).forEach(x => lrcStorage.lyrics.push(x)); 53 | if (!lrcStorage.lyrics.length) return console.error("No lyrics found"); 54 | console.log(`Searching lyrics for (input) "${metadata.title}" by ${metadata.artist} in album "${metadata.album}"`); 55 | console.log(`Whatever you choose will replace lyrics for (actual metadata we got) "${songdata.metadata.title}" by ${songdata.metadata.artist} in album "${songdata.metadata.album}"`); 56 | console.log("Select your lyrics from here"); 57 | lrcStorage.lyrics.forEach((x, k) => console.log(`From ${x.provider} - ${x.synchronized ? "Synchronized" : "Unsynchronized"} - Choice #${k+1}\nPreview:\n${x.lines?.slice(0, 10).map(x => x.text).join("\n")}`)); 58 | console.log("So, what's your favourite? Type `chooseLyrics(N)` where N is your choice."); 59 | console.log("You have to be quick! You can only choose lyrics as the song is playing!"); 60 | } 61 | 62 | function chooseLyrics(input: number){ 63 | if(songdata.metadata.id !== lrcStorage.id){ 64 | lrcStorage.id = undefined; 65 | lrcStorage.lyrics = []; 66 | return console.error("You took your time heh? The song is not playing anymore!"); 67 | } 68 | if(!lrcStorage.lyrics[input - 1]) return console.error("Invalid choice!"); 69 | window.np.chooseLyrics(lrcStorage.lyrics[input - 1]); 70 | console.log("Correctly modified!"); 71 | } 72 | 73 | window.searchForCustomLyrics = searchForCustomLyrics; 74 | window.chooseLyrics = chooseLyrics; -------------------------------------------------------------------------------- /src/www/lib/appicon.ts: -------------------------------------------------------------------------------- 1 | import songdata from "./songdata.js"; 2 | 3 | function normalizeAppName(): string{ 4 | let app = songdata.app; 5 | if(songdata.provider === "MPRIS2"){ 6 | app = app.replace("org.mpris.MediaPlayer2.", ""); 7 | if(app === "plasma-browser-integration") 8 | app = songdata.appName.toLowerCase(); // plasma whyyyyyy 9 | 10 | app = app.replace(/\.instance[0-9]+$/, ""); // chromium.instance12345, firefox.instance12345 11 | } 12 | 13 | switch(app){ 14 | // BROWSERS 15 | case "chrome": 16 | case "chromium": 17 | case "Chrome.exe": 18 | case "Chromium.exe": 19 | return "chrome"; 20 | case "firefox": 21 | case "Firefox.exe": 22 | return "firefox"; 23 | case "librewolf": 24 | case "LibreWolf.exe": 25 | return "librewolf"; 26 | case "MSEdge.exe": 27 | return "msedge"; 28 | case "opera": 29 | case "operagx": // TODO: check if correct 30 | case "Opera.exe": 31 | case "OperaGX.exe": // TODO: check if correct 32 | return "opera"; 33 | case "vivaldi": 34 | case "Vivaldi.exe": 35 | return "vivaldi"; 36 | // MUSIC PLAYERS 37 | case "AIMP.exe": 38 | return "aimp"; 39 | case "amarok": 40 | return "amarok"; 41 | case "clementine": 42 | case "Clementine.exe": 43 | return "clementine"; 44 | case "elisa": 45 | return "elisa"; 46 | case "foobar2000.exe": 47 | return "foobar2000"; 48 | case "org.gnome.Music": 49 | return "gnome-music"; 50 | case "Lollypop": 51 | return "lollypop"; 52 | case "MusicBee.exe": 53 | return "musicbee"; 54 | case "io.github.Pithos": 55 | return "pithos"; 56 | case "qmmp": 57 | return "qmmp"; 58 | case "rhythmbox": 59 | return "rhythmbox"; 60 | case "Sonixd": 61 | case "Sonixd.exe": 62 | case "org.erb.sonixd": 63 | return "sonixd"; 64 | case "spotify": 65 | case "spotifyd": 66 | case "spotify-qt": 67 | case "Spot": 68 | case "Spotify.exe": 69 | case "SpotifyAB.SpotifyMusic_zpdnekdrzrea0!Spotify": 70 | return "spotify"; 71 | case "strawberry": 72 | case "Strawberry.exe": 73 | return "strawberry"; 74 | case "tauon": 75 | return "tauon"; 76 | // MEDIA (MUSIC BUT ALSO VIDEO ETC) PLAYERS 77 | case "Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic": 78 | return "groove"; 79 | case "mpv": 80 | return "mpv"; 81 | case "vlc": 82 | case "VideoLAN.VLC_paz6r1rewnh0a!App": 83 | return "vlc"; 84 | } 85 | 86 | return "default"; 87 | } 88 | 89 | export function getAppIcon(){ 90 | // Traversing to the root and back because WebKit 91 | return "../../../../assets/images/apps/" + normalizeAppName() + ".svg"; 92 | } -------------------------------------------------------------------------------- /src/www/lib/buttons.ts: -------------------------------------------------------------------------------- 1 | import { fullscreen, isElectron } from "./util.js"; 2 | import songdata from "./songdata.js"; 3 | import config from "./config.js"; 4 | 5 | function bindWindowControls(){ 6 | if(isElectron()){ 7 | document.getElementById("minimize")!.onclick = () => window.np.minimize!(); 8 | document.getElementById("close")!.onclick = () => window.np.close!(); 9 | } 10 | document.getElementById("fullscreen")!.onclick = () => fullscreen(); 11 | } 12 | 13 | function bindPlaybackControls(){ 14 | document.getElementById("shuffle")!.onclick = () => window.np.shuffle(); 15 | document.getElementById("previous")!.onclick = () => window.np.previous(); 16 | document.getElementById("playpause")!.onclick = () => window.np.playPause(); 17 | document.getElementById("next")!.onclick = () => window.np.next(); 18 | document.getElementById("repeat")!.onclick = () => window.np.repeat(); 19 | 20 | const lastfm = document.getElementById("lastfm")!; 21 | lastfm.oncontextmenu = (e) => { 22 | e.preventDefault(); 23 | navigator.clipboard.writeText(songdata.lastfm?.url || ""); 24 | }; 25 | lastfm.onclick = () => window.np.openExternal!(songdata.lastfm?.url || ""); 26 | 27 | const spotify = document.getElementById("spotify")!; 28 | spotify.oncontextmenu = (e) => { 29 | e.preventDefault(); 30 | navigator.clipboard.writeText(songdata.spotify?.external_urls.spotify || ""); 31 | }; 32 | spotify.onclick = () => window.np.openExternal!(songdata.spotify?.external_urls.spotify || ""); 33 | } 34 | 35 | function hideButtons(){ 36 | if(!config.spotify.clientSecret) 37 | document.getElementById("spotify")!.style.display = "none"; 38 | } 39 | 40 | hideButtons(); 41 | bindWindowControls(); 42 | bindPlaybackControls(); -------------------------------------------------------------------------------- /src/www/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "../../types"; 2 | 3 | export default await window.np.getConfig() as Config; 4 | 5 | window.np.registerConfigChangedCallback(() => window.location.reload()); -------------------------------------------------------------------------------- /src/www/lib/event.ts: -------------------------------------------------------------------------------- 1 | import { reCenter } from "./lyrics.js"; 2 | import { show, hide } from "./showhide.js"; 3 | import { debounce } from "./util.js"; 4 | 5 | let delayedHideTimeout; 6 | 7 | function delayedHide(){ 8 | delayedHideTimeout = setTimeout(hide, 2000); 9 | } 10 | 11 | function resettingShow(){ 12 | clearTimeout(delayedHideTimeout); 13 | show(); 14 | } 15 | 16 | function showHideListener(){ 17 | document.body.addEventListener("pointerenter", resettingShow); 18 | document.body.addEventListener("pointerleave", delayedHide); 19 | delayedHide(); 20 | } 21 | 22 | function fullscreenListener(){ 23 | // @ts-ignore 24 | if (document.fullscreenElement || document.webkitFullscreenElement) { 25 | document.documentElement.classList.add("fullscreen"); 26 | document.getElementById("fullscreen")!.firstElementChild!.setAttribute("href", "assets/images/glyph.svg#close_fullscreen"); 27 | document.body.addEventListener("pointermove", showHideListener_fullscreen); 28 | document.body.addEventListener("wheel", showHideListener_fullscreen); 29 | delayedHide(); 30 | } else { 31 | document.documentElement.classList.remove("fullscreen"); 32 | document.getElementById("fullscreen")!.firstElementChild!.setAttribute("href", "assets/images/glyph.svg#fullscreen"); 33 | document.body.removeEventListener("pointermove", showHideListener_fullscreen); 34 | document.body.removeEventListener("wheel", showHideListener_fullscreen); 35 | resettingShow(); 36 | } 37 | } 38 | 39 | function showHideListener_fullscreen(){ 40 | resettingShow(); 41 | delayedHide(); 42 | } 43 | 44 | // ------------------ 45 | 46 | // ON RESIZE 47 | window.addEventListener("resize", () => { 48 | debounce(reCenter, 500); 49 | }); 50 | 51 | // ON LOAD 52 | if (!document.documentElement.classList.contains("static")) { 53 | if (document.readyState === "loading") 54 | document.addEventListener("load", showHideListener); 55 | else 56 | showHideListener(); 57 | } 58 | 59 | // ON FULLSCREEN CHANGE 60 | if (!document.documentElement.classList.contains("widget-mode")) { 61 | document.addEventListener("webkitfullscreenchange", fullscreenListener); 62 | document.addEventListener("fullscreenchange", fullscreenListener); 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/www/lib/lang/de.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "Kein Titel wird abgespielt", 5 | PLEASE_PLAY_SONG: "Bitte spiele etwas ab", 6 | PLAYING_ON_APP: "Weiterspielen", 7 | PLAY_COUNT: "%COUNT% mal abgespielt", 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", // If there's a localized version, use it (Scrobbles as in Last.FM) 9 | LOADING_LYRICS: "Songtexte werden geladen", // Google translated, please review 10 | NO_LYRICS: "Keine Songtexte verfügbar", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" von %ARTIST%", 12 | UNKNOWN_ARTIST: "Unbekannter Künstler", 13 | UNKNOWN_TITLE: "Unbekannter Titel", 14 | LYRICS_COPYRIGHT: "Songtexte bereitgestellt von %PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/lang/en.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "Not playing", 5 | PLEASE_PLAY_SONG: "Please play something", 6 | PLAYING_ON_APP: "Playing on", 7 | PLAY_COUNT: "Played %COUNT% times", 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", 9 | LOADING_LYRICS: "Loading lyrics", 10 | NO_LYRICS: "No lyrics available", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" by %ARTIST%", 12 | UNKNOWN_ARTIST: "Unknown artist", 13 | UNKNOWN_TITLE: "Unknown title", 14 | LYRICS_COPYRIGHT: "Lyrics provided by %PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/lang/es.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "No hay pistas reproduciéndose", 5 | PLEASE_PLAY_SONG: "Por favor reproduce algo", 6 | PLAYING_ON_APP: "Reproduciendo en", 7 | PLAY_COUNT: "Reproducido %COUNT% veces", 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", // If there's a localized version, use it (Scrobbles as in Last.FM) 9 | LOADING_LYRICS: "Cargando letras", 10 | NO_LYRICS: "No hay letras disponibles", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" de %ARTIST%", 12 | UNKNOWN_ARTIST: "Artista Desconocido", 13 | UNKNOWN_TITLE: "Título desconocido", 14 | LYRICS_COPYRIGHT: "Letras proporcionadas por %PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/lang/fr.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "Aucune musique en cours de lecture", 5 | PLEASE_PLAY_SONG: "Mettez quelque chose !", 6 | PLAYING_ON_APP: "Lecture sur", 7 | PLAY_COUNT: "Lecturé %COUNT% fois", // Please translate me properly 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", // If there's a localized version, use it (Scrobbles as in Last.FM) 9 | LOADING_LYRICS: "Chargement des paroles", // Google translated, please review 10 | NO_LYRICS: "Paroles non disponibles", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" de %ARTIST%", 12 | UNKNOWN_ARTIST: "Artiste inconnu", 13 | UNKNOWN_TITLE: "Titre inconnu", 14 | LYRICS_COPYRIGHT: "Paroles provenant de %PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/lang/index.ts: -------------------------------------------------------------------------------- 1 | import config from "../config.js"; 2 | 3 | async function importLanguage(name: string){ 4 | try { 5 | return (await import("./" + name + ".js")).default; 6 | } catch (e) { 7 | console.error("Unknown language", name); 8 | return undefined; 9 | } 10 | } 11 | 12 | export default ( 13 | await importLanguage(config.language) || 14 | await importLanguage(navigator.language.toLowerCase()) || 15 | await importLanguage(navigator.language.toLowerCase().split("-")[0]) || 16 | await importLanguage("en") 17 | ); 18 | -------------------------------------------------------------------------------- /src/www/lib/lang/it.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "Nessuna traccia in riproduzione", 5 | PLEASE_PLAY_SONG: "Per favore riproduci qualcosa", 6 | PLAYING_ON_APP: "Riproducendo su", 7 | PLAY_COUNT: "Riprodotto %COUNT% volte", 8 | SCROBBLE_COUNT: "%COUNT% scrobble", 9 | LOADING_LYRICS: "Caricamento dei testi in corso", 10 | NO_LYRICS: "I testi non sono disponibili", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" di %ARTIST%", 12 | UNKNOWN_ARTIST: "Artista sconosciuto", 13 | UNKNOWN_TITLE: "Titolo sconosciuto", 14 | LYRICS_COPYRIGHT: "Testi offerti da %PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/lang/nl.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "Er wordt niets afgespeeld", 5 | PLEASE_PLAY_SONG: "Speel een nummer af", 6 | PLAYING_ON_APP: "Wordt afgespeeld in", 7 | PLAY_COUNT: "%COUNT% keer gespeeld", // Please check me 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", // If there's a localized version, use it (Scrobbles as in Last.FM) 9 | LOADING_LYRICS: "Bezig met laden van songtekst…", 10 | NO_LYRICS: "Er is geen songtekst beschikbaar", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" van %ARTIST%", 12 | UNKNOWN_ARTIST: "Onbekende artiest", 13 | UNKNOWN_TITLE: "Onbekende titel", 14 | LYRICS_COPYRIGHT: "Songtekst afkomstig van %PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/lang/zh-cn.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "目前没有正在播放的音乐", 5 | PLEASE_PLAY_SONG: "请播放一些音乐", 6 | PLAYING_ON_APP: "在", 7 | PLAY_COUNT: "已播放 %COUNT% 次", 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", // If there's a localized version, use it (Scrobbles as in Last.FM) 9 | LOADING_LYRICS: "读取歌词中", 10 | NO_LYRICS: "没有可用的歌词", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" - %ARTIST%", 12 | UNKNOWN_ARTIST: "未知艺术家", 13 | UNKNOWN_TITLE: "未知标题", 14 | LYRICS_COPYRIGHT: "歌词提供:%PROVIDER%", 15 | } as Language; 16 | -------------------------------------------------------------------------------- /src/www/lib/lang/zh-tw.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "../../../types"; 2 | 3 | export default { 4 | NOT_PLAYING: "目前沒有正在播放的音樂", 5 | PLEASE_PLAY_SONG: "請播放一些音樂", 6 | PLAYING_ON_APP: "在", 7 | PLAY_COUNT: "已播放 %COUNT% 次", 8 | SCROBBLE_COUNT: "%COUNT% scrobbles", // If there's a localized version, use it (Scrobbles as in Last.FM) 9 | LOADING_LYRICS: "讀取歌詞中", 10 | NO_LYRICS: "沒有可用的歌詞", 11 | NOW_PLAYING_TITLE: "\"%TITLE%\" - %ARTIST%", 12 | UNKNOWN_ARTIST: "未知藝術家", 13 | UNKNOWN_TITLE: "未知標題", 14 | LYRICS_COPYRIGHT: "歌詞提供:%PROVIDER%", 15 | } as Language; -------------------------------------------------------------------------------- /src/www/lib/npapi/browser-npapi.ts: -------------------------------------------------------------------------------- 1 | import { NowPlayingAPI } from "../../../types"; 2 | import { io } from "../thirdparty/socket.io.esm.min.js"; 3 | import { isElectron } from "../util.js"; 4 | 5 | if(!isElectron()){ 6 | const socket = io(); 7 | window.np = { 8 | previous: () => socket.emit("previous"), 9 | playPause: () => socket.emit("playPause"), 10 | next: () => socket.emit("next"), 11 | 12 | shuffle: () => socket.emit("shuffle"), 13 | repeat: () => socket.emit("repeat"), 14 | 15 | seek: (positionToSeekbar) => socket.emit("seek", positionToSeekbar), 16 | setPosition: (position) => socket.emit("setPosition", position), 17 | 18 | registerPositionCallback: (callback) => socket.on("position", (...args) => callback(...args)), 19 | registerUpdateCallback: (callback) => socket.on("update", (...args) => callback(...args)), 20 | registerLyricsCallback: (callback) => socket.on("refreshLyrics", (...args) => callback(...args)), 21 | registerConfigChangedCallback: (callback) => socket.on("configChanged", (...args) => callback(...args)), 22 | 23 | getSongData: () => new Promise(resolve => socket.emit("getSongData", resolve)), 24 | getConfig: () => new Promise(resolve => socket.emit("getConfig", resolve)), 25 | 26 | searchAllLyrics: (metadata) => new Promise(resolve => socket.emit("searchAllLyrics", metadata, resolve)), 27 | chooseLyrics: (lyrics) => socket.emit("chooseLyrics", lyrics), 28 | 29 | isWidgetMode: () => new Promise(resolve => socket.emit("isWidgetMode", resolve)), 30 | isDebugMode: () => new Promise(resolve => socket.emit("isDebugMode", resolve)), 31 | isElectronRunning: () => new Promise(resolve => socket.emit("isElectronRunning", resolve)), 32 | 33 | getScene: async () => { 34 | let sceneName = new URLSearchParams(location.search).get("scene"); 35 | if (!sceneName) { 36 | if (window.obsstudio) 37 | sceneName = "obs-studio"; 38 | else 39 | sceneName = "default"; 40 | } 41 | return sceneName; 42 | }, 43 | getThemeLocationFor: (theme) => new Promise(resolve => socket.emit("getThemeLocationFor", theme, resolve)), 44 | 45 | openExternal: (uri) => window.open(uri, "_blank"), 46 | } as NowPlayingAPI; 47 | } 48 | -------------------------------------------------------------------------------- /src/www/lib/npapi/electron-npapi.ts: -------------------------------------------------------------------------------- 1 | import { NowPlayingAPI } from "../../../types"; 2 | import { contextBridge, ipcRenderer } from "electron"; 3 | 4 | const npApi: NowPlayingAPI = { 5 | previous: () => ipcRenderer.send("previous"), 6 | playPause: () => ipcRenderer.send("playPause"), 7 | next: () => ipcRenderer.send("next"), 8 | 9 | shuffle: () => ipcRenderer.send("shuffle"), 10 | repeat: () => ipcRenderer.send("repeat"), 11 | 12 | seek: (positionToSeekbar) => ipcRenderer.send("seek", positionToSeekbar), 13 | setPosition: (position) => ipcRenderer.send("setPosition", position), 14 | 15 | registerPositionCallback: (callback) => ipcRenderer.on("position", (_e, ...args) => callback(...args)), 16 | registerUpdateCallback: (callback) => ipcRenderer.on("update", (_e, ...args) => callback(...args)), 17 | registerLyricsCallback: (callback) => ipcRenderer.on("refreshLyrics", (_e, ...args) => callback(...args)), 18 | registerConfigChangedCallback: (callback) => ipcRenderer.on("configChanged", (_e, ...args) => callback(...args)), 19 | 20 | getSongData: () => ipcRenderer.invoke("getSongData"), 21 | getConfig: () => ipcRenderer.invoke("getConfig"), 22 | 23 | searchAllLyrics: (metadata) => ipcRenderer.invoke("searchAllLyrics", metadata), 24 | chooseLyrics: (lyrics) => ipcRenderer.send("chooseLyrics", lyrics), 25 | 26 | isWidgetMode: () => ipcRenderer.invoke("isWidgetMode"), 27 | isDebugMode: () => ipcRenderer.invoke("isDebugMode"), 28 | isElectronRunning: async () => true, 29 | 30 | getScene: () => ipcRenderer.invoke("getScene"), 31 | getThemeLocationFor: (theme) => ipcRenderer.invoke("getThemeLocationFor", theme), 32 | 33 | minimize: () => ipcRenderer.send("minimize"), 34 | close: () => ipcRenderer.send("close"), 35 | openExternal: (uri) => ipcRenderer.send("openExternal", uri), 36 | }; 37 | 38 | contextBridge.exposeInMainWorld("np", npApi); 39 | -------------------------------------------------------------------------------- /src/www/lib/npapi/tsconfig.electron.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../../tsconfig.settings.json", 3 | "files": [ 4 | "electron-npapi.ts" 5 | ], 6 | "compilerOptions": { 7 | "module": "CommonJS", 8 | "moduleResolution": "node", 9 | "outDir": "../../../../dist" /* Redirect output structure to the directory. */ 10 | } 11 | } -------------------------------------------------------------------------------- /src/www/lib/screen.ts: -------------------------------------------------------------------------------- 1 | import config from "./config.js"; 2 | import { isElectron } from "./util.js"; 3 | 4 | // OBS Studio 5 | if (window.obsstudio) 6 | document.documentElement.classList.add("obs-studio"); 7 | 8 | // Electron 9 | if (isElectron()) 10 | document.documentElement.classList.add("electron"); 11 | 12 | // @ts-expect-error 13 | if(document.fullscreenEnabled || document.webkitFullscreenEnabled) 14 | document.documentElement.classList.add("supports-fullscreen"); 15 | 16 | // Scenes 17 | const sceneName = await window.np.getScene(); 18 | 19 | if(sceneName && config.scenes[sceneName]){ 20 | const scene = config.scenes[sceneName]; 21 | 22 | if(scene.font) 23 | (document.querySelector(":root") as HTMLElement).style.setProperty("--font-family", scene.font); 24 | 25 | // write widget mode from config if it exists 26 | if(typeof scene.widgetMode !== "undefined"){ 27 | if (scene.widgetMode) document.documentElement.classList.add("widget-mode"); 28 | else document.documentElement.classList.remove("widget-mode"); 29 | } 30 | 31 | if (scene.nonInteractive) document.documentElement.classList.add("non-interactive"); 32 | if (scene.static) document.documentElement.classList.add("static"); 33 | if (scene.colorblock) document.documentElement.classList.add("colorblock"); 34 | if (scene.defaultColorsAreInverted) document.documentElement.classList.add("inverted-default-colors"); 35 | if (scene.hideWhenNotPlaying) document.documentElement.classList.add("hide-when-not-playing"); 36 | 37 | if (scene.forceIdle) { 38 | document.documentElement.classList.add("idle"); 39 | document.documentElement.classList.add("force-idle"); 40 | } 41 | 42 | if (scene.theme && scene.theme !== "default"){ 43 | const themeLocation = await window.np.getThemeLocationFor(scene.theme); 44 | if(themeLocation){ 45 | const styleNode = document.createElement("link"); 46 | styleNode.rel = "stylesheet"; 47 | styleNode.href = themeLocation; 48 | document.head.appendChild(styleNode); 49 | } 50 | } 51 | 52 | if (typeof scene.bgAnimation !== "undefined" && !scene.bgAnimation) 53 | document.documentElement.classList.add("no-bg-animation"); 54 | 55 | if (typeof scene.showInfoContainer !== "undefined" && !scene.showInfoContainer) 56 | document.documentElement.classList.add("no-info-container"); 57 | 58 | if (typeof scene.showAlbumArt !== "undefined" && !scene.showAlbumArt) 59 | document.documentElement.classList.add("no-album-art"); 60 | 61 | if (typeof scene.showControls !== "undefined" && !scene.showControls) 62 | document.documentElement.classList.add("no-controls"); 63 | 64 | if (typeof scene.showExtraButtons !== "undefined" && !scene.showExtraButtons) 65 | document.documentElement.classList.add("no-extra-buttons"); 66 | 67 | if (typeof scene.showPlayingIndicator !== "undefined" && !scene.showPlayingIndicator) 68 | document.documentElement.classList.add("no-playing-indicator"); 69 | 70 | if (typeof scene.showLyrics !== "undefined" && !scene.showLyrics){ 71 | document.documentElement.classList.add("no-show-lyrics"); 72 | document.documentElement.classList.add("static"); // force it to be true 73 | } 74 | 75 | if (typeof scene.showPlayCountInfo !== "undefined" && !scene.showPlayCountInfo) 76 | document.documentElement.classList.add("no-show-play-count-info"); 77 | 78 | if (typeof scene.showProgress !== "undefined" && !scene.showProgress) 79 | document.documentElement.classList.add("no-progress"); 80 | 81 | if (typeof scene.lyricsBlur !== "undefined" && !scene.lyricsBlur) 82 | document.documentElement.classList.add("no-lyrics-blur"); 83 | 84 | if (typeof scene.playerIcon !== "undefined" && !scene.playerIcon) 85 | document.documentElement.classList.add("no-player-icon"); 86 | 87 | if (typeof scene.colors !== "undefined" && !scene.colors) 88 | document.documentElement.classList.add("no-colors"); 89 | 90 | if (typeof scene.clickableLyrics !== "undefined" && !scene.clickableLyrics) 91 | document.documentElement.classList.add("no-clickable-lyrics"); 92 | } 93 | 94 | if(sceneName && ["electron", "default"].includes(sceneName)){ 95 | // overwrite with value reported by process arguments 96 | if (await window.np.isWidgetMode()) 97 | document.documentElement.classList.add("widget-mode"); 98 | else 99 | document.documentElement.classList.remove("widget-mode"); 100 | } -------------------------------------------------------------------------------- /src/www/lib/seekbar.ts: -------------------------------------------------------------------------------- 1 | import songdata from "./songdata.js"; 2 | 3 | const seekbarWhole = document.getElementById("seekbar")!; 4 | const seekbarFg = document.getElementById("seekbar-fg")!; 5 | const seekbarBall = document.getElementById("seekbar-ball")!; 6 | 7 | seekbarWhole.onclick = seekTo; 8 | 9 | seekbarWhole.onmousedown = (e) => { 10 | e.preventDefault(); 11 | 12 | if(seekbarWhole.classList.contains("draggable")){ 13 | seekbarWhole.onmousemove = (e) => { 14 | e.preventDefault(); 15 | const rect = seekbarWhole.getBoundingClientRect(); 16 | const x = e.clientX - rect.left; 17 | const percentage = Math.round(x / rect.width * 100); 18 | seekbarFg.classList.add("dragging"); 19 | seekbarFg.style.width = percentage + "%"; 20 | seekbarBall.style.left = percentage + "%"; 21 | }; 22 | 23 | seekbarWhole.onmouseup = (e) => { 24 | seekbarWhole.onmouseup = null; 25 | seekbarWhole.onmousemove = null; 26 | seekbarWhole.onmouseleave = null; 27 | seekbarWhole.classList.remove("dragging"); 28 | seekTo(e); 29 | }; 30 | 31 | seekbarWhole.onmouseleave = () => { 32 | seekbarWhole.onmouseup = null; 33 | seekbarWhole.onmousemove = null; 34 | seekbarWhole.onmouseleave = null; 35 | seekbarFg.classList.remove("dragging"); 36 | }; 37 | } 38 | }; 39 | 40 | function seekTo(e) { 41 | const rect = seekbarWhole.getBoundingClientRect(); 42 | const x = e.clientX - rect.left; 43 | const percentage = x / rect.width; 44 | window.np.seek(percentage); 45 | } 46 | 47 | export function updateSeekbarStatus() { 48 | if(songdata.capabilities.canSeek) 49 | seekbarWhole.classList.add("draggable"); 50 | else 51 | seekbarWhole.classList.remove("draggable"); 52 | } 53 | 54 | export function updateSeekbarTime(elapsed: number) { 55 | if (!songdata.metadata.id) 56 | return; 57 | 58 | if(songdata.reportsPosition) 59 | document.documentElement.classList.remove("not-reporting-position"); 60 | else 61 | document.documentElement.classList.add("not-reporting-position"); 62 | 63 | const seekbarPercent = Math.max(0, Math.min(100, elapsed / songdata.metadata.length * 100)); 64 | if (!seekbarFg.classList.contains("dragging")){ 65 | seekbarFg!.style.width = `${seekbarPercent}%`; 66 | seekbarBall!.style.left = `${seekbarPercent}%`; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/www/lib/showhide.ts: -------------------------------------------------------------------------------- 1 | export function hide() { 2 | if (document.documentElement.classList.contains("static")) 3 | return; 4 | 5 | document.documentElement.classList.add("idle"); 6 | } 7 | 8 | export function show() { 9 | if (document.documentElement.classList.contains("force-idle")) 10 | return; 11 | 12 | document.documentElement.classList.remove("idle"); 13 | } 14 | -------------------------------------------------------------------------------- /src/www/lib/songdata.ts: -------------------------------------------------------------------------------- 1 | import type { SongData } from "../../types"; 2 | 3 | export default Object.assign({}, await window.np.getSongData()) as SongData; -------------------------------------------------------------------------------- /src/www/lib/util.ts: -------------------------------------------------------------------------------- 1 | export function secondsToTime(duration: number) { 2 | duration = Math.floor(duration); 3 | let seconds = duration % 60, 4 | minutes = Math.floor(duration / 60) % 60; 5 | 6 | return minutes.toString().padStart(2, "0") + ":" + seconds.toString().padStart(2, "0"); 7 | } 8 | 9 | export function fullscreen() { 10 | // @ts-ignore 11 | if (document.fullscreenElement || document.webkitFullscreenElement){ 12 | if (document.exitFullscreen) 13 | document.exitFullscreen(); 14 | // @ts-ignore 15 | else if (document.webkitExitFullscreen) 16 | // @ts-ignore 17 | document.webkitExitFullscreen(); 18 | } else { 19 | if (document.documentElement.requestFullscreen) 20 | document.documentElement.requestFullscreen(); 21 | // @ts-ignore 22 | else if (document.documentElement.webkitRequestFullscreen) 23 | // @ts-ignore 24 | document.documentElement.webkitRequestFullscreen(); 25 | } 26 | } 27 | 28 | export function isElectron(): boolean{ 29 | if (navigator.userAgent.toLowerCase().indexOf(" electron/") > -1) return true; 30 | return false; 31 | } 32 | 33 | export function debounce(callback: Function, time: number, leading: boolean = false, ...args: any[]): Promise { 34 | let timer: number | undefined; 35 | return new Promise((resolve, reject) => { 36 | try{ 37 | if (leading) { 38 | if (!timer) 39 | resolve(callback(...args)); 40 | } 41 | 42 | window.clearTimeout(timer); 43 | 44 | timer = window.setTimeout(() => { 45 | if(leading) 46 | timer = undefined; 47 | else 48 | resolve(callback(...args)); 49 | }, time); 50 | }catch(e){ 51 | reject(e); 52 | } 53 | }); 54 | } 55 | 56 | export function animateScroll(element: HTMLElement, duration: number = 500) { 57 | if(!element) return; 58 | 59 | const parent = element.parentElement; 60 | if(!parent) return; 61 | 62 | let start: number; 63 | 64 | const begin = parent.scrollTop; 65 | const goal = ((element.offsetTop - parent.offsetTop) + (element.offsetHeight / 2)) - (parent.offsetHeight / 2); 66 | 67 | const status = { 68 | invalidated: false, 69 | completed: false 70 | }; 71 | 72 | function step(timestamp: number){ 73 | if (start === undefined) 74 | start = timestamp; 75 | 76 | const elapsedTimestamp = timestamp - start; 77 | const elapsed = Math.min(1, elapsedTimestamp / duration); 78 | const easing = (t: number) => 1 + --t * t * t * t * t; 79 | const timed = easing(elapsed); 80 | 81 | const target = begin * (1 - timed) + goal * timed; 82 | 83 | if (elapsed >= 1 || parent!.matches(`${parent!.nodeName}:hover`) || status.invalidated) { 84 | status.completed = true; 85 | return; 86 | } 87 | 88 | parent!.scrollTo({ 89 | top: target, 90 | // @ts-ignore wtffff 91 | behavior: "instant" 92 | }); 93 | 94 | window.requestAnimationFrame(step); 95 | } 96 | 97 | window.requestAnimationFrame(step); 98 | return status; 99 | } -------------------------------------------------------------------------------- /src/www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.settings.json", 3 | "exclude": [ 4 | "./lib/npapi/electron-npapi.ts" 5 | ], 6 | "compilerOptions": { 7 | "outDir": "../../dist", /* Redirect output structure to the directory. */ 8 | "checkJs": false 9 | } 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./src/main" 6 | }, 7 | { 8 | "path": "./src/www/lib/npapi/tsconfig.electron.json" 9 | }, 10 | { 11 | "path": "./src/www" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | "allowJs": true, /* Allow javascript files to be compiled. */ 11 | "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | "strictNullChecks": true, /* Enable strict null checks. */ 29 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 33 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 41 | 42 | /* Module Resolution Options */ 43 | // "resolveJsonModule": true, 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | --------------------------------------------------------------------------------