├── .env ├── .eslintignore ├── .eslintrc.cjs ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── music-service-request.md ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── assets ├── icon-128.png ├── icon.png └── images │ ├── amazon-logo.svg │ ├── apple-logo.svg │ ├── icon-filled.svg │ ├── mini-player-example.png │ ├── redirect-example.png │ ├── spotify-logo.svg │ ├── wave-graphic-dark.svg │ ├── wave-graphic-light.svg │ ├── youtube-logo.svg │ └── ytm-plus-logo.png ├── docs ├── BuildingAnAdapter.md └── CONTRIBUTING.md ├── package.json ├── pnpm-lock.yaml ├── src ├── adapters │ ├── amazon-music │ │ ├── AmazonAdapter.ts │ │ ├── AmazonBackgroundController.ts │ │ ├── AmazonContentController.ts │ │ ├── AmazonContentObserver.ts │ │ └── types.ts │ ├── apple-music │ │ ├── AppleAdapter.ts │ │ ├── AppleBackgroundController.ts │ │ ├── AppleContentController.ts │ │ ├── AppleContentObserver.ts │ │ └── types.ts │ ├── index.ts │ ├── spotify │ │ ├── SpotifyAdapter.ts │ │ ├── SpotifyBackgroundController.ts │ │ ├── SpotifyContentController.ts │ │ ├── SpotifyContentObserver.ts │ │ ├── auth.ts │ │ ├── constants.ts │ │ └── types.ts │ └── youtube-music │ │ ├── YouTubeMusicAdapter.ts │ │ ├── YouTubeMusicBackgroundController.ts │ │ ├── YouTubeMusicContentController.ts │ │ ├── YouTubeMusicContentObserver.ts │ │ └── types.ts ├── background │ ├── createInstallHandler.ts │ ├── index.ts │ ├── messages │ │ ├── BROADCAST.ts │ │ ├── CREATE_TRACK_NOTIFICATION.ts │ │ ├── DISPATCH.ts │ │ ├── GET_REDIRECT_LINK.ts │ │ ├── GET_SELF_TAB.ts │ │ ├── GET_SETTINGS.ts │ │ ├── MINIMIZE_WINDOW.ts │ │ ├── OPEN_OPTIONS_PAGE.ts │ │ ├── REDIRECT_TO_TAB.ts │ │ └── SEND_ANALYTICS_EVENT.ts │ ├── registerHubMessageHandlers.ts │ └── updateMusicServiceTabs.ts ├── constants │ ├── permissions.ts │ ├── port.ts │ ├── search.ts │ └── urls.ts ├── contents │ ├── adapter.ts │ ├── amazon-music-redux.ts │ ├── common.ts │ ├── picture-in-picture.tsx │ ├── redirect-popup.tsx │ └── redirect.ts ├── core │ ├── adapter │ │ ├── config.ts │ │ ├── controller.ts │ │ ├── feature.ts │ │ ├── index.ts │ │ ├── observer.ts │ │ └── register.ts │ ├── keys.ts │ ├── links │ │ ├── index.ts │ │ ├── link.ts │ │ └── matching.ts │ ├── message-handlers │ │ ├── createMusicControllerHandler.ts │ │ ├── createRedirectHandler.ts │ │ ├── createTabsHandler.ts │ │ └── index.ts │ ├── messaging │ │ ├── hub.ts │ │ ├── index.ts │ │ ├── sendToBackground.ts │ │ └── sendToContent.ts │ ├── notifications.ts │ └── player.ts ├── options.tsx ├── popup.tsx ├── store │ ├── combinedReducers.ts │ ├── index.ts │ └── slices │ │ ├── musicServiceTabs.ts │ │ ├── popupOpen.ts │ │ ├── queue.ts │ │ ├── search.ts │ │ └── settings.ts ├── tabs │ └── onboard.tsx ├── types │ ├── MusicControllerMessage.ts │ ├── MusicService.ts │ ├── MusicServiceTab.ts │ ├── PlayerState.ts │ ├── QueueItem.ts │ ├── RepeatMode.ts │ ├── Settings.ts │ ├── SynQWindow.ts │ ├── TabsMessage.ts │ ├── Track.ts │ ├── Util.ts │ └── index.ts ├── ui │ ├── onboard │ │ ├── Onboard.tsx │ │ ├── components │ │ │ ├── NextArrow.tsx │ │ │ ├── PreviousArrow.tsx │ │ │ └── Screen.tsx │ │ └── screens │ │ │ ├── AcceptPermissions.tsx │ │ │ ├── Complete.tsx │ │ │ ├── SelectPreferredService.tsx │ │ │ └── YtmPlusIntro.tsx │ ├── options │ │ ├── components │ │ │ ├── Header.tsx │ │ │ ├── KeyControlsSection.tsx │ │ │ ├── NotificationsSection.tsx │ │ │ ├── OptionsContent.tsx │ │ │ ├── OptionsSection.tsx │ │ │ ├── PopOutSection.tsx │ │ │ ├── PreferredMusicServiceSection.tsx │ │ │ └── PreferredServiceLinksSection.tsx │ │ └── index.tsx │ ├── pip │ │ ├── PipToggleButton.tsx │ │ └── PipUi.tsx │ ├── popup │ │ ├── Layout.tsx │ │ ├── Popup.tsx │ │ ├── PopupRoutes.tsx │ │ ├── components │ │ │ ├── ControlButtons │ │ │ │ └── index.tsx │ │ │ ├── MusicServiceButton │ │ │ │ └── index.tsx │ │ │ ├── Player │ │ │ │ └── index.tsx │ │ │ └── PlayerControls │ │ │ │ └── index.tsx │ │ ├── contexts │ │ │ ├── PopupContextProvidersWrapper.tsx │ │ │ └── PopupSettingsContext.tsx │ │ ├── hooks │ │ │ └── usePopupMusicServiceTab.ts │ │ ├── index.css │ │ └── screens │ │ │ ├── AcceptPermissions │ │ │ └── index.tsx │ │ │ ├── Controller │ │ │ ├── index.tsx │ │ │ └── useControllerScreen.ts │ │ │ ├── SelectPlatform │ │ │ └── index.tsx │ │ │ └── SelectTab │ │ │ └── index.tsx │ ├── redirect │ │ └── RedirectPopup.tsx │ └── shared │ │ ├── components │ │ ├── AlbumArt │ │ │ ├── index.tsx │ │ │ └── useAlbumArt.ts │ │ ├── ArtistName │ │ │ ├── index.tsx │ │ │ └── useArtistName.ts │ │ ├── Header │ │ │ └── index.tsx │ │ ├── ListItemMenu │ │ │ └── index.tsx │ │ ├── Logo │ │ │ └── index.tsx │ │ ├── MarqueeText │ │ │ └── index.tsx │ │ ├── NextButton │ │ │ ├── index.tsx │ │ │ └── useNextButton.ts │ │ ├── PlayPauseButton │ │ │ ├── index.tsx │ │ │ └── usePlayPauseButton.ts │ │ ├── PreviousButton │ │ │ ├── index.tsx │ │ │ └── usePreviousButton.ts │ │ ├── Queue │ │ │ ├── index.tsx │ │ │ └── useQueue.ts │ │ ├── RepeatButton │ │ │ ├── index.tsx │ │ │ └── useRepeatButton.ts │ │ ├── ShuffleButton │ │ │ └── index.tsx │ │ ├── TrackListItem │ │ │ └── index.tsx │ │ ├── TrackSeeker │ │ │ ├── index.tsx │ │ │ └── useTrackSeeker.ts │ │ ├── TrackTitle │ │ │ ├── index.tsx │ │ │ └── useTrackTitle.ts │ │ ├── VolumeButton │ │ │ ├── index.tsx │ │ │ └── useVolumeButton.ts │ │ └── VolumeSlider │ │ │ ├── index.tsx │ │ │ └── useVolumeSlider.ts │ │ ├── contexts │ │ ├── DocumentContextProvidersWrapper.tsx │ │ └── MusicServiceTab.tsx │ │ ├── hooks │ │ ├── useAnalytic.ts │ │ ├── useDocumentMusicServiceTab.ts │ │ ├── usePermissionsCheck.ts │ │ └── useTabQuery.ts │ │ └── styles │ │ └── MarqueeStylesProvider.tsx └── util │ ├── analytics.ts │ ├── debounce.ts │ ├── findIndexes.ts │ ├── generateRequestId.ts │ ├── imageUrlToDataUrl.ts │ ├── musicService.ts │ ├── onDocumentReady.ts │ ├── store.ts │ ├── time.ts │ ├── volume.ts │ └── waitForElement.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | PLASMO_PUBLIC_SYNQ_WEBSITE=https://www.synqapp.io 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .plasmo 2 | build 3 | node_modules 4 | package.json 5 | tsconfig.json 6 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@synqapp/eslint-config'], 3 | env: { 4 | node: true 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: needs attention 6 | assignees: mkossoris 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Music service** 14 | What music service are you using SynQ with? (e.g. YouTube Music, Spotify, etc) 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Device (please complete the following information):** 30 | - OS: [e.g. MacOS, Windows] 31 | - Browser [e.g. Chrome, Edge] 32 | - Version [e.g. 4.0.1] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: needs attention 6 | assignees: mkossoris 7 | 8 | --- 9 | 10 | **Important: If your request is to add support for a music service, please use the "Music service request" template instead** 11 | 12 | **Is your feature request related to a problem? Please describe.** 13 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 14 | 15 | **Describe the solution you'd like** 16 | A clear and concise description of what you want to happen. 17 | 18 | **Describe alternatives you've considered** 19 | A clear and concise description of any alternative solutions or features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/music-service-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Music service request 3 | about: Request a new music service to be supported by SynQ 4 | title: "[SERVICE REQUEST]" 5 | labels: needs attention 6 | assignees: mkossoris 7 | 8 | --- 9 | 10 | **What is the name of the music service?** 11 | Provide the music service name. 12 | 13 | **Confirm that the music service supports web-based listening:** 14 | [Yes/No] 15 | 16 | **Please provide a link to the music service:** 17 | Provide the music service link. 18 | 19 | **If the music service requires logging in to use, please provide a demo video of the web player:** 20 | Provide a video demo, if applicable. 21 | 22 | **Are you interested in developing the Adapter for this music service?** 23 | [Yes/No] 24 | 25 | **Anything else we should know?** 26 | Provide any additional context. 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | #cache 13 | .turbo 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .pnpm-debug.log* 24 | 25 | # local env files 26 | .env.* 27 | 28 | out/ 29 | build/ 30 | dist/ 31 | 32 | # plasmo - https://www.plasmo.com 33 | .plasmo 34 | 35 | # bpp - http://bpp.browser.market/ 36 | keys.json 37 | 38 | # typescript 39 | .tsbuildinfo 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .plasmo 2 | assets 3 | build 4 | node_modules -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('prettier').Options} 3 | */ 4 | module.exports = { 5 | ...require('@synqapp/prettier-config') 6 | }; 7 | -------------------------------------------------------------------------------- /assets/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SynQApp/Extension/849c3db960580c7be7de074e04f3f7e50e5d4b42/assets/icon-128.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SynQApp/Extension/849c3db960580c7be7de074e04f3f7e50e5d4b42/assets/icon.png -------------------------------------------------------------------------------- /assets/images/apple-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 28 | 29 | 30 | 31 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /assets/images/icon-filled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/images/mini-player-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SynQApp/Extension/849c3db960580c7be7de074e04f3f7e50e5d4b42/assets/images/mini-player-example.png -------------------------------------------------------------------------------- /assets/images/redirect-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SynQApp/Extension/849c3db960580c7be7de074e04f3f7e50e5d4b42/assets/images/redirect-example.png -------------------------------------------------------------------------------- /assets/images/spotify-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/images/youtube-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ]> 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /assets/images/ytm-plus-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SynQApp/Extension/849c3db960580c7be7de074e04f3f7e50e5d4b42/assets/images/ytm-plus-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synq-extension", 3 | "displayName": "SynQ for Spotify, YouTube, Amazon Music, more", 4 | "version": "4.0.7", 5 | "description": "Mini player with universal music sharing for Spotify, Apple Music, YouTube Music, and Amazon Music.", 6 | "author": "SynQ, LLC", 7 | "scripts": { 8 | "dev": "plasmo dev", 9 | "build": "npx rimraf ./build ./.plasmo && plasmo build", 10 | "package": "plasmo package", 11 | "format:all": "prettier --write \"**/*.{js,ts,json}\"", 12 | "format:check": "prettier --check \"**/*.{js,ts,json}\"", 13 | "lint:all": "eslint --fix \"**/*.{js,ts,json}\"", 14 | "lint:check": "eslint \"**/*.{js,ts,json}\"", 15 | "check": "pnpm run lint:check && pnpm run format:check" 16 | }, 17 | "dependencies": { 18 | "@fortawesome/free-regular-svg-icons": "^6.4.0", 19 | "@fortawesome/free-solid-svg-icons": "^6.4.0", 20 | "@fortawesome/react-fontawesome": "^0.2.0", 21 | "@plasmohq/messaging": "^0.5.0", 22 | "@plasmohq/redux-persist": "^6.1.0", 23 | "@plasmohq/storage": "^1.7.2", 24 | "@reduxjs/toolkit": "^1.9.5", 25 | "@spotify/web-api-ts-sdk": "^1.1.2", 26 | "@synqapp/ui": "^1.0.0", 27 | "cheerio": "1.0.0-rc.12", 28 | "fast-levenshtein": "^3.0.0", 29 | "glob-to-regexp": "^0.4.1", 30 | "plasmo": "0.83.0", 31 | "react": "18.2.0", 32 | "react-dom": "18.2.0", 33 | "react-fast-marquee": "^1.6.0", 34 | "react-redux": "^8.1.2", 35 | "react-router-dom": "^6.14.0", 36 | "react-slick": "^0.29.0", 37 | "redux": "^4.2.1", 38 | "redux-persist-webextension-storage": "^1.0.2", 39 | "slick-carousel": "^1.8.1", 40 | "styled-components": "^6.0.3", 41 | "uuid": "^9.0.1" 42 | }, 43 | "devDependencies": { 44 | "@plasmohq/prettier-plugin-sort-imports": "3.6.4", 45 | "@synqapp/eslint-config": "^1.1.0", 46 | "@synqapp/prettier-config": "^1.1.0", 47 | "@types/chrome": "0.0.244", 48 | "@types/fast-levenshtein": "^0.0.4", 49 | "@types/glob-to-regexp": "^0.4.4", 50 | "@types/node": "18.15.12", 51 | "@types/react": "18.0.37", 52 | "@types/react-dom": "18.0.11", 53 | "@types/react-slick": "^0.23.13", 54 | "@types/redux-persist-webextension-storage": "^1.0.0", 55 | "@types/spotify-web-playback-sdk": "^0.1.17", 56 | "@types/styled-components": "^5.1.26", 57 | "@types/uuid": "^9.0.8", 58 | "prettier": "2.8.7", 59 | "rimraf": "^5.0.1", 60 | "typescript": "5.0.4", 61 | "sharp": "^0.34.0" 62 | }, 63 | "manifest": { 64 | "web_accessible_resources": [ 65 | { 66 | "resources": [ 67 | "assets/icon.png" 68 | ], 69 | "matches": [ 70 | "https://www.synqapp.io/*" 71 | ] 72 | } 73 | ], 74 | "permissions": [ 75 | "storage" 76 | ], 77 | "optional_host_permissions": [ 78 | "https://*/*", 79 | "*://*/*", 80 | "" 81 | ], 82 | "optional_permissions": [ 83 | "tabs", 84 | "notifications" 85 | ], 86 | "externally_connectable": { 87 | "matches": [ 88 | "*://music.apple.com/*", 89 | "*://*.spotify.com/*", 90 | "*://music.youtube.com/*", 91 | "*://music.amazon.com/*" 92 | ] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/adapters/amazon-music/AmazonAdapter.ts: -------------------------------------------------------------------------------- 1 | import AmazonLogo from 'data-base64:~assets/images/amazon-logo.svg'; 2 | 3 | import type { ContentController, MusicServiceAdapter } from '~core/adapter'; 4 | import { MUSIC_SERVICE } from '~types'; 5 | 6 | import { AmazonBackgroundController } from './AmazonBackgroundController'; 7 | import { AmazonContentController } from './AmazonContentController'; 8 | import { AmazonMusicObserver } from './AmazonContentObserver'; 9 | 10 | export const AmazonAdapter: MusicServiceAdapter = { 11 | displayName: 'Amazon Music', 12 | id: MUSIC_SERVICE.AMAZONMUSIC, 13 | baseUrl: 'https://music.amazon.com/', 14 | icon: AmazonLogo, 15 | urlMatches: ['*://music.amazon.com/*'], 16 | disabledFeatures: ['like', 'dislike'], 17 | enabledKeyControls: { 18 | volumeDown: true, 19 | volumeUp: true 20 | }, 21 | backgroundController: () => new AmazonBackgroundController(), 22 | contentController: () => new AmazonContentController(), 23 | contentObserver: (contentController: ContentController) => 24 | new AmazonMusicObserver(contentController as AmazonContentController) 25 | }; 26 | -------------------------------------------------------------------------------- /src/adapters/amazon-music/types.ts: -------------------------------------------------------------------------------- 1 | export interface Maestro { 2 | getAudioPlayer(): { 3 | getAudioElement(): HTMLAudioElement; 4 | }; 5 | getVolume(): number; 6 | isPlaying(): boolean; 7 | getCurrentTime(): number; 8 | getConfig(): { 9 | tier: string[]; 10 | }; 11 | addEventListener(event: string, callback: () => void): void; 12 | removeEventListener(event: string, callback: () => void): void; 13 | } 14 | 15 | export interface AmznMusic { 16 | appConfig: { 17 | accessToken: string; 18 | customerId: string; 19 | csrf: { 20 | token: string; 21 | ts: string; 22 | rnd: string; 23 | }; 24 | deviceId: string; 25 | displayLanguage: string; 26 | sessionId: string; 27 | version: string; 28 | }; 29 | } 30 | 31 | export interface NativeAmazonMusicQueueItem { 32 | id: string; 33 | primaryLink: { 34 | deeplink: string; 35 | }; 36 | primaryText: string; 37 | secondaryText1: string; 38 | secondaryText2: string; 39 | secondaryText3: string; 40 | image: string; 41 | } 42 | 43 | export interface NativeAmazonAlbum { 44 | primaryLink: { 45 | deeplink: string; 46 | }; 47 | primaryText: { 48 | text: string; 49 | }; 50 | secondaryText: string; 51 | } 52 | 53 | export interface NativeAmazonTracksWidget { 54 | header: 'Tracks'; 55 | items: NativeAmazonMusicQueueItem[]; 56 | } 57 | 58 | export interface NativeAmazonAlbumsWidget { 59 | header: 'Albums'; 60 | items: NativeAmazonAlbum[]; 61 | } 62 | 63 | export interface NativeAmazonArtistsWidget { 64 | header: 'Artists'; 65 | items: NativeAmazonAlbum[]; 66 | } 67 | 68 | export interface NativeAmazonMusicSearchResult { 69 | methods: { 70 | template: { 71 | widgets: (NativeAmazonTracksWidget | NativeAmazonAlbumsWidget)[]; 72 | }; 73 | }[]; 74 | } 75 | -------------------------------------------------------------------------------- /src/adapters/apple-music/AppleAdapter.ts: -------------------------------------------------------------------------------- 1 | import AppleLogo from 'data-base64:~assets/images/apple-logo.svg'; 2 | 3 | import type { ContentController, MusicServiceAdapter } from '~core/adapter'; 4 | import { MUSIC_SERVICE } from '~types'; 5 | 6 | import { AppleBackgroundController } from './AppleBackgroundController'; 7 | import { AppleContentController } from './AppleContentController'; 8 | import { AppleObserver } from './AppleContentObserver'; 9 | 10 | export const AppleAdapter: MusicServiceAdapter = { 11 | displayName: 'Apple Music', 12 | id: MUSIC_SERVICE.APPLEMUSIC, 13 | baseUrl: 'https://music.apple.com/', 14 | icon: AppleLogo, 15 | urlMatches: ['*://music.apple.com/*'], 16 | disabledFeatures: ['like', 'dislike'], 17 | enabledKeyControls: { 18 | next: true, 19 | previous: true, 20 | volumeDown: true, 21 | volumeUp: true 22 | }, 23 | backgroundController: () => new AppleBackgroundController(), 24 | contentController: () => new AppleContentController(), 25 | contentObserver: (contentController: ContentController) => 26 | new AppleObserver(contentController as AppleContentController) 27 | }; 28 | -------------------------------------------------------------------------------- /src/adapters/apple-music/AppleContentObserver.ts: -------------------------------------------------------------------------------- 1 | import type { ContentObserver } from '~core/adapter'; 2 | import type { ReconnectingHub } from '~core/messaging/hub'; 3 | import { updateCurrentTrack, updatePlaybackState } from '~core/player'; 4 | 5 | import type { AppleContentController } from './AppleContentController'; 6 | 7 | const playbackStateChangedEvents = [ 8 | 'playbackStateDidChange', 9 | 'playbackTimeDidChange', 10 | 'playbackDurationDidChange', 11 | 'playbackProgressDidChange', 12 | 'playbackVolumeDidChange', 13 | 'repeatModeDidChange' 14 | ]; 15 | 16 | export class AppleObserver implements ContentObserver { 17 | private _nowPlayingItemDidChangeHandler!: () => void; 18 | private _playbackStateChangeHandler!: () => void; 19 | 20 | public constructor(private _controller: AppleContentController) {} 21 | 22 | public observe(): void { 23 | const interval = setInterval(() => { 24 | if (this._controller.getPlayer()) { 25 | clearInterval(interval); 26 | 27 | /** 28 | * Create the handlers here so that we can remove it later. 29 | * Needs to be wrapped this way so that we can use `this`. 30 | */ 31 | this._nowPlayingItemDidChangeHandler = async () => { 32 | await this._handleTrackUpdated(); 33 | }; 34 | 35 | this._playbackStateChangeHandler = async () => { 36 | await this._handlePlaybackUpdated(); 37 | }; 38 | 39 | /** 40 | * Add the event listeners. 41 | */ 42 | this._controller 43 | .getPlayer() 44 | .addEventListener( 45 | 'nowPlayingItemDidChange', 46 | this._nowPlayingItemDidChangeHandler 47 | ); 48 | 49 | playbackStateChangedEvents.forEach((event) => { 50 | this._controller 51 | .getPlayer() 52 | .addEventListener(event, this._playbackStateChangeHandler); 53 | }); 54 | } 55 | }, 500); 56 | } 57 | 58 | private async _handleTrackUpdated(): Promise { 59 | const currentTrack = this._controller.getCurrentTrack(); 60 | updateCurrentTrack(currentTrack); 61 | } 62 | 63 | private async _handlePlaybackUpdated(): Promise { 64 | const playerState = this._controller.getPlayerState(); 65 | await updatePlaybackState(playerState); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/adapters/apple-music/types.ts: -------------------------------------------------------------------------------- 1 | export interface NativeAppleMusicMediaItem { 2 | attributes: { 3 | artwork: { 4 | url: string; 5 | }; 6 | albumName: string; 7 | artistName: string; 8 | name: string; 9 | playParams: { 10 | id: string; 11 | }; 12 | durationInMillis: number; 13 | }; 14 | } 15 | 16 | export interface NativeAppleMusicQueueItem { 17 | item: NativeAppleMusicMediaItem; 18 | } 19 | 20 | export interface NativeAppleMusicUser { 21 | subscription: { 22 | active: boolean; 23 | }; 24 | } 25 | 26 | export interface AppleMusicApi { 27 | search: ( 28 | query: string, 29 | options: { limit: number; types: string } 30 | ) => Promise<{ 31 | songs: { 32 | data: NativeAppleMusicMediaItem[]; 33 | }; 34 | }>; 35 | song: (id: string) => Promise; 36 | album: (id: string) => Promise; 37 | artist: (id: string) => Promise; 38 | } 39 | 40 | export interface MusicKit { 41 | play: () => void; 42 | pause: () => void; 43 | skipToNextItem: () => void; 44 | skipToPreviousItem: () => void; 45 | repeatMode: number; 46 | volume: number; 47 | isPlaying: boolean; 48 | currentPlaybackTime: number; 49 | seekToTime: (time: number) => void; 50 | nowPlayingItem: NativeAppleMusicMediaItem; 51 | nowPlayingItemIndex: number; 52 | queue: { 53 | _queueItems: NativeAppleMusicQueueItem[]; 54 | }; 55 | changeToMediaItem: (id: string) => void; 56 | changeToMediaAtIndex: (index: number) => void; 57 | playLater: (options: { song: string }) => Promise; 58 | me: () => Promise; 59 | api: AppleMusicApi; 60 | addEventListener: (event: string, callback: () => void) => void; 61 | removeEventListener: (event: string, callback: () => void) => void; 62 | } 63 | -------------------------------------------------------------------------------- /src/adapters/index.ts: -------------------------------------------------------------------------------- 1 | import { AmazonAdapter } from './amazon-music/AmazonAdapter'; 2 | import { AppleAdapter } from './apple-music/AppleAdapter'; 3 | import { SpotifyAdapter } from './spotify/SpotifyAdapter'; 4 | import { YouTubeMusicAdapter } from './youtube-music/YouTubeMusicAdapter'; 5 | 6 | // Ordered by popularity 7 | export default [ 8 | SpotifyAdapter, 9 | AppleAdapter, 10 | YouTubeMusicAdapter, 11 | AmazonAdapter 12 | ]; 13 | -------------------------------------------------------------------------------- /src/adapters/spotify/SpotifyAdapter.ts: -------------------------------------------------------------------------------- 1 | import SpotifyLogo from 'data-base64:~assets/images/spotify-logo.svg'; 2 | 3 | import type { ContentController, MusicServiceAdapter } from '~core/adapter'; 4 | import { MUSIC_SERVICE } from '~types'; 5 | 6 | import { SpotifyBackgroundController } from './SpotifyBackgroundController'; 7 | import { SpotifyContentController } from './SpotifyContentController'; 8 | import { SpotifyObserver } from './SpotifyContentObserver'; 9 | 10 | export const SpotifyAdapter: MusicServiceAdapter = { 11 | displayName: 'Spotify', 12 | id: MUSIC_SERVICE.SPOTIFY, 13 | baseUrl: 'https://open.spotify.com/', 14 | icon: SpotifyLogo, 15 | urlMatches: ['*://open.spotify.com/*'], 16 | disabledFeatures: ['dislike', 'queue'], 17 | enabledKeyControls: { 18 | next: true, 19 | previous: true, 20 | volumeDown: true, 21 | volumeUp: true 22 | }, 23 | backgroundController: () => new SpotifyBackgroundController(), 24 | contentController: () => new SpotifyContentController(), 25 | contentObserver: (contentController: ContentController) => 26 | new SpotifyObserver(contentController as SpotifyContentController) 27 | }; 28 | -------------------------------------------------------------------------------- /src/adapters/spotify/auth.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyEndpoints } from '~adapters/spotify/constants'; 2 | 3 | export const getAuthorizationToken = async (): Promise => { 4 | const response = await fetch( 5 | `${SpotifyEndpoints.GET_AUTH_TOKEN}?reason=transport&productType=web_player`, 6 | { 7 | credentials: 'include' 8 | } 9 | ); 10 | const json = await response.json(); 11 | return json.accessToken; 12 | }; 13 | -------------------------------------------------------------------------------- /src/adapters/spotify/constants.ts: -------------------------------------------------------------------------------- 1 | export const SpotifyEndpoints = { 2 | ALBUMS: 'https://api.spotify.com/v1/albums', 3 | ARTISTS: 'https://api.spotify.com/v1/artists', 4 | CURRENTLY_PLAYING: 'https://api.spotify.com/v1/me/player/currently-playing', 5 | GET_AUTH_TOKEN: 'https://open.spotify.com/get_access_token', 6 | GET_QUEUE: 'https://api.spotify.com/v1/me/player/queue', 7 | IS_IN_LIBRARY: 'https://api.spotify.com/v1/me/tracks/contains', 8 | MODIFY_LIBRARY: 'https://api.spotify.com/v1/me/tracks', 9 | NEXT: 'https://api.spotify.com/v1/me/player/next', 10 | PAUSE: 'https://api.spotify.com/v1/me/player/pause', 11 | PLAY: 'https://api.spotify.com/v1/me/player/play', 12 | PLAYER_STATE: 'https://api.spotify.com/v1/me/player', 13 | PREVIOUS: 'https://api.spotify.com/v1/me/player/previous', 14 | SEARCH: 'https://api.spotify.com/v1/search', 15 | SEEK_TO: 'https://api.spotify.com/v1/me/player/seek', 16 | SET_REPEAT_MODE: 'https://api.spotify.com/v1/me/player/repeat', 17 | SET_VOLUME: 'https://api.spotify.com/v1/me/player/volume', 18 | TRACKS: 'https://api.spotify.com/v1/tracks' 19 | }; 20 | -------------------------------------------------------------------------------- /src/adapters/spotify/types.ts: -------------------------------------------------------------------------------- 1 | export interface NativeSpotifyPodcastTrack { 2 | type: 'episode'; 3 | id: string; 4 | uri: string; 5 | name: string; 6 | show: { 7 | name: string; 8 | publisher: string; 9 | }; 10 | images: { 11 | url: string; 12 | }[]; 13 | duration_ms: number; 14 | } 15 | 16 | export interface NativeSpotifySongTrack { 17 | type: 'track'; 18 | id: string; 19 | uri: string; 20 | name: string; 21 | artists: { 22 | name: string; 23 | }[]; 24 | album: { 25 | images: { 26 | url: string; 27 | }[]; 28 | name: string; 29 | }; 30 | duration_ms: number; 31 | } 32 | 33 | export interface NativeSpotifyAlbum { 34 | type: 'album'; 35 | id: string; 36 | uri: string; 37 | name: string; 38 | artists: { 39 | name: string; 40 | }[]; 41 | images: { 42 | url: string; 43 | }[]; 44 | } 45 | 46 | export interface NativeSpotifyArtist { 47 | type: 'artist'; 48 | id: string; 49 | uri: string; 50 | name: string; 51 | images: { 52 | url: string; 53 | }[]; 54 | } 55 | 56 | export type NativeSpotifyTrack = 57 | | NativeSpotifyPodcastTrack 58 | | NativeSpotifySongTrack; 59 | -------------------------------------------------------------------------------- /src/adapters/youtube-music/YouTubeMusicAdapter.ts: -------------------------------------------------------------------------------- 1 | import YoutubeLogo from 'data-base64:~assets/images/youtube-logo.svg'; 2 | 3 | import type { ContentController, MusicServiceAdapter } from '~core/adapter'; 4 | import { MUSIC_SERVICE } from '~types'; 5 | 6 | import { YouTubeMusicBackgroundController } from './YouTubeMusicBackgroundController'; 7 | import { YouTubeMusicContentController } from './YouTubeMusicContentController'; 8 | import { YouTubeMusicObserver } from './YouTubeMusicContentObserver'; 9 | 10 | export const YouTubeMusicAdapter: MusicServiceAdapter = { 11 | displayName: 'YouTube Music', 12 | id: MUSIC_SERVICE.YOUTUBEMUSIC, 13 | baseUrl: 'https://music.youtube.com/', 14 | icon: YoutubeLogo, 15 | urlMatches: ['*://music.youtube.com/*'], 16 | disabledFeatures: [], 17 | enabledKeyControls: { 18 | next: true, 19 | previous: true, 20 | volumeDown: true, 21 | volumeUp: true 22 | }, 23 | backgroundController: () => new YouTubeMusicBackgroundController(), 24 | contentController: () => new YouTubeMusicContentController(), 25 | contentObserver: (contentController: ContentController) => 26 | new YouTubeMusicObserver(contentController as YouTubeMusicContentController) 27 | }; 28 | -------------------------------------------------------------------------------- /src/adapters/youtube-music/YouTubeMusicContentObserver.ts: -------------------------------------------------------------------------------- 1 | import type { ContentObserver } from '~core/adapter'; 2 | import { updateCurrentTrack, updatePlaybackState } from '~core/player'; 3 | 4 | import type { YouTubeMusicContentController } from './YouTubeMusicContentController'; 5 | 6 | export class YouTubeMusicObserver implements ContentObserver { 7 | private _onStateChangeHandler!: () => void; 8 | private _onVideoDataChangeHandler!: () => void; 9 | private _mutationObservers: MutationObserver[] = []; 10 | 11 | constructor(private _controller: YouTubeMusicContentController) {} 12 | 13 | public observe(): void { 14 | const interval = setInterval(() => { 15 | if (this._controller.getPlayer()) { 16 | clearInterval(interval); 17 | 18 | this._setupPlayerStateObserver(); 19 | this._setupSongInfoObserver(); 20 | } 21 | }, 500); 22 | } 23 | 24 | private _setupPlayerStateObserver() { 25 | this._onStateChangeHandler = async () => { 26 | await this._handlePlaybackUpdated(); 27 | }; 28 | 29 | this._controller 30 | .getPlayer() 31 | .addEventListener('onStateChange', this._onStateChangeHandler); 32 | 33 | const playerStateObserver = new MutationObserver(async () => { 34 | await this._handlePlaybackUpdated(); 35 | }); 36 | 37 | const progressBarKnobElement = document.querySelector( 38 | '#progress-bar #sliderKnob .slider-knob-inner' 39 | ); 40 | if (progressBarKnobElement) { 41 | playerStateObserver.observe(progressBarKnobElement, { 42 | attributeFilter: ['value'] 43 | }); 44 | } 45 | 46 | const volumeElement = document.getElementById('volume-slider'); 47 | if (volumeElement) { 48 | playerStateObserver.observe(volumeElement, { 49 | attributeFilter: ['value'] 50 | }); 51 | } 52 | 53 | const repeatButton = document.querySelector('.repeat.ytmusic-player-bar'); 54 | if (repeatButton) { 55 | playerStateObserver.observe(repeatButton, { 56 | attributeFilter: ['aria-label'] 57 | }); 58 | } 59 | 60 | this._mutationObservers.push(playerStateObserver); 61 | } 62 | 63 | private _setupSongInfoObserver() { 64 | this._onVideoDataChangeHandler = async () => { 65 | await this._handleTrackUpdated(); 66 | }; 67 | 68 | this._controller 69 | .getPlayer() 70 | .addEventListener('videodatachange', this._onVideoDataChangeHandler); 71 | 72 | const songInfoObserver = new MutationObserver(async () => { 73 | await this._handleTrackUpdated(); 74 | }); 75 | 76 | const likeButton = document.querySelector( 77 | '.ytmusic-player-bar #button-shape-like' 78 | ); 79 | if (likeButton) { 80 | songInfoObserver.observe(likeButton, { 81 | attributeFilter: ['aria-pressed'] 82 | }); 83 | } 84 | 85 | const dislikeButton = document.querySelector( 86 | '.ytmusic-player-bar #button-shape-dislike' 87 | ); 88 | if (dislikeButton) { 89 | songInfoObserver.observe(dislikeButton, { 90 | attributeFilter: ['aria-pressed'] 91 | }); 92 | } 93 | } 94 | 95 | private async _handleTrackUpdated(): Promise { 96 | const currentTrack = this._controller.getCurrentTrack(); 97 | updateCurrentTrack(currentTrack); 98 | } 99 | 100 | private async _handlePlaybackUpdated(): Promise { 101 | const playerState = this._controller.getPlayerState(); 102 | await updatePlaybackState(playerState); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/adapters/youtube-music/types.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from 'redux'; 2 | 3 | export interface NativeYouTubeMusicQueueItem { 4 | playlistPanelVideoWrapperRenderer: { 5 | primaryRenderer: { 6 | playlistPanelVideoRenderer: NativeYouTubeMusicQueueItemRendererData; 7 | }; 8 | }; 9 | playlistPanelVideoRenderer: NativeYouTubeMusicQueueItemRendererData; 10 | } 11 | 12 | export interface NativeYouTubeMusicTextRun { 13 | text: string; 14 | } 15 | 16 | export interface NativeYouTubeMusicThumbnail { 17 | url: string; 18 | width: number; 19 | height: number; 20 | } 21 | 22 | export interface NativeYouTubeMusicQueueItemRendererData { 23 | videoId: string; 24 | title: { 25 | runs: NativeYouTubeMusicTextRun[]; 26 | }; 27 | longBylineText: { 28 | runs: NativeYouTubeMusicTextRun[]; 29 | }; 30 | thumbnail: { 31 | thumbnails: NativeYouTubeMusicThumbnail[]; 32 | }; 33 | lengthText: { 34 | runs: NativeYouTubeMusicTextRun[]; 35 | }; 36 | } 37 | 38 | export interface NativeYouTubeMusicMoviePlayer { 39 | addEventListener: (event: string, callback: () => void) => void; 40 | getCurrentTime: () => number; 41 | getDuration: () => number; 42 | getPlayerState: () => number; 43 | getVideoData: () => { 44 | author: string; 45 | title: string; 46 | video_id: string; 47 | }; 48 | getVideoUrl: () => string; 49 | getVolume: () => number; 50 | nextVideo: () => void; 51 | pauseVideo: () => void; 52 | playVideo: () => void; 53 | previousVideo: () => void; 54 | removeEventListener: (event: string, callback: () => void) => void; 55 | seekTo: (seconds: number) => void; 56 | setVolume: (volume: number) => void; 57 | } 58 | 59 | export interface YtmAppState { 60 | player: { 61 | playerResponse: { 62 | videoDetails: { 63 | videoId: string; 64 | }; 65 | }; 66 | }; 67 | queue: { 68 | items: NativeYouTubeMusicQueueItem[]; 69 | selectedItemIndex: number; 70 | }; 71 | } 72 | 73 | export interface YtmApp { 74 | store: Store; 75 | playerUiState_: 'INACTIVE'; 76 | } 77 | 78 | export interface YtmSearchResultRun { 79 | text: string; 80 | navigationEndpoint?: { 81 | clickTrackingParams: string; 82 | browseEndpoint: { 83 | browseId: string; 84 | }; 85 | }; 86 | } 87 | 88 | export interface YtmSearchApiResultMusicCardShelfRenderer { 89 | musicCardShelfRenderer: { 90 | subtitle: { 91 | runs: YtmSearchResultRun[]; 92 | }; 93 | thumbnail: { 94 | thumbnails: NativeYouTubeMusicThumbnail[]; 95 | }; 96 | title: { 97 | runs: YtmSearchResultRun[]; 98 | }; 99 | }; 100 | } 101 | 102 | export interface YtmSearchApiResultMusicResponsiveListItemRenderer { 103 | musicResponsiveListItemRenderer: { 104 | flexColumns: { 105 | musicResponsiveListItemFlexColumnRenderer: { 106 | text: { 107 | runs: YtmSearchResultRun[]; 108 | }; 109 | }; 110 | }[]; 111 | navigationEndpoint: { 112 | browseEndpoint: { 113 | browseId: string; 114 | }; 115 | }; 116 | }; 117 | } 118 | 119 | export interface YtmSearchApiResultMusicShelfRenderer { 120 | musicShelfRenderer: { 121 | contents: YtmSearchApiResultMusicResponsiveListItemRenderer[]; 122 | title: { 123 | runs: YtmSearchResultRun[]; 124 | }; 125 | }; 126 | } 127 | 128 | export interface YtmSearchApiResult { 129 | contents: { 130 | tabbedSearchResultsRenderer: { 131 | tabs: { 132 | tabRenderer: { 133 | content: { 134 | sectionListRenderer: { 135 | contents: ( 136 | | YtmSearchApiResultMusicCardShelfRenderer 137 | | YtmSearchApiResultMusicShelfRenderer 138 | )[]; 139 | }; 140 | }; 141 | }; 142 | }[]; 143 | }; 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /src/background/createInstallHandler.ts: -------------------------------------------------------------------------------- 1 | import { store } from '~store'; 2 | import { clearMusicServiceTabs } from '~store/slices/musicServiceTabs'; 3 | import { setSettings } from '~store/slices/settings'; 4 | import type { Settings } from '~types'; 5 | 6 | const MIN_SYNQ_VERSION = '4.0.0'; 7 | const OPTIONS_KEY = 'options'; 8 | const LAST_FM_SESSION_KEY = 'lastfm-info'; 9 | 10 | const getPreviousOptions = async () => { 11 | return (await chrome.storage.sync.get(OPTIONS_KEY))[OPTIONS_KEY]; 12 | }; 13 | 14 | const getPreviousLastFmSession = async () => { 15 | return (await chrome.storage.sync.get(LAST_FM_SESSION_KEY))[ 16 | LAST_FM_SESSION_KEY 17 | ]; 18 | }; 19 | 20 | const transferSettings = async () => { 21 | const previousOptions = await getPreviousOptions(); 22 | const settings = store.getState().settings; 23 | 24 | const notificationsEnabled = previousOptions.notifications; 25 | const miniPlayerKeyControlsEnabled = previousOptions.miniKeyControl; 26 | const musicServiceKeyControlsEnabled = previousOptions.ytmKeyControl; 27 | const redirectsEnabled = previousOptions.spotifyToYTM; 28 | 29 | const newSettings: Settings = { 30 | ...settings, 31 | notificationsEnabled, 32 | miniPlayerKeyControlsEnabled, 33 | musicServiceKeyControlsEnabled, 34 | redirectsEnabled, 35 | preferredMusicService: 'YOUTUBEMUSIC' 36 | }; 37 | 38 | store.dispatch(setSettings(newSettings)); 39 | }; 40 | 41 | const openOnboardingPage = async (update: boolean) => { 42 | const queryParams = new URLSearchParams(); 43 | 44 | if (update) { 45 | queryParams.set('update', 'true'); 46 | 47 | const lastFmSession = await getPreviousLastFmSession(); 48 | 49 | if (lastFmSession?.key) { 50 | queryParams.set('lastfm', 'true'); 51 | } 52 | } 53 | 54 | const url = `${chrome.runtime.getURL( 55 | `tabs/onboard.html` 56 | )}?${queryParams.toString()}`; 57 | chrome.tabs.create({ url }); 58 | }; 59 | 60 | export const initializeInstallHandler = () => { 61 | chrome.runtime.onInstalled.addListener(async (installDetails) => { 62 | store.dispatch(clearMusicServiceTabs()); 63 | 64 | if (installDetails.reason === 'install') { 65 | openOnboardingPage(false); 66 | } else if (installDetails.reason === 'update') { 67 | if ( 68 | installDetails.previousVersion && 69 | installDetails.previousVersion < MIN_SYNQ_VERSION 70 | ) { 71 | await transferSettings(); 72 | await openOnboardingPage(true); 73 | } 74 | } 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { getHubMap, startHub } from '@plasmohq/messaging/pub-sub'; 2 | 3 | import { initializeInstallHandler } from './createInstallHandler'; 4 | import { registerHubMessageHandlers } from './registerHubMessageHandlers'; 5 | import { updateMusicServiceTabs } from './updateMusicServiceTabs'; 6 | 7 | startHub(); 8 | updateMusicServiceTabs(); 9 | initializeInstallHandler(); 10 | 11 | chrome.runtime.onConnectExternal.addListener((port) => { 12 | registerHubMessageHandlers(port); 13 | 14 | updateMusicServiceTabs(); 15 | 16 | port.onDisconnect.addListener(() => { 17 | updateMusicServiceTabs(); 18 | }); 19 | }); 20 | 21 | chrome.runtime.onConnect.addListener((port) => { 22 | if (!port.sender?.tab?.id) { 23 | return; 24 | } 25 | 26 | getHubMap().set(port.sender?.tab?.id, port); 27 | 28 | registerHubMessageHandlers(port); 29 | 30 | updateMusicServiceTabs(); 31 | 32 | port.onDisconnect.addListener(() => { 33 | if (!port.sender?.tab?.id) { 34 | return; 35 | } 36 | 37 | getHubMap().delete(port.sender.tab.id); 38 | updateMusicServiceTabs(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/background/messages/BROADCAST.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | import { 3 | type PubSubMessage, 4 | broadcast, 5 | getHubMap 6 | } from '@plasmohq/messaging/pub-sub'; 7 | 8 | const handler: PlasmoMessaging.MessageHandler = async ( 9 | req, 10 | res 11 | ) => { 12 | if (req.body?.to) { 13 | const hubMap = getHubMap(); 14 | const port = hubMap.get(req.body.to); 15 | 16 | if (port) { 17 | port.postMessage(req.body); 18 | } 19 | } else if (req.body) { 20 | broadcast(req.body); 21 | } 22 | res.send(undefined); 23 | }; 24 | 25 | export default handler; 26 | -------------------------------------------------------------------------------- /src/background/messages/CREATE_TRACK_NOTIFICATION.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import { createTrackNotification } from '~core/notifications'; 4 | import { store } from '~store'; 5 | import type { Track } from '~types'; 6 | import { sendEvent } from '~util/analytics'; 7 | 8 | const isPopupOpen = (): boolean => { 9 | return store.getState().popupOpen; 10 | }; 11 | 12 | const isCurrentTab = async (tabId: number) => { 13 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 14 | return tabs.map((tab) => tab.id).includes(tabId); 15 | }; 16 | 17 | export const handler: PlasmoMessaging.MessageHandler = async (req) => { 18 | const track = req.body; 19 | const sender = req.sender; 20 | 21 | if (!track) { 22 | return; 23 | } 24 | 25 | await sendEvent({ 26 | name: 'track_played' 27 | }); 28 | 29 | const state = store.getState(); 30 | 31 | if (!state.settings.notificationsEnabled) { 32 | return; 33 | } 34 | 35 | const tabId = sender?.tab?.id; 36 | 37 | if (isPopupOpen() || !tabId || (await isCurrentTab(tabId))) { 38 | return; 39 | } 40 | 41 | await createTrackNotification(track); 42 | }; 43 | 44 | export default handler; 45 | -------------------------------------------------------------------------------- /src/background/messages/DISPATCH.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from 'redux'; 2 | 3 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 4 | 5 | import { store } from '~store'; 6 | 7 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 8 | const action = req.body; 9 | 10 | if (!action) { 11 | return; 12 | } 13 | 14 | store.dispatch(action); 15 | }; 16 | 17 | export default handler; 18 | -------------------------------------------------------------------------------- /src/background/messages/GET_REDIRECT_LINK.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import type { MusicService } from '~/types'; 4 | import adapters from '~adapters'; 5 | import type { BackgroundController, TrackSearchResult } from '~core/adapter'; 6 | import { getBestMatch } from '~core/links'; 7 | import type { LinkType } from '~core/links'; 8 | 9 | interface GetRedirectLinkRequest { 10 | destinationMusicService: MusicService; 11 | artistName: string; 12 | trackName?: string; 13 | albumName?: string; 14 | duration: number; 15 | linkType: LinkType; 16 | } 17 | 18 | const handler: PlasmoMessaging.MessageHandler = async ( 19 | req, 20 | res 21 | ) => { 22 | if (!req.body) { 23 | res.send(undefined); 24 | return; 25 | } 26 | 27 | const linkRequest = req.body; 28 | 29 | const adapter = adapters.find( 30 | (adapter) => adapter.id === linkRequest.destinationMusicService 31 | ); 32 | const backgroundController = adapter?.backgroundController(); 33 | 34 | if (!backgroundController) { 35 | res.send(undefined); 36 | return; 37 | } 38 | 39 | if (linkRequest.linkType === 'TRACK') { 40 | const link = await getTrackRedirectLink(linkRequest, backgroundController); 41 | res.send(link); 42 | } else if (linkRequest.linkType === 'ALBUM') { 43 | const link = await getAlbumRedirectLink(linkRequest, backgroundController); 44 | res.send(link); 45 | } else if (linkRequest.linkType === 'ARTIST') { 46 | const link = await getArtistRedirectLink(linkRequest, backgroundController); 47 | res.send(link); 48 | } 49 | }; 50 | 51 | export default handler; 52 | 53 | const getTrackRedirectLink = async ( 54 | linkRequest: GetRedirectLinkRequest, 55 | backgroundController: BackgroundController 56 | ) => { 57 | if (!linkRequest.trackName) { 58 | return undefined; 59 | } 60 | 61 | const searchResults = await backgroundController.searchTracks({ 62 | artistName: linkRequest.artistName, 63 | name: linkRequest.trackName, 64 | albumName: linkRequest.albumName, 65 | duration: linkRequest.duration 66 | }); 67 | 68 | const bestResult = getBestMatch( 69 | linkRequest, 70 | searchResults.map((result) => ({ 71 | ...result, 72 | trackName: result.name, 73 | linkType: 'TRACK' 74 | })) 75 | ); 76 | 77 | return bestResult.link; 78 | }; 79 | 80 | const getAlbumRedirectLink = async ( 81 | linkRequest: GetRedirectLinkRequest, 82 | backgroundController: BackgroundController 83 | ) => { 84 | if (!linkRequest.albumName) { 85 | return undefined; 86 | } 87 | 88 | const searchResults = await backgroundController.searchAlbums({ 89 | name: linkRequest.albumName, 90 | artistName: linkRequest.artistName 91 | }); 92 | 93 | const bestResult = getBestMatch( 94 | linkRequest, 95 | searchResults.map((result) => ({ 96 | ...result, 97 | albumName: result.name, 98 | linkType: 'ALBUM' 99 | })) 100 | ); 101 | 102 | return bestResult.link; 103 | }; 104 | 105 | const getArtistRedirectLink = async ( 106 | linkRequest: GetRedirectLinkRequest, 107 | backgroundController: BackgroundController 108 | ) => { 109 | const searchResults = await backgroundController.searchArtists({ 110 | name: linkRequest.artistName 111 | }); 112 | 113 | const bestResult = getBestMatch( 114 | linkRequest, 115 | searchResults.map((result) => ({ 116 | artistName: result.name, 117 | link: result.link, 118 | linkType: 'ARTIST' 119 | })) 120 | ); 121 | 122 | return bestResult.link; 123 | }; 124 | -------------------------------------------------------------------------------- /src/background/messages/GET_SELF_TAB.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | export const handler: PlasmoMessaging.MessageHandler = async ( 4 | req, 5 | res 6 | ) => { 7 | res.send(req.sender?.tab); 8 | }; 9 | 10 | export default handler; 11 | -------------------------------------------------------------------------------- /src/background/messages/GET_SETTINGS.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import { store } from '~store'; 4 | 5 | export const handler: PlasmoMessaging.MessageHandler = async ( 6 | req, 7 | res 8 | ) => { 9 | const settings = store.getState().settings; 10 | res.send(settings); 11 | }; 12 | 13 | export default handler; 14 | -------------------------------------------------------------------------------- /src/background/messages/MINIMIZE_WINDOW.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 4 | const windowId = req.sender?.tab?.windowId; 5 | 6 | if (windowId) { 7 | chrome.windows.update(windowId, { state: 'minimized' }); 8 | } 9 | 10 | res.send(undefined); 11 | }; 12 | 13 | export default handler; 14 | -------------------------------------------------------------------------------- /src/background/messages/OPEN_OPTIONS_PAGE.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import { store } from '~store'; 4 | 5 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 6 | // Using chrome.runtime.openOptionsPage() would be ideal, but it doesn't 7 | // work when the popout is used to trigger the message because Chrome tries 8 | // to open the options page in the popout window, which is not allowed. Instead, 9 | // we try to open the options page in the same window as the music service tab. 10 | 11 | const state = store.getState(); 12 | const musicServiceTabIds = state.musicServiceTabs.map((tab) => tab.tabId); 13 | 14 | const optionsUrl = chrome.runtime.getURL('options.html'); 15 | const windows = await chrome.windows.getAll(); 16 | 17 | const musicServiceTabWindow = windows.find((window) => 18 | window.tabs?.some((tab) => musicServiceTabIds.includes(tab.id as number)) 19 | ); 20 | 21 | await chrome.tabs.create({ 22 | url: optionsUrl, 23 | windowId: musicServiceTabWindow?.id 24 | }); 25 | 26 | res.send(undefined); 27 | }; 28 | 29 | export default handler; 30 | -------------------------------------------------------------------------------- /src/background/messages/REDIRECT_TO_TAB.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | interface RedirectToTabBody { 4 | tabName: string; 5 | searchParams: Record; 6 | } 7 | 8 | /** 9 | * 10 | */ 11 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 12 | const { tabName, searchParams } = req.body as RedirectToTabBody; 13 | 14 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 15 | const tab = tabs[0]; 16 | 17 | if (!tab) { 18 | res.send(undefined); 19 | } 20 | 21 | chrome.tabs.update(tab.id!, { 22 | url: `tabs/${tabName}.html?${new URLSearchParams(searchParams).toString()}` 23 | }); 24 | 25 | res.send(undefined); 26 | }; 27 | 28 | export default handler; 29 | -------------------------------------------------------------------------------- /src/background/messages/SEND_ANALYTICS_EVENT.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import { type Event, sendEvent } from '~util/analytics'; 4 | 5 | /** 6 | * No-op. This file just defines the message for Plasmo. Background script 7 | * does not need to do anything when this message is received. 8 | */ 9 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 10 | if (req.body) { 11 | await sendEvent(req.body); 12 | } 13 | 14 | res.send(undefined); 15 | }; 16 | 17 | export default handler; 18 | -------------------------------------------------------------------------------- /src/background/registerHubMessageHandlers.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import createTrackNotificationMessageHandler from './messages/CREATE_TRACK_NOTIFICATION'; 4 | import dispatchMessageHandler from './messages/DISPATCH'; 5 | import getSelfTabMessageHandler from './messages/GET_SELF_TAB'; 6 | import getSettingsMessageHandler from './messages/GET_SETTINGS'; 7 | 8 | export const registerHubMessageHandlers = (port: chrome.runtime.Port) => { 9 | port.onMessage.addListener(async (message) => { 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | const sendResponse = (response: any) => { 12 | const responseMessage = { 13 | requestId: message.requestId, 14 | body: response 15 | }; 16 | 17 | port.postMessage(responseMessage); 18 | }; 19 | 20 | const req: PlasmoMessaging.Request = { 21 | body: message.body, 22 | name: message.name, 23 | sender: port.sender 24 | }; 25 | 26 | const res: PlasmoMessaging.Response = { 27 | send: sendResponse 28 | }; 29 | 30 | switch (message.name) { 31 | case 'DISPATCH': 32 | await dispatchMessageHandler(req, res); 33 | break; 34 | 35 | case 'GET_SELF_TAB': 36 | await getSelfTabMessageHandler(req, res); 37 | break; 38 | 39 | case 'CREATE_TRACK_NOTIFICATION': 40 | await createTrackNotificationMessageHandler(req, res); 41 | break; 42 | 43 | case 'GET_SETTINGS': 44 | await getSettingsMessageHandler(req, res); 45 | break; 46 | } 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/background/updateMusicServiceTabs.ts: -------------------------------------------------------------------------------- 1 | import { broadcast } from '@plasmohq/messaging/pub-sub'; 2 | 3 | import { store } from '~store'; 4 | import { clearMusicServiceTabs } from '~store/slices/musicServiceTabs'; 5 | import { TabsMessage } from '~types/TabsMessage'; 6 | 7 | export const updateMusicServiceTabs = () => { 8 | store.dispatch(clearMusicServiceTabs()); 9 | 10 | // Prevent race condition where the tab updates before the store is cleared 11 | setTimeout(() => { 12 | broadcast({ 13 | payload: { name: TabsMessage.UPDATE_TAB } 14 | }); 15 | }, 250); 16 | }; 17 | -------------------------------------------------------------------------------- /src/constants/permissions.ts: -------------------------------------------------------------------------------- 1 | export const PERMISSIONS = ['tabs', 'notifications']; 2 | export const HOSTS = ['https://*/*', '*://*/*', '']; 3 | -------------------------------------------------------------------------------- /src/constants/port.ts: -------------------------------------------------------------------------------- 1 | export const POPUP_PORT = 'popup'; 2 | -------------------------------------------------------------------------------- /src/constants/search.ts: -------------------------------------------------------------------------------- 1 | export const SEARCH_LIMIT = 15; 2 | export const SEARCH_OFFSET = 0; 3 | -------------------------------------------------------------------------------- /src/constants/urls.ts: -------------------------------------------------------------------------------- 1 | export const SPOTIFY_URL_MATCH = '*://open.spotify.com/*'; 2 | export const YOUTUBE_MUSIC_URL_MATCH = '*://music.youtube.com/*'; 3 | export const AMAZON_MUSIC_URL_MATCH = '*://music.amazon.com/*'; 4 | export const APPLE_MUSIC_URL_MATCH = '*://music.apple.com/*'; 5 | 6 | export const ALL_URL_MATCHES = [ 7 | SPOTIFY_URL_MATCH, 8 | YOUTUBE_MUSIC_URL_MATCH, 9 | AMAZON_MUSIC_URL_MATCH, 10 | APPLE_MUSIC_URL_MATCH 11 | ]; 12 | 13 | export const SPOTIFY_URL = 'https://open.spotify.com'; 14 | export const YOUTUBE_MUSIC_URL = 'https://music.youtube.com'; 15 | export const AMAZON_MUSIC_URL = 'https://music.amazon.com'; 16 | export const APPLE_MUSIC_URL = 'https://music.apple.com'; 17 | -------------------------------------------------------------------------------- /src/contents/adapter.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoCSConfig } from 'plasmo'; 2 | 3 | import adapters from '~adapters'; 4 | import { registerAdapter } from '~core/adapter'; 5 | import { matchAdapter } from '~core/adapter/register'; 6 | 7 | export const config: PlasmoCSConfig = { 8 | matches: [ 9 | '*://music.amazon.com/*', 10 | '*://music.apple.com/*', 11 | '*://open.spotify.com/*', 12 | '*://music.youtube.com/*' 13 | ], 14 | all_frames: true, 15 | world: 'MAIN' 16 | }; 17 | 18 | const initialize = () => { 19 | const adapter = matchAdapter(window.location.href, adapters); 20 | 21 | if (!adapter) { 22 | return; 23 | } 24 | 25 | registerAdapter(adapter); 26 | }; 27 | 28 | initialize(); 29 | -------------------------------------------------------------------------------- /src/contents/amazon-music-redux.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoCSConfig } from 'plasmo'; 2 | import type { Store } from 'redux'; 3 | import type { StoreEnhancerStoreCreator } from 'redux'; 4 | import { type StoreEnhancer } from 'redux'; 5 | 6 | export const config: PlasmoCSConfig = { 7 | matches: ['*://music.amazon.com/*'], 8 | all_frames: true, 9 | world: 'MAIN', 10 | run_at: 'document_start' 11 | }; 12 | 13 | /** 14 | * Below is a highly modified version of: https://github.dev/reduxjs/redux-devtools/blob/main/extension/src/pageScript/index.ts 15 | */ 16 | 17 | interface ComposeConfig { 18 | name?: string; 19 | } 20 | 21 | window.__REDUX_STORES__ = []; 22 | 23 | /** 24 | * A custom compose function which exposes the store on the window object. 25 | */ 26 | const synqCompose = 27 | (config: ComposeConfig) => 28 | (...funcs: StoreEnhancer[]): StoreEnhancer => { 29 | return (...args) => { 30 | // Amazon Music passes in a single StoreEnhancer function. Calling it returns 31 | // a StoreEnhancerStoreCreator function which is used to create a Redux store. 32 | const createStore = funcs[0](...args); 33 | 34 | // We return a new StoreEnhancerStoreCreator function which wraps createStore 35 | // so we can expose the store on the window object. 36 | return ((...enhancerArgs) => { 37 | const store = createStore(...enhancerArgs) as unknown as Store & { 38 | name: string; 39 | }; 40 | 41 | // We need to set the name of the store so that we can find it later. 42 | (store as any).name = config.name; 43 | window.__REDUX_STORES__.push(store); 44 | 45 | return store; 46 | }) as StoreEnhancerStoreCreator; 47 | }; 48 | }; 49 | 50 | /** 51 | * Redux provides a browser extension for debugging Redux applications. It works by 52 | * exposing a global variable called __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ which the host 53 | * application can use to compose their Redux store. Amazon Music checks if this variable 54 | * is defined, and if it is, it uses it to compose its store. We can use this to inject 55 | * our own compose function which exposes the store on the window object for us to use. 56 | */ 57 | Object.defineProperty(window, '__REDUX_DEVTOOLS_EXTENSION_COMPOSE__', { 58 | get: () => { 59 | return synqCompose; 60 | }, 61 | set: () => { 62 | /** 63 | * Preventing Redux Dev Tools from setting the compose function in case a user 64 | * has it installed and it gets called after our code. 65 | */ 66 | console.info( 67 | 'PREVENTING REDUX DEV TOOLS FROM SETTING COMPOSE. If you want to use Redux Dev Tools, please disable SynQ temporarily.' 68 | ); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /src/contents/common.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoCSConfig } from 'plasmo'; 2 | 3 | import { enableKeyControls } from '~core/keys'; 4 | 5 | export const config: PlasmoCSConfig = { 6 | matches: [ 7 | '*://music.apple.com/*', 8 | '*://open.spotify.com/*', 9 | '*://music.youtube.com/*', 10 | '*://music.amazon.com/*' 11 | ], 12 | run_at: 'document_start' 13 | }; 14 | 15 | const initialize = () => { 16 | window.addEventListener('SynQ:GetExtensionId', () => { 17 | window.dispatchEvent( 18 | new CustomEvent('SynQ:ExtensionId', { detail: chrome.runtime.id }) 19 | ); 20 | }); 21 | 22 | enableKeyControls(); 23 | }; 24 | 25 | initialize(); 26 | -------------------------------------------------------------------------------- /src/contents/picture-in-picture.tsx: -------------------------------------------------------------------------------- 1 | import { Button, UiProvider } from '@synqapp/ui'; 2 | import type { PlasmoCSConfig, PlasmoCSUIProps, PlasmoGetStyle } from 'plasmo'; 3 | import { useEffect, useState } from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { Provider } from 'react-redux'; 6 | 7 | import { sendToContent } from '~core/messaging/sendToContent'; 8 | import { store } from '~store'; 9 | import { PipToggleButton } from '~ui/pip/PipToggleButton'; 10 | import { PipUi } from '~ui/pip/PipUi'; 11 | import { sendAnalytic } from '~util/analytics'; 12 | 13 | declare let window: { 14 | documentPictureInPicture?: { 15 | requestWindow: (options?: { 16 | width?: number; 17 | height?: number; 18 | }) => Promise; 19 | }; 20 | }; 21 | 22 | export const config: PlasmoCSConfig = { 23 | matches: [ 24 | '*://music.apple.com/*', 25 | '*://open.spotify.com/*', 26 | '*://music.youtube.com/*', 27 | '*://music.amazon.com/*' 28 | ], 29 | all_frames: true 30 | }; 31 | 32 | export const getRootContainer = () => { 33 | const container = document.createElement('div'); 34 | container.setAttribute('id', 'synq-pip-container'); 35 | container.style.position = 'fixed'; 36 | container.style.top = '120px'; 37 | container.style.right = '0'; 38 | container.style.zIndex = '99'; 39 | 40 | document.body.append(container); 41 | 42 | const style = document.createElement('style'); 43 | style.innerHTML = ` 44 | #synq-pip-container > .plasmo-csui-container { 45 | position: unset !important; 46 | } 47 | `; 48 | 49 | document.head.append(style); 50 | 51 | return container; 52 | }; 53 | 54 | const PipTriggerUi = ({ anchor }: PlasmoCSUIProps) => { 55 | const [showButton, setShowButton] = useState(false); 56 | 57 | useEffect(() => { 58 | if (window?.documentPictureInPicture) { 59 | setShowButton(true); 60 | } 61 | }, []); 62 | 63 | const handleButtonClick = async () => { 64 | const pipWindow = await window.documentPictureInPicture?.requestWindow({ 65 | width: 350, 66 | height: 350 67 | }); 68 | 69 | if (!pipWindow) return; 70 | 71 | const container = pipWindow.document.createElement('div'); 72 | pipWindow.document.body.append(container); 73 | 74 | const pipRoot = createRoot(container); 75 | pipRoot.render( 76 | 77 | 78 | 79 | ); 80 | 81 | setShowButton(false); 82 | 83 | sendAnalytic({ name: 'pip_opened' }); 84 | 85 | pipWindow.addEventListener('pagehide', () => { 86 | setShowButton(true); 87 | sendAnalytic({ name: 'pip_closed' }); 88 | }); 89 | }; 90 | 91 | return ( 92 | 93 | 94 | {showButton && } 95 | 96 | 97 | ); 98 | }; 99 | 100 | export default PipTriggerUi; 101 | -------------------------------------------------------------------------------- /src/contents/redirect-popup.tsx: -------------------------------------------------------------------------------- 1 | import { UiProvider } from '@synqapp/ui'; 2 | import type { PlasmoCSConfig, PlasmoCSUIProps, PlasmoGetStyle } from 'plasmo'; 3 | import { useEffect, useState } from 'react'; 4 | import { Provider } from 'react-redux'; 5 | import { StyleSheetManager } from 'styled-components'; 6 | 7 | import { type LinkType, parseLink } from '~core/links'; 8 | import { store } from '~store'; 9 | import { RedirectPopup } from '~ui/redirect/RedirectPopup'; 10 | import { sendAnalytic } from '~util/analytics'; 11 | 12 | export const config: PlasmoCSConfig = { 13 | matches: [ 14 | '*://music.apple.com/*', 15 | '*://open.spotify.com/*', 16 | '*://music.youtube.com/*', 17 | '*://music.amazon.com/*' 18 | ] 19 | }; 20 | 21 | /** 22 | * Allows styled-components to inject styles into the Plasmo element. 23 | */ 24 | export const getStyle: PlasmoGetStyle = () => { 25 | const style = document.createElement('style'); 26 | style.setAttribute('data-styled', ''); 27 | return style; 28 | }; 29 | 30 | export const getShadowHostId = () => 'synq-redirect-popup'; 31 | 32 | const RedirectUi = ({ anchor }: PlasmoCSUIProps) => { 33 | const [showPopup, setShowPopup] = useState(false); 34 | const [linkType, setLinkType] = useState(undefined); 35 | 36 | useEffect(() => { 37 | const parsedLink = parseLink(window.location.href); 38 | 39 | if (!parsedLink) { 40 | return; 41 | } 42 | 43 | const { musicService, trackId, albumId, artistId, type } = parsedLink; 44 | 45 | if ( 46 | !musicService || 47 | (type === 'TRACK' && !trackId) || 48 | (type === 'ALBUM' && !albumId) || 49 | (type === 'ARTIST' && !artistId) 50 | ) { 51 | return; 52 | } 53 | 54 | setLinkType(type); 55 | 56 | const state = store.getState(); 57 | const settings = state.settings; 58 | 59 | if ( 60 | settings.preferredMusicService !== musicService && 61 | settings.redirectsEnabled 62 | ) { 63 | setShowPopup(true); 64 | 65 | sendAnalytic({ 66 | name: 'redirect_popup_shown', 67 | params: { 68 | musicService, 69 | preferredMusicService: settings.preferredMusicService, 70 | type: `${musicService}_${settings.preferredMusicService}` 71 | } 72 | }); 73 | } 74 | }, []); 75 | 76 | const handleClose = () => { 77 | setShowPopup(false); 78 | 79 | const parsedLink = parseLink(window.location.href); 80 | 81 | if (!parsedLink) { 82 | return; 83 | } 84 | 85 | const { musicService } = parsedLink; 86 | 87 | const state = store.getState(); 88 | const settings = state.settings; 89 | 90 | sendAnalytic({ 91 | name: 'redirect_popup_dismissed', 92 | params: { 93 | musicService, 94 | preferredMusicService: settings.preferredMusicService, 95 | type: `${musicService}_${settings.preferredMusicService}` 96 | } 97 | }); 98 | }; 99 | 100 | const shadowRoot = document.getElementById(getShadowHostId())?.shadowRoot; 101 | 102 | return ( 103 | 104 | 105 | 106 | 111 | 112 | 113 | 114 | ); 115 | }; 116 | 117 | export default RedirectUi; 118 | -------------------------------------------------------------------------------- /src/contents/redirect.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoCSConfig } from 'plasmo'; 2 | 3 | import { sendToBackground } from '~core/messaging'; 4 | import { onDocumentReady } from '~util/onDocumentReady'; 5 | 6 | export const config: PlasmoCSConfig = { 7 | matches: ['https://*.synqapp.io/redirect*'], 8 | all_frames: true 9 | }; 10 | 11 | declare let window: Window & { 12 | setDestinationLink: (link: string) => void; 13 | }; 14 | 15 | const initialize = async () => { 16 | const url = new URL(window.location.href); 17 | const destinationMusicService = url.searchParams.get( 18 | 'destinationMusicService' 19 | ); 20 | const name = url.searchParams.get('name'); 21 | const artistName = url.searchParams.get('artistName'); 22 | const albumName = url.searchParams.get('albumName'); 23 | const duration = url.searchParams.get('duration'); 24 | const linkType = url.searchParams.get('linkType'); 25 | 26 | const link = await sendToBackground({ 27 | name: 'GET_REDIRECT_LINK', 28 | body: { 29 | destinationMusicService, 30 | trackName: name, 31 | artistName, 32 | albumName, 33 | duration, 34 | linkType 35 | } 36 | }); 37 | 38 | const event = new CustomEvent('SynQ:SetDestinationLink', { 39 | detail: link 40 | }); 41 | 42 | window.dispatchEvent(event); 43 | }; 44 | 45 | onDocumentReady(async () => { 46 | console.info('SynQ: Redirect initialized'); 47 | await initialize(); 48 | }); 49 | -------------------------------------------------------------------------------- /src/core/adapter/config.ts: -------------------------------------------------------------------------------- 1 | import type { MusicService } from '~/types'; 2 | import type { KeyControlsOptions } from '~core/keys'; 3 | 4 | import type { BackgroundController, ContentController } from './controller'; 5 | import type { Feature } from './feature'; 6 | import type { ContentObserver } from './observer'; 7 | 8 | /** 9 | * A music service adapter is a the main entity each supported music service 10 | * has. It contains all the necessary information to initialize the music service 11 | * in for SynQ. 12 | */ 13 | export interface MusicServiceAdapter { 14 | /** 15 | * The readable name of the music service. 16 | */ 17 | displayName: string; 18 | /** 19 | * The unique identifier of the music service. 20 | */ 21 | id: MusicService; 22 | /** 23 | * The base URL of the music service. This is used when the user selects a 24 | * music service from the popup to open the music service in a new tab. 25 | */ 26 | baseUrl: string; 27 | /** 28 | * The icon of the music service. 29 | */ 30 | icon: string; 31 | /** 32 | * The glob patterns to match the music service URL for content scripts. 33 | */ 34 | urlMatches: string[]; 35 | /** 36 | * The list of features that are not supported for the music service. 37 | */ 38 | disabledFeatures: Feature[]; 39 | /** 40 | * The key controls options to enable for the music service. Some music 41 | * services already have their own key controls, so we need to disable them 42 | * for SynQ to work properly. 43 | */ 44 | enabledKeyControls: KeyControlsOptions; 45 | /** 46 | * A factory function to create the background controller for the music service. 47 | */ 48 | backgroundController: () => BackgroundController; 49 | /** 50 | * A factory function to create the content controller for the music service. 51 | */ 52 | contentController: () => ContentController; 53 | /** 54 | * A factory function to create the content observer for the music service. 55 | * @param contentController The content controller for the music service. 56 | */ 57 | contentObserver: (contentController: ContentController) => ContentObserver; 58 | } 59 | -------------------------------------------------------------------------------- /src/core/adapter/feature.ts: -------------------------------------------------------------------------------- 1 | export type Feature = 'dislike' | 'like' | 'queue'; 2 | -------------------------------------------------------------------------------- /src/core/adapter/index.ts: -------------------------------------------------------------------------------- 1 | export { registerAdapter } from './register'; 2 | 3 | export type { MusicServiceAdapter } from './config'; 4 | export type { 5 | BackgroundController, 6 | ContentController, 7 | AlbumLinkDetails, 8 | ArtistLinkDetails, 9 | TrackLinkDetails, 10 | SearchTracksInput, 11 | SearchAlbumsInput, 12 | SearchArtistsInput, 13 | AlbumSearchResult, 14 | ArtistSearchResult, 15 | TrackSearchResult 16 | } from './controller'; 17 | export type { ContentObserver } from './observer'; 18 | -------------------------------------------------------------------------------- /src/core/adapter/observer.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOrPromise } from '~types'; 2 | 3 | export interface ContentObserver { 4 | /** 5 | * Begin observing the music player and emitting updates on change. 6 | */ 7 | observe(): ValueOrPromise; 8 | } 9 | -------------------------------------------------------------------------------- /src/core/adapter/register.ts: -------------------------------------------------------------------------------- 1 | import globToRegExp from 'glob-to-regexp'; 2 | 3 | import { 4 | createMusicControllerHandler, 5 | createRedirectHandler, 6 | createTabsHandler 7 | } from '~core/message-handlers'; 8 | import { type ReconnectingHub, connectToHub } from '~core/messaging/hub'; 9 | import { onDocumentReady } from '~util/onDocumentReady'; 10 | 11 | import type { MusicServiceAdapter } from './config'; 12 | 13 | declare let window: Window & { 14 | hub: ReconnectingHub; 15 | }; 16 | 17 | /** 18 | * Match the current URL to an adapter. 19 | * @param url 20 | * @param adapters 21 | * @returns {MusicServiceAdapter | undefined} 22 | */ 23 | export const matchAdapter = (url: string, adapters: MusicServiceAdapter[]) => { 24 | return adapters.find((adapter) => 25 | adapter.urlMatches.some((match) => { 26 | const regex = globToRegExp(match); 27 | return regex.test(url); 28 | }) 29 | ); 30 | }; 31 | 32 | /** 33 | * Initialize the music service in the content script. 34 | * 35 | * @param controller 36 | * @returns 37 | */ 38 | export const registerAdapter = (service: MusicServiceAdapter) => { 39 | const initialize = (extensionId: string) => { 40 | console.info(`SynQ: Initializing ${service.displayName}`); 41 | 42 | const hub = connectToHub(extensionId); 43 | window.hub = hub; 44 | 45 | const controller = service.contentController(); 46 | const observer = service.contentObserver(controller); 47 | 48 | createMusicControllerHandler(controller, hub); 49 | createTabsHandler(controller, observer, hub); 50 | createRedirectHandler(controller, hub); 51 | 52 | observer.observe(); 53 | }; 54 | 55 | onDocumentReady(() => { 56 | window.addEventListener('SynQ:ExtensionId', (e) => { 57 | const extensionId = (e as CustomEvent).detail; 58 | initialize(extensionId); 59 | }); 60 | 61 | window.dispatchEvent(new CustomEvent('SynQ:GetExtensionId')); 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/core/keys.ts: -------------------------------------------------------------------------------- 1 | import adapters from '~adapters'; 2 | import { sendToContent } from '~core/messaging/sendToContent'; 3 | import { persistor, store } from '~store'; 4 | import { MusicControllerMessage } from '~types'; 5 | 6 | import { matchAdapter } from './adapter/register'; 7 | 8 | export interface KeyControlsOptions { 9 | playPause?: boolean; 10 | next?: boolean; 11 | previous?: boolean; 12 | volumeUp?: boolean; 13 | volumeDown?: boolean; 14 | } 15 | 16 | let keyControlsListener: ((event: KeyboardEvent) => void) | undefined; 17 | 18 | export const enableKeyControls = () => { 19 | persistor.subscribe(() => { 20 | const state = store.getState(); 21 | const { musicServiceKeyControlsEnabled } = state.settings; 22 | 23 | if (!musicServiceKeyControlsEnabled) { 24 | removeKeyControlsListener(); 25 | return; 26 | } 27 | 28 | const adapter = matchAdapter(window.location.href, adapters); 29 | const keyControlsOptions = adapter?.enabledKeyControls; 30 | 31 | if (keyControlsOptions) { 32 | addKeyControlsListener(keyControlsOptions); 33 | } 34 | }); 35 | }; 36 | 37 | export const addKeyControlsListener = ( 38 | keyControlsOptions: KeyControlsOptions 39 | ) => { 40 | if (keyControlsListener) { 41 | return; 42 | } 43 | 44 | keyControlsListener = (event) => { 45 | const { key } = event; 46 | 47 | switch (key) { 48 | case ' ': 49 | if (keyControlsOptions.playPause) { 50 | sendToContent({ 51 | name: MusicControllerMessage.PLAY_PAUSE 52 | }); 53 | } 54 | break; 55 | case 'ArrowRight': 56 | if (keyControlsOptions.next) { 57 | sendToContent({ 58 | name: MusicControllerMessage.NEXT 59 | }); 60 | } 61 | break; 62 | case 'ArrowLeft': 63 | if (keyControlsOptions.previous) { 64 | sendToContent({ 65 | name: MusicControllerMessage.PREVIOUS 66 | }); 67 | } 68 | break; 69 | case 'ArrowUp': 70 | if (keyControlsOptions.volumeUp) { 71 | sendToContent({ 72 | name: MusicControllerMessage.SET_VOLUME, 73 | body: { 74 | relative: true, 75 | volume: 10 76 | } 77 | }); 78 | } 79 | break; 80 | case 'ArrowDown': 81 | if (keyControlsOptions.volumeDown) { 82 | sendToContent({ 83 | name: MusicControllerMessage.SET_VOLUME, 84 | body: { 85 | relative: true, 86 | volume: -10 87 | } 88 | }); 89 | } 90 | break; 91 | } 92 | }; 93 | 94 | document.addEventListener('keydown', keyControlsListener); 95 | }; 96 | 97 | export const removeKeyControlsListener = () => { 98 | if (!keyControlsListener) { 99 | return; 100 | } 101 | 102 | document.removeEventListener('keydown', keyControlsListener); 103 | keyControlsListener = undefined; 104 | }; 105 | -------------------------------------------------------------------------------- /src/core/links/index.ts: -------------------------------------------------------------------------------- 1 | export { getLink, parseLink } from './link'; 2 | export { getBestMatch } from './matching'; 3 | export type { ParsedLink, LinkType } from './link'; 4 | -------------------------------------------------------------------------------- /src/core/links/link.ts: -------------------------------------------------------------------------------- 1 | import adapters from '~adapters'; 2 | import type { MusicService } from '~types'; 3 | 4 | import { matchAdapter } from '../adapter/register'; 5 | 6 | export const LinkType = { 7 | ALBUM: 'ALBUM', 8 | ARTIST: 'ARTIST', 9 | TRACK: 'TRACK' 10 | } as const; 11 | 12 | export type LinkType = (typeof LinkType)[keyof typeof LinkType]; 13 | 14 | export interface ParsedLink { 15 | musicService: MusicService; 16 | trackId?: string; 17 | artistId?: string; 18 | albumId?: string; 19 | type: LinkType; 20 | } 21 | 22 | /** 23 | * Creates a string link from a parsed link. Cannot be used within ContentControllers. 24 | * @param link 25 | * @returns 26 | */ 27 | export const getLink = (link: ParsedLink): string => { 28 | const { musicService } = link; 29 | 30 | const adapter = adapters.find((adapter) => adapter.id === musicService); 31 | 32 | if (!adapter) { 33 | throw new Error(`No parser for ${musicService}`); 34 | } 35 | 36 | const backgroundController = adapter.backgroundController(); 37 | return backgroundController.getLink(link); 38 | }; 39 | 40 | /** 41 | * Parses a string link into structured. Cannot be used within ContentControllers. 42 | * @param link 43 | * @returns 44 | */ 45 | export const parseLink = (link: string): ParsedLink | null => { 46 | const adapter = matchAdapter(link, adapters); 47 | 48 | if (!adapter) { 49 | throw new Error(`No parser for ${link}`); 50 | } 51 | 52 | const backgroundController = adapter.backgroundController(); 53 | return backgroundController.parseLink(link); 54 | }; 55 | -------------------------------------------------------------------------------- /src/core/message-handlers/createMusicControllerHandler.ts: -------------------------------------------------------------------------------- 1 | import type { ContentController } from '~core/adapter'; 2 | import type { ReconnectingHub } from '~core/messaging/hub'; 3 | import { MusicControllerMessage } from '~types'; 4 | 5 | /** 6 | * Register a controller handler that handles events from other components 7 | * in the extension. 8 | */ 9 | export const createContentControllerHandler = ( 10 | controller: ContentController, 11 | hub: ReconnectingHub 12 | ) => { 13 | hub.addListener(async (message) => { 14 | switch (message?.name) { 15 | case MusicControllerMessage.PLAY: { 16 | await controller.play(); 17 | break; 18 | } 19 | 20 | case MusicControllerMessage.PLAY_PAUSE: { 21 | await controller.playPause(); 22 | break; 23 | } 24 | 25 | case MusicControllerMessage.PAUSE: { 26 | await controller.pause(); 27 | break; 28 | } 29 | 30 | case MusicControllerMessage.NEXT: { 31 | await controller.next(); 32 | break; 33 | } 34 | 35 | case MusicControllerMessage.PREVIOUS: { 36 | await controller.previous(); 37 | break; 38 | } 39 | 40 | case MusicControllerMessage.TOGGLE_LIKE: { 41 | await controller.toggleLike(); 42 | break; 43 | } 44 | 45 | case MusicControllerMessage.TOGGLE_DISLIKE: { 46 | await controller.toggleDislike(); 47 | break; 48 | } 49 | 50 | case MusicControllerMessage.TOGGLE_MUTE: { 51 | await controller.toggleMute(); 52 | break; 53 | } 54 | 55 | case MusicControllerMessage.SET_VOLUME: { 56 | await controller.setVolume(message.body.volume, message.body.relative); 57 | break; 58 | } 59 | 60 | case MusicControllerMessage.SEEK_TO: { 61 | await controller.seekTo(message.body.time); 62 | break; 63 | } 64 | 65 | case MusicControllerMessage.TOGGLE_REPEAT_MODE: { 66 | await controller.toggleRepeatMode(); 67 | break; 68 | } 69 | 70 | case MusicControllerMessage.PLAY_QUEUE_TRACK: { 71 | await controller.playQueueTrack( 72 | message.body.trackId, 73 | message.body.duplicateIndex 74 | ); 75 | break; 76 | } 77 | 78 | default: { 79 | break; 80 | } 81 | } 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /src/core/message-handlers/createRedirectHandler.ts: -------------------------------------------------------------------------------- 1 | import type { ContentController } from '~core/adapter/controller'; 2 | import type { LinkType } from '~core/links'; 3 | import { sendToBackground } from '~core/messaging'; 4 | import type { ReconnectingHub } from '~core/messaging/hub'; 5 | import { MusicControllerMessage, type Settings } from '~types'; 6 | 7 | /** 8 | * Register a controller handler that handles events from other components 9 | * in the extension. 10 | */ 11 | export const createRedirectHandler = ( 12 | controller: ContentController, 13 | hub: ReconnectingHub 14 | ) => { 15 | hub.addListener(async (message) => { 16 | switch (message?.name) { 17 | case MusicControllerMessage.REDIRECT: { 18 | await handleRedirect(controller, message.body?.linkType); 19 | break; 20 | } 21 | } 22 | }); 23 | }; 24 | 25 | const handleRedirect = async ( 26 | controller: ContentController, 27 | linkType: LinkType 28 | ): Promise => { 29 | const settings = await sendToBackground({ 30 | name: 'GET_SETTINGS' 31 | }); 32 | 33 | const params = new URLSearchParams({ 34 | destinationMusicService: settings.preferredMusicService, 35 | linkType 36 | }); 37 | 38 | switch (linkType) { 39 | case 'TRACK': { 40 | const trackDetails = await controller.getTrackLinkDetails(); 41 | 42 | if (!trackDetails) { 43 | return; 44 | } 45 | 46 | params.set('name', trackDetails.name); 47 | params.set('artistName', trackDetails.artistName); 48 | params.set('duration', trackDetails.duration?.toString() ?? ''); 49 | params.set('albumName', trackDetails.albumName ?? ''); 50 | params.set('albumCoverUrl', trackDetails.albumCoverUrl ?? ''); 51 | 52 | break; 53 | } 54 | case 'ALBUM': { 55 | const albumDetails = await controller.getAlbumLinkDetails(); 56 | 57 | if (!albumDetails) { 58 | return; 59 | } 60 | 61 | params.set('albumName', albumDetails.name ?? ''); 62 | params.set('artistName', albumDetails.artistName ?? ''); 63 | params.set('albumCoverUrl', albumDetails.albumCoverUrl ?? ''); 64 | 65 | break; 66 | } 67 | case 'ARTIST': { 68 | const artistDetails = await controller.getArtistLinkDetails(); 69 | 70 | if (!artistDetails) { 71 | return; 72 | } 73 | 74 | params.set('artistName', artistDetails.name ?? ''); 75 | params.set('albumCoverUrl', artistDetails.artistImageUrl ?? ''); 76 | 77 | break; 78 | } 79 | } 80 | 81 | const url = `${ 82 | process.env.PLASMO_PUBLIC_SYNQ_WEBSITE 83 | }/redirect?${params.toString()}`; 84 | window.location.href = url; 85 | }; 86 | -------------------------------------------------------------------------------- /src/core/message-handlers/createTabsHandler.ts: -------------------------------------------------------------------------------- 1 | import type { ContentController, ContentObserver } from '~core/adapter'; 2 | import { sendToBackground } from '~core/messaging'; 3 | import type { ReconnectingHub } from '~core/messaging/hub'; 4 | import { updateMusicServiceTab } from '~store/slices/musicServiceTabs'; 5 | import { type MusicServiceTab } from '~types'; 6 | import { TabsMessage } from '~types/TabsMessage'; 7 | import { getMusicServiceFromUrl } from '~util/musicService'; 8 | import { dispatchFromContent } from '~util/store'; 9 | 10 | export const createTabsHandler = ( 11 | controller: ContentController, 12 | observer: ContentObserver, 13 | hub: ReconnectingHub 14 | ) => { 15 | hub.addListener(async (message) => { 16 | switch (message?.name) { 17 | case TabsMessage.UPDATE_TAB: 18 | await handleUpdateTab(controller); 19 | break; 20 | } 21 | }); 22 | }; 23 | 24 | const handleUpdateTab = async ( 25 | controller: ContentController 26 | ): Promise => { 27 | const currentTrack = await controller.getCurrentTrack(); 28 | const playerState = await controller.getPlayerState(); 29 | const tab = await sendToBackground({ 30 | name: 'GET_SELF_TAB' 31 | }); 32 | 33 | if (!tab.url) { 34 | return; 35 | } 36 | 37 | const musicService = getMusicServiceFromUrl(tab.url); 38 | 39 | if (!musicService) { 40 | return; 41 | } 42 | 43 | const musicServiceTab: MusicServiceTab = { 44 | tabId: tab.id!, 45 | musicService, 46 | currentTrack, 47 | playbackState: playerState 48 | }; 49 | 50 | dispatchFromContent(updateMusicServiceTab(musicServiceTab)); 51 | }; 52 | -------------------------------------------------------------------------------- /src/core/message-handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { createContentControllerHandler as createMusicControllerHandler } from './createMusicControllerHandler'; 2 | export { createRedirectHandler } from './createRedirectHandler'; 3 | export { createTabsHandler } from './createTabsHandler'; 4 | -------------------------------------------------------------------------------- /src/core/messaging/hub.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | import { connectToHub as plasmoConnectToHub } from '@plasmohq/messaging/pub-sub'; 3 | 4 | import { generateRequestId } from '../../util/generateRequestId'; 5 | 6 | type Listener = ( 7 | message: PlasmoMessaging.Request, 8 | from?: number, 9 | to?: number 10 | ) => void; 11 | 12 | /** 13 | * A reconnecting hub enabling MAIN world content scripts to communicate with the 14 | * background service worker. 15 | */ 16 | export interface ReconnectingHub { 17 | addListener: (listener: Listener) => void; 18 | postMessage: ( 19 | message: PlasmoMessaging.Request 20 | ) => Promise; 21 | port: chrome.runtime.Port; 22 | } 23 | 24 | let hubPort: chrome.runtime.Port; 25 | const listeners: Listener[] = []; 26 | 27 | export const connectToHub = (extensionId: string): ReconnectingHub => { 28 | if (!extensionId?.length) { 29 | throw new Error('Extension ID is required to connect to hub'); 30 | } 31 | 32 | // The background service worker continually disconnects, 33 | // and we need to reconnect whenever it does, which this recursive 34 | // function does. 35 | const connect = () => { 36 | hubPort = plasmoConnectToHub(extensionId); 37 | 38 | listeners.forEach((listener) => { 39 | hubPort.onMessage.addListener((message) => { 40 | listener(message.payload, message.from, message.to); 41 | }); 42 | }); 43 | 44 | hubPort.onDisconnect.addListener(() => { 45 | connect(); 46 | }); 47 | }; 48 | 49 | connect(); 50 | 51 | return createReconnectingHub(); 52 | }; 53 | 54 | const createReconnectingHub = (): ReconnectingHub => { 55 | return { 56 | addListener: (listener: Listener) => { 57 | listeners.push(listener); 58 | hubPort.onMessage.addListener((message) => { 59 | listener(message.payload, message.from, message.to); 60 | }); 61 | }, 62 | postMessage: (message) => { 63 | return new Promise((resolve) => { 64 | const requestId = generateRequestId(); 65 | 66 | const listener = (response: any): void => { 67 | if (response.requestId === requestId) { 68 | hubPort.onMessage.removeListener(listener); 69 | resolve(response.body); 70 | } 71 | 72 | setTimeout(() => { 73 | hubPort.onMessage.removeListener(listener); 74 | resolve(undefined); 75 | }, 5000); 76 | }; 77 | 78 | hubPort.onMessage.addListener(listener); 79 | 80 | hubPort.postMessage({ ...message, requestId }); 81 | }); 82 | }, 83 | port: hubPort 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/core/messaging/index.ts: -------------------------------------------------------------------------------- 1 | export { sendToBackground } from './sendToBackground'; 2 | -------------------------------------------------------------------------------- /src/core/messaging/sendToBackground.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MessagesMetadata, 3 | type PlasmoMessaging, 4 | sendToBackground as plasmoSendToBackground 5 | } from '@plasmohq/messaging'; 6 | 7 | import type { ReconnectingHub } from './hub'; 8 | 9 | declare let window: Window & { 10 | hub: ReconnectingHub; 11 | }; 12 | 13 | /** 14 | * Send a message to the background service worker. Works both in MAIN and ISOLATED worlds. 15 | * @param request The message to send to the background service worker. 16 | * @returns The response from the background service worker. 17 | */ 18 | export const sendToBackground = async ( 19 | request: PlasmoMessaging.Request 20 | ) => { 21 | if (window.hub) { 22 | return (await window.hub.postMessage(request)) as ResponseBody; 23 | } else { 24 | return (await plasmoSendToBackground( 25 | request as PlasmoMessaging.Request 26 | )) as ResponseBody; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/core/messaging/sendToContent.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from '@plasmohq/messaging'; 2 | 3 | import { sendToBackground } from '~core/messaging'; 4 | 5 | /** 6 | * Sends a message to content scripts. If tabId is not provided, the message is 7 | * broadcast to all content scripts. 8 | * @param message The message to send to the content script. 9 | * @param tabId The tab ID to send the message to. 10 | */ 11 | export const sendToContent = ( 12 | message: PlasmoMessaging.Request, 13 | tabId?: number 14 | ) => { 15 | sendToBackground({ 16 | name: 'BROADCAST', 17 | body: { 18 | to: tabId, 19 | payload: message 20 | } 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/core/notifications.ts: -------------------------------------------------------------------------------- 1 | import { store } from '~store'; 2 | import type { Track } from '~types'; 3 | import { imageUrlToDataUrl } from '~util/imageUrlToDataUrl'; 4 | 5 | export const createTrackNotification = async (track: Track) => { 6 | const state = store.getState(); 7 | 8 | if (!state.settings.notificationsEnabled) { 9 | return; 10 | } 11 | 12 | const baseNotification: chrome.notifications.NotificationOptions = { 13 | type: 'basic', 14 | iconUrl: ICON_PATH, 15 | title: track.name, 16 | message: createMessage(track) 17 | }; 18 | 19 | let imageUrl = undefined; 20 | 21 | try { 22 | if (track.albumCoverUrl) { 23 | imageUrl = await imageUrlToDataUrl(track.albumCoverUrl); 24 | 25 | chrome.notifications.create({ 26 | ...baseNotification, 27 | type: 'basic', 28 | iconUrl: imageUrl 29 | }); 30 | } else { 31 | chrome.notifications.create(baseNotification); 32 | } 33 | } catch (error) { 34 | console.error(error); 35 | chrome.notifications.create(baseNotification); 36 | } 37 | }; 38 | 39 | const ICON_PATH = 'assets/icon.png'; 40 | 41 | const createMessage = (track: Track) => { 42 | return `${track.artistName}${ 43 | track.albumName && ` \u2022 ${track.albumName}` 44 | }`; 45 | }; 46 | -------------------------------------------------------------------------------- /src/core/player.ts: -------------------------------------------------------------------------------- 1 | import { 2 | updateMusicServiceTabCurrentTrack, 3 | updateMusicServiceTabPlayerState 4 | } from '~store/slices/musicServiceTabs'; 5 | import type { PlaybackState, Track } from '~types'; 6 | import { dispatchFromContent } from '~util/store'; 7 | 8 | import { sendToBackground } from './messaging'; 9 | 10 | let CURRENT_TRACK_ID: string | null = null; 11 | 12 | /** 13 | * Update the playback state of the music service tab. This should only be called from ContentObservers. 14 | * @param playbackState The new playback state 15 | * @returns {Promise} 16 | */ 17 | export const updatePlaybackState = async ( 18 | playbackState: PlaybackState | null 19 | ): Promise => { 20 | const tab = await sendToBackground({ 21 | name: 'GET_SELF_TAB' 22 | }); 23 | 24 | if (!tab.id) { 25 | return; 26 | } 27 | 28 | dispatchFromContent( 29 | updateMusicServiceTabPlayerState({ 30 | tabId: tab.id!, 31 | playbackState 32 | }) 33 | ); 34 | }; 35 | 36 | let PREVENT_TRACK_NOTIFICATION = false; 37 | 38 | /** 39 | * Update the current track of the music service tab. This should only be called from ContentObservers. 40 | * @param currentTrack The new current track 41 | * @returns {Promise} 42 | */ 43 | export const updateCurrentTrack = async ( 44 | currentTrack: Track | null 45 | ): Promise => { 46 | const tab = await sendToBackground({ 47 | name: 'GET_SELF_TAB' 48 | }); 49 | 50 | if (!tab.id) { 51 | return; 52 | } 53 | 54 | dispatchFromContent( 55 | updateMusicServiceTabCurrentTrack({ 56 | tabId: tab.id!, 57 | currentTrack 58 | }) 59 | ); 60 | 61 | if ( 62 | !currentTrack?.id || 63 | PREVENT_TRACK_NOTIFICATION || 64 | CURRENT_TRACK_ID === currentTrack?.id 65 | ) { 66 | return; 67 | } 68 | 69 | PREVENT_TRACK_NOTIFICATION = true; 70 | 71 | setTimeout(() => { 72 | PREVENT_TRACK_NOTIFICATION = false; 73 | }, 2000); 74 | 75 | await sendToBackground({ 76 | name: 'CREATE_TRACK_NOTIFICATION', 77 | body: currentTrack 78 | }); 79 | 80 | CURRENT_TRACK_ID = currentTrack?.id || null; 81 | }; 82 | -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | // Entry point file for Options required by Plasmo. 2 | 3 | import { UiProvider } from '@synqapp/ui'; 4 | import { Provider } from 'react-redux'; 5 | 6 | import { store } from '~store'; 7 | 8 | import { Options } from './ui/options'; 9 | 10 | const OptionsIndex = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default OptionsIndex; 21 | -------------------------------------------------------------------------------- /src/popup.tsx: -------------------------------------------------------------------------------- 1 | // Entry point file for Popup required by Plasmo. 2 | 3 | import './ui/popup/index.css'; 4 | 5 | import { UiProvider } from '@synqapp/ui'; 6 | import { useEffect } from 'react'; 7 | import { Provider } from 'react-redux'; 8 | import { MemoryRouter } from 'react-router-dom'; 9 | 10 | import { POPUP_PORT } from '~constants/port'; 11 | import { store } from '~store'; 12 | 13 | import Popup from './ui/popup/Popup'; 14 | import PopupContextProvidersWrapper from './ui/popup/contexts/PopupContextProvidersWrapper'; 15 | 16 | const PopupIndex = () => { 17 | useEffect(() => { 18 | chrome.runtime.connect({ name: POPUP_PORT }); 19 | }, []); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default PopupIndex; 35 | -------------------------------------------------------------------------------- /src/store/combinedReducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import musicServiceTabsReducer from './slices/musicServiceTabs'; 4 | import popupOpenReducer from './slices/popupOpen'; 5 | import queueReducer from './slices/queue'; 6 | import searchReducer from './slices/search'; 7 | import settingsReducer from './slices/settings'; 8 | 9 | const rootReducer = combineReducers({ 10 | musicServiceTabs: musicServiceTabsReducer, 11 | popupOpen: popupOpenReducer, 12 | queue: queueReducer, 13 | search: searchReducer, 14 | settings: settingsReducer 15 | }); 16 | 17 | export type RootState = ReturnType; 18 | 19 | export default rootReducer; 20 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { 3 | type TypedUseSelectorHook, 4 | useDispatch, 5 | useSelector 6 | } from 'react-redux'; 7 | import { localStorage } from 'redux-persist-webextension-storage'; 8 | 9 | import { 10 | FLUSH, 11 | PAUSE, 12 | PERSIST, 13 | PURGE, 14 | REGISTER, 15 | REHYDRATE, 16 | RESYNC, 17 | persistReducer, 18 | persistStore 19 | } from '@plasmohq/redux-persist'; 20 | import type { 21 | PersistConfig, 22 | Storage as PersistStorage 23 | } from '@plasmohq/redux-persist/lib/types'; 24 | import { Storage } from '@plasmohq/storage'; 25 | 26 | import rootReducer, { type RootState } from './combinedReducers'; 27 | 28 | const persistConfig: PersistConfig = { 29 | key: 'synq-root', 30 | version: 1, 31 | storage: localStorage as PersistStorage 32 | }; 33 | 34 | // @ts-ignore 35 | const persistedReducer = persistReducer(persistConfig, rootReducer); 36 | 37 | export const store = configureStore({ 38 | reducer: persistedReducer, 39 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 40 | // @ts-ignore 41 | middleware: (getDefaultMiddleware) => 42 | getDefaultMiddleware({ 43 | serializableCheck: { 44 | ignoredActions: [ 45 | FLUSH, 46 | REHYDRATE, 47 | PAUSE, 48 | PERSIST, 49 | PURGE, 50 | REGISTER, 51 | RESYNC 52 | ] 53 | } 54 | }) 55 | }); 56 | 57 | export const persistor = persistStore(store); 58 | 59 | // This is what makes Redux sync properly with multiple pages 60 | // Open your extension's options page and popup to see it in action 61 | new Storage({ area: 'local' }).watch({ 62 | [`persist:${persistConfig.key}`]: () => { 63 | persistor.resync(); 64 | } 65 | }); 66 | 67 | export type AppDispatch = typeof store.dispatch; 68 | 69 | // Export the hooks with the types from the mock store 70 | export const useAppDispatch: () => AppDispatch = useDispatch; 71 | export const useAppSelector: TypedUseSelectorHook = useSelector; 72 | -------------------------------------------------------------------------------- /src/store/slices/musicServiceTabs.ts: -------------------------------------------------------------------------------- 1 | import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | import type { MusicServiceTab } from '~types'; 4 | 5 | const initialState: MusicServiceTab[] = []; 6 | 7 | const musicServiceTabsSlice = createSlice({ 8 | name: 'musicServiceTabs', 9 | initialState: initialState, 10 | reducers: { 11 | addMusicServiceTab: (state, action: PayloadAction) => { 12 | state.push(action.payload); 13 | }, 14 | removeMusicServiceTab: (state, action: PayloadAction) => { 15 | const tabId = action.payload; 16 | const index = state.findIndex((tab) => tab.tabId === tabId); 17 | if (index !== -1) { 18 | state.splice(index, 1); 19 | } 20 | }, 21 | updateMusicServiceTab: (state, action: PayloadAction) => { 22 | const tab = action.payload; 23 | const index = state.findIndex((t) => t.tabId === tab.tabId); 24 | if (index !== -1) { 25 | state[index] = tab; 26 | } else { 27 | state.push(tab); 28 | } 29 | }, 30 | updateMusicServiceTabPlayerState: ( 31 | state, 32 | action: PayloadAction<{ 33 | tabId: number; 34 | playbackState: MusicServiceTab['playbackState']; 35 | }> 36 | ) => { 37 | const { tabId, playbackState } = action.payload; 38 | const index = state.findIndex((tab) => tab.tabId === tabId); 39 | if (index !== -1) { 40 | state[index].playbackState = playbackState; 41 | } 42 | }, 43 | updateMusicServiceTabCurrentTrack: ( 44 | state, 45 | action: PayloadAction<{ 46 | tabId: number; 47 | currentTrack: MusicServiceTab['currentTrack']; 48 | }> 49 | ) => { 50 | const { tabId, currentTrack } = action.payload; 51 | const index = state.findIndex((tab) => tab.tabId === tabId); 52 | if (index !== -1) { 53 | state[index].currentTrack = currentTrack; 54 | } 55 | }, 56 | updateMusicServiceTabPictureInPicture: ( 57 | state, 58 | action: PayloadAction<{ 59 | tabId: number; 60 | pictureInPicture: MusicServiceTab['pictureInPicture']; 61 | }> 62 | ) => { 63 | const { tabId, pictureInPicture } = action.payload; 64 | const index = state.findIndex((tab) => tab.tabId === tabId); 65 | if (index !== -1) { 66 | state[index].pictureInPicture = pictureInPicture; 67 | } 68 | }, 69 | clearMusicServiceTabs: () => initialState 70 | } 71 | }); 72 | 73 | export const { 74 | addMusicServiceTab, 75 | removeMusicServiceTab, 76 | clearMusicServiceTabs, 77 | updateMusicServiceTab, 78 | updateMusicServiceTabCurrentTrack, 79 | updateMusicServiceTabPlayerState, 80 | updateMusicServiceTabPictureInPicture 81 | } = musicServiceTabsSlice.actions; 82 | 83 | export default musicServiceTabsSlice.reducer; 84 | -------------------------------------------------------------------------------- /src/store/slices/popupOpen.ts: -------------------------------------------------------------------------------- 1 | import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | const popupOpenSlice = createSlice({ 4 | name: 'popupOpen', 5 | initialState: false, 6 | reducers: { 7 | setPopupOpen: (_, action: PayloadAction) => { 8 | return action.payload; 9 | } 10 | } 11 | }); 12 | 13 | export const { setPopupOpen } = popupOpenSlice.actions; 14 | 15 | export default popupOpenSlice.reducer; 16 | -------------------------------------------------------------------------------- /src/store/slices/queue.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | import type { QueueItem } from '~types'; 4 | 5 | const initialState: QueueItem[] = []; 6 | 7 | const queueSlice = createSlice({ 8 | name: 'queue', 9 | initialState: initialState, 10 | reducers: { 11 | setQueue: (_, action) => action.payload, 12 | clearQueue: () => [] 13 | } 14 | }); 15 | 16 | export const { setQueue, clearQueue } = queueSlice.actions; 17 | 18 | export default queueSlice.reducer; 19 | -------------------------------------------------------------------------------- /src/store/slices/search.ts: -------------------------------------------------------------------------------- 1 | import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | import type { TrackSearchResult } from '~types'; 4 | 5 | interface SearchSliceState { 6 | results: TrackSearchResult[]; 7 | loading: boolean; 8 | } 9 | 10 | const initialState: SearchSliceState = { 11 | results: [], 12 | loading: false 13 | }; 14 | 15 | const searchSlice = createSlice({ 16 | name: 'search', 17 | initialState: initialState, 18 | reducers: { 19 | setSearchResults: (state, action: PayloadAction) => { 20 | state.results = action.payload; 21 | state.loading = false; 22 | }, 23 | setSearchLoading: (state, action: PayloadAction) => { 24 | state.loading = action.payload; 25 | }, 26 | clearSearchResults: () => initialState 27 | } 28 | }); 29 | 30 | export const { setSearchResults, setSearchLoading, clearSearchResults } = 31 | searchSlice.actions; 32 | 33 | export default searchSlice.reducer; 34 | -------------------------------------------------------------------------------- /src/store/slices/settings.ts: -------------------------------------------------------------------------------- 1 | import { type PayloadAction, createSlice } from '@reduxjs/toolkit'; 2 | 3 | import { type MusicService } from '~types'; 4 | import { type Settings } from '~types'; 5 | 6 | const initialState: Settings = { 7 | miniPlayerKeyControlsEnabled: true, 8 | musicServiceKeyControlsEnabled: true, 9 | notificationsEnabled: true, 10 | popOutButtonEnabled: true, 11 | preferredMusicService: 'SPOTIFY', 12 | redirectsEnabled: true 13 | }; 14 | 15 | const sessionSlice = createSlice({ 16 | name: 'session', 17 | initialState: initialState, 18 | reducers: { 19 | setPreferredMusicService: (state, action: PayloadAction) => { 20 | state.preferredMusicService = action.payload; 21 | }, 22 | setMiniPlayerKeyControlsEnabled: ( 23 | state, 24 | action: PayloadAction 25 | ) => { 26 | state.miniPlayerKeyControlsEnabled = action.payload; 27 | }, 28 | setMusicServiceKeyControlsEnabled: ( 29 | state, 30 | action: PayloadAction 31 | ) => { 32 | state.musicServiceKeyControlsEnabled = action.payload; 33 | }, 34 | setNotificationsEnabled: (state, action: PayloadAction) => { 35 | state.notificationsEnabled = action.payload; 36 | }, 37 | setPopOutButtonEnabled: (state, action: PayloadAction) => { 38 | state.popOutButtonEnabled = action.payload; 39 | }, 40 | setSynqLinkPopupsEnabled: (state, action: PayloadAction) => { 41 | state.redirectsEnabled = action.payload; 42 | }, 43 | setSettings: (state, action: PayloadAction) => { 44 | Object.assign(state, action.payload); 45 | } 46 | } 47 | }); 48 | 49 | export const { 50 | setMiniPlayerKeyControlsEnabled, 51 | setMusicServiceKeyControlsEnabled, 52 | setNotificationsEnabled, 53 | setPopOutButtonEnabled, 54 | setPreferredMusicService, 55 | setSynqLinkPopupsEnabled, 56 | setSettings 57 | } = sessionSlice.actions; 58 | 59 | export default sessionSlice.reducer; 60 | -------------------------------------------------------------------------------- /src/tabs/onboard.tsx: -------------------------------------------------------------------------------- 1 | import { UiProvider } from '@synqapp/ui'; 2 | import { Provider } from 'react-redux'; 3 | 4 | import { store } from '~store'; 5 | import { Onboard } from '~ui/onboard/Onboard'; 6 | 7 | const OnboardPage = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default OnboardPage; 18 | -------------------------------------------------------------------------------- /src/types/MusicControllerMessage.ts: -------------------------------------------------------------------------------- 1 | export enum MusicControllerMessage { 2 | PLAY = 'PLAY', 3 | PLAY_PAUSE = 'PLAY_PAUSE', 4 | PAUSE = 'PAUSE', 5 | NEXT = 'NEXT', 6 | PREVIOUS = 'PREVIOUS', 7 | TOGGLE_SHUFFLE = 'TOGGLE_SHUFFLE', 8 | TOGGLE_LIKE = 'TOGGLE_LIKE', 9 | TOGGLE_DISLIKE = 'TOGGLE_DISLIKE', 10 | TOGGLE_MUTE = 'TOGGLE_MUTE', 11 | SET_VOLUME = 'SET_VOLUME', 12 | SEEK_TO = 'SEEK_TO', 13 | START_TRACK = 'START_TRACK', 14 | TOGGLE_REPEAT_MODE = 'TOGGLE_REPEAT_MODE', 15 | PLAY_QUEUE_TRACK = 'PLAY_QUEUE_TRACK', 16 | SEARCH_TRACKS = 'SEARCH_TRACKS', 17 | REDIRECT = 'REDIRECT' 18 | } 19 | -------------------------------------------------------------------------------- /src/types/MusicService.ts: -------------------------------------------------------------------------------- 1 | export const MUSIC_SERVICE = { 2 | AMAZONMUSIC: 'AMAZONMUSIC', 3 | APPLEMUSIC: 'APPLEMUSIC', 4 | SPOTIFY: 'SPOTIFY', 5 | YOUTUBEMUSIC: 'YOUTUBEMUSIC' 6 | } as const; 7 | 8 | export type MusicService = (typeof MUSIC_SERVICE)[keyof typeof MUSIC_SERVICE]; 9 | -------------------------------------------------------------------------------- /src/types/MusicServiceTab.ts: -------------------------------------------------------------------------------- 1 | import type { MusicService } from '~/types'; 2 | 3 | import type { PlaybackState } from './PlayerState'; 4 | import type { Track } from './Track'; 5 | 6 | export interface MusicServiceTab { 7 | musicService: MusicService; 8 | tabId: number; 9 | playbackState?: PlaybackState | null; 10 | currentTrack?: Track | null; 11 | queue?: Track[]; 12 | pictureInPicture?: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/PlayerState.ts: -------------------------------------------------------------------------------- 1 | import type { QueueItem, RepeatMode } from '.'; 2 | 3 | export interface PlaybackState { 4 | isPlaying: boolean; 5 | currentTime: number; 6 | volume: number; 7 | repeatMode: RepeatMode; 8 | queue: QueueItem[]; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/QueueItem.ts: -------------------------------------------------------------------------------- 1 | import type { Track } from '.'; 2 | 3 | export interface QueueItem { 4 | isPlaying: boolean; 5 | track?: Track | null; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/RepeatMode.ts: -------------------------------------------------------------------------------- 1 | export enum RepeatMode { 2 | NO_REPEAT = 'NO_REPEAT', 3 | REPEAT_ONE = 'REPEAT_ONE', 4 | REPEAT_ALL = 'REPEAT_ALL' 5 | } 6 | -------------------------------------------------------------------------------- /src/types/Settings.ts: -------------------------------------------------------------------------------- 1 | import type { MusicService } from '~/types'; 2 | 3 | export interface Settings { 4 | miniPlayerKeyControlsEnabled: boolean; 5 | musicServiceKeyControlsEnabled: boolean; 6 | notificationsEnabled: boolean; 7 | popOutButtonEnabled: boolean; 8 | preferredMusicService: MusicService; 9 | redirectsEnabled: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/SynQWindow.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from 'redux'; 2 | 3 | export {}; 4 | 5 | type StoreWithName = Store & { name: string }; 6 | 7 | declare global { 8 | interface Window { 9 | _SYNQ_SELECTED_TAB: boolean; 10 | __REDUX_STORES__: StoreWithName[]; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/types/TabsMessage.ts: -------------------------------------------------------------------------------- 1 | export enum TabsMessage { 2 | UPDATE_TAB = 'UPDATE_TAB' 3 | } 4 | -------------------------------------------------------------------------------- /src/types/Track.ts: -------------------------------------------------------------------------------- 1 | export interface Track { 2 | albumCoverUrl?: string; 3 | albumName?: string; 4 | artistName: string; 5 | duration: number; 6 | id: string; 7 | isDisliked?: boolean; 8 | isLiked?: boolean; 9 | link?: string; 10 | name: string; 11 | type?: 'song' | 'podcast'; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/Util.ts: -------------------------------------------------------------------------------- 1 | export type ValueOrPromise = T | Promise; 2 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MusicControllerMessage'; 2 | export * from './MusicService'; 3 | export * from './MusicServiceTab'; 4 | export * from './PlayerState'; 5 | export * from './QueueItem'; 6 | export * from './RepeatMode'; 7 | export * from './Settings'; 8 | export * from './Track'; 9 | export * from './Util'; 10 | -------------------------------------------------------------------------------- /src/ui/onboard/Onboard.tsx: -------------------------------------------------------------------------------- 1 | import { token } from '@synqapp/ui'; 2 | import Slider, { type Settings } from 'react-slick'; 3 | import { createGlobalStyle, styled, useTheme } from 'styled-components'; 4 | 5 | import 'slick-carousel/slick/slick.css'; 6 | import 'slick-carousel/slick/slick-theme.css'; 7 | 8 | import { useRef } from 'react'; 9 | 10 | import Logo from '~ui/shared/components/Logo'; 11 | import { useAnalytic } from '~ui/shared/hooks/useAnalytic'; 12 | 13 | import { NextArrow } from './components/NextArrow'; 14 | import { PreviousArrow } from './components/PreviousArrow'; 15 | import { AcceptPermissions } from './screens/AcceptPermissions'; 16 | import { Complete } from './screens/Complete'; 17 | import { SelectPreferredService } from './screens/SelectPreferredService'; 18 | import { YtmPlusIntro } from './screens/YtmPlusIntro'; 19 | 20 | export const Onboard = () => { 21 | useAnalytic({ 22 | name: 'onboarding_screen' 23 | }); 24 | const url = new URL(window.location.href); 25 | const isUpdate = url.searchParams.get('update') === 'true'; 26 | 27 | const theme = useTheme(); 28 | const sliderRef = useRef(null); 29 | 30 | const goToNextSlide = () => { 31 | sliderRef.current?.slickNext(); 32 | }; 33 | 34 | const settings: Settings = { 35 | dots: true, 36 | dotsClass: 'slick-dots slick-dots-light', 37 | infinite: false, 38 | speed: 500, 39 | slidesToShow: 1, 40 | slidesToScroll: 1, 41 | prevArrow: , 42 | nextArrow: 43 | }; 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | 52 | 53 | {isUpdate && } 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | const GlobalStyle = createGlobalStyle` 66 | body { 67 | margin: 0; 68 | padding: 0; 69 | background-color: ${token('colors.background')}; 70 | width: 100vw; 71 | height: 100vh; 72 | overflow: hidden; 73 | } 74 | 75 | .slick-dots { 76 | position: fixed; 77 | bottom: 30px; 78 | } 79 | 80 | .slick-dots-light li button:before { 81 | color: ${token('colors.onBackgroundLow')}; 82 | } 83 | 84 | .slick-dots-light li.slick-active button:before { 85 | color: ${token('colors.onBackground')}; 86 | } 87 | 88 | .slick-slider { 89 | z-index: 1000; 90 | } 91 | `; 92 | 93 | const Container = styled.div` 94 | height: 100vh; 95 | overflow: hidden; 96 | `; 97 | 98 | const LogoContainer = styled.div` 99 | padding: 0 ${token('spacing.md')}; 100 | `; 101 | 102 | const BackgroundGlow = styled.div` 103 | background: radial-gradient( 104 | 450px circle, 105 | ${token('colors.base.orange.3')}55 0%, 106 | transparent 100% 107 | ); 108 | width: 900px; 109 | height: 900px; 110 | filter: blur(80px); 111 | position: absolute; 112 | top: 150px; 113 | z-index: 0; 114 | `; 115 | 116 | const BackgroundGlow2 = styled.div` 117 | background: radial-gradient( 118 | 450px circle, 119 | ${token('colors.base.pink.3')}55 0%, 120 | transparent 100% 121 | ); 122 | width: 900px; 123 | height: 900px; 124 | filter: blur(80px); 125 | position: absolute; 126 | top: 550px; 127 | right: 200px; 128 | z-index: 0; 129 | `; 130 | -------------------------------------------------------------------------------- /src/ui/onboard/components/NextArrow.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | faChevronLeft, 3 | faChevronRight 4 | } from '@fortawesome/free-solid-svg-icons'; 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 6 | import type { CustomArrowProps } from 'react-slick'; 7 | import { styled, useTheme } from 'styled-components'; 8 | 9 | export const NextArrow = ({ 10 | onClick, 11 | slideCount, 12 | currentSlide 13 | }: CustomArrowProps) => { 14 | const isLastSlide = slideCount && currentSlide === slideCount - 1; 15 | 16 | return !isLastSlide ? ( 17 | 18 | 24 | 25 | ) : null; 26 | }; 27 | 28 | const Container = styled.div` 29 | background: none; 30 | border: none; 31 | color: #0000; 32 | cursor: pointer; 33 | display: block; 34 | font-size: 25px; 35 | height: 20px; 36 | line-height: 0; 37 | outline: none; 38 | padding: 0; 39 | position: fixed; 40 | right: 30px; 41 | top: 50%; 42 | transform: translate(0, -50%); 43 | width: 20px; 44 | z-index: 1000; 45 | `; 46 | -------------------------------------------------------------------------------- /src/ui/onboard/components/PreviousArrow.tsx: -------------------------------------------------------------------------------- 1 | import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import type { CustomArrowProps } from 'react-slick'; 4 | import { styled } from 'styled-components'; 5 | 6 | export const PreviousArrow = ({ 7 | onClick, 8 | slideCount, 9 | currentSlide 10 | }: CustomArrowProps) => { 11 | const isFirstSlide = slideCount && currentSlide === 0; 12 | 13 | return !isFirstSlide ? ( 14 | 15 | 21 | 22 | ) : null; 23 | }; 24 | 25 | const Container = styled.div` 26 | background: none; 27 | border: none; 28 | color: #0000; 29 | cursor: pointer; 30 | display: block; 31 | font-size: 25px; 32 | height: 20px; 33 | line-height: 0; 34 | outline: none; 35 | padding: 0; 36 | position: fixed; 37 | top: 50%; 38 | transform: translate(0, -50%); 39 | width: 20px; 40 | z-index: 1000; 41 | `; 42 | -------------------------------------------------------------------------------- /src/ui/onboard/components/Screen.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from 'styled-components'; 2 | 3 | interface ScreenProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export const Screen = ({ children }: ScreenProps) => { 8 | return {children}; 9 | }; 10 | 11 | const Container = styled.div` 12 | width: calc(100vw - 80px); 13 | height: calc(100vh - 80px); 14 | margin: auto; 15 | `; 16 | -------------------------------------------------------------------------------- /src/ui/onboard/screens/AcceptPermissions.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Stack, Text, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { HOSTS, PERMISSIONS } from '~constants/permissions'; 5 | import { sendAnalytic } from '~util/analytics'; 6 | 7 | import { Screen } from '../components/Screen'; 8 | 9 | interface AcceptPermissionsProps { 10 | goToNextSlide: () => void; 11 | } 12 | 13 | export const AcceptPermissions = ({ 14 | goToNextSlide 15 | }: AcceptPermissionsProps) => { 16 | const handleAcceptPermissions = () => { 17 | chrome.permissions.request( 18 | { 19 | permissions: PERMISSIONS, 20 | origins: HOSTS 21 | }, 22 | (granted) => { 23 | // The callback argument will be true if the user granted the permissions. 24 | if (granted) { 25 | goToNextSlide(); 26 | } else { 27 | alert( 28 | 'Permission denied. SynQ will not work until the permissions have been accepted.' 29 | ); 30 | } 31 | } 32 | ); 33 | 34 | sendAnalytic({ 35 | name: 'accept_permissions' 36 | }); 37 | }; 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | Accept Required Permissions 45 | 46 | 47 | SynQ requires some permissions to work. It requires access to{' '} 48 | store data like your preferences,{' '} 49 | access music service websites to 50 | enable the mini player,{' '} 51 | generate notifications on song 52 | change, etc. 53 | 54 | 55 | Accept Permissions 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | const Container = styled(Flex)` 64 | height: calc(100vh - 100px); 65 | overflow: hidden; 66 | `; 67 | 68 | const TitleText = styled(Text)` 69 | text-align: center; 70 | margin: 0 auto; 71 | display: block; 72 | `; 73 | 74 | const DescriptionText = styled(TitleText)` 75 | max-width: 750px; 76 | color: ${token('colors.onBackgroundMedium')}; 77 | `; 78 | 79 | const HighlightedText = styled.span` 80 | color: ${token('colors.onBackground')}; 81 | font-weight: ${token('typography.fontWeights.semibold')}; 82 | `; 83 | 84 | const ContinueButton = styled(Button)` 85 | margin: 0 auto; 86 | display: block; 87 | `; 88 | -------------------------------------------------------------------------------- /src/ui/onboard/screens/SelectPreferredService.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Stack, Text, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import adapters from '~adapters'; 5 | import { useAppDispatch, useAppSelector } from '~store'; 6 | import { setPreferredMusicService } from '~store/slices/settings'; 7 | import { MUSIC_SERVICE, type MusicService } from '~types'; 8 | import MusicServiceButton from '~ui/popup/components/MusicServiceButton'; 9 | import { sendAnalytic } from '~util/analytics'; 10 | 11 | import { Screen } from '../components/Screen'; 12 | 13 | interface SelectPreferredServiceProps { 14 | goToNextSlide: () => void; 15 | } 16 | 17 | export const SelectPreferredService = ({ 18 | goToNextSlide 19 | }: SelectPreferredServiceProps) => { 20 | const preferredMusicService = useAppSelector( 21 | (state) => state.settings.preferredMusicService 22 | ); 23 | const dispatch = useAppDispatch(); 24 | 25 | const handleChange = (value: string) => { 26 | if (!Object.values(MUSIC_SERVICE).includes(value as MusicService)) { 27 | return; 28 | } 29 | 30 | dispatch(setPreferredMusicService(value as MusicService)); 31 | 32 | sendAnalytic({ 33 | name: 'onboarding_select_service', 34 | params: { 35 | service: value 36 | } 37 | }); 38 | }; 39 | 40 | const handleMusicServiceClick = (service: MusicService) => { 41 | handleChange(service); 42 | goToNextSlide(); 43 | }; 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | Select your preferred music service 51 | 52 | 53 | SynQ works with several music services. Select your preferred music 54 | service to get started. 55 | 56 | 57 | {adapters.map((adapter) => { 58 | return ( 59 | handleMusicServiceClick(adapter.id)} 64 | selected={preferredMusicService === adapter.id} 65 | /> 66 | ); 67 | })} 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | const Container = styled(Flex)` 76 | height: calc(100vh - 100px); 77 | overflow: hidden; 78 | `; 79 | 80 | const TitleText = styled(Text)` 81 | text-align: center; 82 | margin: 0 auto; 83 | display: block; 84 | `; 85 | 86 | const DescriptionText = styled(Text)` 87 | text-align: center; 88 | margin: 0 auto; 89 | display: block; 90 | max-width: 750px; 91 | color: ${token('colors.onBackgroundMedium')}; 92 | `; 93 | 94 | const MusicServiceButtons = styled(Stack)` 95 | margin: ${token('spacing.lg')} auto 0; 96 | width: 300px; 97 | `; 98 | -------------------------------------------------------------------------------- /src/ui/onboard/screens/YtmPlusIntro.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Stack, Text, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { Screen } from '../components/Screen'; 5 | 6 | interface YtmPlusIntroProps { 7 | goToNextSlide: () => void; 8 | } 9 | 10 | export const YtmPlusIntro = ({ goToNextSlide }: YtmPlusIntroProps) => { 11 | const url = new URL(window.location.href); 12 | const showLastFmMessage = url.searchParams.get('lastfm') === 'true'; 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 27 | YTM+ is now SynQ 28 | 29 | 30 | 31 | From the developers of YTM+, SynQ upgrades your YTM+ experience 32 | with all-new features and a fresh look. 33 | 34 | 35 | 36 | Get Started 37 | 38 | 39 | {showLastFmMessage && ( 40 | 41 | 42 | 43 | Note: You previously connected to Last.fm with YTM+. SynQ no 44 | longer supports Last.fm, but you can still scrobble your music 45 | by using the officially-recommended extension on{' '} 46 | 50 | Chrome 51 | {' '} 52 | or{' '} 53 | 57 | Edge 58 | 59 | . 60 | 61 | 62 | 63 | )} 64 | 65 | 66 | ); 67 | }; 68 | 69 | const Container = styled(Flex)` 70 | height: calc(100vh - 100px); 71 | overflow: hidden; 72 | position: relative; 73 | `; 74 | 75 | const TitleText = styled(Text)` 76 | text-align: center; 77 | margin: 0 auto; 78 | display: block; 79 | `; 80 | 81 | const DescriptionText = styled(Text)` 82 | text-align: center; 83 | margin: 0 auto; 84 | display: block; 85 | max-width: 750px; 86 | `; 87 | 88 | const ContinueButton = styled(Button)` 89 | margin: 0 auto; 90 | display: block; 91 | `; 92 | 93 | const LastFmMessageContainer = styled(Flex)` 94 | position: absolute; 95 | bottom: 50px; 96 | height: unset; 97 | `; 98 | 99 | const LastFmMessage = styled.div` 100 | border-radius: ${token('radii.xl')}; 101 | background: ${token('colors.background')}; 102 | color: ${token('colors.onSurface')}; 103 | padding: ${token('spacing.md')}; 104 | width: 800px; 105 | z-index: 1000; 106 | `; 107 | 108 | const LastFmMessageText = styled(Text)` 109 | text-align: center; 110 | color: ${token('colors.onBackgroundMedium')}; 111 | `; 112 | 113 | const Link = styled.a` 114 | color: ${token('colors.onBackground')}; 115 | text-decoration: underline; 116 | cursor: pointer; 117 | `; 118 | -------------------------------------------------------------------------------- /src/ui/options/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { token } from '@synqapp/ui'; 2 | import styled from 'styled-components'; 3 | 4 | import Logo from '~ui/shared/components/Logo'; 5 | 6 | export const Header = () => { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | const Container = styled.header` 15 | margin: ${token('spacing.sm')} ${token('spacing.xl')}; 16 | `; 17 | -------------------------------------------------------------------------------- /src/ui/options/components/KeyControlsSection.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Switch } from '@synqapp/ui'; 2 | 3 | import { useAppDispatch, useAppSelector } from '~store'; 4 | import { 5 | setMiniPlayerKeyControlsEnabled, 6 | setMusicServiceKeyControlsEnabled 7 | } from '~store/slices/settings'; 8 | import { sendAnalytic } from '~util/analytics'; 9 | 10 | import { OptionsSection } from './OptionsSection'; 11 | 12 | export const KeyControlsSection = () => { 13 | const miniPlayerControlsEnabled = useAppSelector( 14 | (state) => state.settings.miniPlayerKeyControlsEnabled 15 | ); 16 | const musicServiceControlsEnabled = useAppSelector( 17 | (state) => state.settings.musicServiceKeyControlsEnabled 18 | ); 19 | const dispatch = useAppDispatch(); 20 | 21 | const handleMiniPlayerControlsChange = (value: boolean) => { 22 | dispatch(setMiniPlayerKeyControlsEnabled(value)); 23 | sendAnalytic({ 24 | name: 'mini_player_key_controls_enabled', 25 | params: { value } 26 | }); 27 | }; 28 | 29 | const handleMusicServiceControlsChange = (value: boolean) => { 30 | dispatch(setMusicServiceKeyControlsEnabled(value)); 31 | sendAnalytic({ 32 | name: 'music_service_key_controls_enabled', 33 | params: { value } 34 | }); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 44 | Enable mini player key controls 45 | 46 | 50 | Enable music service key controls 51 | 52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/ui/options/components/NotificationsSection.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@synqapp/ui'; 2 | 3 | import { useAppDispatch, useAppSelector } from '~store'; 4 | import { setNotificationsEnabled } from '~store/slices/settings'; 5 | import { sendAnalytic } from '~util/analytics'; 6 | 7 | import { OptionsSection } from './OptionsSection'; 8 | 9 | export const NotificationsSection = () => { 10 | const notificationsEnabled = useAppSelector( 11 | (state) => state.settings.notificationsEnabled 12 | ); 13 | const dispatch = useAppDispatch(); 14 | 15 | const handleChange = (value: boolean) => { 16 | dispatch(setNotificationsEnabled(value)); 17 | sendAnalytic({ 18 | name: 'notifications_enabled', 19 | params: { value } 20 | }); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | Allow notifications on song change 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/ui/options/components/OptionsContent.tsx: -------------------------------------------------------------------------------- 1 | import { Text, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { KeyControlsSection } from './KeyControlsSection'; 5 | import { NotificationsSection } from './NotificationsSection'; 6 | import { PopOutSection } from './PopOutSection'; 7 | import { PreferredMusicServiceSection } from './PreferredMusicServiceSection'; 8 | import { PreferredServiceLinksSection } from './PreferredServiceLinksSection'; 9 | 10 | export const OptionsContent = () => { 11 | return ( 12 | 13 | 14 | 15 | We're open source! 16 | 17 | 18 | Please star our GitHub repo if you like this extension, and if you're 19 | a programmer, feel free to contribute to the code! 20 | 21 | 29 | 30 | 31 | Settings 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const Container = styled.div` 43 | width: 90%; 44 | max-width: 600px; 45 | margin: 0 auto; 46 | `; 47 | 48 | const Title = styled(Text)` 49 | font-weight: ${token('typography.fontWeights.bold')}; 50 | `; 51 | 52 | const GitHubContainer = styled.div` 53 | background-color: rgb(255 255 255 / 7%); 54 | border: 1px solid #ffffff; 55 | border-radius: 4px; 56 | padding: 12px; 57 | `; 58 | 59 | const GitHubTitle = styled(Text)` 60 | margin: 0; 61 | `; 62 | -------------------------------------------------------------------------------- /src/ui/options/components/OptionsSection.tsx: -------------------------------------------------------------------------------- 1 | import { Text, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | interface OptionsSectionProps { 5 | children: React.ReactNode; 6 | title: string; 7 | } 8 | 9 | export const OptionsSection = ({ title, children }: OptionsSectionProps) => { 10 | return ( 11 | 12 | 13 | {title} 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | const Container = styled.section` 21 | margin-bottom: ${token('spacing.2xl')}; 22 | `; 23 | 24 | const Title = styled(Text)` 25 | font-weight: ${token('typography.fontWeights.semibold')}; 26 | `; 27 | -------------------------------------------------------------------------------- /src/ui/options/components/PopOutSection.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@synqapp/ui'; 2 | 3 | import { useAppDispatch, useAppSelector } from '~store'; 4 | import { setPopOutButtonEnabled } from '~store/slices/settings'; 5 | import { sendAnalytic } from '~util/analytics'; 6 | 7 | import { OptionsSection } from './OptionsSection'; 8 | 9 | export const PopOutSection = () => { 10 | const synqLinkPopupsEnabled = useAppSelector( 11 | (state) => state.settings.popOutButtonEnabled 12 | ); 13 | const dispatch = useAppDispatch(); 14 | 15 | const handleChange = (value: boolean) => { 16 | dispatch(setPopOutButtonEnabled(value)); 17 | sendAnalytic({ 18 | name: 'pop_out_button_enabled', 19 | params: { value } 20 | }); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | Show pop out button on music services 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/ui/options/components/PreferredMusicServiceSection.tsx: -------------------------------------------------------------------------------- 1 | import { Radio, RadioGroup, Stack } from '@synqapp/ui'; 2 | 3 | import adapters from '~adapters'; 4 | import { useAppDispatch, useAppSelector } from '~store'; 5 | import { setPreferredMusicService } from '~store/slices/settings'; 6 | import { MUSIC_SERVICE, type MusicService } from '~types'; 7 | import { sendAnalytic } from '~util/analytics'; 8 | 9 | import { OptionsSection } from './OptionsSection'; 10 | 11 | export const PreferredMusicServiceSection = () => { 12 | const preferredMusicService = useAppSelector( 13 | (state) => state.settings.preferredMusicService 14 | ); 15 | const dispatch = useAppDispatch(); 16 | 17 | const handleChange = (value: string) => { 18 | if (!Object.values(MUSIC_SERVICE).includes(value as MusicService)) { 19 | return; 20 | } 21 | 22 | dispatch(setPreferredMusicService(value as MusicService)); 23 | 24 | sendAnalytic({ 25 | name: 'preferred_music_service_changed', 26 | params: { value } 27 | }); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | {adapters.map((adapter) => ( 35 | 36 | {adapter.displayName} 37 | 38 | ))} 39 | 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/ui/options/components/PreferredServiceLinksSection.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from '@synqapp/ui'; 2 | 3 | import { useAppDispatch, useAppSelector } from '~store'; 4 | import { setSynqLinkPopupsEnabled } from '~store/slices/settings'; 5 | import { sendAnalytic } from '~util/analytics'; 6 | 7 | import { OptionsSection } from './OptionsSection'; 8 | 9 | export const PreferredServiceLinksSection = () => { 10 | const synqLinkPopupsEnabled = useAppSelector( 11 | (state) => state.settings.redirectsEnabled 12 | ); 13 | const dispatch = useAppDispatch(); 14 | 15 | const handleChange = (value: boolean) => { 16 | dispatch(setSynqLinkPopupsEnabled(value)); 17 | sendAnalytic({ 18 | name: 'preferred_service_links_changed', 19 | params: { value } 20 | }); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | Show a popup to open links in your preferred music service 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/ui/options/index.tsx: -------------------------------------------------------------------------------- 1 | import { Scrollable, token } from '@synqapp/ui'; 2 | import { createGlobalStyle, useTheme } from 'styled-components'; 3 | 4 | import { Header } from './components/Header'; 5 | import { OptionsContent } from './components/OptionsContent'; 6 | 7 | export const Options = () => { 8 | const theme = useTheme(); 9 | 10 | return ( 11 | <> 12 | 13 | 14 |
15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | const GlobalStyle = createGlobalStyle` 22 | body { 23 | background-color: ${token('colors.background')}; 24 | margin: 0; 25 | padding: 0; 26 | height: 100%; 27 | width: 100%; 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/ui/pip/PipToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import { Image, token } from '@synqapp/ui'; 2 | import SynQIcon from 'data-base64:~assets/images/icon-filled.svg'; 3 | import { styled } from 'styled-components'; 4 | 5 | import { useAppSelector } from '~store'; 6 | 7 | interface PipToggleButtonProps { 8 | onClick: () => void; 9 | } 10 | 11 | export const PipToggleButton = ({ onClick }: PipToggleButtonProps) => { 12 | const settings = useAppSelector((state) => state.settings); 13 | 14 | return settings.popOutButtonEnabled ? ( 15 | 16 | 17 | 18 | 19 | 20 | ) : null; 21 | }; 22 | 23 | export const ToggleButton = styled.button` 24 | background: linear-gradient( 25 | to bottom, 26 | ${token('colors.base.orange.4')} 0%, 27 | ${token('colors.base.pink.4')} 100% 28 | ); 29 | border-bottom-left-radius: calc(${token('radii.md')}); 30 | border-top-left-radius: calc(${token('radii.md')}); 31 | border: none; 32 | box-shadow: none; 33 | cursor: pointer; 34 | height: 80px; 35 | outline: none; 36 | width: 35px; 37 | z-index: 999; 38 | `; 39 | 40 | export const ToggleButtonContent = styled.div` 41 | align-items: center; 42 | background: ${token('colors.surface')}; 43 | border-bottom-left-radius: calc(${token('radii.md')} - 1px); 44 | border-top-left-radius: calc(${token('radii.md')} - 1px); 45 | display: flex; 46 | height: 76px; 47 | justify-content: center; 48 | position: absolute; 49 | right: 0; 50 | top: 2px; 51 | width: 33px; 52 | `; 53 | 54 | export const ToggleButtonImage = styled(Image)` 55 | height: 25px; 56 | width: 25px; 57 | `; 58 | -------------------------------------------------------------------------------- /src/ui/pip/PipUi.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Text, UiProvider, token } from '@synqapp/ui'; 2 | import { Provider } from 'react-redux'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | import styled, { 5 | StyleSheetManager, 6 | createGlobalStyle, 7 | useTheme 8 | } from 'styled-components'; 9 | 10 | import { store } from '~store'; 11 | import Popup from '~ui/popup/Popup'; 12 | import { PopupSettingsProvider } from '~ui/popup/contexts/PopupSettingsContext'; 13 | import { DocumentContextProvidersWrapper } from '~ui/shared/contexts/DocumentContextProvidersWrapper'; 14 | import { MarqueeStylesProvider } from '~ui/shared/styles/MarqueeStylesProvider'; 15 | 16 | interface PipUiProps { 17 | pipDocument: Document; 18 | } 19 | 20 | export const PipUi = ({ pipDocument }: PipUiProps) => { 21 | const theme = useTheme(); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 38 | 39 | 40 | {/* Smiley at end */} 41 | 42 | Nothing to see here :) 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | const PipGlobalStyles = createGlobalStyle` 55 | body { 56 | margin: 0; 57 | padding: 0; 58 | background: ${token('colors.background')}; 59 | } 60 | `; 61 | 62 | const Container = styled(Stack)` 63 | align-items: flex-start; 64 | justify-content: flex-start; 65 | height: 100vh; 66 | overflow: hidden; 67 | 68 | & > * { 69 | flex: 0 0 auto; 70 | } 71 | `; 72 | 73 | const ExpandedText = styled(Text)` 74 | writing-mode: tb-rl; 75 | margin-top: ${token('spacing.sm')}; 76 | `; 77 | -------------------------------------------------------------------------------- /src/ui/popup/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { useAppDispatch } from '~store'; 5 | import { updateMusicServiceTabPictureInPicture } from '~store/slices/musicServiceTabs'; 6 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 7 | 8 | import Header from '../shared/components/Header'; 9 | 10 | interface LayoutProps { 11 | children: React.ReactNode; 12 | hideButton?: boolean; 13 | } 14 | 15 | const Layout = ({ children, hideButton }: LayoutProps) => { 16 | const { musicServiceTab } = useMusicServiceTab(); 17 | const dispatch = useAppDispatch(); 18 | 19 | return ( 20 | 21 |
28 | musicServiceTab?.tabId && 29 | dispatch( 30 | updateMusicServiceTabPictureInPicture({ 31 | tabId: musicServiceTab?.tabId, 32 | pictureInPicture: !musicServiceTab?.pictureInPicture 33 | }) 34 | ) 35 | } 36 | : undefined 37 | } 38 | /> 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | const Container = styled.div` 45 | background: ${token('colors.background')}; 46 | transition: all 0.2s ease-in-out; 47 | width: 350px; 48 | `; 49 | 50 | const Content = styled.div` 51 | width: 100%; 52 | height: calc(100% - 40px); 53 | `; 54 | 55 | export default Layout; 56 | -------------------------------------------------------------------------------- /src/ui/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | 4 | import { addKeyControlsListener, removeKeyControlsListener } from '~core/keys'; 5 | import { useAppSelector } from '~store'; 6 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 7 | import { usePermissionsCheck } from '~ui/shared/hooks/usePermissionsCheck'; 8 | 9 | import { useTabsQuery } from '../shared/hooks/useTabQuery'; 10 | import AppRoutes from './PopupRoutes'; 11 | import { usePopupSettings } from './contexts/PopupSettingsContext'; 12 | 13 | const Popup = () => { 14 | const navigate = useNavigate(); 15 | const tabs = useAppSelector((state) => state.musicServiceTabs); 16 | const settings = useAppSelector((state) => state.settings); 17 | const popupSettings = usePopupSettings(); 18 | const { musicServiceTab: selectedTab } = useMusicServiceTab(); 19 | const permissionsAccepted = usePermissionsCheck(); 20 | 21 | useEffect(() => { 22 | if ( 23 | !settings?.miniPlayerKeyControlsEnabled || 24 | !popupSettings?.keyControls 25 | ) { 26 | return; 27 | } 28 | 29 | addKeyControlsListener({ 30 | playPause: true, 31 | next: true, 32 | previous: true, 33 | volumeUp: true, 34 | volumeDown: true 35 | }); 36 | 37 | return () => { 38 | removeKeyControlsListener(); 39 | }; 40 | }, [settings]); 41 | 42 | useEffect(() => { 43 | if (permissionsAccepted === false) { 44 | navigate('/accept-permissions'); 45 | } else if (selectedTab) { 46 | navigate('/'); 47 | } else if (!tabs || tabs.length === 0) { 48 | navigate('/select-platform'); 49 | } else if (tabs.length > 1) { 50 | navigate('/select-tab'); 51 | } 52 | }, [tabs.length, selectedTab, permissionsAccepted]); 53 | 54 | return ; 55 | }; 56 | 57 | export default Popup; 58 | -------------------------------------------------------------------------------- /src/ui/popup/PopupRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | 3 | import { AcceptPermissionsScreen } from './screens/AcceptPermissions'; 4 | import ControllerScreen from './screens/Controller'; 5 | import SelectPlatformScreen from './screens/SelectPlatform'; 6 | import SelectTabScreen from './screens/SelectTab'; 7 | 8 | const AppRoutes = () => { 9 | return ( 10 | 11 | } /> 12 | } /> 13 | } /> 14 | } /> 15 | 16 | ); 17 | }; 18 | 19 | export default AppRoutes; 20 | -------------------------------------------------------------------------------- /src/ui/popup/components/ControlButtons/index.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, token } from '@synqapp/ui'; 2 | import { useState } from 'react'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | import { NextButton } from '~ui/shared/components/NextButton'; 6 | import { PlayPauseButton } from '~ui/shared/components/PlayPauseButton'; 7 | import { PreviousButton } from '~ui/shared/components/PreviousButton'; 8 | import { RepeatButton } from '~ui/shared/components/RepeatButton'; 9 | import { VolumeButton } from '~ui/shared/components/VolumeButton'; 10 | import { VolumeSlider } from '~ui/shared/components/VolumeSlider'; 11 | 12 | export const ControlButtons = () => { 13 | const [showVolumeSlider, setShowVolumeSlider] = useState(false); 14 | 15 | const handleVolumeMouseEnter = () => { 16 | setShowVolumeSlider(true); 17 | }; 18 | 19 | const handleVolumeMouseLeave = () => { 20 | setShowVolumeSlider(false); 21 | }; 22 | 23 | return ( 24 | 25 | 26 |
30 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 52 | 53 | 54 |
55 | ); 56 | }; 57 | 58 | const ControlButtonsContainer = styled.div` 59 | position: relative; 60 | height: 30px; 61 | `; 62 | 63 | const ControlButtonsFlex = styled(Flex)` 64 | position: absolute; 65 | z-index: 1; 66 | `; 67 | 68 | interface HideableButtonContainerProps { 69 | $hidden?: boolean; 70 | } 71 | 72 | const HideableButtonContainer = styled.div` 73 | opacity: 1; 74 | 75 | ${({ $hidden }) => 76 | $hidden && 77 | css` 78 | opacity: 0; 79 | `} 80 | `; 81 | 82 | interface VolumeSliderContainerProps { 83 | $show?: boolean; 84 | } 85 | 86 | const VolumeSliderContainer = styled(Flex)` 87 | height: 100%; 88 | opacity: 0; 89 | transition: opacity 0.1s ease-in-out; 90 | position: absolute; 91 | z-index: 0; 92 | width: 85%; 93 | right: ${token('spacing.2xs')}; 94 | padding-left: ${token('spacing.xs')}; 95 | 96 | ${({ $show }) => 97 | $show && 98 | css` 99 | opacity: 1; 100 | z-index: 1; 101 | `} 102 | `; 103 | -------------------------------------------------------------------------------- /src/ui/popup/components/MusicServiceButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Image, Text, token } from '@synqapp/ui'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | interface MusicServiceButtonProps { 5 | name: string; 6 | urlMatch?: string; 7 | url?: string; 8 | onClick?: () => void; 9 | logoSrc: string; 10 | selected?: boolean; 11 | } 12 | 13 | const MusicServiceButton = ({ 14 | name, 15 | url, 16 | onClick, 17 | urlMatch, 18 | logoSrc, 19 | selected 20 | }: MusicServiceButtonProps) => { 21 | const handleClick = () => { 22 | if (onClick) { 23 | onClick(); 24 | } 25 | 26 | if (!url || !urlMatch) { 27 | return; 28 | } 29 | 30 | chrome.tabs.query({ url: urlMatch }, (tabs) => { 31 | if (tabs?.length) { 32 | chrome.tabs.update(tabs[0]?.id!, { active: true }); 33 | } else { 34 | chrome.tabs.create({ url }); 35 | } 36 | }); 37 | }; 38 | 39 | return ( 40 | 46 | {`Logo{' '} 53 | 54 | {name} 55 | 56 | 57 | ); 58 | }; 59 | 60 | interface StyledButtonProps { 61 | $selected?: boolean; 62 | } 63 | 64 | const StyledButton = styled(Button)` 65 | border: 1px solid transparent; 66 | border-radius: ${token('radii.xl')}; 67 | background: ${token('colors.surface01')}; 68 | display: block; 69 | padding: ${token('spacing.md')} 0; 70 | transition: border 0.2s ease-in-out; 71 | font-weight: ${token('typography.fontWeights.medium')}; 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | gap: ${token('spacing.sm')}; 76 | 77 | &:hover { 78 | border: 1px solid ${token('colors.borderHigh')}; 79 | } 80 | 81 | ${({ $selected }) => 82 | $selected && 83 | css` 84 | border: 1px solid ${token('colors.borderHigh')}; 85 | `} 86 | `; 87 | 88 | const ButtonText = styled(Text)` 89 | margin: 0; 90 | letter-spacing: 0.3px; 91 | `; 92 | 93 | export default MusicServiceButton; 94 | -------------------------------------------------------------------------------- /src/ui/popup/components/Player/index.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, token } from '@synqapp/ui'; 2 | import styled from 'styled-components'; 3 | 4 | import { AlbumArt } from '../../../shared/components/AlbumArt'; 5 | import { PlayerControls } from '../PlayerControls'; 6 | 7 | export const Player = () => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | const AlbumArtContainer = styled.div` 21 | height: 105px; 22 | max-height: 105px; 23 | max-width: 105px; 24 | min-height: 105px; 25 | min-width: 105px; 26 | width: 105px; 27 | 28 | margin: 0 auto; 29 | `; 30 | 31 | const PlayerControlsContainer = styled.div` 32 | height: fit-content; 33 | margin-left: ${token('spacing.sm')}; 34 | margin-top: ${token('spacing.none')}; 35 | width: calc(100% - 105px - ${token('spacing.sm')}); 36 | `; 37 | -------------------------------------------------------------------------------- /src/ui/popup/components/PlayerControls/index.tsx: -------------------------------------------------------------------------------- 1 | import { token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { ArtistName } from '~/ui/shared/components/ArtistName'; 5 | import { TrackTitle } from '~/ui/shared/components/TrackTitle'; 6 | import { TrackSeeker } from '~ui/shared/components/TrackSeeker'; 7 | 8 | import { ControlButtons } from '../ControlButtons'; 9 | 10 | export const PlayerControls = () => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 |
21 |
22 | ); 23 | }; 24 | 25 | const PlayerControlsContainer = styled.div` 26 | width: 100%; 27 | `; 28 | 29 | const TrackSeekerContainer = styled.div` 30 | margin-top: ${token('spacing.sm')}; 31 | `; 32 | -------------------------------------------------------------------------------- /src/ui/popup/contexts/PopupContextProvidersWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { usePopupMusicServiceTab } from '~ui/popup/hooks/usePopupMusicServiceTab'; 2 | import { MusicServiceTabProvider } from '~ui/shared/contexts/MusicServiceTab'; 3 | 4 | import { 5 | type PopupSettingsContextValue, 6 | PopupSettingsProvider 7 | } from './PopupSettingsContext'; 8 | 9 | interface PopupContextProvidersWrapperProps { 10 | children: React.ReactNode; 11 | settings?: PopupSettingsContextValue; 12 | } 13 | 14 | export const PopupContextProvidersWrapper = ({ 15 | children, 16 | settings 17 | }: PopupContextProvidersWrapperProps) => { 18 | const musicServiceTabValue = usePopupMusicServiceTab(); 19 | 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | }; 26 | 27 | export default PopupContextProvidersWrapper; 28 | -------------------------------------------------------------------------------- /src/ui/popup/contexts/PopupSettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | export interface PopupSettingsContextValue { 4 | queueCollapsible: boolean; 5 | document: Document; 6 | keyControls: boolean; 7 | } 8 | 9 | const DEFAULT_POPUP_SETTINGS: PopupSettingsContextValue = { 10 | queueCollapsible: true, 11 | document: document, 12 | keyControls: true 13 | }; 14 | 15 | const PopupSettingsContext = createContext( 16 | DEFAULT_POPUP_SETTINGS 17 | ); 18 | 19 | interface PopupSettingsProviderProps { 20 | children: React.ReactNode; 21 | value?: PopupSettingsContextValue; 22 | } 23 | 24 | /** 25 | * Returns the selected tab ID and a function to set the selected tab ID. 26 | * Automatically selects the tab if there is only one. Otherwise, the tab 27 | * must be selected manually. 28 | */ 29 | export const PopupSettingsProvider = ({ 30 | children, 31 | value 32 | }: PopupSettingsProviderProps) => { 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | }; 39 | 40 | export const usePopupSettings = () => useContext(PopupSettingsContext); 41 | -------------------------------------------------------------------------------- /src/ui/popup/hooks/usePopupMusicServiceTab.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { sendToContent } from '~core/messaging/sendToContent'; 4 | import { useAppSelector } from '~store'; 5 | import type { MusicServiceTab } from '~types'; 6 | import { TabsMessage } from '~types/TabsMessage'; 7 | 8 | export const usePopupMusicServiceTab = () => { 9 | const musicServiceTabs = useAppSelector((state) => state.musicServiceTabs); 10 | const [musicServiceTab, setMusicServiceTab] = useState< 11 | MusicServiceTab | undefined 12 | >(undefined); 13 | const [manuallySelected, setManuallySelected] = useState(false); 14 | 15 | useEffect(() => { 16 | // If a music service tab is already selected, then just update it 17 | if (musicServiceTab) { 18 | setMusicServiceTab( 19 | musicServiceTabs?.find((tab) => tab.tabId === musicServiceTab?.tabId) 20 | ); 21 | } 22 | 23 | // If the user has manually selected a music service tab, then don't override it 24 | if (manuallySelected) { 25 | return; 26 | } 27 | 28 | // If more than one music service tab is open, then don't auto select 29 | if (musicServiceTabs?.length > 1) { 30 | setMusicServiceTab(undefined); 31 | } 32 | 33 | // If there is only one music service tab open, then auto select it 34 | if (musicServiceTabs?.length === 1) { 35 | setMusicServiceTab(musicServiceTabs[0]); 36 | } 37 | }, [musicServiceTabs, manuallySelected]); 38 | 39 | useEffect(() => { 40 | if (!musicServiceTab) { 41 | return; 42 | } 43 | 44 | sendToContent({ 45 | name: TabsMessage.SET_SELECTED_TAB, 46 | body: musicServiceTab.tabId 47 | }); 48 | }, [musicServiceTab]); 49 | 50 | const _setMusicServiceTab = (tab: MusicServiceTab) => { 51 | setMusicServiceTab(tab); 52 | setManuallySelected(true); 53 | }; 54 | 55 | return { 56 | musicServiceTab, 57 | setMusicServiceTab: _setMusicServiceTab 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/ui/popup/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/popup/screens/AcceptPermissions/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Text, token } from '@synqapp/ui'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { styled } from 'styled-components'; 4 | 5 | import { HOSTS, PERMISSIONS } from '~constants/permissions'; 6 | import Layout from '~ui/popup/Layout'; 7 | 8 | export const AcceptPermissionsScreen = () => { 9 | const navigate = useNavigate(); 10 | 11 | const handleAcceptPermissions = () => { 12 | chrome.permissions.request( 13 | { 14 | permissions: PERMISSIONS, 15 | origins: HOSTS 16 | }, 17 | (granted) => { 18 | // The callback argument will be true if the user granted the permissions. 19 | if (granted) { 20 | navigate('/'); 21 | } else { 22 | alert( 23 | 'Permission denied. SynQ will not work until the permissions have been accepted.' 24 | ); 25 | } 26 | } 27 | ); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 34 | Accept Permissions 35 | 36 | 37 | SynQ requires some permissions to work. This includes{' '} 38 | storing data,{' '} 39 | accessing music service websites,{' '} 40 | generating notifications, etc. 41 | 42 |
43 | 44 | Accept 45 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | const Container = styled.div` 52 | padding: ${token('spacing.md')}; 53 | `; 54 | 55 | const PermissionsText = styled(Text)` 56 | text-align: center; 57 | margin: auto; 58 | `; 59 | 60 | const DescriptionText = styled(PermissionsText)` 61 | color: ${token('colors.onBackgroundMedium')}; 62 | `; 63 | 64 | const AcceptButton = styled(Button)` 65 | margin: auto; 66 | display: block; 67 | `; 68 | -------------------------------------------------------------------------------- /src/ui/popup/screens/Controller/useControllerScreen.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useControllerScreen = () => { 4 | const [showQueue, setShowQueue] = useState(false); 5 | 6 | return { 7 | showQueue, 8 | setShowQueue 9 | }; 10 | }; 11 | 12 | export default useControllerScreen; 13 | -------------------------------------------------------------------------------- /src/ui/popup/screens/SelectTab/index.tsx: -------------------------------------------------------------------------------- 1 | import { List, Scrollable, Text, token } from '@synqapp/ui'; 2 | import { useMemo } from 'react'; 3 | import { styled } from 'styled-components'; 4 | 5 | import type { MusicService } from '~/types'; 6 | import { useAppSelector } from '~store'; 7 | import Layout from '~ui/popup/Layout'; 8 | import { TrackListItem } from '~ui/shared/components/TrackListItem'; 9 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 10 | import { getMusicServiceName } from '~util/musicService'; 11 | 12 | const SelectTabScreen = () => { 13 | const tabs = useAppSelector((state) => state.musicServiceTabs); 14 | const { setMusicServiceTab } = useMusicServiceTab(); 15 | 16 | const tabGroups = useMemo(() => { 17 | const groups = tabs.reduce((acc, tab) => { 18 | const { musicService } = tab; 19 | 20 | if (!acc[musicService]) { 21 | acc[musicService] = []; 22 | } 23 | 24 | acc[musicService].push(tab); 25 | return acc; 26 | }, {} as Record); 27 | 28 | return Object.entries(groups); 29 | }, [tabs]); 30 | 31 | return ( 32 | 33 | 34 | {tabGroups.map(([musicService, tabs]) => ( 35 | <> 36 | 37 | {getMusicServiceName(musicService as MusicService)} 38 | 39 | 40 | {tabs.map((tab) => ( 41 | setMusicServiceTab(tab)} 46 | imageUrl={tab.currentTrack?.albumCoverUrl ?? ''} 47 | imageAlt={`Album art for ${tab.currentTrack?.name}`} 48 | /> 49 | ))} 50 | 51 | 52 | ))} 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default SelectTabScreen; 59 | 60 | const Container = styled(Scrollable)` 61 | background: ${token('colors.background')}; 62 | padding-bottom: ${token('spacing.xs')}; 63 | overflow-y: auto; 64 | max-height: 525px; 65 | `; 66 | 67 | const HeaderText = styled(Text)` 68 | margin: ${token('spacing.2xs')} ${token('spacing.lg')}; 69 | font-weight: ${token('typography.fontWeights.semibold')}; 70 | `; 71 | 72 | const TabListHeaderText = styled(Text)` 73 | margin: ${token('spacing.xs')} ${token('spacing.lg')} 0; 74 | font-weight: ${token('typography.fontWeights.medium')}; 75 | /* color: ${token('colors.onBackgroundMedium')}; */ 76 | `; 77 | 78 | const TabList = styled(List)` 79 | margin: ${token('spacing.xs')} ${token('spacing.lg')} ${token('spacing.md')}; 80 | border-radius: ${token('radii.lg')}; 81 | overflow: hidden; 82 | `; 83 | 84 | const TabListItem = styled(TrackListItem)` 85 | padding-left: ${token('spacing.sm')}; 86 | `; 87 | -------------------------------------------------------------------------------- /src/ui/shared/components/AlbumArt/useAlbumArt.ts: -------------------------------------------------------------------------------- 1 | import type { MusicService } from '~/types'; 2 | import adapters from '~adapters'; 3 | import { sendToContent } from '~core/messaging/sendToContent'; 4 | import { MusicControllerMessage } from '~types'; 5 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 6 | import { sendAnalytic } from '~util/analytics'; 7 | 8 | const LIKE_ENABLED_SERVICES = new Set( 9 | adapters 10 | .filter((adapter) => !adapter.disabledFeatures?.includes('like')) 11 | .map((adapter) => adapter.id) 12 | ); 13 | 14 | const DISLIKE_ENABLED_SERVICES = new Set( 15 | adapters 16 | .filter((adapter) => !adapter.disabledFeatures?.includes('dislike')) 17 | .map((adapter) => adapter.id) 18 | ); 19 | 20 | export const useAlbumArt = () => { 21 | const { musicServiceTab } = useMusicServiceTab(); 22 | const currentTrack = musicServiceTab?.currentTrack; 23 | 24 | const handleLikeClick = LIKE_ENABLED_SERVICES.has( 25 | musicServiceTab?.musicService 26 | ) 27 | ? () => { 28 | sendToContent( 29 | { 30 | name: MusicControllerMessage.TOGGLE_LIKE 31 | }, 32 | musicServiceTab?.tabId 33 | ); 34 | sendAnalytic({ 35 | name: 'toggle_like' 36 | }); 37 | } 38 | : undefined; 39 | 40 | const handleDislikeClick = DISLIKE_ENABLED_SERVICES.has( 41 | musicServiceTab?.musicService 42 | ) 43 | ? () => { 44 | sendToContent( 45 | { 46 | name: MusicControllerMessage.TOGGLE_DISLIKE 47 | }, 48 | musicServiceTab?.tabId 49 | ); 50 | sendAnalytic({ 51 | name: 'toggle_dislike' 52 | }); 53 | } 54 | : undefined; 55 | 56 | return { 57 | trackName: currentTrack?.name, 58 | isLiked: currentTrack?.isLiked, 59 | isDisliked: currentTrack?.isDisliked, 60 | src: currentTrack?.albumCoverUrl, 61 | handleLikeClick, 62 | handleDislikeClick 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/ui/shared/components/ArtistName/index.tsx: -------------------------------------------------------------------------------- 1 | import { type TextProps, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { MarqueeText } from '../MarqueeText'; 5 | import { useArtistName } from './useArtistName'; 6 | 7 | interface ArtistNameProps { 8 | size: TextProps['size']; 9 | } 10 | 11 | export const ArtistName = ({ size }: ArtistNameProps) => { 12 | const artistName = useArtistName(); 13 | 14 | return ( 15 | 16 | {artistName} 17 | 18 | ); 19 | }; 20 | 21 | const ArtistNameText = styled(MarqueeText)` 22 | .text { 23 | margin: ${token('spacing.3xs')} 0 0; 24 | color: ${token('colors.onBackgroundMedium')}; 25 | line-height: 14px; 26 | font-weight: ${token('typography.fontWeights.regular')}; 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /src/ui/shared/components/ArtistName/useArtistName.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '~store'; 2 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 3 | 4 | export const useArtistName = () => { 5 | const { musicServiceTab } = useMusicServiceTab(); 6 | const currentTrack = musicServiceTab?.currentTrack; 7 | 8 | return currentTrack?.artistName; 9 | }; 10 | -------------------------------------------------------------------------------- /src/ui/shared/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, token } from '@synqapp/ui'; 2 | import { Icon } from '@synqapp/ui'; 3 | import styled from 'styled-components'; 4 | 5 | import { sendToBackground } from '~core/messaging'; 6 | 7 | import Logo from '../Logo'; 8 | 9 | interface HeaderProps { 10 | actionButton?: { 11 | name: string; 12 | onClick: () => void; 13 | }; 14 | } 15 | 16 | const Header = ({ actionButton }: HeaderProps) => { 17 | const handleSettingsClick = () => { 18 | sendToBackground({ 19 | name: 'OPEN_OPTIONS_PAGE' 20 | }); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | {actionButton && ( 29 | 30 | {actionButton.name} 31 | 32 | )} 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | const HeaderStyled = styled.header` 43 | background: ${token('colors.background')}; 44 | height: 40px; 45 | padding: ${token('spacing.xs')} ${token('spacing.sm')} 0; 46 | `; 47 | 48 | const SessionButton = styled(Button)` 49 | font-family: ${token('typography.fontFamilies.body')}; 50 | font-size: ${token('typography.fontSizes.sm')}; 51 | height: 30px; 52 | line-height: ${token('spacing.none')}; 53 | margin-right: ${token('spacing.md')}; 54 | padding: ${token('spacing.3xs')} ${token('spacing.sm')}; 55 | 56 | &::before { 57 | display: none; 58 | } 59 | `; 60 | 61 | const SettingsButton = styled(Button)` 62 | align-items: center; 63 | background: transparent; 64 | border: none; 65 | box-shadow: none; 66 | cursor: pointer; 67 | display: flex; 68 | justify-content: center; 69 | outline: none; 70 | padding: 0; 71 | width: fit-content; 72 | `; 73 | 74 | const SettingsIcon = styled(Icon)` 75 | color: white; 76 | width: 24px; 77 | height: 24px; 78 | `; 79 | 80 | export default Header; 81 | -------------------------------------------------------------------------------- /src/ui/shared/components/ListItemMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { 4 | Icon, 5 | type IconProps, 6 | Menu, 7 | MenuItem, 8 | type MenuProps, 9 | Text, 10 | token 11 | } from '@synqapp/ui'; 12 | import { useEffect, useRef, useState } from 'react'; 13 | import { css, styled, useTheme } from 'styled-components'; 14 | 15 | interface ListItemMenuItem { 16 | icon?: IconProps['icon']; 17 | onClick: () => void; 18 | text: string; 19 | } 20 | 21 | interface ListItemMenuProps { 22 | menuItems: ListItemMenuItem[]; 23 | portalContainer?: MenuProps['portalContainer']; 24 | } 25 | 26 | export const ListItemMenu = ({ 27 | menuItems, 28 | portalContainer 29 | }: ListItemMenuProps) => { 30 | const theme = useTheme(); 31 | const iconRef = useRef(null); 32 | const [showMenu, setShowMenu] = useState(false); 33 | 34 | // Close secondary actions menu when scrolling 35 | useEffect(() => { 36 | if (!showMenu || !portalContainer) { 37 | return; 38 | } 39 | 40 | const handleScroll = () => { 41 | setShowMenu(false); 42 | }; 43 | 44 | portalContainer.addEventListener('scroll', handleScroll, true); 45 | 46 | return () => { 47 | portalContainer.removeEventListener('scroll', handleScroll); 48 | }; 49 | }, [showMenu]); 50 | 51 | const handleMenuButtonClick = (e: React.MouseEvent) => { 52 | e.stopPropagation(); 53 | setShowMenu(!showMenu); 54 | }; 55 | 56 | const handleMenuItemClick = (onActionClick: () => void) => { 57 | setShowMenu(false); 58 | onActionClick(); 59 | }; 60 | 61 | return ( 62 | <> 63 | 68 | 73 | 74 | setShowMenu(false)} 77 | anchorEl={iconRef.current!} 78 | anchorOrigin={{ 79 | vertical: 'bottom', 80 | horizontal: 'right' 81 | }} 82 | anchorPosition={{ 83 | y: 8 84 | }} 85 | portalContainer={portalContainer} 86 | > 87 | {menuItems.map((item) => ( 88 | handleMenuItemClick(item.onClick)} 91 | leftIcon={ 92 | item.icon && ( 93 | 99 | ) 100 | } 101 | > 102 | 103 | {item.text} 104 | 105 | 106 | ))} 107 | 108 | 109 | ); 110 | }; 111 | 112 | const MenuStyled = styled(Menu)` 113 | background: ${token('colors.surface02')}; 114 | `; 115 | 116 | interface SecondaryActionMenuButtonProps { 117 | $active: boolean; 118 | } 119 | 120 | const MenuButton = styled.button` 121 | align-items: center; 122 | background-color: transparent; 123 | border-radius: 50%; 124 | border: none; 125 | cursor: pointer; 126 | display: flex; 127 | height: 35px; 128 | justify-content: center; 129 | margin: 0; 130 | padding: 0; 131 | transition: background-color 0.2s ease-in-out; 132 | width: 35px; 133 | 134 | &:hover { 135 | background-color: ${token('colors.surface02')}; 136 | } 137 | 138 | ${({ $active }) => 139 | $active && 140 | css` 141 | background-color: ${token('colors.surface02')}; 142 | `} 143 | `; 144 | -------------------------------------------------------------------------------- /src/ui/shared/components/Logo/index.tsx: -------------------------------------------------------------------------------- 1 | import { Image, Text, type TextProps, token } from '@synqapp/ui'; 2 | import Icon from 'data-base64:~assets/images/icon-filled.svg'; 3 | import styled from 'styled-components'; 4 | 5 | type LogoSize = 'controller' | 'page'; 6 | 7 | const TextSizeMap: Record = { 8 | controller: 'xl', 9 | page: '3xl' 10 | }; 11 | 12 | const ImageSizeMap: Record = { 13 | controller: 35, 14 | page: 60 15 | }; 16 | 17 | interface LogoProps { 18 | size: LogoSize; 19 | } 20 | 21 | const Logo = ({ size = 'controller' }: LogoProps) => { 22 | return ( 23 | 24 | 25 | 26 | SynQ 27 | 28 | 29 | ); 30 | }; 31 | 32 | const Container = styled.div` 33 | align-items: center; 34 | display: inline-flex; 35 | `; 36 | 37 | interface LogoImageProps { 38 | $size: number; 39 | } 40 | 41 | const LogoImage = styled(Image)` 42 | height: ${({ $size }) => $size}px; 43 | width: ${({ $size }) => $size}px; 44 | `; 45 | 46 | const LogoText = styled(Text)` 47 | font-weight: ${token('typography.fontWeights.medium')}; 48 | `; 49 | 50 | export default Logo; 51 | -------------------------------------------------------------------------------- /src/ui/shared/components/MarqueeText/index.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text, token } from '@synqapp/ui'; 2 | import type { TextProps } from '@synqapp/ui'; 3 | import { useEffect, useRef, useState } from 'react'; 4 | import Marquee from 'react-fast-marquee'; 5 | import { styled } from 'styled-components'; 6 | 7 | interface MarqueeTextProps extends TextProps { 8 | children: string | string[]; 9 | className?: string; 10 | documentContainer?: Document; 11 | } 12 | 13 | export const MarqueeText = ({ 14 | children, 15 | as, 16 | className, 17 | documentContainer = document, 18 | ...textProps 19 | }: MarqueeTextProps) => { 20 | const [play, setPlay] = useState(false); 21 | const textRef = useRef(null); 22 | const divRef = useRef(null); 23 | 24 | const hasOverflow = () => { 25 | if (!textRef.current) { 26 | return false; 27 | } 28 | 29 | return textRef.current.offsetWidth < textRef.current.scrollWidth; 30 | }; 31 | 32 | // The mouseleave event is not always reliable, so we also check the mouse position 33 | useEffect(() => { 34 | if (!divRef.current || !play) { 35 | return; 36 | } 37 | 38 | const handleMouseMove = (e: MouseEvent) => { 39 | const rect = divRef.current?.getBoundingClientRect(); 40 | 41 | if ( 42 | rect && 43 | (e.clientX < rect.left || 44 | e.clientX > rect.right || 45 | e.clientY < rect.top || 46 | e.clientY > rect.bottom) 47 | ) { 48 | setPlay(false); 49 | } 50 | }; 51 | 52 | documentContainer.addEventListener('mousemove', handleMouseMove); 53 | 54 | return () => { 55 | documentContainer.removeEventListener('mousemove', handleMouseMove); 56 | }; 57 | }, [divRef.current, play]); 58 | 59 | const handleMouseEnter = () => { 60 | if (hasOverflow()) { 61 | setPlay(true); 62 | } 63 | }; 64 | 65 | const handleMouseLeave = () => { 66 | setPlay(false); 67 | }; 68 | 69 | return ( 70 |
76 | {play ? ( 77 | 78 | 79 | {children} 80 | 81 | 82 | 83 | ) : ( 84 | 85 | 91 | {children} 92 | 93 | 94 | )} 95 |
96 | ); 97 | }; 98 | 99 | const Space = styled.span` 100 | display: block; 101 | width: ${token('spacing.md')}; 102 | `; 103 | 104 | const StaticTextFlex = styled(Flex)` 105 | overflow: hidden; 106 | `; 107 | 108 | const StaticText = styled(Text)` 109 | overflow: hidden; 110 | text-overflow: ellipsis; 111 | white-space: nowrap; 112 | width: 100%; 113 | `; 114 | -------------------------------------------------------------------------------- /src/ui/shared/components/NextButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@synqapp/ui'; 2 | import { css, styled, useTheme } from 'styled-components'; 3 | 4 | import { useNextButton } from './useNextButton'; 5 | 6 | interface NextButtonProps { 7 | size?: number; 8 | } 9 | 10 | export const NextButton = ({ size }: NextButtonProps) => { 11 | const { handleClick } = useNextButton(); 12 | const theme = useTheme(); 13 | 14 | if (!size) { 15 | size = 32; 16 | } 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | interface ContainerProps { 31 | $size?: string; 32 | } 33 | 34 | const Container = styled.button` 35 | align-items: center; 36 | background: none; 37 | border: none; 38 | cursor: pointer; 39 | display: flex; 40 | justify-content: center; 41 | opacity: 1; 42 | transition: opacity 0.1s ease-in-out; 43 | 44 | ${({ $size }) => 45 | $size && 46 | css` 47 | height: ${$size}; 48 | width: ${$size}; 49 | `} 50 | `; 51 | -------------------------------------------------------------------------------- /src/ui/shared/components/NextButton/useNextButton.ts: -------------------------------------------------------------------------------- 1 | import { sendToContent } from '~core/messaging/sendToContent'; 2 | import { MusicControllerMessage } from '~types'; 3 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 4 | import { sendAnalytic } from '~util/analytics'; 5 | 6 | export const useNextButton = () => { 7 | const { musicServiceTab } = useMusicServiceTab(); 8 | 9 | const handleClick = () => { 10 | sendToContent( 11 | { 12 | name: MusicControllerMessage.NEXT 13 | }, 14 | musicServiceTab?.tabId 15 | ); 16 | sendAnalytic({ 17 | name: 'next' 18 | }); 19 | }; 20 | 21 | return { 22 | handleClick 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/ui/shared/components/PlayPauseButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Icon } from '@synqapp/ui'; 2 | import styled, { css, useTheme } from 'styled-components'; 3 | 4 | import { usePlayPauseButton } from './usePlayPauseButton'; 5 | 6 | interface PlayPauseButtonProps { 7 | size?: number; 8 | } 9 | 10 | export const PlayPauseButton = ({ size }: PlayPauseButtonProps) => { 11 | const { isPlaying, handleClick } = usePlayPauseButton(); 12 | const theme = useTheme(); 13 | 14 | if (!size) { 15 | size = 30; 16 | } 17 | 18 | return ( 19 | 20 | 21 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | interface ContainerProps { 33 | $size?: string; 34 | } 35 | 36 | const Container = styled(Button)` 37 | padding: 0; 38 | border-radius: 50%; 39 | opacity: 1; 40 | transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out 0s; 41 | 42 | &::before { 43 | border-radius: 50%; 44 | } 45 | 46 | ${({ $size }) => 47 | $size && 48 | css` 49 | height: ${$size}; 50 | width: ${$size}; 51 | `} 52 | `; 53 | 54 | const PlayPauseButtonContent = styled(Flex)` 55 | position: absolute; 56 | height: 100%; 57 | width: 100%; 58 | top: 0; 59 | left: 0; 60 | `; 61 | -------------------------------------------------------------------------------- /src/ui/shared/components/PlayPauseButton/usePlayPauseButton.ts: -------------------------------------------------------------------------------- 1 | import { sendToContent } from '~core/messaging/sendToContent'; 2 | import { MusicControllerMessage } from '~types'; 3 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 4 | import { sendAnalytic } from '~util/analytics'; 5 | 6 | export const usePlayPauseButton = () => { 7 | const { musicServiceTab } = useMusicServiceTab(); 8 | const playerState = musicServiceTab?.playbackState; 9 | 10 | const handleClick = () => { 11 | sendToContent( 12 | { 13 | name: MusicControllerMessage.PLAY_PAUSE 14 | }, 15 | musicServiceTab?.tabId 16 | ); 17 | sendAnalytic({ 18 | name: 'play_pause' 19 | }); 20 | }; 21 | 22 | return { 23 | isPlaying: playerState?.isPlaying, 24 | handleClick 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/ui/shared/components/PreviousButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@synqapp/ui'; 2 | import { css, styled, useTheme } from 'styled-components'; 3 | 4 | import { usePreviousButton } from './usePreviousButton'; 5 | 6 | interface PreviousButtonProps { 7 | size?: number; 8 | } 9 | 10 | export const PreviousButton = ({ size }: PreviousButtonProps) => { 11 | const { handleClick } = usePreviousButton(); 12 | const theme = useTheme(); 13 | 14 | if (!size) { 15 | size = 32; 16 | } 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | interface ContainerProps { 31 | $size?: string; 32 | } 33 | 34 | const Container = styled.button` 35 | align-items: center; 36 | background: none; 37 | border: none; 38 | cursor: pointer; 39 | display: flex; 40 | justify-content: center; 41 | opacity: 1; 42 | transition: opacity 0.1s ease-in-out; 43 | 44 | ${({ $size }) => 45 | $size && 46 | css` 47 | height: ${$size}; 48 | width: ${$size}; 49 | `} 50 | `; 51 | -------------------------------------------------------------------------------- /src/ui/shared/components/PreviousButton/usePreviousButton.ts: -------------------------------------------------------------------------------- 1 | import { sendToContent } from '~core/messaging/sendToContent'; 2 | import { MusicControllerMessage } from '~types'; 3 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 4 | import { sendAnalytic } from '~util/analytics'; 5 | 6 | export const usePreviousButton = () => { 7 | const { musicServiceTab } = useMusicServiceTab(); 8 | 9 | const handleClick = () => { 10 | sendToContent( 11 | { 12 | name: MusicControllerMessage.PREVIOUS 13 | }, 14 | musicServiceTab?.tabId 15 | ); 16 | sendAnalytic({ 17 | name: 'previous' 18 | }); 19 | }; 20 | 21 | return { 22 | handleClick 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/ui/shared/components/Queue/index.tsx: -------------------------------------------------------------------------------- 1 | import { List, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import type { MusicService } from '~/types'; 5 | 6 | import { ListItemMenu } from '../ListItemMenu'; 7 | import { TrackListItem } from '../TrackListItem'; 8 | import { useQueue } from './useQueue'; 9 | 10 | interface QueueProps { 11 | startAt?: 'top' | 'next'; 12 | count?: number; 13 | documentContainer?: Document; 14 | } 15 | 16 | export const Queue = ({ 17 | startAt = 'top', 18 | count, 19 | documentContainer = document 20 | }: QueueProps) => { 21 | const { 22 | handlePlayQueueTrack, 23 | handleVisitTrackOnMusicService, 24 | musicService, 25 | musicServiceName, 26 | queueItems 27 | } = useQueue(startAt, count); 28 | 29 | return ( 30 |
31 | 32 | {queueItems.map(({ track, isPlaying }, index) => ( 33 |
34 | 42 | track && handlePlayQueueTrack(track?.id, index) 43 | } 44 | primaryText={track?.name ?? 'Unknown Track'} 45 | rightNode={ 46 | track?.link && ( 47 | { 54 | handleVisitTrackOnMusicService(track?.link); 55 | } 56 | } 57 | ]} 58 | /> 59 | ) 60 | } 61 | secondaryText={`${track?.artistName}${ 62 | track?.albumName && ` • ${track?.albumName}` 63 | }`} 64 | /> 65 |
66 | ))} 67 |
68 |
69 | ); 70 | }; 71 | 72 | const QueueList = styled(List)` 73 | background: ${token('colors.surface')}; 74 | `; 75 | -------------------------------------------------------------------------------- /src/ui/shared/components/Queue/useQueue.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import type { MusicService } from '~/types'; 4 | import { getLink } from '~core/links'; 5 | import { sendToContent } from '~core/messaging/sendToContent'; 6 | import { MusicControllerMessage } from '~types'; 7 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 8 | import { sendAnalytic } from '~util/analytics'; 9 | import { findIndexes } from '~util/findIndexes'; 10 | import { getMusicServiceName } from '~util/musicService'; 11 | 12 | export const useQueue = (startAt: 'top' | 'next', count?: number) => { 13 | const { musicServiceTab } = useMusicServiceTab(); 14 | const playerState = musicServiceTab?.playbackState; 15 | const currentTrack = musicServiceTab?.currentTrack; 16 | 17 | const queue = useMemo(() => { 18 | let currentQueue = playerState?.queue; 19 | 20 | if (!currentQueue) { 21 | return []; 22 | } 23 | 24 | if (startAt === 'top') { 25 | currentQueue = currentQueue.slice(0); 26 | } else { 27 | const currentTrackIndex = currentQueue.findIndex( 28 | (item) => item.track?.id === currentTrack?.id 29 | ); 30 | 31 | currentQueue = currentQueue.slice(currentTrackIndex); 32 | } 33 | 34 | if (count) { 35 | currentQueue = currentQueue.slice(0, count); 36 | } 37 | 38 | return currentQueue; 39 | }, [playerState, count, currentTrack?.id, startAt]); 40 | 41 | const musicServiceName = useMemo( 42 | () => 43 | musicServiceTab ? getMusicServiceName(musicServiceTab.musicService) : '', 44 | [musicServiceTab] 45 | ); 46 | 47 | const handlePlayQueueTrack = (trackId: string, trackIndex: number) => { 48 | const trackIndexes = findIndexes( 49 | queue, 50 | (item) => item.track?.id === trackId 51 | ); 52 | const duplicateIndex = trackIndexes.indexOf(trackIndex); 53 | 54 | sendToContent( 55 | { 56 | name: MusicControllerMessage.PLAY_QUEUE_TRACK, 57 | body: { 58 | trackId, 59 | duplicateIndex 60 | } 61 | }, 62 | musicServiceTab?.tabId 63 | ); 64 | 65 | sendAnalytic({ 66 | name: 'play_queue_track' 67 | }); 68 | }; 69 | 70 | const handleVisitTrackOnMusicService = (link?: string) => { 71 | if (!link) { 72 | return; 73 | } 74 | 75 | window.open(link, '_blank'); 76 | 77 | sendAnalytic({ 78 | name: 'visit_music_service_link' 79 | }); 80 | }; 81 | 82 | return { 83 | handlePlayQueueTrack, 84 | handleVisitTrackOnMusicService, 85 | musicService: musicServiceTab?.musicService, 86 | musicServiceName, 87 | queueItems: queue 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /src/ui/shared/components/RepeatButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@synqapp/ui'; 2 | import { css, styled, useTheme } from 'styled-components'; 3 | 4 | import { RepeatMode } from '~types'; 5 | 6 | import { useRepeatButton } from './useRepeatButton'; 7 | 8 | interface RepeatButtonProps { 9 | size?: number; 10 | } 11 | 12 | export const RepeatButton = ({ size }: RepeatButtonProps) => { 13 | const { repeatMode, handleClick } = useRepeatButton(); 14 | const theme = useTheme(); 15 | 16 | if (!size) { 17 | size = 32; 18 | } 19 | 20 | return ( 21 | 22 | 32 | 33 | ); 34 | }; 35 | 36 | interface ContainerProps { 37 | $size?: string; 38 | } 39 | 40 | const Container = styled.button` 41 | align-items: center; 42 | background: none; 43 | border: none; 44 | cursor: pointer; 45 | display: flex; 46 | justify-content: center; 47 | opacity: 1; 48 | transition: opacity 0.1s ease-in-out; 49 | 50 | ${({ $size }) => 51 | $size && 52 | css` 53 | height: ${$size}; 54 | width: ${$size}; 55 | `} 56 | `; 57 | -------------------------------------------------------------------------------- /src/ui/shared/components/RepeatButton/useRepeatButton.ts: -------------------------------------------------------------------------------- 1 | import { sendToContent } from '~core/messaging/sendToContent'; 2 | import { useAppSelector } from '~store'; 3 | import { MusicControllerMessage } from '~types'; 4 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 5 | import { sendAnalytic } from '~util/analytics'; 6 | 7 | export const useRepeatButton = () => { 8 | const { musicServiceTab } = useMusicServiceTab(); 9 | const playerState = musicServiceTab?.playbackState; 10 | 11 | const repeatMode = playerState?.repeatMode; 12 | 13 | const handleClick = () => { 14 | sendToContent( 15 | { 16 | name: MusicControllerMessage.TOGGLE_REPEAT_MODE 17 | }, 18 | musicServiceTab?.tabId 19 | ); 20 | sendAnalytic({ 21 | name: 'toggle_repeat_mode' 22 | }); 23 | }; 24 | 25 | return { 26 | repeatMode, 27 | handleClick 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/ui/shared/components/ShuffleButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@synqapp/ui'; 2 | import { css, styled, useTheme } from 'styled-components'; 3 | 4 | interface ShuffleButtonProps { 5 | size?: number; 6 | } 7 | 8 | export const ShuffleButton = ({ size }: ShuffleButtonProps) => { 9 | const theme = useTheme(); 10 | 11 | if (!size) { 12 | size = 32; 13 | } 14 | 15 | return ( 16 | console.log('shuffle')} $size={`${size}px`}> 17 | 23 | 24 | ); 25 | }; 26 | 27 | interface ContainerProps { 28 | $size?: string; 29 | } 30 | 31 | const Container = styled.button` 32 | align-items: center; 33 | background: none; 34 | border: none; 35 | cursor: pointer; 36 | display: flex; 37 | justify-content: center; 38 | opacity: 1; 39 | transition: opacity 0.1s ease-in-out; 40 | 41 | ${({ $size }) => 42 | $size && 43 | css` 44 | height: ${$size}; 45 | width: ${$size}; 46 | `} 47 | `; 48 | -------------------------------------------------------------------------------- /src/ui/shared/components/TrackSeeker/index.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Stack, Text, token } from '@synqapp/ui'; 2 | import { Slider } from '@synqapp/ui'; 3 | import { css, styled } from 'styled-components'; 4 | 5 | import { secondsToLengthText } from '~util/time'; 6 | 7 | import { useTrackSeeker } from './useTrackSeeker'; 8 | 9 | interface TrackSeekerProps { 10 | width?: number; 11 | inline?: boolean; 12 | sliderThickness?: number; 13 | } 14 | 15 | export const TrackSeeker = ({ 16 | width, 17 | inline, 18 | sliderThickness 19 | }: TrackSeekerProps) => { 20 | const { currentTime, duration, handleSeek } = useTrackSeeker(); 21 | 22 | const renderSeekSlider = () => { 23 | return ( 24 | 25 | 31 | 32 | ); 33 | }; 34 | 35 | const renderCurrentTime = () => { 36 | return ( 37 | 38 | {secondsToLengthText(currentTime)} 39 | 40 | ); 41 | }; 42 | 43 | const renderDuration = () => { 44 | return ( 45 | 46 | {secondsToLengthText(duration)} 47 | 48 | ); 49 | }; 50 | 51 | return inline ? ( 52 | 53 | {renderCurrentTime()} 54 | {renderSeekSlider()} 55 | {renderDuration()} 56 | 57 | ) : ( 58 | 59 | {renderSeekSlider()} 60 | 61 | {renderCurrentTime()} 62 | {renderDuration()} 63 | 64 | 65 | ); 66 | }; 67 | 68 | interface SeekContainerProps { 69 | $width?: number; 70 | $sliderThickness?: number; 71 | } 72 | 73 | const SeekContainer = styled.div` 74 | height: 6px; 75 | width: 100%; 76 | position: relative; 77 | 78 | ${({ $width }) => 79 | $width && 80 | css` 81 | width: ${$width}px; 82 | `} 83 | 84 | ${({ $sliderThickness }) => 85 | $sliderThickness && 86 | css` 87 | height: ${$sliderThickness}px; 88 | `} 89 | `; 90 | 91 | interface TimeTextProps { 92 | $inline?: boolean; 93 | } 94 | 95 | const TimeText = styled(Text)` 96 | margin: 0; 97 | 98 | ${({ $inline }) => 99 | !$inline && 100 | css` 101 | margin: ${token('spacing.2xs')} 0 0; 102 | `} 103 | `; 104 | -------------------------------------------------------------------------------- /src/ui/shared/components/TrackSeeker/useTrackSeeker.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import { sendToContent } from '~core/messaging/sendToContent'; 4 | import { MusicControllerMessage } from '~types'; 5 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 6 | import { sendAnalytic } from '~util/analytics'; 7 | 8 | export const useTrackSeeker = () => { 9 | const { musicServiceTab } = useMusicServiceTab(); 10 | const playerState = musicServiceTab?.playbackState; 11 | const currentTrack = musicServiceTab?.currentTrack; 12 | 13 | const percentage = useMemo(() => { 14 | if (!currentTrack?.duration || !playerState?.currentTime) { 15 | return 0; 16 | } 17 | 18 | return (playerState.currentTime / currentTrack.duration) * 100; 19 | }, [playerState?.currentTime, currentTrack?.duration]); 20 | 21 | const handleSeek = (e: React.ChangeEvent) => { 22 | const time = parseInt(e.target.value); 23 | 24 | sendToContent( 25 | { 26 | name: MusicControllerMessage.SEEK_TO, 27 | body: { 28 | time 29 | } 30 | }, 31 | musicServiceTab?.tabId 32 | ); 33 | 34 | sendAnalytic( 35 | { 36 | name: 'seek_to' 37 | }, 38 | 1000 39 | ); 40 | }; 41 | 42 | return { 43 | currentTime: playerState?.currentTime, 44 | duration: currentTrack?.duration, 45 | percentage, 46 | handleSeek 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/ui/shared/components/TrackTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import { Text, type TextProps, token } from '@synqapp/ui'; 2 | import { styled } from 'styled-components'; 3 | 4 | import { MarqueeText } from '../MarqueeText'; 5 | import { useTrackTitle } from './useTrackTitle'; 6 | 7 | interface TrackTitleProps { 8 | size: TextProps['size']; 9 | weight?: TextProps['weight']; 10 | } 11 | 12 | export const TrackTitle = ({ size, weight }: TrackTitleProps) => { 13 | const trackTitle = useTrackTitle(); 14 | 15 | return trackTitle ? ( 16 | 17 | {trackTitle} 18 | 19 | ) : ( 20 | 21 | No Music Playing 22 | 23 | ); 24 | }; 25 | 26 | const TrackTitleText = styled(MarqueeText)` 27 | .text { 28 | margin: 0; 29 | } 30 | `; 31 | 32 | const NoMusicText = styled(Text)` 33 | color: ${token('colors.onBackgroundLow')}; 34 | margin: 0; 35 | `; 36 | -------------------------------------------------------------------------------- /src/ui/shared/components/TrackTitle/useTrackTitle.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '~store'; 2 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 3 | 4 | export const useTrackTitle = () => { 5 | const { musicServiceTab } = useMusicServiceTab(); 6 | const currentTrack = musicServiceTab?.currentTrack; 7 | 8 | return currentTrack?.name; 9 | }; 10 | -------------------------------------------------------------------------------- /src/ui/shared/components/VolumeButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@synqapp/ui'; 2 | import { css, styled, useTheme } from 'styled-components'; 3 | 4 | import { useVolumeButton } from './useVolumeButton'; 5 | 6 | interface VolumeButtonProps { 7 | size?: number; 8 | } 9 | 10 | export const VolumeButton = ({ size }: VolumeButtonProps) => { 11 | const { muted, handleClick } = useVolumeButton(); 12 | const theme = useTheme(); 13 | 14 | if (!size) { 15 | size = 32; 16 | } 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | interface ContainerProps { 31 | $size?: string; 32 | } 33 | 34 | const Container = styled.button` 35 | align-items: center; 36 | background: none; 37 | border: none; 38 | cursor: pointer; 39 | display: flex; 40 | justify-content: center; 41 | opacity: 1; 42 | transition: opacity 0.1s ease-in-out; 43 | 44 | ${({ $size }) => 45 | $size && 46 | css` 47 | height: ${$size}; 48 | width: ${$size}; 49 | `} 50 | `; 51 | -------------------------------------------------------------------------------- /src/ui/shared/components/VolumeButton/useVolumeButton.ts: -------------------------------------------------------------------------------- 1 | import { sendToContent } from '~core/messaging/sendToContent'; 2 | import { MusicControllerMessage } from '~types'; 3 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 4 | import { sendAnalytic } from '~util/analytics'; 5 | 6 | export const useVolumeButton = () => { 7 | const { musicServiceTab } = useMusicServiceTab(); 8 | const playerState = musicServiceTab?.playbackState; 9 | 10 | const handleClick = () => { 11 | sendToContent( 12 | { 13 | name: MusicControllerMessage.TOGGLE_MUTE 14 | }, 15 | musicServiceTab?.tabId 16 | ); 17 | sendAnalytic({ 18 | name: 'toggle_mute' 19 | }); 20 | }; 21 | 22 | return { 23 | muted: playerState?.volume === 0, 24 | handleClick 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/ui/shared/components/VolumeSlider/index.tsx: -------------------------------------------------------------------------------- 1 | import { Slider } from '@synqapp/ui'; 2 | import { css, styled } from 'styled-components'; 3 | 4 | import { useVolumeSlider } from './useVolumeSlider'; 5 | 6 | interface VolumeSliderProps { 7 | sliderThickness?: number; 8 | width?: string | number; 9 | } 10 | 11 | export const VolumeSlider = ({ sliderThickness, width }: VolumeSliderProps) => { 12 | const { volume, handleVolumeSliderChange } = useVolumeSlider(); 13 | 14 | return ( 15 | 24 | ); 25 | }; 26 | 27 | const Container = styled(Slider)` 28 | height: 6px; 29 | 30 | ${({ width }) => 31 | width && 32 | css` 33 | width: ${width}; 34 | `} 35 | `; 36 | -------------------------------------------------------------------------------- /src/ui/shared/components/VolumeSlider/useVolumeSlider.ts: -------------------------------------------------------------------------------- 1 | import { sendToContent } from '~core/messaging/sendToContent'; 2 | import { useAppSelector } from '~store'; 3 | import { MusicControllerMessage } from '~types'; 4 | import { useMusicServiceTab } from '~ui/shared/contexts/MusicServiceTab'; 5 | import { sendAnalytic } from '~util/analytics'; 6 | 7 | export const useVolumeSlider = () => { 8 | const { musicServiceTab } = useMusicServiceTab(); 9 | const playerState = musicServiceTab?.playbackState; 10 | 11 | const handleVolumeSliderChange = (e: React.ChangeEvent) => { 12 | const volume = parseInt(e.target.value); 13 | 14 | sendToContent( 15 | { 16 | name: MusicControllerMessage.SET_VOLUME, 17 | body: { 18 | volume 19 | } 20 | }, 21 | musicServiceTab?.tabId 22 | ); 23 | 24 | sendAnalytic( 25 | { 26 | name: 'set_volume' 27 | }, 28 | 1000 29 | ); 30 | }; 31 | 32 | return { 33 | volume: playerState?.volume, 34 | handleVolumeSliderChange 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/ui/shared/contexts/DocumentContextProvidersWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { MusicServiceTabProvider } from '~ui/shared/contexts/MusicServiceTab'; 2 | 3 | import { useDocumentMusicServiceTab } from '../hooks/useDocumentMusicServiceTab'; 4 | 5 | interface ContextsWrapperProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export const DocumentContextProvidersWrapper = ({ 10 | children 11 | }: ContextsWrapperProps) => { 12 | const musicServiceTab = useDocumentMusicServiceTab(); 13 | 14 | return ( 15 | 20 | {children} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/ui/shared/contexts/MusicServiceTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | import type { MusicServiceTab } from '~types'; 4 | 5 | interface MusicServiceTabContextValue { 6 | musicServiceTab?: MusicServiceTab; 7 | setMusicServiceTab: (tab: MusicServiceTab) => void; 8 | } 9 | 10 | const MusicServiceTabContext = createContext({ 11 | setMusicServiceTab: () => {} 12 | }); 13 | 14 | interface MusicServiceTabProviderProps { 15 | children: React.ReactNode; 16 | value: MusicServiceTabContextValue; 17 | } 18 | 19 | /** 20 | * Returns the selected tab ID and a function to set the selected tab ID. 21 | * Automatically selects the tab if there is only one. Otherwise, the tab 22 | * must be selected manually. 23 | */ 24 | export const MusicServiceTabProvider = ({ 25 | children, 26 | value 27 | }: MusicServiceTabProviderProps) => { 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | }; 34 | 35 | export const useMusicServiceTab = () => useContext(MusicServiceTabContext); 36 | -------------------------------------------------------------------------------- /src/ui/shared/hooks/useAnalytic.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { sendToBackground } from '~core/messaging'; 4 | import type { Event } from '~util/analytics'; 5 | 6 | interface UseAnalyticsProps extends Event { 7 | immediate?: boolean; 8 | } 9 | 10 | /** 11 | * React hook to send events to Google Analytics 12 | */ 13 | export const useAnalytic = ({ name, params }: UseAnalyticsProps) => { 14 | useEffect(() => { 15 | sendToBackground({ 16 | name: 'SEND_ANALYTICS_EVENT', 17 | body: { 18 | name, 19 | params 20 | } 21 | }); 22 | }, [name, params]); 23 | }; 24 | -------------------------------------------------------------------------------- /src/ui/shared/hooks/useDocumentMusicServiceTab.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { sendToBackground } from '~core/messaging'; 4 | import { useAppSelector } from '~store'; 5 | import type { MusicServiceTab } from '~types'; 6 | 7 | export const useDocumentMusicServiceTab = () => { 8 | const musicServiceTabs = useAppSelector((state) => state.musicServiceTabs); 9 | const [tabId, setTabId] = useState(undefined); 10 | const [musicServiceTab, setMusicServiceTab] = useState< 11 | MusicServiceTab | undefined 12 | >(undefined); 13 | 14 | useEffect(() => { 15 | const updateTab = async () => { 16 | const tab = await sendToBackground({ 17 | name: 'GET_SELF_TAB' 18 | }); 19 | 20 | if (!tab) { 21 | return; 22 | } 23 | 24 | setTabId(tab.id); 25 | }; 26 | 27 | updateTab(); 28 | }, []); 29 | 30 | useEffect(() => { 31 | if (!tabId) { 32 | return; 33 | } 34 | 35 | const musicServiceTab = musicServiceTabs.find( 36 | (musicServiceTab) => musicServiceTab.tabId === tabId 37 | ); 38 | 39 | if (!musicServiceTab) { 40 | return; 41 | } 42 | 43 | setMusicServiceTab(musicServiceTab); 44 | }, [musicServiceTabs, tabId]); 45 | 46 | return musicServiceTab; 47 | }; 48 | -------------------------------------------------------------------------------- /src/ui/shared/hooks/usePermissionsCheck.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { HOSTS, PERMISSIONS } from '~constants/permissions'; 4 | 5 | export const usePermissionsCheck = () => { 6 | const [permissionsAccepted, setPermissionsAccepted] = useState< 7 | undefined | boolean 8 | >(undefined); 9 | 10 | useEffect(() => { 11 | chrome?.permissions?.contains( 12 | { 13 | permissions: PERMISSIONS, 14 | origins: HOSTS 15 | }, 16 | (result) => { 17 | setPermissionsAccepted(result); 18 | } 19 | ); 20 | }, []); 21 | 22 | return permissionsAccepted; 23 | }; 24 | -------------------------------------------------------------------------------- /src/ui/shared/hooks/useTabQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { ALL_URL_MATCHES } from '../../../constants/urls'; 4 | 5 | export const useTabsQuery = () => { 6 | const [tabs, setTabs] = useState(null); 7 | 8 | useEffect(() => { 9 | chrome.tabs.query({ url: ALL_URL_MATCHES }, (tabs) => { 10 | if (tabs?.length) { 11 | setTabs(tabs); 12 | } 13 | }); 14 | }, []); 15 | 16 | return tabs; 17 | }; 18 | -------------------------------------------------------------------------------- /src/ui/shared/styles/MarqueeStylesProvider.tsx: -------------------------------------------------------------------------------- 1 | /* From https://github.com/justin-chu/react-fast-marquee/blob/master/src/components/Marquee.scss */ 2 | 3 | import { createGlobalStyle } from 'styled-components'; 4 | 5 | export const MarqueeStylesProvider = createGlobalStyle` 6 | .rfm-marquee-container { 7 | overflow-x: hidden; 8 | display: flex; 9 | flex-direction: row; 10 | position: relative; 11 | width: var(--width); 12 | transform: var(--transform); 13 | 14 | &:hover div { 15 | animation-play-state: var(--pause-on-hover); 16 | } 17 | 18 | &:active div { 19 | animation-play-state: var(--pause-on-click); 20 | } 21 | } 22 | 23 | .rfm-overlay { 24 | position: absolute; 25 | width: 100%; 26 | height: 100%; 27 | 28 | @mixin gradient { 29 | background: linear-gradient(to right, var(--gradient-color), transparent); 30 | } 31 | 32 | &::before, 33 | &::after { 34 | @include gradient; 35 | content: ""; 36 | height: 100%; 37 | position: absolute; 38 | width: var(--gradient-width); 39 | z-index: 2; 40 | pointer-events: none; 41 | touch-action: none; 42 | } 43 | 44 | &::after { 45 | right: 0; 46 | top: 0; 47 | transform: rotateZ(180deg); 48 | } 49 | 50 | &::before { 51 | left: 0; 52 | top: 0; 53 | } 54 | } 55 | 56 | .rfm-marquee { 57 | flex: 0 0 auto; 58 | min-width: var(--min-width); 59 | z-index: 1; 60 | display: flex; 61 | flex-direction: row; 62 | align-items: center; 63 | animation: scroll var(--duration) linear var(--delay) var(--iteration-count); 64 | animation-play-state: var(--play); 65 | animation-delay: var(--delay); 66 | animation-direction: var(--direction); 67 | 68 | @keyframes scroll { 69 | 0% { 70 | transform: translateX(0%); 71 | } 72 | 100% { 73 | transform: translateX(-100%); 74 | } 75 | } 76 | } 77 | 78 | .rfm-initial-child-container { 79 | flex: 0 0 auto; 80 | display: flex; 81 | min-width: auto; 82 | flex-direction: row; 83 | align-items: center; 84 | } 85 | 86 | .rfm-child { 87 | transform: var(--transform); 88 | } 89 | `; 90 | -------------------------------------------------------------------------------- /src/util/analytics.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | 3 | import { sendToBackground } from '~core/messaging'; 4 | 5 | import { debounce as debounceFn } from './debounce'; 6 | 7 | const GA_ENDPOINT = 'https://www.google-analytics.com/mp/collect'; 8 | 9 | const SESSION_EXPIRATION_IN_MIN = 30; 10 | const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100; 11 | 12 | async function getOrCreateSessionId() { 13 | // Store session in memory storage 14 | let sessionTimestamp = (await chrome.storage.local.get('sessionTimestamp')) 15 | .sessionTimestamp; 16 | 17 | // Check if session exists and is still valid 18 | const currentTimeInMs = Date.now(); 19 | if (sessionTimestamp) { 20 | // Calculate how long ago the session was last updated 21 | const durationInMin = (currentTimeInMs - sessionTimestamp) / 60000; 22 | // Check if last update lays past the session expiration threshold 23 | if (durationInMin > SESSION_EXPIRATION_IN_MIN) { 24 | // Delete old session id to start a new session 25 | sessionTimestamp = null; 26 | } else { 27 | // Update timestamp to keep session alive 28 | sessionTimestamp = currentTimeInMs; 29 | await chrome.storage.local.set({ sessionData: sessionTimestamp }); 30 | } 31 | } 32 | 33 | if (!sessionTimestamp) { 34 | // Create and store a new session 35 | sessionTimestamp = { 36 | session_id: currentTimeInMs.toString(), 37 | timestamp: currentTimeInMs.toString() 38 | }; 39 | await chrome.storage.local.set({ sessionData: sessionTimestamp }); 40 | } 41 | return sessionTimestamp.session_id; 42 | } 43 | 44 | const getOrCreateClientId = async () => { 45 | let clientId = (await chrome.storage.local.get('clientId')).clientId; 46 | if (!clientId) { 47 | // Generate a unique client ID, the actual value is not relevant 48 | clientId = uuid(); 49 | await chrome.storage.local.set({ clientId }); 50 | } 51 | return clientId; 52 | }; 53 | 54 | export interface Event { 55 | name: string; 56 | params?: any; 57 | } 58 | 59 | export const sendEvent = async (event: Event) => { 60 | await sendEvents([event]); 61 | }; 62 | 63 | export const sendEvents = async (events: Event[]) => { 64 | const clientId = await getOrCreateClientId(); 65 | const sessionId = await getOrCreateSessionId(); 66 | 67 | events.forEach((event) => { 68 | if (!event.params) { 69 | event.params = {}; 70 | } 71 | 72 | event.params.session_id = sessionId; 73 | event.params.engagement_time_msec = DEFAULT_ENGAGEMENT_TIME_IN_MSEC; 74 | }); 75 | 76 | const queryParams = new URLSearchParams({ 77 | measurement_id: process.env.PLASMO_PUBLIC_GA_MEASUREMENT_ID!, 78 | api_secret: process.env.PLASMO_PUBLIC_GA_SECRET! 79 | }); 80 | 81 | fetch(`${GA_ENDPOINT}?${queryParams.toString()}`, { 82 | method: 'POST', 83 | body: JSON.stringify({ 84 | client_id: clientId, 85 | events 86 | }) 87 | }); 88 | }; 89 | 90 | const sendEventWithBackground = (event: Event) => { 91 | sendToBackground({ 92 | name: 'SEND_ANALYTICS_EVENT', 93 | body: { 94 | name: event.name, 95 | params: event.params 96 | } 97 | }); 98 | }; 99 | 100 | export const sendAnalytic = (event: Event, debounce?: number) => { 101 | if (debounce) { 102 | debounceFn( 103 | () => { 104 | sendEventWithBackground(event); 105 | }, 106 | event.name, 107 | debounce 108 | ); 109 | } else { 110 | sendEventWithBackground(event); 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /src/util/debounce.ts: -------------------------------------------------------------------------------- 1 | const debounceMap = new Map(); 2 | 3 | export const debounce = (fn: () => unknown, key: string, delay: number) => { 4 | if (debounceMap.has(key)) { 5 | clearTimeout(debounceMap.get(key)!); 6 | } 7 | 8 | debounceMap.set( 9 | key, 10 | setTimeout(() => { 11 | fn(); 12 | debounceMap.delete(key); 13 | }, delay) 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/util/findIndexes.ts: -------------------------------------------------------------------------------- 1 | export const findIndexes = (arr: T[], predicate: (item: T) => boolean) => { 2 | return arr.reduce((acc, item, index) => { 3 | if (predicate(item)) { 4 | acc.push(index); 5 | } 6 | 7 | return acc; 8 | }, [] as number[]); 9 | }; 10 | -------------------------------------------------------------------------------- /src/util/generateRequestId.ts: -------------------------------------------------------------------------------- 1 | export const generateRequestId = () => { 2 | return Math.random().toString(36).substring(7); 3 | }; 4 | -------------------------------------------------------------------------------- /src/util/imageUrlToDataUrl.ts: -------------------------------------------------------------------------------- 1 | const blobToDataUrl = (blob: Blob): Promise => { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | 5 | reader.onload = () => { 6 | resolve(reader.result as string); 7 | }; 8 | 9 | reader.onerror = () => { 10 | reject(reader.error); 11 | }; 12 | 13 | reader.readAsDataURL(blob); 14 | }); 15 | }; 16 | 17 | export const imageUrlToDataUrl = async (url: string): Promise => { 18 | const response = await fetch(url); 19 | const blob = await response.blob(); 20 | const dataUrl = await blobToDataUrl(blob); 21 | 22 | return dataUrl; 23 | }; 24 | -------------------------------------------------------------------------------- /src/util/musicService.ts: -------------------------------------------------------------------------------- 1 | import type { MusicService } from '~/types'; 2 | import adapters from '~adapters'; 3 | import { matchAdapter } from '~core/adapter/register'; 4 | 5 | export const getMusicServiceFromUrl = (url: string): MusicService | null => { 6 | const adapter = matchAdapter(url, adapters); 7 | return adapter?.id ?? null; 8 | }; 9 | 10 | export const getMusicServiceName = (musicService: MusicService): string => { 11 | return ( 12 | adapters.find((adapter) => adapter.id === musicService)?.displayName ?? '' 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/util/onDocumentReady.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calls the given function when the document is ready. 3 | */ 4 | export const onDocumentReady = (fn: () => void) => { 5 | if ( 6 | document.readyState === 'complete' || 7 | document.readyState === 'interactive' 8 | ) { 9 | setTimeout(fn, 1); 10 | } else { 11 | document.addEventListener('DOMContentLoaded', fn); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/util/store.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from 'redux'; 2 | 3 | import { sendToBackground } from '~core/messaging'; 4 | 5 | export const dispatchFromContent = (action: Action) => { 6 | sendToBackground({ name: 'DISPATCH', body: action }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/util/time.ts: -------------------------------------------------------------------------------- 1 | export const lengthTextToSeconds = (lengthText: string): number => { 2 | const parts = lengthText.split(':'); 3 | 4 | let duration = 0; 5 | 6 | parts.forEach((part, index) => { 7 | duration += parseInt(part) * Math.pow(60, parts.length - index - 1); 8 | }); 9 | 10 | return duration; 11 | }; 12 | 13 | export const secondsToLengthText = (seconds?: number): string => { 14 | if (seconds !== 0 && !seconds) { 15 | return '-:--'; 16 | } 17 | 18 | const hours = Math.floor(seconds / 3600); 19 | const minutes = Math.floor((seconds - hours * 3600) / 60); 20 | const remainingSeconds = seconds - hours * 3600 - minutes * 60; 21 | 22 | const hoursText = hours.toString(); 23 | const minutesText = minutes.toString().padStart(hours ? 2 : 1, '0'); 24 | const secondsText = remainingSeconds.toString().padStart(2, '0'); 25 | 26 | if (hours > 0) { 27 | return `${hoursText}:${minutesText}:${secondsText}`; 28 | } 29 | 30 | return `${minutesText}:${secondsText}`; 31 | }; 32 | -------------------------------------------------------------------------------- /src/util/volume.ts: -------------------------------------------------------------------------------- 1 | export const normalizeVolume = (volume: number) => { 2 | return Math.max(0, Math.min(100, volume)); 3 | }; 4 | -------------------------------------------------------------------------------- /src/util/waitForElement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wait for an element to appear on the page. 3 | * @param selector The CSS selector to wait for. 4 | * @param timeout The timeout to wait for in milliseconds. 5 | * @param all Whether to return all matching elements. 6 | */ 7 | export function waitForElement( 8 | selector: string, 9 | timeout?: number, 10 | all?: false 11 | ): Promise; 12 | 13 | export function waitForElement( 14 | selector: string, 15 | timeout?: number, 16 | all?: true 17 | ): Promise>; 18 | 19 | export function waitForElement( 20 | selector: string, 21 | timeout = 10000, 22 | all?: boolean 23 | ): Promise> { 24 | const startTime = Date.now(); 25 | 26 | return new Promise((resolve, reject) => { 27 | const element = all 28 | ? document.querySelectorAll(selector) 29 | : document.querySelector(selector); 30 | 31 | if (element) { 32 | resolve(element); 33 | return; 34 | } 35 | 36 | const observer = new MutationObserver((mutations, observerInstance) => { 37 | const element = all 38 | ? document.querySelectorAll(selector) 39 | : document.querySelector(selector); 40 | 41 | if (element) { 42 | observerInstance.disconnect(); 43 | resolve(element); 44 | } 45 | }); 46 | 47 | observer.observe(document.body, { 48 | childList: true, 49 | subtree: true 50 | }); 51 | 52 | const interval = setInterval(() => { 53 | if (Date.now() - startTime > timeout) { 54 | clearInterval(interval); 55 | observer.disconnect(); 56 | reject(new Error(`Timeout waiting for element: ${selector}`)); 57 | } 58 | }, 100); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "plasmo/templates/tsconfig.base", 3 | "exclude": ["node_modules", "./src/types/ReactBeautifulDnd.d.ts"], 4 | "include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"], 5 | "compilerOptions": { 6 | "paths": { 7 | "~*": ["./src/*"], 8 | "@synq/react-beautiful-dnd": ["./src/types/ReactBeautifulDnd.d.ts"], 9 | "spotify-web-playback-sdk": ["@types/spotify-web-playback-sdk"] 10 | }, 11 | "baseUrl": ".", 12 | "strict": true 13 | } 14 | } 15 | --------------------------------------------------------------------------------