├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.md
└── workflows
│ ├── build-mac.yml
│ ├── build-win.yml
│ ├── codeql-analysis.yml
│ ├── prettier.yml
│ └── virustotal.yml
├── .gitignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── @types
└── zephra
│ └── index.d.ts
├── LICENSE
├── README.md
├── builder
├── I18n.d.ts
├── stable-macos.json
├── stable-win-store.json
└── stable.json
├── crowdin.yml
├── i18n
├── af-ZA.json
├── de-DE.json
├── en-US.json
├── fr-FR.json
├── pt-BR.json
└── ru-RU.json
├── package-lock.json
├── package.json
├── src
├── assets
│ ├── app
│ │ ├── usaflag.gif
│ │ └── usaflag.png
│ ├── appx
│ │ ├── SmallTile.png
│ │ ├── Square150x150Logo.png
│ │ ├── Square44x44Logo.png
│ │ ├── Square44x44Logo.targetsize-44_altform-unplated.png
│ │ ├── StoreLogo.png
│ │ └── Wide310x150Logo.png
│ ├── icons
│ │ ├── darwin
│ │ │ └── logo.png
│ │ └── win32
│ │ │ ├── icon.ico
│ │ │ └── logo.png
│ ├── logo.png
│ ├── statusBarIcon.png
│ ├── statusBarIconError.png
│ ├── tray
│ │ ├── logo@18.png
│ │ └── logo@32.png
│ ├── trayLogo@18.png
│ └── trayLogo@32.png
├── browser
│ ├── css
│ │ ├── style.css
│ │ └── wakandaForever.css
│ ├── index.html
│ ├── preload.ts
│ └── renderer
│ │ ├── api.ts
│ │ ├── eventSettings.ts
│ │ ├── i18n.ts
│ │ ├── index.ts
│ │ ├── lastFM.ts
│ │ ├── listeners.ts
│ │ ├── modal.ts
│ │ ├── tsconfig.json
│ │ ├── updater.ts
│ │ └── utils.ts
├── index.ts
├── managers
│ ├── SongData.ts
│ ├── bridge.ts
│ ├── browser.ts
│ ├── discord.ts
│ ├── i18n.ts
│ ├── ipc.ts
│ ├── lastFM.ts
│ ├── launch.ts
│ ├── modal.ts
│ ├── sentry.ts
│ ├── store.ts
│ ├── tray.ts
│ ├── updater.ts
│ └── watchdog.ts
└── utils
│ ├── advancedSongDataSearch.ts
│ ├── apiRequest.ts
│ ├── checkAppDependency.ts
│ ├── checkSupporter.ts
│ ├── crowdin.ts
│ ├── execPromise.ts
│ ├── functions.ts
│ ├── getAppDataPath.ts
│ ├── getLibrarySongArtwork.ts
│ ├── json.ts
│ ├── msStoreModal.ts
│ ├── notifications.ts
│ ├── protocol.ts
│ ├── quitITunes.ts
│ ├── replaceVariables.ts
│ ├── theme.ts
│ └── watchdog
│ ├── details.ts
│ ├── index.ts
│ ├── installer.ts
│ ├── state.ts
│ └── uninstaller.ts
└── tsconfig.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: zephra
2 | github: zephraOSS
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: File a bug/issue
3 | title: "BUG | short description of your bug"
4 | labels: [ bug ]
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: "## Welcome! Is there an existing issue for this?"
9 | - type: markdown
10 | attributes:
11 | value: "### Please search to see if an issue already exists for the bug you encountered."
12 | - type: textarea
13 | attributes:
14 | label: Current Behavior
15 | description: A concise description of what you're experiencing.
16 | validations:
17 | required: true
18 | - type: textarea
19 | attributes:
20 | label: Expected Behavior
21 | description: A concise description of what you expected to happen.
22 | validations:
23 | required: true
24 | - type: textarea
25 | attributes:
26 | label: Steps To Reproduce
27 | description: Steps to reproduce the behavior.
28 | placeholder: |
29 | 1. In this environment...
30 | 2. With this config...
31 | 3. Run '...'
32 | 4. See error...
33 | validations:
34 | required: false
35 | - type: input
36 | attributes:
37 | label: OS
38 | description: What operating system are you using?
39 | placeholder: ex. Windows 10
40 | validations:
41 | required: true
42 | - type: input
43 | attributes:
44 | label: OS Version
45 | description: What version of the operating system are you using?
46 | placeholder: ex. 20H2
47 | validations:
48 | required: false
49 | - type: input
50 | attributes:
51 | label: AMRPC Version
52 | description: What version of AMRPC are you using?
53 | placeholder: ex. 4.0.0
54 | validations:
55 | required: true
56 | - type: dropdown
57 | attributes:
58 | label: AMRPC Mode
59 | options:
60 | - iTunes
61 | - Apple Music
62 | default: 1
63 | - type: textarea
64 | attributes:
65 | label: Anything else?
66 | description: |
67 | Links? References? Anything that will give us more context about the issue you are encountering!
68 |
69 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
70 |
71 | *[How do I find the log file?](https://github.com/ZephraCloud/Apple-Music-RPC/wiki/Creating-a-bug-report#how-do-i-find-the-log-file)*
72 | validations:
73 | required: false
74 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: 'FEATURE REQUEST | '
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/build-mac.yml:
--------------------------------------------------------------------------------
1 | name: Build (macOS)
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | paths:
7 | - ".github/workflows/build-mac.yml"
8 | - "src/**"
9 | - "package.json"
10 |
11 | jobs:
12 | release:
13 | runs-on: ${{ matrix.os }}
14 |
15 | strategy:
16 | matrix:
17 | os: [macos-latest]
18 |
19 | steps:
20 | - name: Check out Git repository
21 | uses: actions/checkout@v1
22 |
23 | - name: Install Node.js, NPM and Yarn
24 | uses: actions/setup-node@v1
25 | with:
26 | node-version: 16
27 |
28 | - name: Install dependencies
29 | run: npm ci
30 |
31 | - name: Create i18n types
32 | run: npm run generate:i18n-types-ci
33 |
34 | - name: Build
35 | run: npm run build:macos
36 | env:
37 | GH_TOKEN: ${{ secrets.github_token }}
38 | ELECTRON: true
39 | USE_HARD_LINKS: false
40 |
--------------------------------------------------------------------------------
/.github/workflows/build-win.yml:
--------------------------------------------------------------------------------
1 | name: Build (Windows)
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | paths:
7 | - ".github/workflows/build-win.yml"
8 | - "src/**"
9 | - "package.json"
10 |
11 | jobs:
12 | release:
13 | runs-on: ${{ matrix.os }}
14 |
15 | strategy:
16 | matrix:
17 | os: [windows-latest]
18 |
19 | steps:
20 | - name: Check out Git repository
21 | uses: actions/checkout@v1
22 |
23 | - name: Install Node.js, NPM and Yarn
24 | uses: actions/setup-node@v1
25 | with:
26 | node-version: 16
27 |
28 | - name: Install dependencies
29 | run: npm ci
30 |
31 | - name: Create i18n types
32 | run: npm run generate:i18n-types-ci
33 |
34 | - name: Build
35 | run: npm run build:win
36 | env:
37 | GH_TOKEN: ${{ secrets.github_token }}
38 | ELECTRON: true
39 | USE_HARD_LINKS: false
40 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: ["main"]
17 | paths:
18 | - ".github/workflows/codeql-analysis.yml"
19 | - "src/**"
20 | pull_request:
21 | # The branches below must be a subset of the branches above
22 | branches: ["main"]
23 | paths:
24 | - ".github/workflows/codeql-analysis.yml"
25 | - "src/**"
26 | schedule:
27 | - cron: "45 0 * * 0"
28 |
29 | jobs:
30 | analyze:
31 | name: Analyze
32 | runs-on: ubuntu-latest
33 | permissions:
34 | actions: read
35 | contents: read
36 | security-events: write
37 |
38 | strategy:
39 | fail-fast: false
40 | matrix:
41 | language: ["javascript"]
42 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
43 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
44 |
45 | steps:
46 | - name: Checkout repository
47 | uses: actions/checkout@v3
48 |
49 | # Initializes the CodeQL tools for scanning.
50 | - name: Initialize CodeQL
51 | uses: github/codeql-action/init@v2
52 | with:
53 | languages: ${{ matrix.language }}
54 | # If you wish to specify custom queries, you can do so here or in a config file.
55 | # By default, queries listed here will override any specified in a config file.
56 | # Prefix the list here with "+" to use these queries and those in the config file.
57 |
58 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
59 | # queries: security-extended,security-and-quality
60 |
61 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
62 | # If this step fails, then you should remove it and run the build manually (see below)
63 | - name: Autobuild
64 | uses: github/codeql-action/autobuild@v2
65 |
66 | # ℹ️ Command-line programs to run using the OS shell.
67 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
68 |
69 | # If the Autobuild fails above, remove it and uncomment the following three lines.
70 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
71 |
72 | # - run: |
73 | # echo "Run, Build Application using script"
74 | # ./location_of_script_within_repo/buildscript.sh
75 |
76 | - name: Perform CodeQL Analysis
77 | uses: github/codeql-action/analyze@v2
78 | with:
79 | category: "/language:${{matrix.language}}"
80 |
--------------------------------------------------------------------------------
/.github/workflows/prettier.yml:
--------------------------------------------------------------------------------
1 | name: Prettier
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | prettier:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@v2
14 | with:
15 | # Make sure the actual branch is checked out when running on pull requests
16 | ref: ${{ github.head_ref }}
17 | # This is important to fetch the changes to the previous commit
18 | fetch-depth: 0
19 |
20 | - name: Prettify code
21 | uses: creyD/prettier_action@v4.1.1
22 | with:
23 | only_changed: True
24 |
--------------------------------------------------------------------------------
/.github/workflows/virustotal.yml:
--------------------------------------------------------------------------------
1 | name: VirusTotal - Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | virustotal:
9 | runs-on: ubuntu-latest
10 | steps:
11 | -
12 | name: VirusTotal Scan
13 | uses: crazy-max/ghaction-virustotal@v2
14 | with:
15 | update_release_body: true
16 | vt_api_key: ${{ secrets.VT_API_KEY }}
17 | files: |
18 | .exe$
19 | .dmg$
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea
2 | /app
3 | /dist
4 | /node_modules
5 | /src/language
6 | /@types/zephra/I18n.d.ts
7 |
8 | /builder/cert.pfx
9 |
10 | .DS_Store
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 4,
4 | "semi": true,
5 | "singleQuote": false
6 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deepscan.enable": true
3 | }
--------------------------------------------------------------------------------
/@types/zephra/index.d.ts:
--------------------------------------------------------------------------------
1 | interface currentTrack {
2 | name: string;
3 | artist: string;
4 | album: string;
5 | mediaKind: string;
6 | duration: number;
7 | endTime?: number;
8 | elapsedTime: number;
9 | remainingTime: number;
10 | url: string;
11 | genre: string;
12 | releaseYear: number;
13 | id: number;
14 | artwork: string;
15 | playerState?: "playing" | "paused" | "stopped";
16 | }
17 |
18 | interface PresenceData {
19 | details?: string;
20 | state?: string;
21 | startTimestamp?: number;
22 | endTimestamp?: number;
23 | largeImageKey?: string;
24 | largeImageText?: string;
25 | smallImageKey?: string;
26 | smallImageText?: string;
27 | partyId?: string;
28 | partySize?: number;
29 | partyMax?: number;
30 | matchSecret?: string;
31 | joinSecret?: string;
32 | spectateSecret?: string;
33 | buttons?: [PresenceDataButton, PresenceDataButton?];
34 | }
35 |
36 | interface PresenceDataButton {
37 | label: string;
38 | url: string;
39 | }
40 |
41 | interface ModalData {
42 | id?: string;
43 | title: string;
44 | description: string;
45 | priority?: boolean;
46 | i18n?: {
47 | [language: string]: ModalI18n;
48 | };
49 | buttons?: ModalButton[];
50 | }
51 |
52 | interface ModalButton {
53 | label: string;
54 | style: string;
55 | events?: ModalButtonEvent[];
56 | }
57 |
58 | interface ModalButtonEvent {
59 | /**
60 | * The name of the event
61 | * @example "onclick"
62 | */
63 | name: string;
64 | /**
65 | * The value of the event
66 | * @example "closeModal(this.parentElement.id)"
67 | */
68 | value?: string;
69 | type?: "close" | "delete";
70 | save?: string;
71 | /**
72 | * The action of the event
73 | */
74 | action?: () => void;
75 | }
76 |
77 | interface ModalI18n {
78 | title: string;
79 | description: string;
80 | buttons?: {
81 | [label: string]: string;
82 | };
83 | }
84 |
85 | interface APIUserRoles {
86 | id: string;
87 | name: string;
88 | }
89 |
90 | interface AppDependencies {
91 | music: boolean;
92 | iTunes: boolean;
93 | appleMusic: boolean;
94 | watchDog: boolean;
95 | discord: boolean;
96 | }
97 |
98 | interface SongDataT {
99 | url: string;
100 | collectionId: number | string;
101 | trackId: number | string;
102 | explicit: boolean;
103 | artwork: string;
104 | }
105 |
106 | interface ImgBBResponse {
107 | data: {
108 | id: string;
109 | title: string;
110 | url_viewer: string;
111 | url: string;
112 | display_url: string;
113 | width: number;
114 | height: number;
115 | size: number;
116 | time: string;
117 | expiration: string;
118 | image: {
119 | filename: string;
120 | name: string;
121 | mime: string;
122 | extension: string;
123 | url: string;
124 | };
125 | thumb: {
126 | filename: string;
127 | name: string;
128 | mime: string;
129 | extension: string;
130 | url: string;
131 | };
132 | medium: {
133 | filename: string;
134 | name: string;
135 | mime: string;
136 | extension: string;
137 | url: string;
138 | };
139 | delete_url: string;
140 | };
141 | success: boolean;
142 | status: number;
143 | }
144 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | # Apple Music RPC for Discord
3 |
4 |  
5 |
6 | ## Apple Music Preview
7 | [Apple Music Preview](https://apps.microsoft.com/store/detail/apple-music-preview/9PFHDD62MXS1) integration is currently in beta testing. [View Releases](https://github.com/zephraOSS/Apple-Music-RPC/releases)
8 |
9 | ## Installation
10 | * [Microsoft Store](https://docs.amrpc.zephra.cloud/installation/windows/microsoft-store)
11 | * [Windows](https://docs.amrpc.zephra.cloud/installation/windows)
12 | * [macOS](https://docs.amrpc.zephra.cloud/installation/macos)
13 |
14 | ## You need help?
15 | * Take a look at [the documentation](https://docs.amrpc.zephra.cloud)
16 | * Join [our Discord](https://discord.gg/APDghNfJhQ)
17 |
18 | ## Information
19 | This RPC is only for iTunes/Apple Music. If you use Apple Music in your browser (macOS, Windows, Linux), check out [Apple Music RPC for PreMiD](https://premid.app/store/presences/Apple%20Music)!
20 |
21 | ## Support
22 |
23 |

24 |
25 |
--------------------------------------------------------------------------------
/builder/I18n.d.ts:
--------------------------------------------------------------------------------
1 | export interface I18n {
2 | [key: string | number | symbol]: any;
3 | }
4 |
--------------------------------------------------------------------------------
/builder/stable-macos.json:
--------------------------------------------------------------------------------
1 | {
2 | "appId": "com.zephra.amrpc",
3 | "productName": "AMRPC",
4 | "artifactName": "AMRPC.${ext}",
5 | "copyright": "Copyright © 2022 zephra",
6 | "protocols": [
7 | {
8 | "name": "AMRPC",
9 | "schemes": ["amrpc"]
10 | }
11 | ],
12 | "asar": true,
13 | "asarUnpack": ["node_modules/apple-bridge/dist/darwin/osascript/"],
14 | "directories": {
15 | "app": "dist",
16 | "buildResources": "src/assets",
17 | "output": "app/macos"
18 | },
19 | "dmg": {
20 | "window": {
21 | "width": 660,
22 | "height": 400
23 | },
24 | "contents": [
25 | {
26 | "x": 180,
27 | "y": 170
28 | },
29 | {
30 | "x": 480,
31 | "y": 170,
32 | "type": "link",
33 | "path": "/Applications"
34 | }
35 | ],
36 | "title": "AMRPC",
37 | "writeUpdateInfo": false
38 | },
39 | "mac": {
40 | "icon": "src/assets/icons/darwin/logo.png",
41 | "target": {
42 | "target": "default",
43 | "arch": ["x64", "arm64"]
44 | },
45 | "extendInfo": {
46 | "LSUIElement": 1
47 | },
48 | "category": "public.app-category.utilities",
49 | "artifactName": "AMRPC-Installer-macOS-${arch}.${ext}",
50 | "publish": {
51 | "provider": "github",
52 | "releaseType": "draft"
53 | },
54 | "darkModeSupport": true
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/builder/stable-win-store.json:
--------------------------------------------------------------------------------
1 | {
2 | "appId": "com.zephra.amrpc",
3 | "productName": "AMRPC",
4 | "artifactName": "AMRPC.${ext}",
5 | "copyright": "Copyright © 2022 zephra",
6 | "protocols": [
7 | {
8 | "name": "AMRPC",
9 | "schemes": ["amrpc"]
10 | }
11 | ],
12 | "asar": true,
13 | "asarUnpack": ["node_modules/apple-bridge/dist/win32/wscript/"],
14 | "directories": {
15 | "app": "dist",
16 | "buildResources": "src/assets",
17 | "output": "app/win/store"
18 | },
19 | "appx": {
20 | "applicationId": "zephra.AMRPC",
21 | "backgroundColor": "transparent",
22 | "displayName": "AMRPC",
23 | "identityName": "62976zephra.AMRPC",
24 | "publisher": "CN=11A3B5E5-0C40-4A2A-9E60-5A3AC8090080",
25 | "publisherDisplayName": "zephra",
26 | "languages": ["en-US", "de-DE"],
27 | "addAutoLaunchExtension": true
28 | },
29 | "win": {
30 | "target": "appx",
31 | "icon": "src/assets/appx/StoreLogo.png"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/builder/stable.json:
--------------------------------------------------------------------------------
1 | {
2 | "appId": "com.zephra.amrpc",
3 | "productName": "AMRPC",
4 | "artifactName": "AMRPC.${ext}",
5 | "copyright": "Copyright © 2022 zephra",
6 | "protocols": [
7 | {
8 | "name": "AMRPC",
9 | "schemes": ["amrpc"]
10 | }
11 | ],
12 | "asar": true,
13 | "asarUnpack": ["node_modules/apple-bridge/dist/win32/wscript/"],
14 | "directories": {
15 | "app": "dist",
16 | "buildResources": "src/assets",
17 | "output": "app/win/normal"
18 | },
19 | "nsis": {
20 | "createDesktopShortcut": "always"
21 | },
22 | "win": {
23 | "target": "nsis",
24 | "icon": "src/assets/icons/win32/icon.ico",
25 | "artifactName": "AMRPC-Installer.exe",
26 | "verifyUpdateCodeSignature": false,
27 | "publisherName": "zephra",
28 | "publish": ["github"]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/crowdin.yml:
--------------------------------------------------------------------------------
1 | files:
2 | - source: /i18n/en-US.json
3 | translation: /i18n/%locale%.json
4 |
--------------------------------------------------------------------------------
/i18n/af-ZA.json:
--------------------------------------------------------------------------------
1 | {
2 | "error": {
3 | "admin": "Moet asseblief nie AMRPC met administrateur regte laat loop nie!",
4 | "noTrack": "Geen snit bespeur nie",
5 | "jsFileExtension": "Daar is 'n fout met die JS-lêerassosiasiedata. Klik asseblief op die knoppie hieronder om meer te wete te kom.",
6 | "cmd": "Daar is 'n fout met opdragprompt. Klik asseblief op die knoppie hieronder om meer te wete te kom."
7 | },
8 | "tray": {
9 | "restart": "Herbegin",
10 | "reportProblem": "Report a Problem",
11 | "quitITunes": {
12 | "info": "This takes about 3 seconds",
13 | "button": "Quit iTunes"
14 | },
15 | "quit": "Hou op"
16 | },
17 | "settings": {
18 | "config": {
19 | "autoLaunch": "Outomatiese bekendstelling",
20 | "autoUpdates": "Automatic Updates",
21 | "betaUpdates": "Beta Updates",
22 | "installUpdate": {
23 | "label": "Install Update",
24 | "button": "Install %version%",
25 | "buttonNA": "No update available"
26 | },
27 | "show": "Wys teenwoordigheid",
28 | "hideOnPause": "Versteek teenwoordigheid op pouse",
29 | "artworkPrioLocal": "Prioritize Local Artwork",
30 | "showTimestamps": "Wys Tydstempels",
31 | "showAlbumArtwork": "Wys albumkunswerk",
32 | "performanceMode": "Prestasiemodus",
33 | "hardwareAcceleration": "Hardeware versnelling",
34 | "checkIfMusicInstalled": "Check if Music is installed",
35 | "colorTheme": {
36 | "label": "Voorkeurkleurtema",
37 | "light": "Lig",
38 | "dark": "Donker",
39 | "auto": "Donker van sonsondergang tot sonsopkoms",
40 | "os": "Stelsel verstek"
41 | },
42 | "language": "Verkose Taal",
43 | "rpcLargeImageText": "Presence Large Image Text",
44 | "rpcDetails": "Teenwoordigheid Besonderhede",
45 | "rpcState": "Teenwoordigheid Staat",
46 | "enableLastFM": "Aktiveer Last.fm",
47 | "lastFMUser": {
48 | "label": "Last.fm-gebruiker",
49 | "connect": "Koppel",
50 | "reconnect": "Herkoppel",
51 | "connecting": "Koppel tans...",
52 | "cancel": "Kanselleer"
53 | },
54 | "enableCache": "Aktiveer Cache",
55 | "resetCache": "Stel kas terug"
56 | },
57 | "common": {
58 | "reset": "Terugstel"
59 | },
60 | "category": {
61 | "general": "Algemeen",
62 | "softwareUpdate": "Software Update",
63 | "cache": "Cache Management",
64 | "advanced": "Advanced"
65 | },
66 | "warn": {
67 | "music": {
68 | "title": "Aandag",
69 | "description": "Die Musiek-toepassing kon nie gevind word nie. Maak asseblief seker dat die Musiek-toepassing geïnstalleer is."
70 | },
71 | "discord": {
72 | "title": "Aandag",
73 | "description": "Discord kon nie gevind word nie. Maak asseblief seker dat Discord geïnstalleer is."
74 | }
75 | },
76 | "note": {
77 | "language": "As jou taal ontbreek, kontak ons.",
78 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.",
79 | "cache": {
80 | "size": "Huidige kasgrootte: %size%, %count% items",
81 | "warn": "WAARSKUWING As u die kas deaktiveer, sal alle gekasdata uitvee.",
82 | "info": "Die kas stoor alle albumkunswerk en ander data om die hoeveelheid versoeke aan Apple te verminder. Dit sal die laaityd van die albumkunswerk en skakel verbeter."
83 | },
84 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed."
85 | },
86 | "extra": {
87 | "github": "Kyk op GitHub",
88 | "koFi": "Skenk",
89 | "reloadPage": "Herlaai",
90 | "restartApp": "Herbegin vereis"
91 | },
92 | "modal": {
93 | "newUpdate": {
94 | "title": "Sagteware opdatering",
95 | "description": "Weergawe %version% is beskikbaar vir jou rekenaar.",
96 | "installed": {
97 | "description": "Weergawe %version% is beskikbaar vir jou rekenaar en is gereed om te installeer.",
98 | "buttons": {
99 | "install": "Installeer nou",
100 | "later": "Later"
101 | }
102 | },
103 | "buttons": {
104 | "downloadAndInstall": "Laai af en installeer",
105 | "download": "Laai af en installeer later"
106 | }
107 | },
108 | "koFi": {
109 | "title": "Vereis Premium",
110 | "description": "Om hierdie kenmerk te gebruik, word 'n eenmalige skenking of 'n lidmaatskap vereis. Jy kan skenk hier. As jy reeds geskenk het, probeer om AMRPC te herbegin. Leer meer"
111 | },
112 | "microsoftStore": {
113 | "description": "Het jy geweet dat jy AMRPC vanaf die Microsoft Store kan installeer? Maar maak seker dat u dit deaktiveer Outomatiese bekendstelling in die instellings voordat u dit vanaf die Microsoft Store installeer.",
114 | "buttons": {
115 | "download": "Laai nou af"
116 | }
117 | },
118 | "buttons": {
119 | "no": "Geen",
120 | "later": "Nie nou nie",
121 | "yes": "Ja",
122 | "okay": "Goed",
123 | "learnMore": "Leer meer",
124 | "close": "Gesluit"
125 | }
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/i18n/de-DE.json:
--------------------------------------------------------------------------------
1 | {
2 | "error": {
3 | "admin": "Bitte starte AMRPC nicht mit Administrator-Rechten!",
4 | "noTrack": "Kein Lied erkannt",
5 | "jsFileExtension": "Es liegt ein Fehler bei den JS-Dateizuordnungsdaten vor. Klicke auf den Button unten, um mehr zu erfahren.",
6 | "cmd": "Es liegt ein Fehler mit der Eingabeaufforderung vor. Klicke auf den Button unten, um mehr zu erfahren."
7 | },
8 | "tray": {
9 | "restart": "Neustart",
10 | "reportProblem": "Problem melden",
11 | "quitITunes": {
12 | "info": "Vorgang dauert ca. 3 Sekunden",
13 | "button": "iTunes beenden"
14 | },
15 | "quit": "Beenden"
16 | },
17 | "settings": {
18 | "config": {
19 | "autoLaunch": "Autostart",
20 | "autoUpdates": "Automatische Updates",
21 | "betaUpdates": "Beta Updates",
22 | "installUpdate": {
23 | "label": "Update installieren",
24 | "button": "%version% installieren",
25 | "buttonNA": "Kein Update verfügbar"
26 | },
27 | "show": "Presence anzeigen",
28 | "hideOnPause": "Presence während Pause ausblenden",
29 | "artworkPrioLocal": "Lokales Artwork priorisieren",
30 | "showTimestamps": "Timestamps anzeigen",
31 | "showAlbumArtwork": "Album Artwork anzeigen",
32 | "performanceMode": "Performance Modus",
33 | "hardwareAcceleration": "Hardware-Beschleunigung",
34 | "checkIfMusicInstalled": "Music-Installation überprüfen",
35 | "colorTheme": {
36 | "label": "Bevorzugtes Farbschema",
37 | "light": "Hell",
38 | "dark": "Dunkel",
39 | "auto": "Dunkel von Sonnenuntergang bis -aufgang",
40 | "os": "System Farbschema"
41 | },
42 | "language": "Bevorzugte Sprache",
43 | "rpcLargeImageText": "Presence Large Image Text",
44 | "rpcDetails": "Presence Details",
45 | "rpcState": "Presence State",
46 | "enableLastFM": "Last.fm aktivieren",
47 | "lastFMUser": {
48 | "label": "Last.fm Benutzer",
49 | "connect": "Verbinden",
50 | "reconnect": "Neu verbinden",
51 | "connecting": "Verbinden...",
52 | "cancel": "Abbrechen"
53 | },
54 | "enableCache": "Cache aktivieren",
55 | "resetCache": "Cache löschen"
56 | },
57 | "common": {
58 | "reset": "Löschen"
59 | },
60 | "category": {
61 | "general": "Allgemein",
62 | "softwareUpdate": "Software Update",
63 | "cache": "Cache Management",
64 | "advanced": "Erweitert"
65 | },
66 | "warn": {
67 | "music": {
68 | "title": "Achtung",
69 | "description": "Die Musik App konnte nicht gefunden werden. Bitte stelle sicher, dass die Musik App installiert ist."
70 | },
71 | "discord": {
72 | "title": "Achtung",
73 | "description": "Discord konnte nicht gefunden werden. Bitte stelle sicher, dass Discord installiert ist."
74 | }
75 | },
76 | "note": {
77 | "language": "Wenn deine Sprache fehlt, kontaktiere uns.",
78 | "artworkPrioLocal": "Wenn ein lokales Artwork verfügbar ist, wird es anstelle des Artworks der Apple Music API verwendet.",
79 | "cache": {
80 | "size": "Derzeitige Cache-Größe: %size%, %count% Einträge",
81 | "warn": "WARNUNG Wenn der Cache deaktiviert wird, werden alle Daten gelöscht.",
82 | "info": "Der Cache speichert alle Album-Cover und andere Daten, um die Anzahl der Anfragen an Apple zu reduzieren. Dadurch wird die Ladezeit des Album-Covers und des Links verbessert."
83 | },
84 | "checkIfMusicInstalled": "Nur deaktivieren, falls Probleme mit den Music Checks auftreten und du sicher bist, dass Music installiert ist."
85 | },
86 | "extra": {
87 | "github": "Auf GitHub ansehen",
88 | "koFi": "Spenden",
89 | "reloadPage": "Neu laden",
90 | "restartApp": "Neustart erforderlich"
91 | },
92 | "modal": {
93 | "newUpdate": {
94 | "title": "Software Update",
95 | "description": "Die Version %version% ist für deinen PC verfügbar.",
96 | "installed": {
97 | "description": "Die Version %version% ist für deinen PC verfügbar und kann installiert werden.",
98 | "buttons": {
99 | "install": "Jetzt installieren",
100 | "later": "Später"
101 | }
102 | },
103 | "buttons": {
104 | "downloadAndInstall": "Laden & installieren",
105 | "download": "Laden & später installieren"
106 | }
107 | },
108 | "koFi": {
109 | "title": "Erfordert Premium",
110 | "description": "Um dieses Feature nutzen zu können, ist eine one-time Spende oder Mitgliedschaft erforderlich. Du kannst hier spenden. Solltest du bereits gespendet haben, starte einmal AMRPC neu. Mehr erfahren"
111 | },
112 | "microsoftStore": {
113 | "description": "Wusstest du, dass du AMRPC auch im Microsoft Store herunterladen kannst? Aber stelle sicher, dass du Auto Launch in den Einstellungen deaktivierst, bevor du AMRPC im Microsoft Store installierst.",
114 | "buttons": {
115 | "download": "Jetzt herunterladen"
116 | }
117 | },
118 | "buttons": {
119 | "no": "Nein",
120 | "later": "Nicht jetzt",
121 | "yes": "Ja",
122 | "okay": "Okay",
123 | "learnMore": "Mehr erfahren",
124 | "close": "Schließen"
125 | }
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/i18n/en-US.json:
--------------------------------------------------------------------------------
1 | {
2 | "general": {
3 | "buttons": {
4 | "openSettings": "Open Settings"
5 | }
6 | },
7 | "error": {
8 | "admin": "Please do not run AMRPC with administrator privileges!",
9 | "noTrack": "No track detected",
10 | "jsFileExtension": "There is an error with the JS file association data. Please click on the button below to learn more.",
11 | "cmd": "There is an error with Command Prompt. Please click on the button below to learn more.",
12 | "iTunesInstalledCheck": "AMRPC couldn't check if iTunes is installed. If you're sure that iTunes is installed, go to Settings > Advanced and disable \"Check if Music is installed\".",
13 | "watchDog": {
14 | "title": "AMRPC WatchDog not installed",
15 | "description": "WatchDog is required for Apple Music (Preview). Do you want to install it?"
16 | }
17 | },
18 | "tray": {
19 | "restart": "Restart",
20 | "reportProblem": "Report a Problem",
21 | "quitITunes": {
22 | "info": "Takes about 3 seconds",
23 | "button": "Quit iTunes"
24 | },
25 | "quit": "Quit"
26 | },
27 | "settings": {
28 | "config": {
29 | "autoLaunch": "Auto Launch",
30 | "autoUpdates": "Automatic Updates",
31 | "betaUpdates": "Beta Updates",
32 | "installUpdate": {
33 | "label": "Install Update",
34 | "button": "Install %version%",
35 | "buttonNA": "No update available"
36 | },
37 | "show": "Show Presence",
38 | "hideOnPause": "Hide Presence on Pause",
39 | "artworkPrioLocal": "Prioritize Local Artwork",
40 | "showTimestamps": "Show Timestamps",
41 | "showAlbumArtwork": "Show Album Artwork",
42 | "performanceMode": "Performance Mode",
43 | "hardwareAcceleration": "Hardware Acceleration",
44 | "checkIfMusicInstalled": "Check if Music is installed",
45 | "colorTheme": {
46 | "label": "Preferred Color Theme",
47 | "light": "Light",
48 | "dark": "Dark",
49 | "auto": "Dark from Sunset to Sunrise",
50 | "os": "System Default"
51 | },
52 | "language": "Preferred Language",
53 | "rpcLargeImageText": "Presence Large Image Text",
54 | "rpcDetails": "Presence Details",
55 | "rpcState": "Presence State",
56 | "enableLastFM": "Enable Last.fm",
57 | "lastFMUser": {
58 | "label": "Last.fm User",
59 | "connect": "Connect",
60 | "reconnect": "Reconnect",
61 | "connecting": "Connecting...",
62 | "cancel": "Cancel"
63 | },
64 | "enableCache": "Enable Cache",
65 | "resetCache": "Reset Cache"
66 | },
67 | "common": {
68 | "reset": "Reset"
69 | },
70 | "category": {
71 | "general": "General",
72 | "softwareUpdate": "Software Update",
73 | "cache": "Cache Management",
74 | "advanced": "Advanced"
75 | },
76 | "warn": {
77 | "music": {
78 | "title": "Attention",
79 | "description": "The Music app could not be found. Please make sure that the Music app is installed."
80 | },
81 | "discord": {
82 | "title": "Attention",
83 | "description": "Discord could not be found. Please make sure that Discord is installed."
84 | }
85 | },
86 | "note": {
87 | "language": "If your language is missing, contact us.",
88 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.",
89 | "cache": {
90 | "size": "Current cache size: %size%, %count% items",
91 | "warn": "WARNING Disabling the cache will delete all cached data.",
92 | "info": "The cache stores all album artwork and other data to reduce the amount of requests to Apple. This will improve the loading time of the album artwork and link."
93 | },
94 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed."
95 | },
96 | "extra": {
97 | "github": "View on GitHub",
98 | "koFi": "Donate",
99 | "reloadPage": "Reload",
100 | "restartApp": "Restart required"
101 | },
102 | "modal": {
103 | "newUpdate": {
104 | "title": "Software Update",
105 | "description": "Version %version% is available for your PC.",
106 | "installed": {
107 | "description": "Version %version% is available for your PC and is ready to install.",
108 | "buttons": {
109 | "install": "Install now",
110 | "later": "Later"
111 | }
112 | },
113 | "buttons": {
114 | "downloadAndInstall": "Download & install",
115 | "download": "Download & install later"
116 | }
117 | },
118 | "koFi": {
119 | "title": "Requires Premium",
120 | "description": "To use this feature, a one-time donation or a membership is required. You can donate here. If you have already donated, try to restart AMRPC. Learn More"
121 | },
122 | "microsoftStore": {
123 | "description": "Did you know that you can install AMRPC from the Microsoft Store? But make sure to disable Auto Launch in the settings before installing it from the Microsoft Store.",
124 | "buttons": {
125 | "download": "Download now"
126 | }
127 | },
128 | "buttons": {
129 | "no": "No",
130 | "later": "Not now",
131 | "yes": "Yes",
132 | "okay": "Okay",
133 | "learnMore": "Learn more",
134 | "close": "Close"
135 | }
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/i18n/fr-FR.json:
--------------------------------------------------------------------------------
1 | {
2 | "error": {
3 | "admin": "Merci de ne pas exécuter AMRPC avec les droits d'administrateur !",
4 | "noTrack": "Aucune piste détéctée",
5 | "jsFileExtension": "Il y a une erreur dans les données d'association du fichier JS. Veuillez cliquer sur le bouton ci-dessous pour en savoir plus.",
6 | "cmd": "Il y a une erreur avec le terminal de commande. Veuillez cliquer sur le bouton ci-dessous pour en savoir plus."
7 | },
8 | "tray": {
9 | "restart": "Redémarrer",
10 | "reportProblem": "Signaler un problème",
11 | "quitITunes": {
12 | "info": "Cela prend 3 secondes",
13 | "button": "Quitter iTunes"
14 | },
15 | "quit": "Quitter"
16 | },
17 | "settings": {
18 | "config": {
19 | "autoLaunch": "Lancement Automatique",
20 | "autoUpdates": "Mises à jour automatiques",
21 | "betaUpdates": "Mises à jour bêta",
22 | "installUpdate": {
23 | "label": "Installer la mise à jour",
24 | "button": "Installer %version%",
25 | "buttonNA": "Vous êtes à jour"
26 | },
27 | "show": "Afficher la Présence",
28 | "hideOnPause": "Cacher la Présence en Pause",
29 | "artworkPrioLocal": "Pochettes d’album en local",
30 | "showTimestamps": "Afficher le Timestamp",
31 | "showAlbumArtwork": "Afficher la Pochette de l'Album",
32 | "performanceMode": "Mode Performance",
33 | "hardwareAcceleration": "Accélération Hardware",
34 | "checkIfMusicInstalled": "Vérifier si Music est installé",
35 | "colorTheme": {
36 | "label": "Thème de Couleur préféré",
37 | "light": "Clair",
38 | "dark": "Sombre",
39 | "auto": "Sombre du coucher au lever du soleil",
40 | "os": "Valeur par défaut du système"
41 | },
42 | "language": "Langue préférée",
43 | "rpcLargeImageText": "Texte sur l’image de l’activité",
44 | "rpcDetails": "Détails de la Présence",
45 | "rpcState": "État de la Présence",
46 | "enableLastFM": "Activer Last.fm",
47 | "lastFMUser": {
48 | "label": "Utilisateur Last.fm",
49 | "connect": "Connecter",
50 | "reconnect": "Reconnecter",
51 | "connecting": "Connexion...",
52 | "cancel": "Annuler"
53 | },
54 | "enableCache": "Activer le Cache",
55 | "resetCache": "Réinitialiser le Cache"
56 | },
57 | "common": {
58 | "reset": "Réinitialiser"
59 | },
60 | "category": {
61 | "general": "Général",
62 | "softwareUpdate": "Mise à jour logicielle",
63 | "cache": "Gestion du cache",
64 | "advanced": "Paramètres avancés"
65 | },
66 | "warn": {
67 | "music": {
68 | "title": "Attention",
69 | "description": "L'app Musique n'a pas pu être trouvé. Veuillez vérifier que l'app Musique est installé."
70 | },
71 | "discord": {
72 | "title": "Attention",
73 | "description": "Discord n'a pas pu être trouvé. Veuillez vérifier que Discord est bien installé."
74 | }
75 | },
76 | "note": {
77 | "language": "Si votre langue manque, contactez nous.",
78 | "artworkPrioLocal": "Si une pochette d'album en local est disponible, elle sera utilisée à la place de celle renvoyée par l'API d'Apple Music.",
79 | "cache": {
80 | "size": "Taille du cache actuelle : %size%, %count% éléments",
81 | "warn": "ATTENTION Désactiver le cache va supprimer toutes les données en cache.",
82 | "info": "Le cache sauvegarde toutes les couvertures d'album et autres données pour réduire le nombre de requêtes vers Apple. Cela améliore le temps de chargement des couvertures et des liens."
83 | },
84 | "checkIfMusicInstalled": "Désactivez ceci seulement si vous rencontrez des problèmes avec ce que vous avez coché, et que vous êtes sûrs que l'application Music est bien installé."
85 | },
86 | "extra": {
87 | "github": "Voir sur GitHub",
88 | "koFi": "Faire un Don",
89 | "reloadPage": "Recharger",
90 | "restartApp": "Rechargement requis"
91 | },
92 | "modal": {
93 | "newUpdate": {
94 | "title": "Mise à jour du Logiciel",
95 | "description": "La version %version% est disponible pour votre ordinateur.",
96 | "installed": {
97 | "description": "La version %version% est disponible pour votre ordinateur et elle est prête à être installé.",
98 | "buttons": {
99 | "install": "Installer maintenant",
100 | "later": "Plus tard"
101 | }
102 | },
103 | "buttons": {
104 | "downloadAndInstall": "Télécharger & installer",
105 | "download": "Télécharger & installer plus tard"
106 | }
107 | },
108 | "koFi": {
109 | "title": "Nécessite Premium",
110 | "description": "Pour utiliser cette fonctionnalité, une donation-en-une-fois ou un abonnement membre est nécessaire. Vous pouvez faire un don ici. Si vous avez déjà fait un don, essayer de redémarrer AMRPC. En apprendre plus"
111 | },
112 | "microsoftStore": {
113 | "description": "Saviez-vous que vous pouvez installer AMRPC depuis le Microsoft Store ? Mais désactivez Lancement Automatique dans les réglages avant de l'installer depuis le Microsoft Store.",
114 | "buttons": {
115 | "download": "Télécharger maintenant"
116 | }
117 | },
118 | "buttons": {
119 | "no": "Non",
120 | "later": "Pas maintenant",
121 | "yes": "Oui",
122 | "okay": "Ok",
123 | "learnMore": "En apprendre plus",
124 | "close": "Fermer"
125 | }
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/i18n/pt-BR.json:
--------------------------------------------------------------------------------
1 | {
2 | "error": {
3 | "admin": "Não execute o AMRPC como administrador!",
4 | "noTrack": "Nenhuma música detectada",
5 | "jsFileExtension": "Há um erro com o arquivo JS de associação dos dados. Clique no botão abaixo para saber mais.",
6 | "cmd": "Há um erro com o prompt de comando. Clique no botão abaixo para saber mais."
7 | },
8 | "tray": {
9 | "restart": "Reiniciar",
10 | "reportProblem": "Reportar um problema",
11 | "quitITunes": {
12 | "info": "Isso levará em torno de 3 segundos",
13 | "button": "Encerrar iTunes"
14 | },
15 | "quit": "Encerrar"
16 | },
17 | "settings": {
18 | "config": {
19 | "autoLaunch": "Iniciar automaticamente",
20 | "autoUpdates": "Automatic Updates",
21 | "betaUpdates": "Beta Updates",
22 | "installUpdate": {
23 | "label": "Install Update",
24 | "button": "Install %version%",
25 | "buttonNA": "No update available"
26 | },
27 | "show": "Mostrar Presença",
28 | "hideOnPause": "Esconder Presença quando pausar",
29 | "artworkPrioLocal": "Prioritize Local Artwork",
30 | "showTimestamps": "Mostrar marcadores de tempo",
31 | "showAlbumArtwork": "Mostrar arte do álbum",
32 | "performanceMode": "Modo Desempenho",
33 | "hardwareAcceleration": "Aceleração de hardware",
34 | "checkIfMusicInstalled": "Check if Music is installed",
35 | "colorTheme": {
36 | "label": "Esquema de cores",
37 | "light": "Claro",
38 | "dark": "Escuro",
39 | "auto": "Escuro do pôr do sol até nascer do sol",
40 | "os": "Padrão do sistema"
41 | },
42 | "language": "Idioma",
43 | "rpcLargeImageText": "Texto da presença na imagem",
44 | "rpcDetails": "Detalhes da presença",
45 | "rpcState": "Estado da presença",
46 | "enableLastFM": "Habilitar Last.fm",
47 | "lastFMUser": {
48 | "label": "Usuário do Last.fm",
49 | "connect": "Conectar",
50 | "reconnect": "Reconectar",
51 | "connecting": "Conectando...",
52 | "cancel": "Cancelar"
53 | },
54 | "enableCache": "Habilitar cache",
55 | "resetCache": "Redefinir cache"
56 | },
57 | "common": {
58 | "reset": "Redefinir"
59 | },
60 | "category": {
61 | "general": "Geral",
62 | "softwareUpdate": "Software Update",
63 | "cache": "Gerência de cache",
64 | "advanced": "Advanced"
65 | },
66 | "warn": {
67 | "music": {
68 | "title": "Atenção",
69 | "description": "O aplicativo música não pôde ser encontrado. Certifique-se de que o aplicativo música está instalado."
70 | },
71 | "discord": {
72 | "title": "Atenção",
73 | "description": "O Discord não pôde ser encontrado. Certifique-se de que o Discord está instalado."
74 | }
75 | },
76 | "note": {
77 | "language": "Se seu idioma não está disponível, entre em contato conosco.",
78 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.",
79 | "cache": {
80 | "size": "Tamanho atual do cache: %size%, %count% itens",
81 | "warn": "AVISO Desabilitar o cache causará a remoção de todos os dados do cache.",
82 | "info": "O cache guarda todas as artes de álbum e outros dados para reduzir o número de requisições feitos para Apple. Isso pode melhorar o tempo de carregamento da arte do álbum e do link."
83 | },
84 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed."
85 | },
86 | "extra": {
87 | "github": "Ver no GitHub",
88 | "koFi": "Doar",
89 | "reloadPage": "Recarregar",
90 | "restartApp": "Reinício necessário"
91 | },
92 | "modal": {
93 | "newUpdate": {
94 | "title": "Atualização do programa",
95 | "description": "A Versão %version% está disponível para seu PC.",
96 | "installed": {
97 | "description": "A versão %version% está disponível para seu PC e pronta para ser instalada.",
98 | "buttons": {
99 | "install": "Instalar agora",
100 | "later": "Mais tarde"
101 | }
102 | },
103 | "buttons": {
104 | "downloadAndInstall": "Baixar e instalar",
105 | "download": "Baixar e instalar mais tarde"
106 | }
107 | },
108 | "koFi": {
109 | "title": "Requer Premium",
110 | "description": "Para usar essa funcionalidade é necessário fazer uma doação única ou possuir uma inscrição. Você pode doar aqui. Se você já fez uma doação tente reiniciar o AMRPC. Saiba mais"
111 | },
112 | "microsoftStore": {
113 | "description": "Você sabia que pode instalar o AMRPC através da Loja Microsoft? Certifique-se de desabilitar a opção Iniciar automaticamente nas configurações antes de instalar a versão da Loja Microsoft.",
114 | "buttons": {
115 | "download": "Baixar agora"
116 | }
117 | },
118 | "buttons": {
119 | "no": "Não",
120 | "later": "Agora não",
121 | "yes": "Sim",
122 | "okay": "Ok",
123 | "learnMore": "Saiba mais",
124 | "close": "Fechar"
125 | }
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/i18n/ru-RU.json:
--------------------------------------------------------------------------------
1 | {
2 | "error": {
3 | "admin": "Пожалуйста, не запускайте AMRPC с правами администратора!",
4 | "noTrack": "Песня не найдена",
5 | "jsFileExtension": "Возникла ошибка с JS файлом. Пожалуйста, нажмите на кнопку, чтобы узнать больше.",
6 | "cmd": "Возникла ошибка с Командной строкой. Пожалуйста, нажмите на кнопку ниже, чтобы узнать больше."
7 | },
8 | "tray": {
9 | "restart": "Перезапустить",
10 | "reportProblem": "Report a Problem",
11 | "quitITunes": {
12 | "info": "This takes about 3 seconds",
13 | "button": "Quit iTunes"
14 | },
15 | "quit": "Выйти"
16 | },
17 | "settings": {
18 | "config": {
19 | "autoLaunch": "Автозапуск",
20 | "autoUpdates": "Automatic Updates",
21 | "betaUpdates": "Beta Updates",
22 | "installUpdate": {
23 | "label": "Install Update",
24 | "button": "Install %version%",
25 | "buttonNA": "No update available"
26 | },
27 | "show": "Показывать активность",
28 | "hideOnPause": "Скрывать активность, когда песня на паузе",
29 | "artworkPrioLocal": "Prioritize Local Artwork",
30 | "showTimestamps": "Show Timestamps",
31 | "showAlbumArtwork": "Показывать обложку альбома",
32 | "performanceMode": "Режим производительности",
33 | "hardwareAcceleration": "Аппаратное ускорение",
34 | "checkIfMusicInstalled": "Check if Music is installed",
35 | "colorTheme": {
36 | "label": "Предпочтительная тема",
37 | "light": "Светлая",
38 | "dark": "Тёмная",
39 | "auto": "Тёмная только с вечера до утра",
40 | "os": "Системная"
41 | },
42 | "language": "Язык",
43 | "rpcLargeImageText": "Presence Large Image Text",
44 | "rpcDetails": "Вторая строка в активности",
45 | "rpcState": "Третья строка в активности",
46 | "enableLastFM": "Использовать Last.fm",
47 | "lastFMUser": {
48 | "label": "Пользователь Last.fm",
49 | "connect": "Подключить",
50 | "reconnect": "Переподключение",
51 | "connecting": "Подключение...",
52 | "cancel": "Отмена"
53 | },
54 | "enableCache": "Использовать кэш",
55 | "resetCache": "Сбросить кэш"
56 | },
57 | "common": {
58 | "reset": "Сброс"
59 | },
60 | "category": {
61 | "general": "Основные",
62 | "softwareUpdate": "Software Update",
63 | "cache": "Cache Management",
64 | "advanced": "Advanced"
65 | },
66 | "warn": {
67 | "music": {
68 | "title": "Внимание",
69 | "description": "Музыкальное приложение не найдено. Пожалуйста, убедитесь, что музыкальное приложение установлено."
70 | },
71 | "discord": {
72 | "title": "Внимание",
73 | "description": "Discord не найден. Пожалуйста, убедитесь, что Discord установлен."
74 | }
75 | },
76 | "note": {
77 | "language": "Если Вы не нашли ваш язык, свяжитесь с нами.",
78 | "artworkPrioLocal": "If local artwork is available, it will be used instead of the one from Apple Music API.",
79 | "cache": {
80 | "size": "Размер кэша: %size%, %count% файлов",
81 | "warn": "ВНИМАНИЕ Отключение кэша удалит все кэшированные данные.",
82 | "info": "В кэш сохраняются все обложки альбомов и другие данные для уменьшения запросов к Apple. Это сократит время загрузки обложек и ссылок на песню."
83 | },
84 | "checkIfMusicInstalled": "Disable this only if you're encountering issues with the checks, and you're sure that Music is installed."
85 | },
86 | "extra": {
87 | "github": "Репозиторий на GitHub",
88 | "koFi": "Пожертвовать",
89 | "reloadPage": "Перезагрузить",
90 | "restartApp": "Требуется перезагрузка"
91 | },
92 | "modal": {
93 | "newUpdate": {
94 | "title": "Обновление приложения",
95 | "description": "Версия %version% доступна для Вашего ПК.",
96 | "installed": {
97 | "description": "Версия %version% доступна для Вашего ПК и готова к установке.",
98 | "buttons": {
99 | "install": "Установить сейчас",
100 | "later": "Позже"
101 | }
102 | },
103 | "buttons": {
104 | "downloadAndInstall": "Скачать и установить",
105 | "download": "Скачать и установить позже"
106 | }
107 | },
108 | "koFi": {
109 | "title": "Требуется Премиум",
110 | "description": "Чтобы использовать эту функцию Вам нужно совершить одно пожертвование или быть платным подписчиком. Вы можете пожертвовать здесь. Если Вы уже совершили пожертвование, перезапустите AMRPC. Читать больше"
111 | },
112 | "microsoftStore": {
113 | "description": "Вы знали, что Вы можете установить AMRPC в Microsoft Store? Но не забудьте отключить Автозапуск в настройках перед установкой.",
114 | "buttons": {
115 | "download": "Скачать сейчас"
116 | }
117 | },
118 | "buttons": {
119 | "no": "Нет",
120 | "later": "Не сейчас",
121 | "yes": "Да",
122 | "okay": "Окей",
123 | "learnMore": "Читать больше",
124 | "close": "Закрыть"
125 | }
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "amrpc",
3 | "version": "4.3.0",
4 | "description": "Discord RPC for Apple Music",
5 | "scripts": {
6 | "start": "npm run copy && npm run generate:i18n-types && npm run build && electron ./dist/",
7 | "copy": "copyfiles -u 1 -a src/assets/** src/language/** src/browser/*.css src/browser/*.html src/browser/css/*.css dist/ && copyfiles package.json node_modules/** dist/",
8 | "build": "tsc && cd src/browser/renderer/ && tsc",
9 | "build:win": "npm run build && npm run copy && electron-builder --config builder/stable.json",
10 | "build:win-store": "npm run build && npm run copy && electron-builder --config builder/stable-win-store.json",
11 | "build:macos": "npm run build && npm run copy && electron-builder --config builder/stable-macos.json",
12 | "generate:i18n-types": "make_types -i @types/zephra/I18n.d.ts i18n/en-US.json I18n",
13 | "generate:i18n-types-ci": "copyfiles -u 1 -a builder/I18n.d.ts @types/zephra/"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/zephraOSS/Apple-Music-RPC.git"
18 | },
19 | "keywords": [
20 | "discord",
21 | "rpc",
22 | "iTunes",
23 | "Apple",
24 | "Music"
25 | ],
26 | "author": "zephra",
27 | "license": "GPL-3.0",
28 | "bugs": {
29 | "url": "https://github.com/zephraOSS/Apple-Music-RPC/issues"
30 | },
31 | "homepage": "https://github.com/zephraOSS/Apple-Music-RPC#readme",
32 | "dependencies": {
33 | "@crowdin/ota-client": "^0.7.0",
34 | "@sentry/electron": "^4.2.0",
35 | "apple-bridge": "^1.4.1",
36 | "auto-launch": "^5.0.5",
37 | "cheerio": "^1.0.0-rc.12",
38 | "child-process-promise": "^2.2.1",
39 | "decompress": "^4.2.1",
40 | "discord-rpc": "^4.0.1",
41 | "electron-autotheme": "^1.3.2",
42 | "electron-log": "^4.4.4",
43 | "electron-store": "^8.0.1",
44 | "electron-updater": "^5.3.0",
45 | "fetch": "^1.1.0",
46 | "form-data": "^4.0.0",
47 | "fs": "0.0.1-security",
48 | "http": "^0.0.1-security",
49 | "lastfmapi": "^0.1.1",
50 | "url": "^0.11.0",
51 | "ws": "^8.6.0"
52 | },
53 | "devDependencies": {
54 | "@types/auto-launch": "^5.0.2",
55 | "@types/cron": "^1.7.3",
56 | "@types/decompress": "^4.2.4",
57 | "@types/discord-rpc": "^4.0.2",
58 | "@types/ws": "^8.5.3",
59 | "copyfiles": "^2.4.1",
60 | "electron": "^22.3.5",
61 | "electron-builder": "^23.6.0",
62 | "maketypes": "^1.1.2",
63 | "typescript": "^4.6.3"
64 | },
65 | "overrides": {
66 | "minimatch": ">=3.0.5"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/assets/app/usaflag.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/app/usaflag.gif
--------------------------------------------------------------------------------
/src/assets/app/usaflag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/app/usaflag.png
--------------------------------------------------------------------------------
/src/assets/appx/SmallTile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/SmallTile.png
--------------------------------------------------------------------------------
/src/assets/appx/Square150x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Square150x150Logo.png
--------------------------------------------------------------------------------
/src/assets/appx/Square44x44Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Square44x44Logo.png
--------------------------------------------------------------------------------
/src/assets/appx/Square44x44Logo.targetsize-44_altform-unplated.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Square44x44Logo.targetsize-44_altform-unplated.png
--------------------------------------------------------------------------------
/src/assets/appx/StoreLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/StoreLogo.png
--------------------------------------------------------------------------------
/src/assets/appx/Wide310x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/appx/Wide310x150Logo.png
--------------------------------------------------------------------------------
/src/assets/icons/darwin/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/icons/darwin/logo.png
--------------------------------------------------------------------------------
/src/assets/icons/win32/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/icons/win32/icon.ico
--------------------------------------------------------------------------------
/src/assets/icons/win32/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/icons/win32/logo.png
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/logo.png
--------------------------------------------------------------------------------
/src/assets/statusBarIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/statusBarIcon.png
--------------------------------------------------------------------------------
/src/assets/statusBarIconError.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/statusBarIconError.png
--------------------------------------------------------------------------------
/src/assets/tray/logo@18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/tray/logo@18.png
--------------------------------------------------------------------------------
/src/assets/tray/logo@32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/tray/logo@32.png
--------------------------------------------------------------------------------
/src/assets/trayLogo@18.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/trayLogo@18.png
--------------------------------------------------------------------------------
/src/assets/trayLogo@32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zephraOSS/Apple-Music-RPC/27a3eadd980ce3e78a0ac4246f1609bb0d3fc6a6/src/assets/trayLogo@32.png
--------------------------------------------------------------------------------
/src/browser/css/wakandaForever.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #0c0b13 !important;
3 | color: #ffffff8c !important;
4 | --border-color: #664eae !important;
5 | }
6 |
7 | div.control {
8 | background-color: #252046 !important;
9 | }
10 |
11 | div.content form .settings_setting {
12 | background-color: rgb(0 0 0 / 75%) !important;
13 | }
14 |
15 | .cfgSwitch i::before,
16 | .cfgSwitch i {
17 | background-color: #1a0554 !important;
18 | }
19 |
20 | .cfgSwitch i::after {
21 | background-color: #c4a8ff !important;
22 | }
23 |
24 | .cfgSwitch input:checked + i {
25 | background-color: #664eae !important;
26 | }
27 |
--------------------------------------------------------------------------------
/src/browser/preload.ts:
--------------------------------------------------------------------------------
1 | import { contextBridge, ipcRenderer } from "electron";
2 |
3 | console.log("[BROWSER PRELOAD] Ready");
4 |
5 | contextBridge.exposeInMainWorld("electron", {
6 | appVersion: async () => {
7 | return await ipcRenderer.invoke("appVersion");
8 | },
9 | getPlatform: async () => {
10 | return await ipcRenderer.invoke("getPlatform");
11 | },
12 | isWindowsStore: async () => {
13 | return await ipcRenderer.invoke("isWindowsStore");
14 | },
15 | isDeveloper: async () => {
16 | return await ipcRenderer.invoke("isDeveloper");
17 | },
18 | isSupporter: async () => {
19 | return await ipcRenderer.invoke("isSupporter");
20 | },
21 | getLanguages: async () => {
22 | return ipcRenderer.invoke("getLanguages");
23 | },
24 | getLangStrings: () => {
25 | return ipcRenderer.invoke("getLangStrings");
26 | },
27 | getSystemTheme: () => {
28 | return ipcRenderer.invoke("getSystemTheme", {});
29 | },
30 | getTheme: () => {
31 | return ipcRenderer.invoke("getTheme", {});
32 | },
33 | getCurrentTrack: () => {
34 | return ipcRenderer.invoke("getCurrentTrack", {});
35 | },
36 | checkAppDependencies: () => {
37 | return ipcRenderer.invoke("checkAppDependencies", {});
38 | },
39 | appData: {
40 | set: (k, v) => ipcRenderer.invoke("updateAppData", k, v),
41 | get: (k) => {
42 | return ipcRenderer.invoke("getAppData", k);
43 | }
44 | },
45 | config: {
46 | set: (k, v) => ipcRenderer.invoke("updateConfig", k, v),
47 | get: (k) => {
48 | return ipcRenderer.invoke("getConfig", k);
49 | },
50 | reset: (k) => {
51 | return ipcRenderer.invoke("resetConfig", k);
52 | }
53 | },
54 | lastFM: {
55 | getUser: () => {
56 | return ipcRenderer.invoke("lastfm-getUser");
57 | },
58 | connect: (connect: boolean = true) => {
59 | return ipcRenderer.invoke("lastfm-connect", connect);
60 | }
61 | },
62 | fetchChangelog: async () => {
63 | return await ipcRenderer.invoke("fetchChangelog");
64 | },
65 | openURL: (url: string) => {
66 | if (
67 | !url.startsWith("https://") &&
68 | !url.startsWith("http://") &&
69 | !url.startsWith("mailto:")
70 | )
71 | return;
72 |
73 | ipcRenderer.invoke("openURL", url);
74 | },
75 | fetchCacheSize: () => {
76 | return ipcRenderer.invoke("fetchCacheSize");
77 | },
78 | songDataFeedback: (data) => ipcRenderer.invoke("songDataFeedback", data),
79 | resetCache: () => ipcRenderer.invoke("resetCache"),
80 | minimize: () => ipcRenderer.invoke("windowControl", "minimize"),
81 | maximize: () => ipcRenderer.invoke("windowControl", "maximize"),
82 | hide: () => ipcRenderer.invoke("windowControl", "hide"),
83 | reload: () => ipcRenderer.invoke("windowControl", "reload"),
84 | restart: () => ipcRenderer.invoke("appControl", "restart")
85 | });
86 |
87 | contextBridge.exposeInMainWorld("api", {
88 | send: (channel, data) => {
89 | const validChannels = ["update-download", "update-install"];
90 |
91 | if (validChannels.includes(channel)) ipcRenderer.send(channel, data);
92 | },
93 | receive: (channel, func) => {
94 | const validChannels = [
95 | "update-system-theme",
96 | "new-update-available",
97 | "update-download-progress-update",
98 | "update-downloaded",
99 | "get-current-track",
100 | "open-modal",
101 | "lastfm-connect",
102 | "url"
103 | ];
104 |
105 | if (validChannels.includes(channel))
106 | ipcRenderer.on(channel, (_event, ...args) => func(...args));
107 | }
108 | });
109 |
110 | ipcRenderer.invoke("isReady", true);
111 |
--------------------------------------------------------------------------------
/src/browser/renderer/api.ts:
--------------------------------------------------------------------------------
1 | import { Modal } from "./modal.js";
2 | import { updateTheme } from "./utils.js";
3 |
4 | export function init() {
5 | window.api.receive("update-system-theme", (_e, theme) => {
6 | console.log(`[BROWSER RENDERER] Changed theme to ${theme}`);
7 |
8 | updateTheme(theme);
9 | });
10 |
11 | window.api.receive("get-current-track", (data) => {
12 | if (data && data.artwork && data.playerState === "playing") {
13 | document.querySelector(".logo").src =
14 | data.artwork.replace("500x500bb", "40x40bb");
15 |
16 | document
17 | .querySelectorAll("#thumbsUp, #thumbsDown")
18 | .forEach((ele: HTMLElement) => {
19 | ele.style.display = "unset";
20 | });
21 | } else {
22 | document.querySelector(".logo").src =
23 | "../assets/logo.png";
24 |
25 | document
26 | .querySelectorAll("#thumbsUp, #thumbsDown")
27 | .forEach((ele: HTMLElement) => {
28 | ele.style.display = "none";
29 | });
30 | }
31 | });
32 |
33 | window.api.receive("open-modal", async (data) => {
34 | if (data.i18n) {
35 | const lang = await window.electron.config.get("language");
36 |
37 | if (data.i18n[lang]) {
38 | data.title = data.i18n[lang].title;
39 | data.description = data.i18n[lang].description;
40 |
41 | if (data.buttons.length > 0 && data.i18n[lang].buttons) {
42 | data.buttons.forEach((button) => {
43 | button.label = data.i18n[lang].buttons[button.label];
44 | });
45 | }
46 | }
47 | }
48 |
49 | new Modal(data.title, data.description, data.buttons);
50 | });
51 |
52 | window.api.receive("url", async (url: string) => {
53 | console.log("[BROWSER][RENDERER]", "Received URL", url);
54 |
55 | document.getElementById(url).scrollIntoView({
56 | behavior: "smooth",
57 | block: "center"
58 | });
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/src/browser/renderer/eventSettings.ts:
--------------------------------------------------------------------------------
1 | export function init() {
2 | const data = [
3 | {
4 | selector: ".settings_setting:has(input#config_wakandaForeverMode)",
5 | dates: ["YYYY-08-28", "2022-11-11"],
6 | configKey: "wakandaForeverMode",
7 | disabledConfigValue: false
8 | }
9 | ];
10 |
11 | data.forEach(({ selector, dates, configKey, disabledConfigValue }) => {
12 | const ele = document.querySelector(selector);
13 |
14 | if (ele) {
15 | const today = new Date(),
16 | todayString = `${today.getFullYear()}-${
17 | today.getMonth() + 1
18 | }-${today.getDate()}`;
19 |
20 | let notTodayCount = 0;
21 |
22 | dates.forEach(async (date) => {
23 | date = date
24 | .replace("YYYY", today.getFullYear().toString())
25 | .replace("MM", (today.getMonth() + 1).toString())
26 | .replace("DD", today.getDate().toString());
27 |
28 | if (date !== todayString) notTodayCount++;
29 | if (notTodayCount === dates.length) {
30 | ele.remove();
31 |
32 | if (
33 | (await window.electron.config.get(configKey)) !==
34 | disabledConfigValue
35 | ) {
36 | window.electron.config.set(
37 | configKey,
38 | disabledConfigValue
39 | );
40 | }
41 | }
42 | });
43 | }
44 | });
45 | }
46 |
--------------------------------------------------------------------------------
/src/browser/renderer/i18n.ts:
--------------------------------------------------------------------------------
1 | import { fetchCacheSize } from "./index.js";
2 |
3 | export class i18n {
4 | public strings;
5 |
6 | constructor() {
7 | this.updateTranslation();
8 | }
9 |
10 | public async updateTranslation() {
11 | this.strings = await window.electron.getLangStrings();
12 | }
13 |
14 | public async updateLanguage() {
15 | await this.updateTranslation();
16 |
17 | const languages: Array = await window.electron.getLanguages(),
18 | userLanguage = await window.electron.config.get("language");
19 |
20 | document.querySelector("select#config_language").innerHTML = "";
21 |
22 | languages.forEach((lang) => {
23 | const option = document.createElement("option");
24 |
25 | option.value = lang;
26 | option.innerText = lang;
27 |
28 | document.querySelector("#config_language").appendChild(option);
29 | });
30 |
31 | document.querySelector(
32 | "select#config_language"
33 | ).value = userLanguage;
34 |
35 | document
36 | .querySelectorAll(".settings_setting label")
37 | .forEach((ele: HTMLElement) => {
38 | const ls =
39 | this.strings.settings.config[
40 | ele.dataset.for?.replace("config_", "") ??
41 | ele.getAttribute("for")?.replace("config_", "")
42 | ];
43 |
44 | if (ls) {
45 | if (typeof ls === "object") ele.innerHTML = ls["label"];
46 | else ele.innerHTML = ls;
47 | }
48 | });
49 |
50 | document
51 | .querySelectorAll(
52 | ".settings_setting select:not(#config_language) option"
53 | )
54 | .forEach((ele) => {
55 | const ls =
56 | this.strings.settings.config[
57 | ele.parentElement
58 | .getAttribute("id")
59 | .replace("config_", "")
60 | ]?.[ele.getAttribute("value")];
61 |
62 | if (ls) ele.innerHTML = ls;
63 | });
64 |
65 | document
66 | .querySelectorAll(".settings_setting select#config_language option")
67 | .forEach((ele: HTMLOptionElement) => {
68 | const optionLang = ele.value.replace("_", "-"),
69 | optionLangCountry =
70 | optionLang.split("-")[1] ?? optionLang.toUpperCase(),
71 | languageNames = new Intl.DisplayNames([optionLang], {
72 | type: "language",
73 | languageDisplay: "standard"
74 | }),
75 | languageNamesEnglish = new Intl.DisplayNames(["en"], {
76 | type: "language",
77 | languageDisplay: "standard"
78 | });
79 |
80 | let nativeLang = languageNames
81 | .of(optionLang)
82 | .replace(/\((.*?)\)/, `(${optionLangCountry})`);
83 |
84 | const englishLang = languageNamesEnglish
85 | .of(optionLang)
86 | .replace(/\((.*?)\)/, `(${optionLangCountry})`);
87 |
88 | nativeLang =
89 | nativeLang.charAt(0).toUpperCase() + nativeLang.slice(1);
90 |
91 | ele.textContent =
92 | nativeLang === englishLang
93 | ? nativeLang
94 | : `${nativeLang} - ${englishLang}`;
95 | });
96 |
97 | document.querySelectorAll(".extra span").forEach((ele) => {
98 | const ls = this.strings.settings.extra[ele.parentElement.id];
99 |
100 | if (ls) ele.innerHTML = ls;
101 | });
102 |
103 | document.querySelectorAll("[data-i18n]").forEach((ele: HTMLElement) => {
104 | const key = ele.dataset.i18n,
105 | vars = ele.dataset.i18nVars ?? "";
106 |
107 | if (key) {
108 | const translation = this.getStringVar(key, vars);
109 |
110 | if (translation) ele.innerHTML = translation;
111 | else {
112 | console.error(
113 | `Element with data-i18n attribute has no translation for key "${key}"`,
114 | ele
115 | );
116 | }
117 | } else {
118 | console.error(
119 | "Element with data-i18n attribute has no key",
120 | ele
121 | );
122 | }
123 | });
124 |
125 | fetchCacheSize();
126 | }
127 |
128 | public getString(key: string) {
129 | if (!key) return;
130 |
131 | const keys = key.split(".");
132 |
133 | let value = this.strings;
134 |
135 | for (const k of keys) {
136 | value = value?.[k];
137 | }
138 |
139 | return value;
140 | }
141 |
142 | public getStringVar(key: string, vars: { [key: string]: any } | string) {
143 | let string = this.getString(key);
144 |
145 | if (!string) return;
146 |
147 | if (typeof vars === "string") vars = this.varConvert(vars);
148 |
149 | for (const k in vars) {
150 | string = string.replace(`%{${k}}`, vars[k]);
151 | }
152 |
153 | return string;
154 | }
155 |
156 | public autoGetString(ele: HTMLElement, key?: string) {
157 | if (!key) {
158 | key = (
159 | ele.dataset.for ??
160 | ele.parentElement.getAttribute("id") ??
161 | ele.getAttribute("for")
162 | )?.replace("config_", "");
163 | }
164 |
165 | const string = this.getString(key);
166 |
167 | if (!string) return;
168 |
169 | if (typeof string === "object") {
170 | ele.innerHTML = string["label"];
171 | }
172 | }
173 |
174 | /**
175 | * Convert String variables to Object
176 | * @param vars
177 | * @returns {Object}
178 | * @example varConvert("key1=value1,key2=value2")
179 | */
180 | public varConvert(vars: string) {
181 | const result: { [key: string]: any } = {};
182 |
183 | vars.split(",").forEach((v) => {
184 | const [key, value] = v.split("=");
185 |
186 | result[key] = value;
187 | });
188 |
189 | return result;
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/src/browser/renderer/index.ts:
--------------------------------------------------------------------------------
1 | import { Modal } from "./modal.js";
2 | import { i18n as i18nClass } from "./i18n.js";
3 |
4 | import { updateTheme, openURL, newNote } from "./utils.js";
5 |
6 | import { init as initAPI } from "./api.js";
7 | import { init as initLastFM } from "./lastFM.js";
8 | import { init as initUpdater } from "./updater.js";
9 | import { init as initListeners } from "./listeners.js";
10 | import { init as initEventSettings } from "./eventSettings.js";
11 |
12 | initAPI();
13 |
14 | declare global {
15 | interface Window {
16 | electron: any;
17 | api: any;
18 | }
19 | }
20 |
21 | export const restartRequiredMemory = {},
22 | i18n = new i18nClass();
23 |
24 | export let appVersion,
25 | platform,
26 | isSupporter: boolean = null,
27 | isDeveloper: boolean = null,
28 | appDependencies: {
29 | music: boolean;
30 | discord: boolean;
31 | } = null;
32 |
33 | console.log("[BROWSER][RENDERER] Loading...");
34 |
35 | initLastFM();
36 | initEventSettings();
37 | initListeners();
38 | initUpdater();
39 |
40 | updateTheme();
41 | i18n.updateLanguage();
42 |
43 | (async () => {
44 | const seenChangelogs = await window.electron.appData.get("changelog");
45 |
46 | appVersion = await window.electron.appVersion();
47 | platform = await window.electron.getPlatform();
48 | isSupporter = await window.electron.isSupporter();
49 | isDeveloper = await window.electron.isDeveloper();
50 | appDependencies = await window.electron.checkAppDependencies();
51 |
52 | document.querySelector("span#extra_version").textContent = `${
53 | isDeveloper ? "Developer" : ""
54 | } v${appVersion}`;
55 |
56 | document
57 | .querySelector("span#extra_version")
58 | .setAttribute(
59 | "onclick",
60 | `window.electron.openURL('https://github.com/ZephraCloud/Apple-Music-RPC/releases/tag/v${appVersion}')`
61 | );
62 |
63 | if (isDeveloper) {
64 | document.querySelector(
65 | ".settings_setting input#config_autoLaunch"
66 | ).disabled = true;
67 | }
68 |
69 | document
70 | .querySelectorAll("input[os], option[os]")
71 | .forEach((ele: HTMLElement) => {
72 | if (ele.getAttribute("os") !== platform)
73 | (ele.parentNode)?.remove();
74 | });
75 |
76 | if (!appDependencies.music) {
77 | newNote(
78 | "warn",
79 | i18n.strings.settings.warn.music.title,
80 | i18n.strings.settings.warn.music.description
81 | );
82 | }
83 |
84 | if (!appDependencies.discord) {
85 | newNote(
86 | "warn",
87 | i18n.strings.settings.warn.discord.title,
88 | i18n.strings.settings.warn.discord.description
89 | );
90 | }
91 |
92 | if (isSupporter) {
93 | document
94 | .querySelectorAll(".settings_setting_premium")
95 | .forEach((ele) => {
96 | const formEle = ele.querySelector("input, select");
97 |
98 | ele.classList.remove("settings_setting_premium");
99 |
100 | if (formEle) formEle.removeAttribute("disabled");
101 | });
102 | } else {
103 | document
104 | .querySelectorAll(".settings_setting_premium")
105 | .forEach((ele) => {
106 | ele.addEventListener("click", () => {
107 | new Modal(
108 | i18n.strings.settings.modal.koFi.title,
109 | i18n.strings.settings.modal.koFi.description,
110 | [
111 | {
112 | label: i18n.strings.settings.modal.buttons
113 | .later,
114 | style: "btn-grey",
115 | events: [
116 | {
117 | name: "click",
118 | type: "delete"
119 | }
120 | ]
121 | },
122 | {
123 | label: "Ko-fi",
124 | style: "btn-primary",
125 | events: [
126 | {
127 | name: "click",
128 | action: () => {
129 | openURL("https://ko-fi.com/zephra");
130 | }
131 | }
132 | ]
133 | }
134 | ]
135 | );
136 | });
137 | });
138 | }
139 |
140 | window.electron.getCurrentTrack().then((data) => {
141 | if (data && data.artwork && data.playerState === "playing") {
142 | document.querySelector(".logo").src =
143 | data.artwork.replace("500x500bb", "40x40bb");
144 | } else {
145 | document.querySelector(".logo").src =
146 | "../assets/logo.png";
147 | }
148 | });
149 |
150 | if (!seenChangelogs[appVersion]) {
151 | const changelog = await window.electron.fetchChangelog();
152 |
153 | if (changelog && appVersion === changelog.tag_name) {
154 | new Modal(
155 | `Changelog ${changelog.name}`,
156 | // @ts-ignore
157 | marked.parse(changelog.body.replace("# Changelog:\r\n", "")),
158 | [
159 | {
160 | label: i18n.strings.settings.modal.buttons.okay,
161 | style: "btn-grey",
162 | events: [
163 | {
164 | name: "onclick",
165 | value: "updateDataChangelogJS(appVersion, true)",
166 | type: "delete"
167 | }
168 | ]
169 | }
170 | ]
171 | );
172 | }
173 | }
174 | })();
175 |
176 | export function fetchCacheSize() {
177 | window.electron.fetchCacheSize().then((stats) => {
178 | const ele = document.querySelector("#cacheNoteSize"),
179 | string: string = i18n.strings.settings.note.cache.size;
180 |
181 | ele.textContent = string
182 | .replace("%size%", `${stats.fileSize.toFixed(2)} MB`)
183 | .replace("%count%", stats.size);
184 | });
185 | }
186 |
187 | setInterval(fetchCacheSize, 30000);
188 |
189 | console.log("[BROWSER][RENDERER] Loaded");
190 |
--------------------------------------------------------------------------------
/src/browser/renderer/lastFM.ts:
--------------------------------------------------------------------------------
1 | import { i18n } from "./index.js";
2 |
3 | export async function init() {
4 | const lastFMUser: {
5 | username: string;
6 | key: string;
7 | } = await window.electron.lastFM.getUser();
8 |
9 | const userBtn = document.querySelector(
10 | ".cfgButton#connectLastFM"
11 | ),
12 | toggle = document.querySelector(
13 | "#config_enableLastFM"
14 | );
15 |
16 | if (lastFMUser?.username && lastFMUser?.key)
17 | userBtn.textContent = lastFMUser.username;
18 | else if (lastFMUser?.username && !lastFMUser.key)
19 | userBtn.textContent = "Reconnect";
20 | else userBtn.textContent = "Connect";
21 |
22 | if (!(await window.electron.config.get("enableLastFM")))
23 | userBtn.classList.add("disabled");
24 |
25 | toggle.addEventListener("change", () => {
26 | userBtn.classList[toggle.checked ? "remove" : "add"]("disabled");
27 | });
28 |
29 | userBtn.addEventListener("click", () => {
30 | if (userBtn.classList.contains("disabled")) return;
31 |
32 | const strings = i18n.strings;
33 |
34 | switch (userBtn.textContent) {
35 | case strings.settings.config.lastFMUser.cancel:
36 | userBtn.textContent = lastFMUser.username;
37 |
38 | break;
39 |
40 | default:
41 | window.electron.lastFM.connect();
42 | userBtn.textContent =
43 | strings.settings.config.lastFMUser.connecting;
44 |
45 | break;
46 | }
47 | });
48 |
49 | userBtn.addEventListener("mouseover", async () => {
50 | if (userBtn.classList.contains("disabled")) return;
51 |
52 | const strings = await window.electron.getLangStrings();
53 |
54 | switch (userBtn.textContent) {
55 | case strings.settings.config.lastFMUser.connecting:
56 | userBtn.textContent = strings.settings.config.lastFMUser.cancel;
57 |
58 | break;
59 |
60 | case lastFMUser.username:
61 | userBtn.textContent =
62 | strings.settings.config.lastFMUser.reconnect;
63 |
64 | break;
65 | }
66 | });
67 |
68 | userBtn.addEventListener("mouseout", async () => {
69 | if (userBtn.classList.contains("disabled")) return;
70 |
71 | const strings = await window.electron.getLangStrings();
72 |
73 | switch (userBtn.textContent) {
74 | case strings.settings.config.lastFMUser.cancel:
75 | userBtn.textContent =
76 | strings.settings.config.lastFMUser.connecting;
77 |
78 | break;
79 |
80 | case strings.settings.config.lastFMUser.reconnect:
81 | userBtn.textContent = lastFMUser.username;
82 |
83 | break;
84 | }
85 | });
86 |
87 | window.api.receive(
88 | "lastfm-connect",
89 | (session: { username: string; key: string }) => {
90 | userBtn.textContent = session.username;
91 |
92 | lastFMUser.username = session.username;
93 | lastFMUser.key = session.key;
94 | }
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/browser/renderer/listeners.ts:
--------------------------------------------------------------------------------
1 | import {
2 | fetchCacheSize,
3 | platform,
4 | restartRequiredMemory,
5 | i18n
6 | } from "./index.js";
7 | import { updateTheme } from "./utils.js";
8 |
9 | export function init() {
10 | window.addEventListener("offline", () => {
11 | document.body.classList.add("offline");
12 | });
13 |
14 | window.addEventListener("online", () => {
15 | document.body.classList.remove("offline");
16 | });
17 |
18 | document
19 | .querySelector("span.dot.minimize")
20 | ?.addEventListener("click", window.electron.minimize);
21 |
22 | document
23 | .querySelector("span.dot.maximize")
24 | ?.addEventListener("click", window.electron.maximize);
25 |
26 | document
27 | .querySelector("span.dot.close")
28 | ?.addEventListener("click", window.electron.hide);
29 |
30 | document.body.addEventListener("click", (e) => {
31 | if (e.target instanceof HTMLAnchorElement && e.target.href) {
32 | e.preventDefault();
33 | window.electron.openURL(e.target.href);
34 | }
35 | });
36 |
37 | document.querySelectorAll("#thumbsUp, #thumbsDown").forEach((ele) => {
38 | ele.addEventListener("click", (e) => {
39 | window.electron.songDataFeedback(
40 | (e.target).id === "thumbsUp"
41 | );
42 | });
43 | });
44 |
45 | document
46 | .querySelectorAll(".settings_setting input, select")
47 | .forEach(async (ele: HTMLInputElement | HTMLSelectElement) => {
48 | const configKey = ele.id.replace("config_", ""),
49 | eleTag = ele.tagName.toLowerCase(),
50 | eleValue = (value?: string | boolean): string | boolean => {
51 | if (value) {
52 | if (eleTag === "input" && ele.type === "checkbox") {
53 | (ele as HTMLInputElement).checked =
54 | value as boolean;
55 | } else ele.value = value.toString();
56 |
57 | return;
58 | }
59 |
60 | if (eleTag === "input" && ele.type === "checkbox")
61 | return (ele as HTMLInputElement).checked;
62 | else return ele.value;
63 | };
64 |
65 | if (ele.type === "text") {
66 | let timeout;
67 |
68 | ele.addEventListener("keyup", () => {
69 | clearTimeout(timeout);
70 |
71 | timeout = setTimeout(() => {
72 | window.electron.config.set(configKey, eleValue());
73 | }, 1500);
74 | });
75 | } else {
76 | ele.addEventListener("change", async () => {
77 | const value = eleValue(),
78 | configKey = ele.id.replace("config_", "");
79 |
80 | if (ele.dataset.restart === "true")
81 | checkRestartRequired(value, ele.id);
82 |
83 | window.electron.config.set(configKey, value);
84 |
85 | valueChangeEvents(ele);
86 | });
87 | }
88 |
89 | const configValue = await window.electron.config.get(configKey);
90 |
91 | if (configValue?.toString()) {
92 | if (ele.id !== "config_language") eleValue(configValue);
93 |
94 | ele.classList.remove("cfg_loading");
95 | ele.parentElement.classList.remove("cfg_loading");
96 |
97 | if (ele.dataset.restart === "true")
98 | restartRequiredMemory[ele.id] = configValue.toString();
99 |
100 | if (ele.id === "config_wakandaForeverMode" && configValue) {
101 | const ele: HTMLLinkElement = document.createElement("link");
102 |
103 | ele.rel = "stylesheet";
104 | ele.href = "css/wakandaForever.css";
105 |
106 | document.head.appendChild(ele);
107 | }
108 | }
109 | });
110 |
111 | document
112 | .querySelectorAll(".settings_setting button")
113 | .forEach(async (button: HTMLButtonElement) => {
114 | button.addEventListener("click", async (e) => {
115 | e.preventDefault();
116 |
117 | if (
118 | !button.dataset.action ||
119 | button.classList.contains("disabled")
120 | )
121 | return;
122 |
123 | const Await = button.dataset.await === "true",
124 | func = button.dataset.action
125 | .split(".")
126 | .reduce((o, i) => o[i], window.electron);
127 |
128 | if (!Await) func();
129 | else {
130 | const innerText = button.innerText;
131 |
132 | button.innerHTML = ``;
133 |
134 | await window.electron[button.dataset.action]();
135 |
136 | button.innerText = innerText;
137 | }
138 |
139 | if (button.dataset.action === "resetCache") fetchCacheSize();
140 | });
141 | });
142 |
143 | document
144 | .querySelectorAll(".settings_setting[data-enableReset='true']")
145 | .forEach((ele) => {
146 | const formField: HTMLInputElement | HTMLSelectElement =
147 | ele.querySelector("input, select"),
148 | label = ele.querySelector("label:first-of-type");
149 |
150 | const resetButton = document.createElement("span");
151 |
152 | resetButton.classList.add("resetButton");
153 | resetButton.innerHTML = ``;
154 |
155 | resetButton.addEventListener("click", async () => {
156 | const configKey = label
157 | .getAttribute("for")
158 | .replace("config_", "");
159 |
160 | formField.value = await window.electron.config.reset(configKey);
161 | });
162 |
163 | ele.querySelector(".setting_main").appendChild(resetButton);
164 | });
165 |
166 | document
167 | .querySelectorAll(".settings_category[data-restriction-os]")
168 | .forEach(async (ele: HTMLDivElement) => {
169 | const restrictOS = ele.dataset.restrictionOs.split(","),
170 | os = platform ?? (await window.electron.getPlatform());
171 |
172 | console.log(
173 | `OS Restriction for category "${
174 | ele.querySelector("label[for]")?.getAttribute("for") ??
175 | ele.id ??
176 | "Unknown"
177 | }": ${restrictOS.join(", ")} | Current OS: ${os}™`
178 | );
179 |
180 | if (!restrictOS.includes(os)) ele.remove();
181 | });
182 |
183 | document
184 | .querySelectorAll(".settings_setting[data-restriction-os]")
185 | .forEach(async (ele: HTMLDivElement) => {
186 | const restrictOS = ele.dataset.restrictionOs.split(","),
187 | os = platform ?? (await window.electron.getPlatform()),
188 | removeEle = ele.hasAttribute("data-restriction-os-remove");
189 |
190 | console.log(
191 | `OS Restriction for setting "${
192 | ele.querySelector("label[for]")?.getAttribute("for") ??
193 | ele.id ??
194 | "Unknown"
195 | }": ${restrictOS.join(", ")} | Current OS: ${os}™`
196 | );
197 |
198 | if (!restrictOS.includes(os)) {
199 | if (removeEle) ele.remove();
200 | else {
201 | ele.querySelector(
202 | "label.cfgSwitch, select, input"
203 | ).classList.add("cfg_loading");
204 | }
205 | }
206 | });
207 |
208 | document
209 | .querySelectorAll(".settings_category[data-restriction-store]")
210 | .forEach(async (ele: HTMLDivElement) => {
211 | if (await window.electron.isWindowsStore()) ele.remove();
212 | });
213 |
214 | document
215 | .querySelectorAll(
216 | ".settings_category, .settings_setting input, .settings_setting select"
217 | )
218 | .forEach(checkRestrictionSetting);
219 | }
220 |
221 | async function checkRestartRequired(
222 | value: string | boolean,
223 | id: string
224 | ): Promise {
225 | const restartAppSpan =
226 | document.querySelector("span#restartApp"),
227 | reloadAppSpan =
228 | document.querySelector("span#reloadPage"),
229 | isSame = value.toString() === restartRequiredMemory[id];
230 |
231 | restartAppSpan.style["display"] = isSame ? "none" : "inline";
232 | reloadAppSpan.style["display"] = isSame ? "inline" : "none";
233 | }
234 |
235 | function valueChangeEvents(ele): void {
236 | if (ele.id === "config_colorTheme") updateTheme();
237 | else if (ele.id === "config_language") i18n.updateLanguage();
238 | else if (ele.id === "config_autoLaunch")
239 | window.api.send("autolaunch-change", {});
240 | else if (ele.id === "config_wakandaForeverMode") {
241 | if (ele.checked) {
242 | const ele: HTMLLinkElement = document.createElement("link");
243 |
244 | ele.rel = "stylesheet";
245 | ele.href = "css/wakandaForever.css";
246 |
247 | document.head.appendChild(ele);
248 | } else {
249 | document
250 | .querySelector(
251 | "link[href='css/wakandaForever.css']"
252 | )
253 | ?.remove();
254 | }
255 | }
256 |
257 | checkRestrictionSetting(ele);
258 | }
259 |
260 | function checkRestrictionSetting(ele: HTMLElement) {
261 | console.log(ele);
262 |
263 | document
264 | .querySelectorAll(".settings_setting[data-restriction-setting]")
265 | .forEach(async (eleQS: HTMLDivElement) => {
266 | const restrictSetting = eleQS.dataset.restrictionSetting,
267 | restrictValue = eleQS.dataset.restrictionSettingValue ?? "true";
268 |
269 | if (!restrictSetting) return;
270 |
271 | if (ele.id === `config_${restrictSetting}`) {
272 | const configValue = await window.electron.config.get(
273 | restrictSetting
274 | );
275 |
276 | eleQS.classList[
277 | configValue !== restrictValue ? "add" : "remove"
278 | ]("cfg_loading");
279 | }
280 | });
281 |
282 | document
283 | .querySelectorAll(".settings_category[data-restriction-setting]")
284 | .forEach(async (eleQS: HTMLDivElement) => {
285 | const restrictSetting = eleQS.dataset.restrictionSetting;
286 |
287 | if (!restrictSetting) return;
288 |
289 | if (ele.id === `config_${restrictSetting}`) {
290 | const configValue = await window.electron.config.get(
291 | restrictSetting
292 | );
293 |
294 | eleQS.style.display = configValue ? "block" : "none";
295 | }
296 | });
297 | }
298 |
--------------------------------------------------------------------------------
/src/browser/renderer/modal.ts:
--------------------------------------------------------------------------------
1 | import { generateEleId, openURL } from "./utils.js";
2 |
3 | export class Modal {
4 | private readonly title: string;
5 | private readonly description: string;
6 | // @ts-ignore
7 | private buttons: ModalButton[];
8 |
9 | public modalId: string;
10 |
11 | // @ts-ignore
12 | constructor(title: string, description: string, buttons: ModalButton[]) {
13 | const ELE = {
14 | modal: document.createElement("div"),
15 | title: document.createElement("h1"),
16 | description: document.createElement("p"),
17 | body: document.body
18 | };
19 |
20 | this.title = title;
21 | this.description = description;
22 | this.buttons = buttons || [];
23 |
24 | ELE.body.appendChild(ELE.modal);
25 | ELE.modal.appendChild(ELE.title);
26 | ELE.modal.appendChild(ELE.description);
27 |
28 | ELE.modal.classList.add("modal");
29 | ELE.title.classList.add("title");
30 | ELE.description.classList.add("description");
31 |
32 | ELE.title.innerHTML = this.title;
33 | ELE.description.innerHTML = this.description;
34 |
35 | ELE.modal.id = this.modalId = generateEleId();
36 |
37 | for (let i = 0; i < this.buttons.length; i++) {
38 | if (i > 2) return;
39 | const btn = buttons[i],
40 | ele = document.createElement("p");
41 |
42 | ele.classList.add("btn");
43 | ele.classList.add(btn.style);
44 |
45 | if (i === 2) ele.classList.add("btn-last");
46 |
47 | ele.innerHTML = btn.label;
48 |
49 | if (btn.events) {
50 | for (let i2 = 0; i2 < buttons[i].events.length; i2++) {
51 | const event = buttons[i].events[i2];
52 |
53 | if (event.value) ele.setAttribute(event.name, event.value);
54 | if (event.type === "close" || event.type === "delete") {
55 | ele.addEventListener(event.name, () => {
56 | this[event.type]();
57 | if (event.save)
58 | window.electron.appData.set(event.save, true);
59 | });
60 | } else if (event.action)
61 | ele.addEventListener(event.name, () => event.action());
62 | }
63 | }
64 |
65 | ELE.modal.appendChild(ele);
66 | }
67 |
68 | document
69 | .querySelectorAll(".modal a")
70 | .forEach((element: HTMLAnchorElement) => {
71 | element.addEventListener("click", function (e) {
72 | e.preventDefault();
73 | openURL(element.href);
74 |
75 | return false;
76 | });
77 | });
78 |
79 | if (ELE.body.classList.contains("modalIsOpen")) {
80 | ELE.modal.style.display = "none";
81 | ELE.modal.classList.add("awaiting");
82 | } else ELE.body.classList.add("modalIsOpen");
83 | }
84 |
85 | close() {
86 | Modal.close(this.modalId);
87 | }
88 |
89 | open() {
90 | Modal.open(this.modalId);
91 | }
92 |
93 | delete() {
94 | Modal.delete(this.modalId);
95 | }
96 |
97 | static close(id: string) {
98 | const ele = document.querySelector(`div.modal#${id}`);
99 |
100 | ele.style.display = "none";
101 |
102 | document.body.classList.remove("modalIsOpen");
103 |
104 | checkForAwaitingModal();
105 | }
106 |
107 | static open(id: string) {
108 | const ele = document.querySelector(`div.modal#${id}`);
109 |
110 | ele.style.display = "block";
111 | ele.classList.remove("awaiting");
112 |
113 | document.body.classList.add("modalIsOpen");
114 | }
115 |
116 | static delete(id: string) {
117 | const ele = document.querySelector(`div.modal#${id}`);
118 |
119 | ele.remove();
120 |
121 | document.body.classList.remove("modalIsOpen");
122 |
123 | checkForAwaitingModal();
124 | }
125 | }
126 |
127 | function checkForAwaitingModal() {
128 | if (document.querySelector("div.modal.awaiting"))
129 | Modal.open(document.querySelector("div.modal.awaiting").id);
130 | }
131 |
--------------------------------------------------------------------------------
/src/browser/renderer/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "target": "es2015",
5 | "module": "es2015",
6 | "moduleResolution": "node"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/browser/renderer/updater.ts:
--------------------------------------------------------------------------------
1 | import { i18n } from "./index.js";
2 | import { Modal } from "./modal.js";
3 |
4 | export function init() {
5 | const installUpdateBtn =
6 | document.querySelector("#installUpdate");
7 |
8 | installUpdateBtn.addEventListener("click", () => {
9 | if (isButtonActive()) window.api.send("update-install");
10 | });
11 |
12 | window.api.receive("new-update-available", (data) => {
13 | console.log("[BROWSER RENDERER] New update available", data);
14 |
15 | new Modal(
16 | i18n.strings.settings.modal["newUpdate"].title,
17 | i18n.strings.settings.modal["newUpdate"].description.replace(
18 | "%version%",
19 | data.version
20 | ),
21 | [
22 | {
23 | label: i18n.strings.settings.modal["newUpdate"].buttons
24 | .downloadAndInstall,
25 | style: "btn-grey",
26 | events: [
27 | {
28 | name: "onclick",
29 | value: "window.api.send('update-download', true)"
30 | },
31 | {
32 | name: "click",
33 | type: "delete"
34 | }
35 | ]
36 | },
37 | {
38 | label: i18n.strings.settings.modal["newUpdate"].buttons
39 | .download,
40 | style: "btn-grey",
41 | events: [
42 | {
43 | name: "onclick",
44 | value: "window.api.send('update-download', false)"
45 | },
46 | {
47 | name: "click",
48 | type: "delete"
49 | }
50 | ]
51 | },
52 | {
53 | label: i18n.strings.settings.modal.buttons.later,
54 | style: "btn-grey",
55 | events: [
56 | {
57 | name: "click",
58 | type: "delete"
59 | }
60 | ]
61 | }
62 | ]
63 | );
64 | });
65 |
66 | window.api.receive("update-download-progress-update", (_e, data) => {
67 | document.querySelector(
68 | "span#download-progress"
69 | ).style.display = data.percent === 100 ? "none" : "inline-block";
70 |
71 | installUpdateBtn.textContent = data.percent;
72 | });
73 |
74 | window.api.receive("update-downloaded", (_e, data) => {
75 | installUpdateBtn.setAttribute(
76 | "data-i18n-vars",
77 | `version=${data.version}`
78 | );
79 | installUpdateBtn.classList.remove("cfg_loading");
80 |
81 | installUpdateBtn.textContent = i18n.getStringVar(
82 | "settings.config.installUpdate.button",
83 | installUpdateBtn.getAttribute("data-i18n-vars")
84 | );
85 |
86 | new Modal(
87 | i18n.strings.settings.modal["newUpdate"].title,
88 | i18n.strings.settings.modal[
89 | "newUpdate"
90 | ].installed.description.replace("%version%", data.version),
91 | [
92 | {
93 | label: i18n.strings.settings.modal["newUpdate"].installed
94 | .buttons.install,
95 | style: "btn-grey",
96 | events: [
97 | {
98 | name: "onclick",
99 | value: "window.api.send('update-install', {})"
100 | },
101 | {
102 | name: "click",
103 | type: "delete"
104 | }
105 | ]
106 | },
107 | {
108 | label: i18n.strings.settings.modal["newUpdate"].installed
109 | .buttons.later,
110 | style: "btn-grey",
111 | events: [
112 | {
113 | name: "click",
114 | type: "delete"
115 | }
116 | ]
117 | }
118 | ]
119 | );
120 | });
121 |
122 | function isButtonActive() {
123 | return !installUpdateBtn.classList.contains("cfg_loading");
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/browser/renderer/utils.ts:
--------------------------------------------------------------------------------
1 | export function openURL(url) {
2 | if (url) window.electron.openURL(url);
3 | }
4 |
5 | export function generateEleId() {
6 | const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7 |
8 | let result = "";
9 |
10 | for (let i = 0; i < characters.length; i++)
11 | result += characters.charAt(
12 | Math.floor(Math.random() * characters.length)
13 | );
14 |
15 | return result;
16 | }
17 |
18 | export async function updateTheme(theme?: string) {
19 | if (!theme) theme = await window.electron.getTheme();
20 |
21 | document.querySelector("body").setAttribute("data-theme", theme);
22 | }
23 |
24 | export function newNote(
25 | type: string,
26 | titleText: string,
27 | descriptionText: string
28 | ) {
29 | const note = document.createElement("div"),
30 | title: HTMLHeadingElement = document.createElement("h3"),
31 | description: HTMLParagraphElement = document.createElement("p");
32 |
33 | note.classList.add("note");
34 | note.classList.add(`note-${type}`);
35 | title.classList.add("noteTitle");
36 | description.classList.add("noteDescription");
37 |
38 | title.innerText = titleText;
39 | description.innerText = descriptionText;
40 |
41 | note.appendChild(title);
42 | note.appendChild(description);
43 | document.querySelector(".notes").appendChild(note);
44 | }
45 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { app, nativeTheme } from "electron";
2 |
3 | import { appData, config } from "./managers/store";
4 |
5 | import { TrayManager } from "./managers/tray";
6 | import { ModalWatcher } from "./managers/modal";
7 | import { Bridge } from "./managers/bridge";
8 | import { WatchDog } from "./managers/watchdog";
9 | import { Browser } from "./managers/browser";
10 | import { LastFM } from "./managers/lastFM";
11 | import { Updater } from "./managers/updater";
12 |
13 | import { init as initSentry } from "./managers/sentry";
14 | import { init as initAutoLaunch } from "./managers/launch";
15 | import { init as initTheme } from "./utils/theme";
16 | import { init as initMsStoreModal } from "./utils/msStoreModal";
17 | import { init as initCrowdin } from "./utils/crowdin";
18 | import { init as initProtocol } from "./utils/protocol";
19 |
20 | import { checkAppDependency } from "./utils/checkAppDependency";
21 | import { WatchDogInstaller } from "./utils/watchdog";
22 |
23 | import * as log from "electron-log";
24 |
25 | export const isBeta = app.getVersion().includes("beta"),
26 | isRC = app.getVersion().includes("rc");
27 |
28 | export let trayManager: TrayManager;
29 | export let modalWatcher: ModalWatcher;
30 | export let appDependencies: AppDependencies;
31 | export let lastFM: LastFM;
32 | export let bridge: Bridge;
33 | export let watchDog: WatchDog;
34 | export let updater: Updater;
35 |
36 | Object.assign(console, log.functions);
37 |
38 | if (!app.isPackaged) log.transports.file.fileName = "development.log";
39 | else if (isBeta) {
40 | log.transports.file.fileName = `beta-${app
41 | .getVersion()
42 | .replace(/\./g, "_")}.log`;
43 | }
44 | if (!app.requestSingleInstanceLock()) app.quit();
45 |
46 | log.info(
47 | "------------------------------------",
48 | `STARTING - ${app.getVersion()}`,
49 | "------------------------------------"
50 | );
51 |
52 | if (isBeta) {
53 | log.info("[STARTUP]", "Detected beta build. Enabling beta updates");
54 | config.set("betaUpdates", true);
55 | }
56 |
57 | if (isRC) log.info("[STARTUP]", "Detected release candidate build");
58 | if (process.windowsStore) log.info("[STARTUP]", "Detected Windows Store build");
59 |
60 | initSentry();
61 | initProtocol();
62 |
63 | appData.set("installUpdate", false);
64 |
65 | if (process.platform !== "win32") {
66 | config.set("autoUpdates", false);
67 | config.set("betaUpdates", false);
68 | }
69 |
70 | app.on("ready", async () => {
71 | await initCrowdin().catch((err) => log.error("[READY][initCrowdin]", err));
72 |
73 | trayManager = new TrayManager();
74 | modalWatcher = new ModalWatcher();
75 | updater = new Updater();
76 | appDependencies = await checkAppDependency();
77 |
78 | if (
79 | config.get("enableLastFM") &&
80 | config.get("lastFM.username") &&
81 | config.get("lastFM.key")
82 | )
83 | lastFM = new LastFM();
84 |
85 | initTheme();
86 | initAutoLaunch();
87 | initMsStoreModal();
88 |
89 | if (config.get("service") === "music") {
90 | watchDog = new WatchDog();
91 |
92 | if (config.get("watchdog.autoUpdates")) WatchDogInstaller(true);
93 |
94 | // Every hour
95 | setInterval(() => {
96 | if (config.get("watchdog.autoUpdates")) WatchDogInstaller(true);
97 | }, 1000 * 60 * 60);
98 | }
99 |
100 | if (appDependencies.music && appDependencies.discord) bridge = new Bridge();
101 | else Browser.windowAction("show");
102 |
103 | nativeTheme.on("updated", () => {
104 | log.info(
105 | `[Backend] Theme changed to ${
106 | nativeTheme.shouldUseDarkColors ? "dark" : "light"
107 | }`
108 | );
109 |
110 | if (config.get("colorTheme") === "os") {
111 | Browser.send("update-system-theme", false, {
112 | theme: nativeTheme.shouldUseDarkColors ? "dark" : "light"
113 | });
114 | }
115 | });
116 | });
117 |
118 | export function setLastFM(connect: boolean) {
119 | lastFM = new LastFM(connect);
120 | }
121 |
--------------------------------------------------------------------------------
/src/managers/SongData.ts:
--------------------------------------------------------------------------------
1 | import { cache, config } from "./store";
2 | import { apiRequest } from "../utils/apiRequest";
3 |
4 | export class SongData {
5 | public history: SongDataT[] = [];
6 |
7 | public getSongData(
8 | title: string,
9 | album: string,
10 | artist: string
11 | ): Promise {
12 | return new Promise((resolve, reject) => {
13 | if (!title || !album || !artist || title.includes("Connecting…"))
14 | return resolve(null);
15 |
16 | const reqParam = encodeURIComponent(`${title} ${album} ${artist}`)
17 | .replace(/"/g, "%27")
18 | .replace(/"/g, "%22"),
19 | cacheItem = cache.get(
20 | `${title}_:_${album}_:_${artist}`
21 | ) as SongDataT;
22 |
23 | if (cacheItem) return resolve(cacheItem);
24 |
25 | apiRequest(
26 | `search?term=${reqParam}&entity=musicTrack`,
27 | "https://itunes.apple.com/"
28 | ).then((r) => {
29 | if (!r || !r.results?.[0]) return reject("not_found");
30 |
31 | const res = r.results[0],
32 | data: SongDataT = {
33 | url: res.trackViewUrl,
34 | collectionId: res.collectionId,
35 | trackId: res.trackId,
36 | explicit: !res.notExplicit,
37 | artwork: res.artworkUrl100.replace(
38 | "100x100bb",
39 | "500x500bb"
40 | )
41 | };
42 |
43 | if (config.get("enableCache"))
44 | cache.set(`${title}_:_${album}_:_${artist}`, data);
45 |
46 | this.history.push(data);
47 |
48 | resolve(data);
49 | });
50 | });
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/managers/browser.ts:
--------------------------------------------------------------------------------
1 | import { app, BrowserWindow } from "electron";
2 | import { getConfig, setConfig, config } from "./store";
3 | import { init as initIPC } from "./ipc";
4 | import { exec } from "child_process";
5 |
6 | import * as path from "path";
7 | import * as log from "electron-log";
8 |
9 | export class Browser {
10 | private window: BrowserWindow;
11 |
12 | public isReady: boolean = false;
13 |
14 | static awaitsSend: { channel: string; args: any[] }[] = [];
15 | static instance: Browser;
16 |
17 | constructor(url: string = "") {
18 | if (Browser.instance) {
19 | Browser.windowAction("show");
20 | return;
21 | }
22 |
23 | initIPC();
24 |
25 | this.initWindow(undefined, url);
26 |
27 | Browser.instance = this;
28 | }
29 |
30 | initWindow(show: boolean = false, url: string = "") {
31 | const windowState = getConfig("windowState");
32 |
33 | this.window = new BrowserWindow({
34 | x: windowState?.x,
35 | y: windowState?.y,
36 | webPreferences: {
37 | preload: path.join(app.getAppPath(), "browser/preload.js")
38 | },
39 | icon: path.join(app.getAppPath(), "assets/logo.png"),
40 | frame: false,
41 | resizable: false
42 | });
43 |
44 | this.window.loadFile(path.join(app.getAppPath(), "browser/index.html"));
45 |
46 | ["moved", "close"].forEach((event: any) => {
47 | this.window.on(event, Browser.saveWindowState);
48 | });
49 |
50 | this.window.on("closed", () => {
51 | this.window = null;
52 |
53 | if (process.platform === "darwin") app.dock.hide();
54 | });
55 |
56 | if (process.platform === "darwin") {
57 | this.window.on("hide", () => {
58 | const isVisible =
59 | process.platform === "darwin"
60 | ? this.window.isVisibleOnAllWorkspaces()
61 | : this.window.isVisible();
62 |
63 | if (
64 | !this.window.isMinimized() ||
65 | (!isVisible && !this.window.isFocusable())
66 | )
67 | app.dock.hide();
68 | });
69 |
70 | this.window.on("show", app.dock.show);
71 | }
72 |
73 | if (show || url) this.window.show();
74 | if (url) {
75 | this.window.webContents.on("did-finish-load", () => {
76 | this.window.webContents.send("url", url);
77 | });
78 | }
79 | }
80 |
81 | async windowAction(action: string) {
82 | switch (action) {
83 | case "show":
84 | if (this.window) this.window.show();
85 | else this.initWindow(true);
86 |
87 | this.checkAwaits();
88 |
89 | break;
90 | case "hide":
91 | this.window?.hide();
92 |
93 | break;
94 | case "close":
95 | this.window?.close();
96 |
97 | break;
98 | case "minimize":
99 | this.window?.minimize();
100 |
101 | break;
102 | case "maximize":
103 | this.window?.maximize();
104 |
105 | break;
106 | case "restore":
107 | this.window?.restore();
108 |
109 | break;
110 | case "reload":
111 | if (!app.isPackaged) await this.copyBrowserFiles();
112 | this.window?.reload();
113 |
114 | break;
115 | }
116 | }
117 |
118 | saveWindowState() {
119 | if (!this.window) return;
120 |
121 | setConfig("windowState", this.window.getBounds());
122 | }
123 |
124 | send(channel: string, ...args: any[]) {
125 | if (!this.window || !this.window.webContents) return;
126 |
127 | setTimeout(
128 | () => this.window.webContents.send(channel, ...args),
129 | this.isReady ? 200 : 2500
130 | );
131 | }
132 |
133 | checkAwaits() {
134 | if (Browser.awaitsSend.length > 0 && this.window?.isVisible()) {
135 | Browser.awaitsSend.forEach((data) => {
136 | this.send(data.channel, ...data.args);
137 | });
138 | }
139 | }
140 |
141 | async copyBrowserFiles() {
142 | if (app.isPackaged) return;
143 |
144 | return new Promise(function (resolve) {
145 | const execute = exec(
146 | "npm run copy && cd src/browser/renderer/ && tsc",
147 | (error, _stdout, stderr) => {
148 | if (error)
149 | return log.error(
150 | "[Browser][copyBrowserFiles][exec] Error",
151 | error.message
152 | );
153 | if (stderr)
154 | return log.error(
155 | "[Browser][copyBrowserFiles][exec] Stderr",
156 | stderr
157 | );
158 | }
159 | );
160 |
161 | execute.addListener("error", resolve);
162 | execute.addListener("exit", resolve);
163 | });
164 | }
165 |
166 | static getInstance(): Browser {
167 | if (!Browser.instance) Browser.instance = new Browser();
168 |
169 | return Browser.instance;
170 | }
171 |
172 | static windowAction(action: string) {
173 | Browser.getInstance().windowAction(action);
174 | }
175 |
176 | static saveWindowState() {
177 | Browser.getInstance().saveWindowState();
178 | }
179 |
180 | static send(channel: string, create: boolean = false, ...args: any[]) {
181 | if (create) {
182 | Browser.awaitsSend.push({ channel, args });
183 |
184 | Browser.getInstance();
185 | } else if (Browser.instance && Browser.instance.isReady) {
186 | Browser.getInstance().send(channel, ...args);
187 | } else {
188 | Browser.awaitsSend.push({ channel, args });
189 | }
190 | }
191 |
192 | static setTheme(theme: string) {
193 | if (config.get("colorTheme") === "auto")
194 | Browser.send("update-system-theme", false, theme);
195 | }
196 |
197 | static setReady(ready: boolean = true) {
198 | Browser.getInstance().isReady = ready;
199 | Browser.getInstance().checkAwaits();
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/src/managers/discord.ts:
--------------------------------------------------------------------------------
1 | import { Client, Presence, register, User } from "discord-rpc";
2 | import { app } from "electron";
3 |
4 | import { appDependencies, trayManager } from "../index";
5 |
6 | import { config, getConfig, setConfig } from "./store";
7 | import { Browser } from "./browser";
8 | import { Bridge } from "./bridge";
9 | import { SongData } from "./SongData";
10 |
11 | import { checkSupporter } from "../utils/checkSupporter";
12 | import { songDataSearchStation } from "../utils/advancedSongDataSearch";
13 | import { getLibrarySongArtwork } from "../utils/getLibrarySongArtwork";
14 | import { replaceVariables } from "../utils/replaceVariables";
15 |
16 | import * as log from "electron-log";
17 |
18 | export class Discord {
19 | private client: Client;
20 | private isReady: boolean = false;
21 | private startUp: boolean = true;
22 | private defaultLIT: string = `AMRPC - ${
23 | app.isPackaged ? app.getVersion() : "Development"
24 | }`;
25 | private triggerAfterReady: (() => void)[] = [];
26 |
27 | public activity: Presence = {};
28 | public isLive: boolean = false;
29 | public isConnected: boolean = false;
30 | public currentTrack: currentTrack;
31 | public isSupporter: boolean = null;
32 | public songData: SongData = new SongData();
33 |
34 | static instance: Discord;
35 |
36 | constructor() {
37 | if (!appDependencies.discord) return;
38 |
39 | this.connect();
40 |
41 | Discord.instance = this;
42 |
43 | [
44 | "rpcDetails",
45 | "rpcState",
46 | "rpcLargeImageText",
47 | "show",
48 | "showAlbumArtwork",
49 | "showTimestamps"
50 | ].forEach((key) => {
51 | // @ts-ignore
52 | config.onDidChange(key, () => configChange(key));
53 | });
54 |
55 | async function configChange(type: string) {
56 | if (
57 | Discord.instance.currentTrack &&
58 | Object.keys(Discord.instance.currentTrack).length > 0 &&
59 | !Discord.instance.isLive
60 | ) {
61 | if (type.startsWith("rpc")) {
62 | const discordType = type.replace("rpc", ""),
63 | varResult = new replaceVariables(
64 | Discord.instance.currentTrack
65 | ).getResult(discordType);
66 |
67 | Discord.instance.activity[
68 | discordType.charAt(0).toLowerCase() +
69 | discordType.slice(1)
70 | ] = varResult;
71 |
72 | log.info(
73 | "[DISCORD][configChange]",
74 | `${discordType}: ${varResult}`
75 | );
76 |
77 | Discord.setActivity(Discord.instance.activity);
78 | } else if (type === "show") {
79 | if (
80 | config.get("show") &&
81 | Discord.instance.currentTrack &&
82 | Discord.instance.activity
83 | ) {
84 | Discord.setActivity(Discord.instance.activity);
85 | } else Discord.clearActivity();
86 | } else if (type === "showAlbumArtwork") {
87 | Discord.instance.activity.largeImageKey =
88 | config.get("showAlbumArtwork") &&
89 | Discord.instance.currentTrack?.artwork
90 | ? Discord.instance.currentTrack.artwork
91 | : config.get("artwork");
92 |
93 | log.info(
94 | "[DISCORD][configChange]",
95 | `showAlbumArtwork: ${config.get("showAlbumArtwork")}`
96 | );
97 |
98 | Discord.setActivity(Discord.instance.activity);
99 | } else if (type === "showTimestamps") {
100 | if (config.get("showTimestamps")) {
101 | const currentTrack = await Bridge.fetchMusic();
102 |
103 | if (
104 | !currentTrack ||
105 | Object.keys(currentTrack).length === 0
106 | )
107 | return;
108 |
109 | Discord.instance.activity.endTimestamp =
110 | Math.floor(Date.now() / 1000) -
111 | currentTrack.elapsedTime +
112 | currentTrack.duration;
113 | } else delete Discord.instance.activity.endTimestamp;
114 |
115 | log.info(
116 | "[DISCORD][configChange]",
117 | `showTimestamps: ${config.get("showTimestamps")}`
118 | );
119 |
120 | Discord.setActivity(Discord.instance.activity);
121 | }
122 | }
123 | }
124 | }
125 |
126 | connect() {
127 | this.client = new Client({
128 | transport: "ipc"
129 | });
130 |
131 | this.client
132 | .login({
133 | clientId: "842112189618978897"
134 | })
135 | .then(async (client) => {
136 | log.info("[DISCORD]", `Client logged in ${client.user.id}`);
137 |
138 | try {
139 | this.isSupporter = await checkSupporter(client.user.id);
140 | } catch (err) {
141 | log.error("[DISCORD]", `Supporter check error: ${err}`);
142 | }
143 |
144 | if (!this.isSupporter) {
145 | this.activity.largeImageText = this.defaultLIT;
146 |
147 | setConfig("rpcLargeImageText", `AMRPC - %version%`);
148 | }
149 |
150 | this.isReady = true;
151 | this.isConnected = true;
152 | this.startUp = false;
153 |
154 | trayManager.discordConnectionUpdate(true);
155 |
156 | this.triggerAfterReady.forEach((func) => func());
157 | this.triggerAfterReady = [];
158 | })
159 | .catch((err) => {
160 | log.error("[DISCORD]", `Client login error: ${err}`);
161 | log.info("[DISCORD]", "Retrying in 5 seconds...");
162 |
163 | // Could not connect: Discord (most likely) not running
164 | // RPC_CONNECTION_TIMEOUT: Discord (most likely) running and needs restart (most of the time)
165 | // -> retry in 5 seconds
166 |
167 | setTimeout(() => this.connect(), 5000);
168 | });
169 |
170 | this.client.on("disconnected", () => {
171 | log.info("[DISCORD]", "Client disconnected");
172 |
173 | this.isReady = false;
174 | this.isConnected = false;
175 |
176 | trayManager.discordConnectionUpdate(false);
177 |
178 | this.connect();
179 | });
180 |
181 | register("842112189618978897");
182 | }
183 |
184 | setActivity(activity: Presence) {
185 | if (!this.isSupporter) activity.largeImageText = this.defaultLIT;
186 | if (!config.get("showTimestamps")) delete activity.endTimestamp;
187 |
188 | this.activity = activity;
189 |
190 | if (this.isReady) {
191 | const time = Date.now();
192 |
193 | this.client
194 | .setActivity(activity)
195 | .then(() => {
196 | log.info(
197 | "[DISCORD][setActivity]",
198 | `Activity set (${Date.now() - time}ms)`
199 | );
200 | })
201 | .catch((err) => {
202 | log.error("[DISCORD][setActivity]", `Client error: ${err}`);
203 | });
204 | } else {
205 | if (!this.startUp) this.connect();
206 |
207 | this.triggerAfterReady.push(() => this.setActivity(activity));
208 | }
209 | }
210 |
211 | clearActivity() {
212 | if (this.isReady) {
213 | const time = Date.now();
214 |
215 | this.client
216 | .clearActivity()
217 | .then(() => {
218 | log.info(
219 | "[DISCORD][clearActivity]",
220 | `Activity cleared (${Date.now() - time}ms)`
221 | );
222 | })
223 | .catch((err) => {
224 | log.error(
225 | "[DISCORD][clearActivity]",
226 | `Client error: ${err}`
227 | );
228 | });
229 | } else {
230 | if (!this.startUp) this.connect();
231 |
232 | this.triggerAfterReady.push(() => this.clearActivity());
233 | }
234 | }
235 |
236 | async setCurrentTrack(currentTrack: currentTrack) {
237 | const thisCurrenTrack = this.currentTrack;
238 |
239 | if (thisCurrenTrack) delete thisCurrenTrack.url;
240 | if (thisCurrenTrack === currentTrack) return;
241 |
242 | const activity: Presence = {},
243 | replacedVars = new replaceVariables(currentTrack);
244 |
245 | activity.largeImageText = replacedVars.getResult("largeImageText");
246 | activity.largeImageKey = getConfig("artwork");
247 | activity.details = replacedVars.getResult("details");
248 | activity.state = replacedVars.getResult("state");
249 |
250 | if (currentTrack.endTime) activity.endTimestamp = currentTrack.endTime;
251 | else if (currentTrack.duration > 0) {
252 | activity.endTimestamp =
253 | Math.floor(Date.now() / 1000) -
254 | currentTrack.elapsedTime +
255 | currentTrack.duration;
256 |
257 | this.isLive = false;
258 | } else if (!currentTrack.artist && !currentTrack.album) {
259 | if (activity.endTimestamp) delete activity.endTimestamp;
260 |
261 | activity.details = currentTrack.name.substring(0, 128);
262 | activity.state = "LIVE";
263 |
264 | this.isLive = true;
265 | }
266 |
267 | this.currentTrack = currentTrack;
268 |
269 | this.setActivity(activity);
270 |
271 | if (this.isLive) {
272 | const songData = await songDataSearchStation(currentTrack.name);
273 |
274 | if (songData.artwork) activity.largeImageKey = songData.artwork;
275 | if (songData.url)
276 | activity.buttons = [
277 | {
278 | label: "Play on Apple Music",
279 | url: songData.url
280 | }
281 | ];
282 |
283 | this.setActivity(activity);
284 | } else {
285 | if (!config.get("artworkPrioLocal")) {
286 | this.getSongData(currentTrack).catch(async () => {
287 | this.setLocalArtwork(
288 | await Bridge.getCurrentTrackArtwork()
289 | ).catch(() => {
290 | this.activity.largeImageKey = getConfig("artwork");
291 | this.setActivity(this.activity);
292 | });
293 | });
294 | } else {
295 | this.setLocalArtwork(await Bridge.getCurrentTrackArtwork())
296 | .then(() => {
297 | this.getSongData(
298 | currentTrack,
299 | this.activity,
300 | true
301 | ).catch(() => {});
302 | })
303 | .catch(() => {
304 | this.getSongData(currentTrack).catch(() => {
305 | this.activity.largeImageKey = getConfig("artwork");
306 | this.setActivity(this.activity);
307 | });
308 | });
309 | }
310 | }
311 | }
312 |
313 | setLocalArtwork(artwork: string) {
314 | return new Promise(async (resolve, reject) => {
315 | const artworkData = await getLibrarySongArtwork(artwork);
316 |
317 | if (artworkData && artworkData.url) {
318 | this.activity.largeImageKey = artworkData.url;
319 |
320 | Browser.send("get-current-track", false, {
321 | artwork: artworkData.url,
322 | playerState: "playing"
323 | });
324 |
325 | this.setActivity(this.activity);
326 | resolve(null);
327 | } else reject("No artwork found");
328 | });
329 | }
330 |
331 | getSongData(currentTrack, activity = this.activity, noArtwork = false) {
332 | return new Promise((resolve, reject) => {
333 | this.songData
334 | .getSongData(
335 | currentTrack.name,
336 | currentTrack.album,
337 | currentTrack.artist
338 | )
339 | .then((data) => {
340 | currentTrack.url = data.url;
341 |
342 | activity.buttons = [
343 | {
344 | label: "Play on Apple Music",
345 | url: data.url
346 | }
347 | ];
348 |
349 | if (!noArtwork) {
350 | if (!currentTrack.artwork)
351 | currentTrack.artwork = data.artwork;
352 |
353 | Browser.send("get-current-track", false, {
354 | artwork: data.artwork,
355 | playerState: currentTrack.playerState
356 | });
357 |
358 | if (getConfig("showAlbumArtwork"))
359 | activity.largeImageKey = currentTrack.artwork;
360 | }
361 |
362 | this.setActivity(activity);
363 | resolve(null);
364 | })
365 | .catch((err) => {
366 | log.error(
367 | "[DISCORD][setCurrentTrack][getSongData]",
368 | `Error: ${err}`
369 | );
370 |
371 | delete activity.buttons;
372 | delete this.activity.buttons;
373 |
374 | reject(err);
375 | });
376 | });
377 | }
378 |
379 | static getInstance() {
380 | if (!Discord.instance) new Discord();
381 |
382 | return Discord.instance;
383 | }
384 |
385 | static setActivity(activity: Presence) {
386 | Discord.getInstance().setActivity(activity);
387 | }
388 |
389 | static clearActivity() {
390 | Discord.getInstance().clearActivity();
391 | }
392 |
393 | static setCurrentTrack(currentTrack: currentTrack) {
394 | Discord.getInstance().setCurrentTrack(currentTrack);
395 | }
396 | }
397 |
398 | export function getUserData(): Promise {
399 | return new Promise((resolve, reject) => {
400 | const client = new Client({ transport: "ipc" });
401 |
402 | client
403 | .login({
404 | clientId: "686635833226166279"
405 | })
406 | .then(({ user }) => client.destroy().then(() => resolve(user)))
407 | .catch(reject);
408 | });
409 | }
410 |
--------------------------------------------------------------------------------
/src/managers/i18n.ts:
--------------------------------------------------------------------------------
1 | import { config } from "./store";
2 |
3 | import * as fs from "fs";
4 | import * as path from "path";
5 | import * as log from "electron-log";
6 |
7 | import { JSONParse } from "../utils/json";
8 | import getAppDataPath from "../utils/getAppDataPath";
9 |
10 | import type { I18n } from "../../@types/zephra/I18n";
11 |
12 | export class i18n {
13 | public static appDataPath = path.join(getAppDataPath(), "i18n");
14 |
15 | public static onLanguageUpdate(func: (lang: string) => void) {
16 | config.onDidChange("language", func);
17 | }
18 |
19 | public static getLangStrings(): I18n | Record {
20 | const filePath = path.join(
21 | this.appDataPath,
22 | `${config.get("language")}.json`
23 | );
24 |
25 | if (!fs.existsSync(filePath)) {
26 | log.warn(
27 | "[i18n][getLangStrings]",
28 | `Translations file (${config.get(
29 | "language"
30 | )}) not found at ${filePath}`
31 | );
32 |
33 | return {};
34 | }
35 |
36 | return JSONParse(fs.readFileSync(filePath, "utf8"));
37 | }
38 |
39 | public static writeLangStrings(lang: string, strings: any) {
40 | if (!fs.existsSync(this.appDataPath))
41 | fs.mkdirSync(this.appDataPath, { recursive: true });
42 |
43 | try {
44 | fs.writeFileSync(
45 | path.join(this.appDataPath, `${lang}.json`),
46 | JSON.stringify(strings, null, 4)
47 | );
48 | } catch (e) {
49 | log.error("[i18n][writeLangStrings]", e);
50 | }
51 | }
52 |
53 | public static deleteLangDir() {
54 | if (!fs.existsSync(this.appDataPath)) return;
55 |
56 | fs.rmSync(this.appDataPath, {
57 | recursive: true
58 | });
59 | }
60 |
61 | public static getLanguages() {
62 | if (!fs.existsSync(this.appDataPath)) return [];
63 |
64 | return fs
65 | .readdirSync(this.appDataPath)
66 | .map((file) => file.replace(".json", ""));
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/managers/ipc.ts:
--------------------------------------------------------------------------------
1 | import { app, ipcMain, nativeTheme, shell } from "electron";
2 | import { appData, cache, config } from "./store";
3 | import { Browser } from "./browser";
4 | import { Discord } from "./discord";
5 | import { i18n } from "./i18n";
6 |
7 | import { useDarkMode } from "../utils/theme";
8 | import { appDependencies, updater, setLastFM } from "../index";
9 |
10 | import { init as initAutoLaunch } from "./launch";
11 |
12 | import * as log from "electron-log";
13 | import * as fs from "fs";
14 |
15 | import fetch from "node-fetch";
16 |
17 | export function init() {
18 | ipcMain.on("update-download", (_e, install) => {
19 | if (install) appData.set("installUpdate", true);
20 |
21 | updater.downloadUpdate();
22 | });
23 |
24 | ipcMain.on("update-install", () => {
25 | let intervalAttempts = 0;
26 |
27 | const updateInterval = setInterval(() => {
28 | if (updater) {
29 | updater.installUpdate();
30 | clearInterval(updateInterval);
31 | } else if (intervalAttempts >= 10) {
32 | log.error(
33 | "[IPC][updateInstall]",
34 | "Failed to install update (10 attempts)"
35 | );
36 |
37 | clearInterval(updateInterval);
38 | } else intervalAttempts++;
39 | }, 1000);
40 | });
41 |
42 | ipcMain.handle("autolaunch-change", () => {
43 | initAutoLaunch();
44 | });
45 |
46 | ipcMain.handle("appVersion", () => {
47 | return app.getVersion();
48 | });
49 |
50 | ipcMain.handle("getPlatform", () => {
51 | return process.platform;
52 | });
53 |
54 | ipcMain.handle("isWindowsStore", () => {
55 | return process.windowsStore;
56 | });
57 |
58 | ipcMain.handle("isDeveloper", () => {
59 | return !app.isPackaged;
60 | });
61 |
62 | ipcMain.handle("isSupporter", () => {
63 | return appDependencies?.discord
64 | ? Discord.instance?.isSupporter ?? false
65 | : false;
66 | });
67 |
68 | ipcMain.handle("getLanguages", () => {
69 | return i18n.getLanguages();
70 | });
71 |
72 | ipcMain.handle("getLangStrings", () => {
73 | return i18n.getLangStrings();
74 | });
75 |
76 | ipcMain.handle("getSystemTheme", () => {
77 | return nativeTheme.shouldUseDarkColors ? "dark" : "light";
78 | });
79 |
80 | ipcMain.handle("getTheme", () => {
81 | const colorTheme = config.get("colorTheme");
82 |
83 | if (colorTheme === "auto") return useDarkMode() ? "dark" : "light";
84 | else if (colorTheme === "os")
85 | return nativeTheme.shouldUseDarkColors ? "dark" : "light";
86 | else return colorTheme;
87 | });
88 |
89 | ipcMain.handle("getCurrentTrack", () => {
90 | if (
91 | !appDependencies.discord ||
92 | !Discord.instance ||
93 | !Discord.instance.currentTrack
94 | )
95 | return { artwork: null, playerState: null };
96 |
97 | return {
98 | artwork: Discord.instance.currentTrack.artwork ?? null,
99 | playerState: Discord.instance.currentTrack.playerState ?? "stopped"
100 | };
101 | });
102 |
103 | ipcMain.handle("getConfig", (_e, k: string) => {
104 | return config.get(k);
105 | });
106 |
107 | ipcMain.handle("resetConfig", (_e, k: string) => {
108 | // @ts-ignore
109 | config.reset(k);
110 |
111 | return config.get(k);
112 | });
113 |
114 | ipcMain.handle("getAppData", (_e, k: string) => {
115 | return appData.get(k);
116 | });
117 |
118 | ipcMain.handle("updateConfig", (_e, k: string, v: any) => {
119 | if (
120 | k === "rpcLargeImageText" &&
121 | (!appDependencies.discord ||
122 | !Discord.instance ||
123 | (appDependencies.discord && !Discord.instance.isSupporter))
124 | ) {
125 | return log.warn(
126 | "[IPC][UPDATE_CONFIG]",
127 | `User is not a supporter, cannot change large image text (isSupporter: ${Discord.instance?.isSupporter})`
128 | );
129 | }
130 |
131 | if (k === "enableCache" && !v) cache.clear();
132 |
133 | config.set(k, v);
134 | });
135 |
136 | ipcMain.handle("updateAppData", (_e, k: string, v: any) =>
137 | appData.set(k, v)
138 | );
139 |
140 | ipcMain.handle("windowControl", (_e, action: string) => {
141 | Browser.windowAction(action);
142 | });
143 |
144 | ipcMain.handle("appControl", (_e, action) => {
145 | if (action === "restart") {
146 | app.relaunch();
147 | app.exit();
148 | }
149 | });
150 |
151 | ipcMain.handle("fetchChangelog", async () => {
152 | const res = await fetch(
153 | "https://api.github.com/repos/ZephraCloud/Apple-Music-RPC/releases/latest",
154 | {
155 | cache: "no-store"
156 | }
157 | );
158 |
159 | return await res.json();
160 | });
161 |
162 | ipcMain.handle("openURL", (_e, url) => {
163 | if (
164 | !url.startsWith("https://") &&
165 | !url.startsWith("http://") &&
166 | !url.startsWith("amrpc://") &&
167 | !url.startsWith("mailto:")
168 | )
169 | return;
170 |
171 | shell.openExternal(url);
172 | });
173 |
174 | ipcMain.handle("fetchCacheSize", () => {
175 | if (!fs.existsSync(cache.path)) {
176 | log.info("[IPC][fetchCacheSize]", "Cache file does not exist");
177 |
178 | return { size: 0, fileSize: 0 };
179 | }
180 |
181 | return {
182 | size: cache.size,
183 | fileSize: fs.statSync(cache.path).size / (1024 * 1024)
184 | };
185 | });
186 |
187 | ipcMain.handle("songDataFeedback", (_e, _isPositive) => {
188 | if (!appDependencies.discord || !Discord.instance) return;
189 |
190 | // @ts-ignore
191 | const songDataHistory = Discord.instance.songData.history;
192 |
193 | // TODO: Get current song data and send feedback to the API :: Paused until new website is released
194 | });
195 |
196 | ipcMain.handle("isReady", (_e, isReady: boolean) => {
197 | Browser.setReady(isReady);
198 | });
199 |
200 | ipcMain.handle("checkAppDependencies", () => {
201 | return appDependencies;
202 | });
203 |
204 | // Last.fm
205 | ipcMain.handle("lastfm-getUser", () => {
206 | return config.get("lastFM");
207 | });
208 |
209 | ipcMain.handle("lastfm-connect", (_e, connect: boolean = false) => {
210 | setLastFM(connect);
211 | });
212 |
213 | // Button actions
214 | ipcMain.handle("resetCache", () => {
215 | cache.clear();
216 | });
217 | }
218 |
--------------------------------------------------------------------------------
/src/managers/lastFM.ts:
--------------------------------------------------------------------------------
1 | import { shell } from "electron";
2 |
3 | import { config } from "./store";
4 | import { Browser } from "./browser";
5 |
6 | import LastFMAPI from "lastfmapi";
7 |
8 | import * as log from "electron-log";
9 | import * as http from "http";
10 |
11 | export class LastFM {
12 | private lastfm: any;
13 | private connect: boolean;
14 |
15 | constructor(connect: boolean = false) {
16 | if (!config.get("enableLastFM")) {
17 | log.info("[LastFM]", "LastFM is disabled");
18 | return;
19 | }
20 |
21 | log.info("[LastFM]", "Initializing LastFM");
22 |
23 | this.connect = connect;
24 | this.lastfm = new LastFMAPI({
25 | api_key: "f8f2436148e4ee8f54ffb494ce03cfde",
26 | secret: "886a030b18072bb4f27cef686aa10d2c"
27 | });
28 |
29 | this.authenticate();
30 | }
31 |
32 | async authenticate() {
33 | const configData: {
34 | [key: string]: any;
35 | } = config.get("lastFM");
36 |
37 | if (!this.connect && configData?.username && configData?.key) {
38 | log.info("[LastFM]", "Loading data from config");
39 |
40 | this.lastfm.setSessionCredentials(
41 | configData.username,
42 | configData.key
43 | );
44 |
45 | return;
46 | }
47 |
48 | log.info("[LastFM]", "Authenticating with LastFM");
49 |
50 | const authURL = this.lastfm.getAuthenticationUrl({
51 | cb: "http://localhost:9101/lastfm"
52 | });
53 |
54 | shell.openExternal(authURL);
55 |
56 | AuthServer.destroy();
57 |
58 | const token = await new AuthServer().create();
59 |
60 | log.info("[LastFM]", "Token received:", token);
61 |
62 | await this.lastfm.authenticate(token, (err: any, session: any) => {
63 | if (err) return log.error("[LastFM]", err);
64 |
65 | log.info("[LastFM]", "Authenticated with LastFM");
66 |
67 | config.set("lastFM", {
68 | username: session.username,
69 | key: session.key
70 | });
71 |
72 | Browser.send("lastfm-connect", false, session);
73 | });
74 | }
75 |
76 | public nowPlaying(data: {
77 | artist: string;
78 | track: string;
79 | album: string;
80 | duration: number;
81 | }) {
82 | if (
83 | !this.lastfm.sessionCredentials?.username ||
84 | !this.lastfm.sessionCredentials?.key
85 | )
86 | return log.warn(
87 | "[LastFM][nowPlaying]",
88 | "No session credentials found"
89 | );
90 |
91 | if (!data.artist || !data.track || !data.album)
92 | return log.warn("[LastFM][nowPlaying]", "Missing data");
93 |
94 | this.lastfm.track.updateNowPlaying(data, (err: any) => {
95 | if (err) {
96 | log.error("[LastFM]", err);
97 |
98 | if (
99 | err.message ===
100 | "Invalid session key - Please re-authenticate"
101 | ) {
102 | this.connect = true;
103 | this.authenticate();
104 | }
105 | }
106 | });
107 | }
108 |
109 | public scrobble(data: {
110 | artist: string;
111 | track: string;
112 | album: string;
113 | duration: number;
114 | timestamp: number;
115 | }) {
116 | if (
117 | !this.lastfm.sessionCredentials?.username ||
118 | !this.lastfm.sessionCredentials?.key
119 | )
120 | return log.warn(
121 | "[LastFM][scrobble]",
122 | "No session credentials found"
123 | );
124 |
125 | if (!data.artist || !data.track || !data.album)
126 | return log.warn("[LastFM][scrobble]", "Missing data");
127 |
128 | this.lastfm.track.scrobble(data, (err: any) => {
129 | if (err) {
130 | log.error("[LastFM]", err);
131 |
132 | if (
133 | err.message ===
134 | "Invalid session key - Please re-authenticate"
135 | ) {
136 | this.connect = true;
137 | this.authenticate();
138 | }
139 | }
140 | });
141 | }
142 | }
143 |
144 | class AuthServer {
145 | private server: any;
146 |
147 | static instance: AuthServer;
148 |
149 | public create(): Promise {
150 | AuthServer.instance = this;
151 |
152 | return new Promise((resolve, reject) => {
153 | this.server = http.createServer();
154 |
155 | this.server.listen(9101);
156 | this.server.on("request", (req: any, res: any) => {
157 | const url = new URL(req.url, "http://localhost:9101");
158 |
159 | if (url.pathname === "/lastfm") {
160 | const query = url.searchParams,
161 | token = query.get("token");
162 |
163 | if (token) {
164 | log.info("[LastFM]", "Received token from LastFM");
165 |
166 | resolve(token);
167 | } else reject("No token provided");
168 |
169 | this.server.close();
170 | }
171 |
172 | res.end(
173 | `Authentication successful
You can close this tab now
`
174 | );
175 | });
176 | });
177 | }
178 |
179 | static destroy() {
180 | AuthServer.instance?.server.close();
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/src/managers/launch.ts:
--------------------------------------------------------------------------------
1 | import { app } from "electron";
2 | import { getConfig } from "./store";
3 |
4 | import AutoLaunch from "auto-launch";
5 |
6 | import * as log from "electron-log";
7 | import * as path from "path";
8 |
9 | export function init() {
10 | if (!app.isPackaged) return;
11 |
12 | const logNote = process.windowsStore
13 | ? "[AutoLaunch][Windows-Store]"
14 | : "[AutoLaunch]";
15 |
16 | const paths = {
17 | explorer: path.join("C:", "Windows", "explorer.exe"),
18 | app: path.join(
19 | "shell:AppsFolder",
20 | "62976zephra.AMRPC_xe0z77jsegffp!62976zephra.AMRPC"
21 | )
22 | };
23 |
24 | const autoLaunch = new AutoLaunch({
25 | name: "AMRPC",
26 | path: process.windowsStore
27 | ? `${paths.explorer} ${paths.app}`
28 | : app.getPath("exe")
29 | });
30 |
31 | autoLaunch[getConfig("autoLaunch") ? "enable" : "disable"]().catch(
32 | (err) => {
33 | log.warn(`${logNote}[Error]`, err);
34 | }
35 | );
36 |
37 | log.info(logNote, "AutoLaunch initialized");
38 | }
39 |
--------------------------------------------------------------------------------
/src/managers/modal.ts:
--------------------------------------------------------------------------------
1 | import { Browser } from "./browser";
2 | import { appData } from "./store";
3 | import { apiRequest } from "../utils/apiRequest";
4 | import { CronJob } from "cron";
5 |
6 | import * as log from "electron-log";
7 |
8 | // https://docs.amrpc.zephra.cloud/developer-resources/modals for more info
9 | export class ModalWatcher {
10 | constructor() {
11 | this.checkForModals();
12 | // :00, :15, :30, :45
13 | try {
14 | new CronJob("*/15 * * * *", this.checkForModals).start();
15 | } catch (e) {
16 | log.error("[ModalWatcher]", e);
17 | }
18 | }
19 |
20 | private checkForModals() {
21 | log.info("[ModalWatcher][checkForModals] Checking for modals");
22 |
23 | apiRequest("modals.json", "https://api.zephra.cloud/amrpc/").then(
24 | (data: ModalData[]) => {
25 | if (!data || Object.keys(data).length === 0) return;
26 |
27 | data.forEach((modal: ModalData) => {
28 | if (appData.get("modals")[modal.id]) return;
29 |
30 | this.openModal(modal);
31 | });
32 | }
33 | );
34 | }
35 |
36 | private openModal(data: ModalData) {
37 | if (!data || Object.keys(data).length === 0) return;
38 |
39 | log.info("[ModalWatcher][openModal] Opening modal", data.id);
40 |
41 | Browser.send(
42 | "open-modal",
43 | data.priority?.toString() ? data.priority : false,
44 | data
45 | );
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/managers/sentry.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/electron";
2 | import * as log from "electron-log";
3 |
4 | import { app } from "electron";
5 |
6 | export function init() {
7 | if (!app.isPackaged) return log.info("[SENTRY]", "Sentry is disabled");
8 |
9 | Sentry.init({
10 | dsn: "https://5da6c0e155b9475299808dd3daa0cf93@o1209127.ingest.sentry.io/6402650",
11 | environment: /-[a-z]*/g.test(app.getVersion())
12 | ? app.getVersion().replace(/^(\d+)\.(\d+)\.(\d+)-/g, "")
13 | : "production"
14 | });
15 |
16 | log.info("[SENTRY]", "Sentry initialized");
17 | }
18 |
--------------------------------------------------------------------------------
/src/managers/store.ts:
--------------------------------------------------------------------------------
1 | import Store from "electron-store";
2 | import * as log from "electron-log";
3 |
4 | export const config = new Store({
5 | defaults: {
6 | autoLaunch: true,
7 | autoUpdates: process.platform === "win32",
8 | betaUpdates: false,
9 | show: true,
10 | hideOnPause: true,
11 | artworkPrioLocal: false,
12 | showAlbumArtwork: true,
13 | showTimestamps: true,
14 | hardwareAcceleration: true,
15 | enableCache: true,
16 | enableLastFM: false,
17 | checkIfMusicInstalled: true,
18 | service: "amp",
19 | colorTheme: "light",
20 | language: "en-US",
21 | artwork: "applemusic-logo",
22 | rpcLargeImageText: "AMRPC - %version%",
23 | rpcDetails: "%title% - %album%",
24 | rpcState: "%artist%",
25 | lastFM: {
26 | username: "",
27 | key: ""
28 | },
29 | watchdog: {
30 | autoUpdates: true,
31 | mirrorAppState: true
32 | }
33 | }
34 | }),
35 | appData = new Store({
36 | name: "data",
37 | defaults: {
38 | modals: {
39 | nineEleven: false,
40 | appleEvent: false
41 | },
42 | nineElevenCovers: false,
43 | changelog: {},
44 | zephra: {
45 | userId: false,
46 | userAuth: false,
47 | lastAuth: false
48 | }
49 | }
50 | }),
51 | // Key format: "songName_:_albumName_:_artistName"
52 | cache = new Store({
53 | name: "cache",
54 | accessPropertiesByDotNotation: false
55 | });
56 |
57 | // Change old language config to new language config
58 | if (config.get("language").includes("_")) config.reset("language");
59 |
60 | export function getConfig(key: string): any {
61 | return config.get(key);
62 | }
63 |
64 | export function getAppData(key: string): any {
65 | return appData.get(key);
66 | }
67 |
68 | export function setConfig(key: string, value: any) {
69 | config.set(key, value);
70 |
71 | log.info("[STORE][CONFIG]", `Set ${key} to ${JSON.stringify(value)}`);
72 | }
73 |
74 | export function setAppData(key: string, value: any) {
75 | appData.set(key, value);
76 |
77 | log.info("[STORE][APPDATA]", `Set ${key} to ${JSON.stringify(value)}`);
78 | }
79 |
--------------------------------------------------------------------------------
/src/managers/tray.ts:
--------------------------------------------------------------------------------
1 | import { Tray, Menu, app, shell } from "electron";
2 |
3 | import { WatchDogState } from "../utils/watchdog";
4 | import { quitITunes } from "../utils/quitITunes";
5 | import { Browser } from "./browser";
6 | import { i18n } from "./i18n";
7 | import { config } from "./store";
8 |
9 | import * as path from "path";
10 | import * as log from "electron-log";
11 |
12 | export class TrayManager {
13 | private tray: Tray;
14 | private i18n = i18n.getLangStrings();
15 |
16 | private isDiscordConnected = false;
17 |
18 | constructor() {
19 | this.tray = new Tray(
20 | path.join(
21 | app.getAppPath(),
22 | process.platform === "darwin"
23 | ? "assets/statusBarIcon.png"
24 | : "assets/trayLogo@32.png"
25 | )
26 | );
27 |
28 | this.tray.setToolTip("AMRPC");
29 | this.tray.setContextMenu(this.createContextMenu());
30 | this.tray.on("click", () => {
31 | if (process.platform === "win32") new Browser();
32 | else this.tray.popUpContextMenu();
33 | });
34 |
35 | ["service", "watchdog.mirrorAppState"].forEach((key: any) => {
36 | config.onDidChange(key, this.update.bind(this));
37 | });
38 |
39 | i18n.onLanguageUpdate(() => {
40 | this.i18n = i18n.getLangStrings();
41 | this.update();
42 | });
43 | }
44 |
45 | private createContextMenu(): Electron.Menu {
46 | const items = [
47 | {
48 | label: `${
49 | app.isPackaged ? "AMRPC" : "AMRPC - DEV"
50 | } v${app.getVersion()}`,
51 | icon: path.join(app.getAppPath(), "assets/trayLogo@18.png"),
52 | enabled: false
53 | },
54 | {
55 | label:
56 | (process.platform === "darwin" &&
57 | parseFloat(process.release.toString()) <= 10.15) ||
58 | process.platform === "win32"
59 | ? config.get("service") === "itunes"
60 | ? "iTunes"
61 | : "Apple Music (Preview)"
62 | : "Apple Music",
63 | enabled: false
64 | },
65 | {
66 | label: `Discord${
67 | this.isDiscordConnected ? " " : " not "
68 | }connected`,
69 | enabled: false
70 | },
71 | { type: "separator" },
72 | {
73 | label: this.i18n?.tray?.reportProblem ?? "Report a Problem",
74 | click() {
75 | shell.openExternal("https://discord.gg/APDghNfJhQ");
76 | }
77 | },
78 | { type: "separator" },
79 | {
80 | label: "Settings",
81 | click() {
82 | new Browser();
83 | }
84 | },
85 | { type: "separator" },
86 | {
87 | label:
88 | this.i18n?.tray?.quitITunes?.info ??
89 | "This takes about 3 seconds",
90 | enabled: false,
91 | visible:
92 | process.platform === "win32" &&
93 | config.get("service") === "itunes"
94 | },
95 | {
96 | label: this.i18n?.tray?.quitITunes?.button ?? "Quit iTunes",
97 | visible:
98 | process.platform === "win32" &&
99 | config.get("service") === "itunes",
100 | click() {
101 | quitITunes();
102 | }
103 | },
104 | {
105 | type: "separator",
106 | visible:
107 | process.platform === "win32" &&
108 | config.get("service") === "itunes"
109 | },
110 | {
111 | label: this.i18n?.tray?.restart ?? "Restart",
112 | click() {
113 | app.relaunch();
114 | app.exit();
115 | }
116 | },
117 | {
118 | label: this.i18n?.tray?.quit ?? "Quit",
119 | click() {
120 | if (
121 | process.platform === "win32" &&
122 | config.get("service") === "music" &&
123 | config.get("watchdog.mirrorAppState")
124 | ) {
125 | WatchDogState(false)
126 | .then(app.quit)
127 | .catch(() => {
128 | log.error(
129 | "[READY][Quit]",
130 | "Failed to stop WatchDog. Quitting app."
131 | );
132 | app.quit();
133 | });
134 | } else app.quit();
135 | }
136 | }
137 | ];
138 |
139 | return Menu.buildFromTemplate(
140 | // @ts-ignore
141 | items.filter((item) => item.visible !== false)
142 | );
143 | }
144 |
145 | update() {
146 | log.info("[TrayManager]", "Updating tray");
147 |
148 | this.tray.setContextMenu(this.createContextMenu());
149 | }
150 |
151 | public discordConnectionUpdate(isConnected: boolean) {
152 | this.isDiscordConnected = isConnected;
153 |
154 | if (process.platform === "darwin") {
155 | this.tray.setImage(
156 | path.join(
157 | app.getAppPath(),
158 | `assets/statusBarIcon${isConnected ? "" : "Error"}.png`
159 | )
160 | );
161 | }
162 |
163 | this.update();
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/managers/updater.ts:
--------------------------------------------------------------------------------
1 | import { autoUpdater } from "electron-updater";
2 |
3 | import { Browser } from "./browser";
4 | import { appData, config } from "./store";
5 |
6 | import * as log from "electron-log";
7 |
8 | export class Updater {
9 | private userNotified: boolean | string = false;
10 |
11 | constructor() {
12 | log.info("[UPDATER]", "Updater initialized");
13 |
14 | autoUpdater.allowPrerelease = config.get("betaUpdates");
15 | autoUpdater.autoDownload = false;
16 |
17 | autoUpdater.on("update-available", (info) => {
18 | log.info(
19 | "[UPDATER]",
20 | `Update available (${info.version})`,
21 | `Auto Update: ${config.get("autoUpdate")}`,
22 | `Beta Updates: ${config.get("betaUpdates")}`
23 | );
24 |
25 | if (config.get("autoUpdate") && process.platform === "win32")
26 | this.downloadUpdate();
27 | else if (!this.userNotified) {
28 | Browser.send("new-update-available", true, {
29 | version: info.version
30 | });
31 |
32 | this.userNotified = true;
33 | }
34 | });
35 |
36 | autoUpdater.on("error", (err) => log.error("[UPDATER]", err));
37 |
38 | autoUpdater.on("download-progress", (progressObj) => {
39 | if (
40 | progressObj.percent === 25 ||
41 | progressObj.percent === 50 ||
42 | progressObj.percent === 75 ||
43 | progressObj.percent === 100
44 | ) {
45 | log.info(
46 | "[UPDATER]",
47 | `Downloading update... (${progressObj.percent}%)`
48 | );
49 | }
50 |
51 | Browser.send("update-download-progress-update", true, {
52 | percent: progressObj.percent,
53 | transferred: progressObj.transferred,
54 | total: progressObj.total,
55 | speed: progressObj.bytesPerSecond
56 | });
57 | });
58 |
59 | autoUpdater.on("update-downloaded", (info) => {
60 | log.info("[UPDATER]", `Update downloaded (${info.version})`);
61 |
62 | if (appData.get("installUpdate")) this.installUpdate();
63 | else Browser.send("update-downloaded", true, {});
64 | });
65 |
66 | setInterval(() => {
67 | this.checkForUpdates();
68 | }, 1.8e6);
69 |
70 | this.checkForUpdates();
71 | }
72 |
73 | public checkForUpdates() {
74 | log.info("[UPDATER]", "Checking for Updates...");
75 |
76 | autoUpdater[
77 | this.userNotified ? "checkForUpdates" : "checkForUpdatesAndNotify"
78 | ]().then(() => log.info("[UPDATER]", "Update check completed"));
79 | }
80 |
81 | public downloadUpdate() {
82 | log.info("[UPDATER]", "Update download initiated");
83 |
84 | autoUpdater
85 | .downloadUpdate()
86 | .then((r) => {
87 | log.info("[UPDATER]", "Downloading update...", r);
88 | })
89 | .catch((err) => {
90 | log.error("[UPDATER]", "Error downloading update", err);
91 | });
92 | }
93 |
94 | public installUpdate() {
95 | log.info("[UPDATER]", "Installing update... (Quiting)");
96 |
97 | autoUpdater.quitAndInstall();
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/managers/watchdog.ts:
--------------------------------------------------------------------------------
1 | import { dialog, shell } from "electron";
2 |
3 | import { watchDog } from "../index";
4 | import { config } from "./store";
5 | import { i18n } from "./i18n";
6 | import { JSONParse } from "../utils/json";
7 | import {
8 | WatchDogDetails,
9 | WatchDogInstaller,
10 | WatchDogState
11 | } from "../utils/watchdog";
12 |
13 | import WebSocket from "ws";
14 | import EventEmitter from "events";
15 |
16 | import * as log from "electron-log";
17 |
18 | interface WatchDogData {
19 | type: "res" | "event";
20 | title: string;
21 | artist: string;
22 | album: string;
23 | duration: number;
24 | endTime: number; // Timestamp
25 | thumbnailPath: string;
26 | playerState: "playing" | "paused" | "not_started";
27 | }
28 |
29 | export class WatchDog {
30 | private socket: WebSocket;
31 | private emitter = new EventEmitter();
32 |
33 | public watchdogUpdating = false;
34 |
35 | constructor() {
36 | this.init();
37 | }
38 |
39 | async init() {
40 | if (this.watchdogUpdating) {
41 | log.info("[WatchDog]", "WatchDog is updating");
42 |
43 | setTimeout(this.init, 2500);
44 | } else {
45 | if (WatchDogDetails("status")) {
46 | log.info("[WatchDog]", "WatchDog is installed");
47 |
48 | if (await WatchDogDetails("running")) watchDog.connect();
49 | else {
50 | WatchDogState(true);
51 |
52 | setTimeout(async () => {
53 | if (await WatchDogDetails("running"))
54 | watchDog.connect();
55 | else watchDog.init();
56 | }, 2500);
57 | }
58 | } else {
59 | log.info("[WatchDog]", "WatchDog is not installed");
60 |
61 | if (config.get("watchdog.autoUpdates")) {
62 | setTimeout(watchDog.init, 3000);
63 |
64 | return;
65 | }
66 |
67 | const strings = i18n.getLangStrings(),
68 | msgBox = dialog.showMessageBoxSync({
69 | type: "error",
70 | // @ts-ignore
71 | title: strings.error.watchDog.title,
72 | // @ts-ignore
73 | message: strings.error.watchDog.description,
74 | buttons: [
75 | strings.settings.modal.buttons.yes,
76 | strings.settings.modal.buttons.learnMore
77 | ]
78 | });
79 |
80 | switch (msgBox) {
81 | case 0:
82 | WatchDogInstaller(true);
83 | break;
84 |
85 | case 1:
86 | shell.openExternal(
87 | "https://docs.amrpc.zephra.cloud/articles/watchdog"
88 | );
89 | break;
90 |
91 | default:
92 | break;
93 | }
94 | }
95 | }
96 | }
97 |
98 | public connect(): void {
99 | let closeByError = false;
100 |
101 | this.socket = new WebSocket("ws://localhost:9632/watchdog");
102 |
103 | this.socket.addEventListener("open", () => {
104 | log.info("[WatchDog]", "Connected to WebSocket");
105 |
106 | // TEMP
107 | this.socket.send("getCurrentTrack");
108 | });
109 |
110 | this.socket.addEventListener("close", () => {
111 | log.info("[WatchDog]", "Disconnected from WebSocket");
112 |
113 | if (!closeByError) {
114 | log.info("[WatchDog]", "Retrying in 5 seconds...");
115 | setTimeout(this.init, 5000);
116 | } else closeByError = false;
117 | });
118 |
119 | this.socket.addEventListener("error", (e) => {
120 | closeByError = true;
121 |
122 | log.error("[WatchDog]", "Error connecting to WebSocket", e);
123 | log.info("[WatchDog]", "Retrying in 5 seconds...");
124 |
125 | setTimeout(this.init, 5000);
126 | });
127 |
128 | this.socket.addEventListener("message", (e) => {
129 | const data: WatchDogData = JSONParse(e.data as string);
130 |
131 | if (!data || Object.keys(data).length === 0 || !data.playerState)
132 | return;
133 | if (data.type === "res") return;
134 |
135 | if (data.playerState === "playing") {
136 | const { elapsedTime, remainingTime } = this.getTimeData(
137 | data.duration,
138 | data.endTime
139 | );
140 |
141 | this.emitter.emit("playing", {
142 | name: data.title || "",
143 | artist: data.artist || "",
144 | album: data.album || "",
145 | duration: data.duration || 0,
146 | elapsedTime,
147 | remainingTime,
148 | endTime: data.endTime,
149 | playerState: data.playerState
150 | });
151 | } else {
152 | this.emitter.emit(
153 | data.playerState === "not_started"
154 | ? "stopped"
155 | : data.playerState
156 | );
157 | }
158 | });
159 | }
160 |
161 | public close(): void {
162 | this.socket.close();
163 | }
164 |
165 | public reconnect(): void {
166 | this.close();
167 | this.init();
168 | }
169 |
170 | public isConnected(): boolean {
171 | return this.socket && this.socket.readyState === WebSocket.OPEN;
172 | }
173 |
174 | public send(message: string): void {
175 | this.socket.send(message);
176 | }
177 |
178 | public getCurrentTrack(): Promise {
179 | return new Promise((resolve, reject) => {
180 | if (!this.isConnected()) return resolve({} as currentTrack);
181 |
182 | const gThis = this;
183 |
184 | let failCount = 0;
185 |
186 | this.socket.addEventListener("message", onMessage);
187 | this.socket.send("getCurrentTrack");
188 |
189 | function onMessage(e: WebSocket.MessageEvent) {
190 | const data: WatchDogData = JSONParse(e.data as string);
191 |
192 | if (!data || Object.keys(data).length === 0) return reject();
193 |
194 | if (data.type === "event") {
195 | failCount++;
196 |
197 | if (failCount > 4) reject();
198 |
199 | return;
200 | }
201 |
202 | const { elapsedTime, remainingTime } = gThis.getTimeData(
203 | data.duration,
204 | data.endTime
205 | );
206 |
207 | resolve({
208 | name: data.title || "",
209 | artist: data.artist || "",
210 | album: data.album || "",
211 | duration: data.duration || 0,
212 | elapsedTime,
213 | remainingTime,
214 | endTime: data.endTime,
215 | playerState: data.playerState
216 | } as currentTrack);
217 | gThis.socket.removeEventListener("message", onMessage);
218 | }
219 | });
220 | }
221 |
222 | private getTimeData(duration: number, endTime: number) {
223 | const durationMS = duration * 1000,
224 | elapsedTime = (Date.now() - (endTime - durationMS)) / 1000,
225 | remainingTime = (durationMS - elapsedTime * 1000) / 1000;
226 |
227 | return {
228 | elapsedTime,
229 | remainingTime
230 | };
231 | }
232 |
233 | public on(
234 | event: currentTrack["playerState"],
235 | listener: (currentTrack: currentTrack) => void
236 | ) {
237 | this.emitter.on(event, listener);
238 | }
239 |
240 | public emit(
241 | event: currentTrack["playerState"],
242 | currentTrack: currentTrack
243 | ) {
244 | this.emitter.emit(event, currentTrack);
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/src/utils/advancedSongDataSearch.ts:
--------------------------------------------------------------------------------
1 | import fetch from "node-fetch";
2 |
3 | import * as cheerio from "cheerio";
4 | import * as log from "electron-log";
5 |
6 | export async function songDataSearchStation(title: string) {
7 | log.info("[songDataSearchStation]", "Searching for", title);
8 |
9 | const res = await fetch(
10 | `https://music.apple.com/us/search?term=${encodeURIComponent(title)}`,
11 | {
12 | headers: {
13 | "User-Agent": "AMRPC"
14 | },
15 | cache: "no-store"
16 | }
17 | );
18 |
19 | const html = await res.text();
20 |
21 | const $ = cheerio.load(html);
22 |
23 | const results = $(
24 | 'div[aria-label="Top Results"] > .section-content > ul.grid'
25 | ).toArray();
26 |
27 | let songData: {
28 | url?: string;
29 | artwork?: string;
30 | } = {};
31 |
32 | results.forEach((result) => {
33 | if (
34 | Object.keys(songData).length > 0 ||
35 | !$(result)
36 | .find("li.top-search-lockup__secondary")
37 | .text()
38 | .includes("Radio Station")
39 | )
40 | return;
41 |
42 | const srcSet = $(result)
43 | .find("div.top-search-lockup__artwork picture source")
44 | .attr("srcset");
45 |
46 | const artwork = srcSet.split(",")[0].split(" ")[0];
47 |
48 | songData = {
49 | url: `https://music.apple.com/us/search?term=${encodeURIComponent(
50 | title
51 | )}`,
52 | artwork: artwork.replace("110x110", "512x512")
53 | };
54 | });
55 |
56 | return songData;
57 | }
58 |
--------------------------------------------------------------------------------
/src/utils/apiRequest.ts:
--------------------------------------------------------------------------------
1 | import { JSONParse } from "./json";
2 |
3 | import fetch from "node-fetch";
4 |
5 | import * as log from "electron-log";
6 |
7 | /**
8 | * @param path the path to the file
9 | * @param host host url is https://www.zephra.cloud/zaphy/api/
10 | */
11 | export async function apiRequest(
12 | path: string,
13 | host: string = "https://www.zephra.cloud/zaphy/api/"
14 | ) {
15 | try {
16 | const res = await fetch(host + path, {
17 | headers: {
18 | "User-Agent": "AMRPC"
19 | },
20 | cache: "no-store"
21 | });
22 |
23 | return JSONParse(await res.text());
24 | } catch (e) {
25 | log.error("[apiRequest]", "Error:", e);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/utils/checkAppDependency.ts:
--------------------------------------------------------------------------------
1 | import { dialog, shell } from "electron";
2 | import { exec } from "child_process";
3 |
4 | import { i18n } from "../managers/i18n";
5 | import { config } from "../managers/store";
6 |
7 | import * as log from "electron-log";
8 | import execPromise from "./execPromise";
9 | import { Browser } from "../managers/browser";
10 |
11 | export async function checkAppDependency(): Promise {
12 | const iTunes =
13 | process.platform === "win32"
14 | ? await checkIfAppIsInstalled("iTunes")
15 | : true;
16 | const appleMusic =
17 | process.platform === "win32"
18 | ? await checkIfAppIsInstalled("Apple Music")
19 | : null;
20 |
21 | // TODO: check if WatchDog is installed
22 |
23 | if (iTunes || appleMusic) {
24 | log["info"](
25 | `[checkAppDependency][Music] ${
26 | iTunes ? "iTunes Found" : "AppleMusicPreview found"
27 | }`
28 | );
29 | } else {
30 | log["warn"]("[checkAppDependency][Music] Not found");
31 | }
32 |
33 | return {
34 | music: iTunes || appleMusic,
35 | iTunes: iTunes,
36 | appleMusic,
37 | watchDog: true,
38 | discord: true
39 | };
40 | }
41 |
42 | export async function checkIfAppIsInstalled(appName: string): Promise {
43 | if (appName === "iTunes" && !config.get("checkIfMusicInstalled")) {
44 | log.info(
45 | "[checkAppDependency][checkIfAppIsInstalled]",
46 | "Skipping iTunes"
47 | );
48 |
49 | return true;
50 | }
51 |
52 | if (appName === "Apple Music") {
53 | const stdout = await execPromise(
54 | `Get-AppxPackage -Name "AppleInc.AppleMusicWin"`,
55 | { shell: "powershell.exe" }
56 | );
57 |
58 | return stdout.includes("AppleMusicWin");
59 | }
60 |
61 | try {
62 | exec(`where ${appName}`, (err, stdout) => {
63 | if (err) {
64 | log.error("[checkAppDependency][checkIfAppIsInstalled]", err);
65 |
66 | if (err.toString().includes("Command failed: where iTunes")) {
67 | const strings = i18n.getLangStrings();
68 |
69 | if (!strings || Object.keys(strings).length === 0) {
70 | log.error(
71 | "[checkAppDependency][checkIfAppIsInstalled]",
72 | "i18n strings not found"
73 | );
74 |
75 | return false;
76 | }
77 |
78 | if (
79 | dialog.showMessageBoxSync({
80 | type: "info",
81 | title: "AMRPC",
82 | message: strings.error.iTunesInstalledCheck,
83 | buttons: [strings.general.buttons.openSettings]
84 | }) === 0
85 | )
86 | Browser.windowAction("show");
87 | }
88 |
89 | return false;
90 | } else {
91 | return stdout.includes(appName);
92 | }
93 | });
94 | } catch (e) {
95 | log.info(
96 | "[checkAppDependency][checkIfAppIsInstalled]",
97 | "Check the documentation for more information:",
98 | "https://docs.amrpc.zephra.cloud/articles/command-prompt-error"
99 | );
100 | log.error(
101 | "[checkAppDependency][checkIfAppIsInstalled]",
102 | "Check if AMRPC has permission to access Command Prompt"
103 | );
104 | log.error("[checkAppDependency][checkIfAppIsInstalled]", e);
105 |
106 | const strings = i18n.getLangStrings();
107 |
108 | if (!strings || Object.keys(strings).length === 0) {
109 | log.error(
110 | "[checkAppDependency][checkIfAppIsInstalled]",
111 | "i18n strings not found"
112 | );
113 |
114 | return false;
115 | }
116 |
117 | if (
118 | dialog.showMessageBoxSync({
119 | type: "error",
120 | title: "AMRPC - Apple Bridge Error",
121 | message: strings.error.cmd,
122 | buttons: [strings.settings.modal.buttons.learnMore]
123 | }) === 0
124 | ) {
125 | shell.openExternal(
126 | "https://docs.amrpc.zephra.cloud/articles/command-prompt-error"
127 | );
128 | }
129 |
130 | return false;
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/utils/checkSupporter.ts:
--------------------------------------------------------------------------------
1 | import { apiRequest } from "./apiRequest";
2 |
3 | export async function checkSupporter(userId: string) {
4 | const res: APIUserRoles[] = await apiRequest(`user/${userId}/roles`);
5 |
6 | if (!res) return false;
7 |
8 | return res.some(
9 | (role) =>
10 | role.id === "1034108574537883708" /* Features+ */ ||
11 | role.id === "810577364051951648" /* Staff */
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/utils/crowdin.ts:
--------------------------------------------------------------------------------
1 | import { app } from "electron";
2 | import { i18n } from "../managers/i18n";
3 |
4 | import otaClient, { Translations } from "@crowdin/ota-client";
5 |
6 | import * as log from "electron-log";
7 |
8 | /*
9 | OTA Client info:
10 | https://www.npmjs.com/package/@crowdin/ota-client#quick-start
11 | Language codes:
12 | https://developer.crowdin.com/language-codes/
13 | */
14 |
15 | /**
16 | * @description Downloads all translations from Crowdin. Use at app start.
17 | */
18 | export async function init() {
19 | if (!app.isPackaged) {
20 | return log.warn(
21 | "[Crowdin]",
22 | "Not downloading translations in dev mode"
23 | );
24 | }
25 |
26 | log.info("[Crowdin]", "Initializing Crowdin OTA Client");
27 |
28 | const client = new otaClient("4e8945ce96a5a9adcee3308fjap");
29 |
30 | return new Promise(async (resolve, reject) => {
31 | log.info("[Crowdin]", "Downloading translations");
32 |
33 | const translations: Translations = await client.getTranslations();
34 |
35 | if (!translations) {
36 | reject();
37 | return log.warn("[Crowdin]", "No translations available");
38 | }
39 |
40 | log.info("[Crowdin]", "Deleting old translations if exist");
41 |
42 | i18n.deleteLangDir();
43 |
44 | for (const locale of Object.keys(translations)) {
45 | for (const translation of translations[locale]) {
46 | i18n.writeLangStrings(locale, translation.content);
47 | }
48 | }
49 |
50 | resolve();
51 |
52 | log.info("[Crowdin]", "Finished");
53 | });
54 | }
55 |
--------------------------------------------------------------------------------
/src/utils/execPromise.ts:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 |
3 | export default function execPromise(
4 | command: string,
5 | options?: any
6 | ): Promise {
7 | return new Promise(function (resolve, reject) {
8 | exec(command, options, (error, stdout) => {
9 | if (error) {
10 | reject(error);
11 | return;
12 | }
13 | resolve(stdout.toString());
14 | });
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/functions.ts:
--------------------------------------------------------------------------------
1 | import { app } from "electron";
2 |
3 | export function bounce(type: "informational" | "critical") {
4 | if (process.platform !== "darwin") return false;
5 |
6 | if (!app.dock.isVisible()) app.dock.show();
7 |
8 | return app.dock.bounce(type);
9 | }
10 |
--------------------------------------------------------------------------------
/src/utils/getAppDataPath.ts:
--------------------------------------------------------------------------------
1 | import { app } from "electron";
2 |
3 | import path from "path";
4 | import fs from "fs";
5 |
6 | export default function getAppDataPath() {
7 | const appPaths = ["AMRPC", "amrpc", "apple-music-rpc"];
8 |
9 | for (const appPath of appPaths) {
10 | const appDataPath = path.join(app.getPath("appData"), appPath);
11 |
12 | if (fs.existsSync(appDataPath)) return appDataPath;
13 | }
14 |
15 | return null;
16 | }
17 |
--------------------------------------------------------------------------------
/src/utils/getLibrarySongArtwork.ts:
--------------------------------------------------------------------------------
1 | import { Bridge } from "../managers/bridge";
2 | import { JSONParse } from "./json";
3 |
4 | import fetch from "node-fetch";
5 | import FormData from "form-data";
6 |
7 | import * as fs from "fs";
8 | import * as log from "electron-log";
9 |
10 | export async function getLibrarySongArtwork(
11 | artwork
12 | ): Promise {
13 | if (!artwork) artwork = await Bridge.getCurrentTrackArtwork();
14 |
15 | return new Promise(async (resolve, reject) => {
16 | if (!artwork) return resolve(null);
17 |
18 | const form = new FormData();
19 |
20 | form.append("image", fs.readFileSync(artwork).toString("base64"));
21 | form.append(
22 | "name",
23 | `${Date.now()}-${Math.random().toString().replace(".", "")}`
24 | );
25 |
26 | try {
27 | const res = await fetch(
28 | "https://www.zephra.cloud/api/amrpc/image-upload",
29 | {
30 | method: "POST",
31 | headers: {
32 | "mime-type": "multipart/form-data"
33 | },
34 | body: form
35 | }
36 | ),
37 | json = JSONParse(await res.text());
38 |
39 | resolve(json?.data);
40 | } catch (err) {
41 | log.error("[getLibrarySongArtwork]", err);
42 |
43 | reject();
44 | }
45 | });
46 | }
47 |
--------------------------------------------------------------------------------
/src/utils/json.ts:
--------------------------------------------------------------------------------
1 | export function JSONParse(str: string) {
2 | try {
3 | return JSON.parse(str);
4 | } catch (e) {
5 | return null;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/msStoreModal.ts:
--------------------------------------------------------------------------------
1 | import { app } from "electron";
2 | import { Browser } from "../managers/browser";
3 | import { appData } from "../managers/store";
4 | import { i18n } from "../managers/i18n";
5 |
6 | import * as log from "electron-log";
7 |
8 | export function init() {
9 | if (
10 | process.platform !== "win32" ||
11 | process.windowsStore ||
12 | !app.isPackaged ||
13 | appData.get("modals.msStoreModal")
14 | )
15 | return;
16 |
17 | const string = i18n.getLangStrings();
18 |
19 | if (!string || Object.keys(string).length === 0) {
20 | log.warn("[msStoreModal]", "Canceled due to missing language file");
21 | return;
22 | }
23 |
24 | const modal: ModalData = {
25 | title: "Microsoft Store",
26 | description: string.settings.modal.microsoftStore.description,
27 | buttons: [
28 | {
29 | label: string.settings.modal.microsoftStore.buttons.download,
30 | style: "btn-primary",
31 | events: [
32 | {
33 | name: "onclick",
34 | value: "window.electron.openURL('https://amrpc.zephra.cloud#download')"
35 | },
36 | {
37 | name: "click",
38 | type: "delete",
39 | save: "modals.msStoreModal"
40 | }
41 | ]
42 | },
43 | {
44 | label: string.settings.modal.buttons.close,
45 | style: "btn-grey",
46 | events: [
47 | {
48 | name: "click",
49 | type: "delete"
50 | }
51 | ]
52 | },
53 | {
54 | label: string.settings.modal.buttons.no,
55 | style: "btn-red",
56 | events: [
57 | {
58 | name: "click",
59 | type: "delete",
60 | save: "modals.msStoreModal"
61 | }
62 | ]
63 | }
64 | ]
65 | };
66 |
67 | log.info("[msStoreModal]", "Initializing Microsoft Store modal");
68 |
69 | Browser.send("open-modal", true, modal);
70 | }
71 |
--------------------------------------------------------------------------------
/src/utils/notifications.ts:
--------------------------------------------------------------------------------
1 | import { app, Notification } from "electron";
2 |
3 | import path from "path";
4 |
5 | export function createNotification(
6 | title,
7 | description,
8 | show?: boolean
9 | ): Notification {
10 | const notification = new Notification({
11 | title: title,
12 | body: description,
13 | icon: path.join(app.getAppPath(), "assets/logo.png")
14 | });
15 |
16 | if (show) notification.show();
17 |
18 | return notification;
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/protocol.ts:
--------------------------------------------------------------------------------
1 | import { app } from "electron";
2 |
3 | import * as path from "path";
4 | import { Browser } from "../managers/browser";
5 |
6 | export function init() {
7 | if (process.defaultApp) {
8 | if (process.argv.length >= 2) {
9 | app.setAsDefaultProtocolClient("amrpc", process.execPath, [
10 | path.resolve(process.argv[1])
11 | ]);
12 | }
13 | } else {
14 | app.setAsDefaultProtocolClient("amrpc");
15 | }
16 |
17 | app.on("second-instance", (_e, commandLine) => {
18 | let url = commandLine.pop()?.replace("amrpc://", "");
19 |
20 | if (url && url.endsWith("/")) url = url.slice(0, -1);
21 |
22 | if (url === "--allow-file-access-from-files") new Browser();
23 | else {
24 | new Browser(url.replace("settings/", ""));
25 | }
26 | });
27 |
28 | app.on("open-url", (_e, url) => {
29 | if (!url) return;
30 |
31 | url = url.replace("amrpc://", "");
32 |
33 | if (url && url.endsWith("/")) url = url.slice(0, -1);
34 |
35 | if (url === "--allow-file-access-from-files") new Browser();
36 | else {
37 | new Browser(url.replace("settings/", ""));
38 | }
39 | });
40 | }
41 |
--------------------------------------------------------------------------------
/src/utils/quitITunes.ts:
--------------------------------------------------------------------------------
1 | import { quitITunes as quitAppleBridgeITunes } from "apple-bridge";
2 |
3 | import { bridge } from "../index";
4 |
5 | export function quitITunes() {
6 | if (bridge && bridge.scrobbleTimeout) clearTimeout(bridge.scrobbleTimeout);
7 |
8 | quitAppleBridgeITunes();
9 |
10 | if (bridge) bridge.bridge.emit("stopped", "music");
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/replaceVariables.ts:
--------------------------------------------------------------------------------
1 | import { getConfig } from "../managers/store";
2 | import { app } from "electron";
3 |
4 | export class replaceVariables {
5 | private currentTrack: currentTrack;
6 |
7 | constructor(currentTrack: currentTrack) {
8 | this.currentTrack = currentTrack;
9 | }
10 |
11 | getResult(config: string) {
12 | return this.testVars(
13 | getConfig(`rpc${config.charAt(0).toUpperCase() + config.slice(1)}`)
14 | );
15 | }
16 |
17 | testVars(config: string) {
18 | if (!config) return undefined;
19 |
20 | // e.g. "%title% - %album%" => "-"
21 | const separator = config
22 | .replace(/\([^)]*\)/g, "") // Remove all brackets w/ content
23 | ?.split(/%title%|%album%|%artist%|%year%|%version%/g)
24 | ?.find((e) => e.length > 0)
25 | ?.trim()
26 | ?.replace(/[a-zA-Z0-9 ]/g, "");
27 |
28 | let returnStr = "";
29 |
30 | // e.g. "%title% - %album%" =>
31 | // "TITLE_IS_AV - ALBUM_IS_AV" or
32 | // " - ALBUM_IS_AV"
33 | const testStr = config
34 | .replace(
35 | /%title%/g,
36 | this.currentTrack.name ? "_TITLE_IS_AV_" : "_TITLE_NOT_AV_"
37 | )
38 | .replace(
39 | /%album%/g,
40 | this.currentTrack.album ? "_ALBUM_IS_AV_" : "_ALBUM_NOT_AV_"
41 | )
42 | .replace(
43 | /%artist%/g,
44 | this.currentTrack.artist ? "_ARTIST_IS_AV_" : "_ARTIST_NOT_AV_"
45 | )
46 | .replace(
47 | /%year%/g,
48 | this.currentTrack.releaseYear ? "_YEAR_IS_AV_" : "_YEAR_NOT_AV_"
49 | )
50 | .replace(
51 | /%version%/g,
52 | app.getVersion() ? "_VERSION_IS_AV_" : "_VERSION_NOT_AV_"
53 | );
54 |
55 | testStr
56 | .replace(/\([^)]*_NOT_AV_[^)]*\)/g, "") // Remove all brackets w/ content if variable is not available
57 | .split(/_[a-zA-Z]*_NOT_AV_|-/g)
58 | .filter((e) => {
59 | return e.trim() !== undefined && e.trim() !== "";
60 | })
61 | .forEach((e) => {
62 | e = e.trim();
63 |
64 | const regexBracketsMatch = e.match(/\([^)]+\)/g);
65 |
66 | regexBracketsMatch?.forEach((bracketE) => {
67 | const regexMatch =
68 | bracketE.match(/_[a-zA-Z]*_IS_AV_|-/g)?.[0] ?? "",
69 | cfgElement = regexMatch
70 | .replace("_IS_AV_", "")
71 | .slice(1)
72 | .toLowerCase(),
73 | cfgValue = this.getValue(cfgElement);
74 |
75 | let tempE = e;
76 |
77 | if (bracketE.includes("_IS_AV_"))
78 | tempE = tempE.replace(regexMatch, cfgValue);
79 | else tempE = tempE.replace(bracketE, "");
80 |
81 | e = tempE;
82 | });
83 |
84 | // e.g. "_TITLE_IS_AV" => "title"
85 | const regexMatch = e.match(/_[a-zA-Z]*_IS_AV_|-/g)?.[0] ?? "",
86 | cfgElement = regexMatch
87 | .replace("_IS_AV_", "")
88 | .slice(1)
89 | .toLowerCase(),
90 | cfgValue = this.getValue(cfgElement),
91 | regex = new RegExp(`%[a-z]*%|${separator}`, "g");
92 |
93 | if (
94 | regexMatch !== "" &&
95 | (!cfgElement ||
96 | (!cfgValue && e !== config.replace(regex, "").trim()))
97 | )
98 | return;
99 |
100 | if (returnStr) returnStr += ` ${separator} `;
101 |
102 | returnStr +=
103 | cfgElement && cfgValue
104 | ? e.replace(regexMatch, cfgValue)
105 | : e;
106 | });
107 |
108 | return returnStr ? returnStr.substring(0, 128) : undefined;
109 | }
110 |
111 | private getValue(variable: string) {
112 | switch (variable) {
113 | case "title":
114 | return this.currentTrack.name;
115 | case "album":
116 | return this.currentTrack.album;
117 | case "artist":
118 | return this.currentTrack.artist;
119 | case "year":
120 | return this.currentTrack.releaseYear?.toString();
121 | case "version":
122 | return app.isPackaged ? app.getVersion() : "Development";
123 | default:
124 | return undefined;
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/utils/theme.ts:
--------------------------------------------------------------------------------
1 | import { Browser } from "../managers/browser";
2 | import { appData } from "../managers/store";
3 | import { AutoTheme } from "electron-autotheme";
4 |
5 | export let autoTheme: AutoTheme;
6 |
7 | export function init() {
8 | autoTheme = new AutoTheme((useDark: boolean) => {
9 | Browser.setTheme(useDark ? "dark" : "light");
10 | }, appData);
11 | }
12 |
13 | export function useDarkMode() {
14 | return autoTheme.useDarkMode();
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/watchdog/details.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 |
4 | import getAppDataPath from "../getAppDataPath";
5 | import execPromise from "../execPromise";
6 |
7 | export function WatchDogDetails(
8 | type: "version" | "status" | "running"
9 | ): string | boolean | Promise | null {
10 | const appData = getAppDataPath(),
11 | appDataWatchDog = path.join(appData, "/watchdog/");
12 |
13 | if (type === "version") {
14 | if (
15 | !appData ||
16 | !fs.existsSync(path.join(appDataWatchDog, "version.txt"))
17 | )
18 | return null;
19 |
20 | return fs.readFileSync(
21 | path.join(appDataWatchDog, "version.txt"),
22 | "utf-8"
23 | );
24 | } else if (type === "status") {
25 | if (!appData) return null;
26 |
27 | return (
28 | fs.existsSync(appDataWatchDog) &&
29 | fs.existsSync(path.join(appDataWatchDog, "watchdog.exe")) &&
30 | fs.existsSync(path.join(appDataWatchDog, "Newtonsoft.Json.dll")) &&
31 | fs.existsSync(path.join(appDataWatchDog, "websocket-sharp.dll"))
32 | );
33 | } else if (type === "running") {
34 | if (!appData) return null;
35 |
36 | return new Promise((resolve) => {
37 | execPromise("tasklist").then((res) => {
38 | resolve(res.includes("watchdog.exe"));
39 | });
40 | });
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/utils/watchdog/index.ts:
--------------------------------------------------------------------------------
1 | import { WatchDogDetails } from "./details";
2 | import { WatchDogInstaller } from "./installer";
3 | import { WatchDogState } from "./state";
4 | import { WatchDogUninstaller } from "./uninstaller";
5 |
6 | export {
7 | WatchDogDetails,
8 | WatchDogInstaller,
9 | WatchDogState,
10 | WatchDogUninstaller
11 | };
12 |
--------------------------------------------------------------------------------
/src/utils/watchdog/installer.ts:
--------------------------------------------------------------------------------
1 | import { WatchDogDetails, WatchDogState } from "./";
2 | import { watchDog } from "../../index";
3 |
4 | import fetch from "node-fetch";
5 | import fs from "fs";
6 | import path from "path";
7 | import decompress from "decompress";
8 |
9 | import getAppDataPath from "../getAppDataPath";
10 |
11 | import * as log from "electron-log";
12 |
13 | // AppData structure:
14 | // ../watchdog/
15 | // - watchdog.exe
16 | // - [...]
17 | // - version.txt
18 |
19 | export async function WatchDogInstaller(initWatchDog: boolean = false) {
20 | if (watchDog?.watchdogUpdating) return;
21 |
22 | const start = Date.now();
23 |
24 | log.info("[WatchDogInstaller]", "Checking for WatchDog update");
25 |
26 | // Get latest release
27 | const latestRelease = await (
28 | await fetch(
29 | "https://api.github.com/repos/zephraOSS/AMRPC-WatchDog/releases/latest"
30 | )
31 | )?.json(),
32 | currentVersion = WatchDogDetails("version"),
33 | appData = getAppDataPath(),
34 | watchdogPath = path.join(appData, "/watchdog/");
35 |
36 | if (!latestRelease || latestRelease.tag_name === currentVersion) return;
37 | if (!fs.existsSync(watchdogPath)) fs.mkdirSync(watchdogPath);
38 |
39 | watchDog.watchdogUpdating = true;
40 |
41 | WatchDogState(false);
42 |
43 | // Download latest release
44 | const downloadURL = latestRelease.assets?.find(
45 | (asset) => asset.name === "watchdog.zip"
46 | )?.browser_download_url,
47 | downloadPath = path.join(watchdogPath, "watchdog.zip");
48 |
49 | if (!downloadURL) return log.info("[WatchDogInstaller]", "No download URL");
50 |
51 | log.info("[WatchDogInstaller]", "Downloading WatchDog update");
52 |
53 | await fetch(downloadURL).then((res) => {
54 | const dest = fs.createWriteStream(downloadPath);
55 |
56 | res.body.pipe(dest);
57 | log.info("[WatchDogInstaller]", "Downloaded WatchDog update");
58 | });
59 |
60 | log.info("[WatchDogInstaller]", "Deleting old WatchDog files");
61 |
62 | fs.readdirSync(watchdogPath).forEach((file) => {
63 | if (file === "version.txt") return;
64 |
65 | fs.unlinkSync(path.join(watchdogPath, file));
66 | });
67 |
68 | log.info("[WatchDogInstaller]", "Awaiting WatchDog update extraction");
69 |
70 | let extractAttempts = 0;
71 |
72 | const extractInterval = setInterval(async () => {
73 | if (extractAttempts > 30) {
74 | clearInterval(extractInterval);
75 | log.error(
76 | "[WatchDogInstaller]",
77 | "Failed to extract WatchDog update"
78 | );
79 |
80 | return;
81 | } else if (!fs.existsSync(downloadPath)) return extractAttempts++;
82 |
83 | clearInterval(extractInterval);
84 |
85 | log.info("[WatchDogInstaller]", "Extracting WatchDog update");
86 |
87 | await decompress(downloadPath, path.join(watchdogPath));
88 |
89 | fs.writeFileSync(
90 | path.join(watchdogPath, "version.txt"),
91 | latestRelease.tag_name
92 | );
93 |
94 | fs.unlinkSync(downloadPath);
95 |
96 | log.info(
97 | "[WatchDogInstaller]",
98 | `Finished in ${(Date.now() - start) / 1000}s`
99 | );
100 |
101 | watchDog.watchdogUpdating = false;
102 |
103 | if (initWatchDog) watchDog.init();
104 | }, 1000);
105 | }
106 |
--------------------------------------------------------------------------------
/src/utils/watchdog/state.ts:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 |
3 | import path from "path";
4 |
5 | import getAppDataPath from "../getAppDataPath";
6 |
7 | import * as log from "electron-log";
8 |
9 | export function WatchDogState(start: boolean = false) {
10 | const appName = "watchdog.exe";
11 |
12 | return new Promise((resolve, reject) => {
13 | if (start) {
14 | exec(
15 | `start ${path.join(getAppDataPath(), "watchdog", appName)}`,
16 | (error) => {
17 | if (error) {
18 | log.error(
19 | "[WatchDog][State]",
20 | "Error starting WatchDog:",
21 | error.message
22 | );
23 | reject(error);
24 | } else {
25 | log.info("[WatchDog][State]", "Started WatchDog");
26 | resolve();
27 | }
28 | }
29 | );
30 | } else {
31 | log.info("[WatchDog][State]", "Stopping WatchDog");
32 |
33 | exec(`tasklist /FI "IMAGENAME eq ${appName}"`, (error, stdout) => {
34 | if (error) {
35 | log.error(
36 | "[WatchDog][State]",
37 | `Error finding process: ${error.message}`
38 | );
39 | reject(error);
40 |
41 | return;
42 | }
43 |
44 | const lines = stdout.split("\n");
45 |
46 | for (const line of lines) {
47 | if (line.includes(appName)) {
48 | const parts = line.split(/\s+/),
49 | pid = parseInt(parts[1], 10);
50 |
51 | exec(`taskkill /F /PID ${pid}`, (killError) => {
52 | if (killError) {
53 | log.error(
54 | "[WatchDog][State]",
55 | `Error killing process: ${killError.message}`
56 | );
57 | reject(killError);
58 | } else {
59 | log.info(
60 | "[WatchDog][State]",
61 | `Successfully killed WatchDog process with PID ${pid}`
62 | );
63 | resolve();
64 | }
65 | });
66 | }
67 | }
68 | });
69 | }
70 | });
71 | }
72 |
--------------------------------------------------------------------------------
/src/utils/watchdog/uninstaller.ts:
--------------------------------------------------------------------------------
1 | import { WatchDogDetails } from "./details";
2 |
3 | import fs from "fs";
4 | import path from "path";
5 |
6 | import getAppDataPath from "../getAppDataPath";
7 |
8 | import * as log from "electron-log";
9 |
10 | export function WatchDogUninstaller() {
11 | if (!WatchDogDetails("status")) return;
12 |
13 | const appData = path.join(getAppDataPath(), "/watchdog/");
14 |
15 | if (!fs.existsSync(appData)) return;
16 |
17 | log.info("[WatchDogUninstaller]", "Deleting WatchDog files");
18 |
19 | fs.readdirSync(appData).forEach((file) => {
20 | fs.unlinkSync(path.join(appData, file));
21 | });
22 |
23 | fs.rmdirSync(appData);
24 |
25 | log.info("[WatchDogUninstaller]", "Deleted WatchDog files");
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "es2018",
5 | "moduleResolution": "node",
6 | "inlineSourceMap": true,
7 | "outDir": "dist/",
8 | "rootDir": "src/",
9 | "removeComments": true,
10 | "esModuleInterop": true,
11 | "skipLibCheck": true,
12 | "noUnusedParameters": true,
13 | "noUnusedLocals": true
14 | },
15 | "exclude": ["**/node_modules/"]
16 | }
17 |
--------------------------------------------------------------------------------