diff --git a/package.json b/package.json index a629704062..1fb590a542 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@jimp/plugin-color": "1.6.0", "@mdui/icons": "^1.0.3", "@skyra/jaro-winkler": "1.1.1", + "@slack/web-api": "^7.13.0", "@xhayper/discord-rpc": "1.3.0", "async-mutex": "0.5.0", "bgutils-js": "3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ca826278c..0a4ff56955 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@skyra/jaro-winkler': specifier: 1.1.1 version: 1.1.1 + '@slack/web-api': + specifier: ^7.13.0 + version: 7.13.0 '@xhayper/discord-rpc': specifier: 1.3.0 version: 1.3.0(bufferutil@4.0.9)(utf-8-validate@6.0.5) @@ -1242,6 +1245,18 @@ packages: resolution: {integrity: sha512-jT2OWwpajtXTb6opnaIwmBTMpQtKUwl2Ro1zApxIIrpZJon71kZIv6GZSc08LzKO2lpTqUjvD+i7Z2hGuG42KQ==} engines: {node: '>=v18'} + '@slack/logger@4.0.0': + resolution: {integrity: sha512-Wz7QYfPAlG/DR+DfABddUZeNgoeY7d1J39OCR2jR+v7VBsB8ezulDK5szTnDDPDwLH5IWhLvXIHlCFZV7MSKgA==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + + '@slack/types@2.19.0': + resolution: {integrity: sha512-7+QZ38HGcNh/b/7MpvPG6jnw7mliV6UmrquJLqgdxkzJgQEYUcEztvFWRU49z0x4vthF0ixL5lTK601AXrS8IA==} + engines: {node: '>= 12.13.0', npm: '>= 6.12.0'} + + '@slack/web-api@7.13.0': + resolution: {integrity: sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig==} + engines: {node: '>= 18', npm: '>= 8.6.0'} + '@solid-primitives/refs@1.1.2': resolution: {integrity: sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==} peerDependencies: @@ -1349,6 +1364,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -1741,6 +1759,9 @@ packages: resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} engines: {node: '>=6.0.0'} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + babel-plugin-jsx-dom-expressions@0.40.1: resolution: {integrity: sha512-b4iHuirqK7RgaMzB2Lsl7MqrlDgQtVRSSazyrmx7wB3T759ggGjod5Rkok5MfHjQXhR7tRPmdwoeGPqBnW2KfA==} peerDependencies: @@ -2557,6 +2578,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2669,6 +2693,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3054,6 +3087,9 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-electron@2.2.2: + resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3775,6 +3811,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -3791,6 +3831,18 @@ packages: resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==} engines: {node: '>=18'} + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3977,6 +4029,9 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -4078,6 +4133,10 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5846,6 +5905,29 @@ snapshots: '@skyra/jaro-winkler@1.1.1': {} + '@slack/logger@4.0.0': + dependencies: + '@types/node': 24.3.0 + + '@slack/types@2.19.0': {} + + '@slack/web-api@7.13.0': + dependencies: + '@slack/logger': 4.0.0 + '@slack/types': 2.19.0 + '@types/node': 24.3.0 + '@types/retry': 0.12.0 + axios: 1.13.2 + eventemitter3: 5.0.1 + form-data: 4.0.4 + is-electron: 2.2.2 + is-stream: 2.0.1 + p-queue: 6.6.2 + p-retry: 4.6.2 + retry: 0.13.1 + transitivePeerDependencies: + - debug + '@solid-primitives/refs@1.1.2(solid-js@1.9.9)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.9) @@ -5974,6 +6056,8 @@ snapshots: dependencies: '@types/node': 24.3.0 + '@types/retry@0.12.0': {} + '@types/semver@7.7.1': {} '@types/trusted-types@2.0.7': {} @@ -6420,6 +6504,14 @@ snapshots: await-to-js@3.0.0: {} + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-plugin-jsx-dom-expressions@0.40.1(@babel/core@7.28.3): dependencies: '@babel/core': 7.28.3 @@ -7491,6 +7583,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} execa@5.1.1: @@ -7603,6 +7697,8 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -8043,6 +8139,8 @@ snapshots: is-docker@3.0.0: {} + is-electron@2.2.2: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -8759,6 +8857,8 @@ snapshots: p-cancelable@2.1.1: {} + p-finally@1.0.0: {} + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -8773,6 +8873,20 @@ snapshots: p-map@7.0.3: {} + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + package-json-from-dist@1.0.1: {} pako@1.0.11: {} @@ -8916,6 +9030,8 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + proxy-from-env@1.1.0: {} + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -9034,6 +9150,8 @@ snapshots: retry@0.12.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} rimraf@2.6.3: diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index 74a4dc8a87..8cf592cfd9 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -476,6 +476,22 @@ } } }, + "slack-status": { + "description": "Sets your Slack status to the currently playing song", + "menu": { + "set-token": "Set Token", + "token": "Slack API Token", + "clear-activity-after-timeout": "Clear activity after timeout", + "set-inactivity-timeout": "Set inactivity timeout" + }, + "name": "Slack Status", + "prompt": { + "set-inactivity-timeout": { + "label": "Enter inactivity timeout in seconds:", + "title": "Set inactivity timeout" + } + } + }, "downloader": { "backend": { "dialog": { diff --git a/src/plugins/slack-status/constants.ts b/src/plugins/slack-status/constants.ts new file mode 100644 index 0000000000..9f113ebb8f --- /dev/null +++ b/src/plugins/slack-status/constants.ts @@ -0,0 +1,13 @@ +/** + * Throttle time for progress updates in milliseconds + */ +export const SLACK_PROGRESS_THROTTLE_MS = 15_000; +/** + * Time in milliseconds to wait before sending a time update + */ +export const SLACK_TIME_UPDATE_DEBOUNCE_MS = 5000; + +export enum TimerKey { + ClearActivity = 'clearActivity', + UpdateTimeout = 'updateTimeout', +} diff --git a/src/plugins/slack-status/index.ts b/src/plugins/slack-status/index.ts new file mode 100644 index 0000000000..388b5b5ba4 --- /dev/null +++ b/src/plugins/slack-status/index.ts @@ -0,0 +1,25 @@ +import { t } from '@/i18n'; +import { createPlugin } from '@/utils'; +import { backend } from './main'; +import { onMenu } from './menu'; + +export type SlackStatusConfig = { + enabled: boolean; + token: string; + activityTimeoutEnabled?: boolean; + activityTimeoutTime?: number; +}; + +export default createPlugin({ + name: () => t('plugins.slack-status.name'), + description: () => t('plugins.slack-status.description'), + restartNeeded: true, + config: { + enabled: false, + token: '', + activityTimeoutEnabled: true, + activityTimeoutTime: 10 * 60 * 1000, + } as SlackStatusConfig, + menu: onMenu, + backend, +}); diff --git a/src/plugins/slack-status/main.ts b/src/plugins/slack-status/main.ts new file mode 100644 index 0000000000..ef69e409d5 --- /dev/null +++ b/src/plugins/slack-status/main.ts @@ -0,0 +1,69 @@ +import { app } from 'electron'; + +import { registerCallback, SongInfoEvent } from '@/providers/song-info'; +import { createBackend, LoggerPrefix } from '@/utils'; + +import { t } from '@/i18n'; +import { SlackService } from './slack-service'; + +import { SLACK_TIME_UPDATE_DEBOUNCE_MS } from './constants'; + +import type { SlackStatusConfig } from './index'; + +export let slackService = null as SlackService | null; + +export const backend = createBackend< + { + config?: SlackStatusConfig; + lastStatusUpdate: number; + }, + SlackStatusConfig +>({ + lastStatusUpdate: 0, + + async start(ctx) { + const config = await ctx.getConfig(); + slackService = new SlackService(ctx.window, config); + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.init-main'), + config, + ); + + if (config.enabled) { + ctx.window.once('ready-to-show', () => { + registerCallback((songInfo, event) => { + if (event !== SongInfoEvent.TimeChanged) { + slackService?.updateStatus(songInfo); + this.lastStatusUpdate = Date.now(); + } else { + const now = Date.now(); + if (now - this.lastStatusUpdate > SLACK_TIME_UPDATE_DEBOUNCE_MS) { + slackService?.updateStatus(songInfo); + this.lastStatusUpdate = now; + } + } + }); + }); + } + + app.on('before-quit', async () => { + console.log(LoggerPrefix, t('plugins.slack-status.backend.before-quit')); + await slackService?.cleanup(); + }); + }, + + async stop() { + console.log(LoggerPrefix, t('plugins.slack-status.backend.stop')); + await slackService?.cleanup(); + }, + + onConfigChange(newConfig) { + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.on-config-change'), + newConfig, + ); + slackService?.onConfigChange(newConfig); + }, +}); diff --git a/src/plugins/slack-status/menu.ts b/src/plugins/slack-status/menu.ts new file mode 100644 index 0000000000..0bac7932d7 --- /dev/null +++ b/src/plugins/slack-status/menu.ts @@ -0,0 +1,120 @@ +import prompt from 'custom-electron-prompt'; + +import { t } from '@/i18n'; + +import promptOptions from '@/providers/prompt-options'; + +import { setMenuOptions } from '@/config/plugins'; + +import { LoggerPrefix } from '@/utils'; + +import type { MenuTemplate } from '@/menu'; +import type { MenuContext } from '@/types/contexts'; +import type { SlackStatusConfig } from './index'; + +async function promptSlackStatusOptions( + options: SlackStatusConfig, + setConfig: (config: SlackStatusConfig) => void, + window: Electron.BrowserWindow, +): Promise { + console.log(LoggerPrefix, t('plugins.slack-status.menu.open')); + + const output = await prompt( + { + title: t('plugins.slack-status.name'), + label: `
+

${t('plugins.slack-status.name')}

+

How to set up Slack API Token

+
    +
  1. Go to https://api.slack.com/apps and select your app.
  2. +
  3. In the left sidebar, click OAuth & Permissions.
  4. +
  5. Under Scopes, in the User Token Scopes section, add users.profile:write.
  6. +
  7. Click Save Changes.
  8. +
  9. At the top, click Install App to Workspace (or Reinstall App if already installed).
  10. +
  11. Authorize the app when prompted.
  12. +
  13. Copy the token from OAuth Tokens and paste it below.
  14. +
+
+
`, + type: 'multiInput', + useHtmlLabel: true, + multiInputOptions: [ + { + label: t('plugins.slack-status.menu.token'), + value: options.token, + inputAttrs: { + type: 'text', + placeholder: 'xoxc-...', + }, + }, + ], + resizable: true, + width: 620, + height: 520, + ...promptOptions(), + }, + window, + ); + + if (output) { + const updatedOptions = { ...options } as SlackStatusConfig; + if (output[0] !== undefined) updatedOptions.token = output[0]; + setConfig(updatedOptions); + console.log( + LoggerPrefix, + t('plugins.slack-status.menu.set', updatedOptions), + ); + } +} + +export const onMenu = async ({ + window, + getConfig, + setConfig, +}: MenuContext): Promise => { + const config = await getConfig(); + return [ + { + label: t('plugins.slack-status.menu.set-token'), + click: () => promptSlackStatusOptions(config, setConfig, window), + }, + { + label: t('plugins.slack-status.menu.clear-activity-after-timeout'), + type: 'checkbox', + checked: config.activityTimeoutEnabled, + click(item: Electron.MenuItem) { + setConfig({ + ...config, + activityTimeoutEnabled: item.checked, + }); + }, + }, + { + label: t('plugins.slack-status.menu.set-inactivity-timeout'), + click: () => setInactivityTimeout(window, config), + }, + ]; +}; + +async function setInactivityTimeout( + win: Electron.BrowserWindow, + options: SlackStatusConfig, +) { + const output = await prompt( + { + title: t('plugins.slack-status.prompt.set-inactivity-timeout.title'), + label: t('plugins.slack-status.prompt.set-inactivity-timeout.label'), + value: String(Math.round((options.activityTimeoutTime ?? 0) / 1e3)), + type: 'counter', + counterOptions: { minimum: 0, multiFire: true }, + width: 450, + ...promptOptions(), + }, + win, + ); + + if (output) { + options.activityTimeoutTime = Math.round(~~output * 1e3); + setMenuOptions('slack-status', options); + } +} diff --git a/src/plugins/slack-status/slack-service.ts b/src/plugins/slack-status/slack-service.ts new file mode 100644 index 0000000000..cfdd18bdaf --- /dev/null +++ b/src/plugins/slack-status/slack-service.ts @@ -0,0 +1,202 @@ +import { WebClient } from '@slack/web-api'; + +import { t } from '@/i18n'; +import { SLACK_PROGRESS_THROTTLE_MS, TimerKey } from './constants'; +import { TimerManager } from './timer-manager'; + +import { LoggerPrefix } from '@/utils'; + +import type { SongInfo } from '@/providers/song-info'; +import type { SlackStatusConfig } from './index'; + +export class SlackService { + timerManager = new TimerManager(); + + clearActivityTimeout() { + this.timerManager.clear(TimerKey.ClearActivity); + } + + setActivityTimeout() { + this.clearActivityTimeout(); + if ( + this.lastSongInfo?.isPaused === true && + this.config?.activityTimeoutEnabled && + this.config?.activityTimeoutTime && + this.config.activityTimeoutTime > 0 + ) { + this.timerManager.set( + TimerKey.ClearActivity, + () => { + this.clearStatus(); + }, + this.config.activityTimeoutTime, + ); + } + } + config?: SlackStatusConfig; + lastStatus: string = ''; + lastEmoji: string = ''; + lastSongInfo?: SongInfo; + lastStatusUpdate = 0; + albumArtCache: Record = {}; + tempFiles = new Set(); + mainWindow: Electron.BrowserWindow; + slackClient?: WebClient; + + constructor(mainWindow: Electron.BrowserWindow, config?: SlackStatusConfig) { + this.config = config; + this.mainWindow = mainWindow; + if (config?.token) { + this.slackClient = new WebClient(config.token); + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.init'), + config.token ? 'token set' : 'no token', + ); + } else { + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.init'), + 'no token', + ); + } + } + + async updateStatus(songInfo: SongInfo) { + if (!this.config?.enabled || !this.config.token || !this.slackClient) { + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.update-skipped'), + ); + return; + } + + const now = Date.now(); + const elapsedSeconds = songInfo.elapsedSeconds ?? 0; + const songChanged = songInfo.videoId !== this.lastSongInfo?.videoId; + const pauseChanged = songInfo.isPaused !== this.lastSongInfo?.isPaused; + const seeked = + !songChanged && + typeof this.lastSongInfo?.elapsedSeconds === 'number' && + Math.abs((this.lastSongInfo.elapsedSeconds ?? 0) - elapsedSeconds) > 3; + + if ( + (songChanged || pauseChanged || seeked) && + this.lastSongInfo !== undefined + ) { + this.timerManager.clear(TimerKey.UpdateTimeout); + await this.setSlackStatus(songInfo); + this.lastSongInfo = { ...songInfo }; + this.lastStatusUpdate = now; + this.setActivityTimeout(); + return; + } + + if (now - this.lastStatusUpdate > SLACK_PROGRESS_THROTTLE_MS) { + this.timerManager.clear(TimerKey.UpdateTimeout); + await this.setSlackStatus(songInfo); + this.lastSongInfo = { ...songInfo }; + this.lastStatusUpdate = now; + this.setActivityTimeout(); + return; + } + + this.timerManager.clear(TimerKey.UpdateTimeout); + const remainingThrottle = + SLACK_PROGRESS_THROTTLE_MS - (now - this.lastStatusUpdate); + + const songInfoSnapshot = { ...songInfo }; + this.timerManager.set( + TimerKey.UpdateTimeout, + async () => { + await this.setSlackStatus(songInfoSnapshot); + this.lastStatusUpdate = Date.now(); + this.lastSongInfo = { ...songInfoSnapshot }; + this.setActivityTimeout(); + }, + remainingThrottle, + ); + } + + async setSlackStatus(songInfo: SongInfo) { + const statusText = `${songInfo.title} - ${songInfo.artist}`; + const emoji = songInfo.isPaused ? ':double_vertical_bar:' : ':headphones:'; + try { + await this.slackClient!.users.profile.set({ + profile: { + status_text: statusText, + status_emoji: emoji, + status_expiration: 0, + }, + }); + this.lastStatus = statusText; + this.lastEmoji = emoji; + } catch (err) { + console.error( + LoggerPrefix, + t('plugins.slack-status.backend.status-error'), + err, + ); + } + } + + async clearStatus() { + if (!this.config?.enabled || !this.config.token || !this.slackClient) { + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.clear-skipped'), + ); + return; + } + try { + await this.slackClient.users.profile.set({ + profile: { + status_text: '', + status_emoji: '', + status_expiration: 0, + }, + }); + this.lastStatus = ''; + this.lastEmoji = ''; + this.lastSongInfo = undefined; + this.lastStatusUpdate = Date.now(); + this.clearActivityTimeout(); + this.timerManager.clear(TimerKey.UpdateTimeout); + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.status-cleared'), + ); + } catch (err) { + console.error( + LoggerPrefix, + t('plugins.slack-status.backend.clear-error'), + err, + ); + } + } + + onConfigChange(newConfig: SlackStatusConfig) { + this.config = newConfig; + if (newConfig.token) { + this.slackClient = new WebClient(newConfig.token); + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.config-changed'), + 'token set', + ); + } else { + this.slackClient = undefined; + console.log( + LoggerPrefix, + t('plugins.slack-status.backend.config-changed'), + 'no token', + ); + } + } + + async cleanup() { + this.timerManager.clearAll(); + await this.clearStatus(); + console.log(LoggerPrefix, t('plugins.slack-status.backend.cleanup')); + } +} diff --git a/src/plugins/slack-status/timer-manager.ts b/src/plugins/slack-status/timer-manager.ts new file mode 100644 index 0000000000..68876503d7 --- /dev/null +++ b/src/plugins/slack-status/timer-manager.ts @@ -0,0 +1,25 @@ +import type { TimerKey } from './constants'; + +export class TimerManager { + timers = new Map(); + + set(key: TimerKey, fn: () => void, delay: number): void { + this.clear(key); + this.timers.set(key, setTimeout(fn, delay)); + } + + clear(key: TimerKey): void { + const timer = this.timers.get(key); + if (timer) { + clearTimeout(timer); + this.timers.delete(key); + } + } + + clearAll(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + } +}