diff --git a/.eslintrc.json b/.eslintrc.json index 1e12092..ea2921c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,15 +1,23 @@ { + "env": { "browser": true, "node":true }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended"], "ignorePatterns": ["dist"], - "root": true, "parser": "@typescript-eslint/parser", "parserOptions": { - "ecmaVersion": 6, + "ecmaVersion": "latest", "sourceType": "module" }, "plugins": ["@typescript-eslint"], + "root": true, "rules": { + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/naming-convention": "warn", "@typescript-eslint/semi": ["error", "never"], - "arrow-parens": ["warn", "as-needed"], + "arrow-parens": ["error", "as-needed"], "curly": ["error", "multi-or-nest"], "eqeqeq": "warn", "no-throw-literal": "warn", diff --git a/.gitignore b/.gitignore index 9166479..e3a799b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ +.token *.vsix -dist/ -icon/res/ +dist +media/res node_modules -out package-lock.json -src/ref \ No newline at end of file +src/ref diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..ce097dd --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + arrowParens: 'avoid', + printWidth: 120, + semi: false, + singleQuote: true, + trailingComma: 'none' +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index c0a2258..a3cdc1e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,11 @@ { // See http://go.microsoft.com/fwlink/?LinkId=827846 // for the documentation about the extensions.json format - "recommendations": ["dbaeumer.vscode-eslint"] + "recommendations": [ + "dbaeumer.vscode-eslint", + "amodio.tsl-problem-matcher", + "ue.alphabetical-sorter", + "shardulm94.trailing-spaces", + "streetsidesoftware.code-spell-checker" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 2b99907..0fc4e0c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,22 +6,20 @@ "version": "0.2.0", "configurations": [ { - "name": "Extension:webpack", + "name": "Launch Extension webpack", "type": "extensionHost", "request": "launch", - "runtimeExecutable": "${execPath}", "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "outFiles": ["${workspaceFolder}/dist/**/*.js"], - "preLaunchTask": "npm: webpack" + "preLaunchTask": "${defaultBuildTask}" }, { - "name": "Extension:watch", + "name": "Launch Extension ts-watch", "type": "extensionHost", "request": "launch", - "runtimeExecutable": "${execPath}", "args": ["--extensionDevelopmentPath=${workspaceFolder}"], - "outFiles": ["${workspaceFolder}/out/**/*.js"], - "preLaunchTask": "npm: watch" + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "ts-watch" } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 75a21e6..a5dd06f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,42 @@ // Place your settings in this file to overwrite default and user settings. { + "cSpell.words": [ + "Adblocker", + "Btns", + "bufferutil", + "deepskyblue", + "Execa", + "lanly", + "letmeplaythemusic", + "lmptm", + "playpause", + "soundcloud", + "spoticon", + "targetchanged", + "targetcreated", + "targetdestroyed", + "testid", + "treeview", + "uddir", + "ytmusic" + ], + "cSpell.ignorePaths": [ + ".git/objects", + ".vscode", + ".eslintrc.json", + "node_modules", + "package-lock.json" + ], + "editor.rulers": [120], + "explorer.sortOrder": "type", "files.exclude": { + "dist": false, // set this to true to hide the "dist" folder with the compiled JS files "out": false // set this to true to hide the "out" folder with the compiled JS files }, "search.exclude": { + "dist": true, // set this to false to include "dist" folder in search results "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off" -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 305b614..b7ca355 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,30 +5,43 @@ "tasks": [ { "type": "npm", - "script": "watch", - "problemMatcher": "$tsc-watch", + "script": "wp-watch", + "problemMatcher": ["$ts-webpack-watch", "$tslint-webpack-watch"], "isBackground": true, "presentation": { - "reveal": "never" + "reveal": "never", + "group": "watcher" }, "group": { "kind": "build", "isDefault": true - } + }, + "dependsOn": ["npm: task-clean-output"] + }, + { + "label": "ts-watch", + "type": "npm", + "script": "ts-watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { "reveal": "never" }, + "dependsOrder": "sequence", + "dependsOn": ["npm: task-clean-output", "npm: task-copy-static-assets"] }, + // ↓↓↓↓ For vscode command palette ↓↓↓↓ { "type": "npm", - "script": "compile", - "dependsOn": ["npm: copy-static-assets"] + "script": "test-compile", + "dependsOn": ["npm: task-clean-output"] }, { "type": "npm", - "script": "copy-static-assets", + "script": "task-copy-static-assets", "isBackground": false }, { "type": "npm", - "script": "clean-output", + "script": "task-clean-output", "isBackground": false } ] diff --git a/.vscodeignore b/.vscodeignore index 437770f..8a67662 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,14 +1,13 @@ .gitignore -.vscode/ +.token +.vscode **/.eslintrc.json **/*.map **/*.ts **/tsconfig.json -clearOutFiles.ts -copyStaticAssets.ts -icon/res/ +media/res +media/vscodeignore node_modules -out/ -src/ +src vsc-extension-quickstart.md -webpack.config.js \ No newline at end of file +webpack.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 65bbbfd..1022b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,49 @@ All notable changes to the "LetMePlayTheMusic" extension will be documented in t Check [Keep a Changelog](http://keepachangelog.com) for recommendations on how to structure this file. +### References +- https://github.com/microsoft/vscode-generator-code/tree/main/generators/app/templates/ext-command-ts +- https://github.com/microsoft/vscode-extension-samples +- https://www.conventionalcommits.org + ### TODO -- Extention settings scope -- I18n? -- Site English version issue +- Brave setting removal +- I18n +- Playwright vs Puppeteer option +- Seek backward/forward setting option +- Shelljs vs Execa +- Support for other sites + +## [2.0.0] - December 2021 +- Add new feature - treeview +- Rewrite/refactor most of the code - 30+ commits +- New float button style - old vs new in Windows +
+- Fix Spotify bug due to it's style class's changes +- Fix site English version issue - observes another DOM element to update playback status +- Merge play and pause commands into one toggle function +- Move most of the minor inject action to inject script instead +- Re-config project's configs like eslint, tsconfig, vscode setting, and webpack (.js -> .ts) +- Rename directories: icon -> media, scripts -> inject +- Playback icons is sync with the site's playback icons which was behaved contrarily before +- Webpack 5.65.0 compiled successfully in 9980 ms +- 15 files, 1.39MB, 1.63.0 + +### Note + +#### This release mostly has more significant impact on the dev side rather than like a product update. + +#### One funny thing is that the picture in README.md accounts for a large part of this extension's size. + +#### [MediaSession](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession) +- Soundcloud does update `playbackState` but only call `set` for from press play +- Spotify does call `set` to update metadata but playbackState always *none* +- Youtube and YTmusic update `playbackState` consistent with proxy `set` event + +#### Puppeteer +- Seem Puppeteer doesn't keep track pages/tabs' order +- If click too quick, this shows up: `Error: Execution context is not available in detached frame "about:blank" (are you trying to evaluate?)` + Waiting and try/catch was fruitless ## [1.4.0] - October 2020 - Add key shortcuts [#7](https://github.com/lanly-dev/VSCode-LMPTM/issues/7) @@ -24,7 +63,7 @@ Check [Keep a Changelog](http://keepachangelog.com) for recommendations on how t - 11 files, 141.77KB, 1.42.0 ## [1.2.0] - December 2019 -- Add browser execuatble file setting [#3](https://github.com/lanly-dev/VSCode-LMPTM/issues/3) +- Add browser executable file setting [#3](https://github.com/lanly-dev/VSCode-LMPTM/issues/3) - Add Incognito/Private mode setting - Add [User Data Directory](https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md) setting [#4](https://github.com/lanly-dev/VSCode-LMPTM/issues/4) - 11 files, 192KB, 1.41.0 @@ -36,4 +75,4 @@ Check [Keep a Changelog](http://keepachangelog.com) for recommendations on how t ## [1.0.0] - August 2019 - Initial release -- 2117 files, 5.09MB, 1.35.0 \ No newline at end of file +- 2117 files, 5.09MB, 1.35.0 diff --git a/README.md b/README.md index 20f00bb..2f3d961 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,27 @@ [![Version](https://vsmarketplacebadge.apphb.com/version-short/lanly-dev.letmeplaythemusic.svg)](https://marketplace.visualstudio.com/items?itemName=lanly-dev.letmeplaythemusic) -Tired or get annoyed from switching windows in order to pause or skip a song? -Doesn't want to or lazy to install the extra official players to the system? -Want to stay focus on your programing instead of those distracting actions and feelings above? +Tired or get annoyed from switching windows in order to pause or skip a song?\ +Doesn't want to or lazy to install the extra official players to the system?\ +Or perhaps your keyboard doesn't have the handy media hotkeys?\ +Wanted to stay focus on your programing instead of those shortcomings above? -If yes, you are in luck! +If yes, you are in luck!\ This extension launches a Chrome/Chromium browser to the 4 popular music sites and you can control the playback from within your favorite Visual Studio Code editor. -Extra [use case](https://github.com/lanly-dev/VSCode-LMPTM/issues/8#issuecomment-661796089) - you could use this extension to follow programming tutorials on Youtube with the handy seek forward/backward key shortcuts. +>Extra [use case](https://github.com/lanly-dev/VSCode-LMPTM/issues/8#issuecomment-661796089) - you could use this extension to follow programming tutorials on Youtube with the handy seek forward/backward key shortcuts. -[How to use it?](https://github.com/lanly-dev/VSCode-LMPTM/issues/1) +>[How to use it?](https://github.com/lanly-dev/VSCode-LMPTM/issues/1) -## Features + +## Features Supports SoundCloud, Spotify, Youtube and Youtube Music ## Requirements - Required Chromium-based browser ## Extension Settings - * `lmptm.browserPath`: Specify custom browser executable file path. * `lmptm.ignoreDisableSync`: Ignore --disable-sync, this option is specifically for [Brave](https://brave.com) browser. * `lmptm.incognitoMode`: Specify whether to launch browser in incognito/private mode. @@ -31,14 +31,21 @@ Required Chromium-based browser * `lmptm.userDataDirectory`: Specify [user data directory](https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md), this will be ignored if **User Data** setting is unchecked. ## Known Issues -- Work only with English version of the supported music sites - Does not work with Opera browser -- Won't be able to login Youtube and SoundCLoud(email method) +- Won't be able to login Youtube and SoundCloud(email method) ## Release Notes +### 2.0.0 +- Add treeview - so now the the app can switch between tabs + 1. The page has to be picked before being able to use in the treeview + 2. You can select the treeview items that have the play/pause icon + 3. The emoji ⛏️ means the tab is currently picked + 4. First click switches the picked icon to target tab, later clicks toggles playing/paused status of the target tab media's playback + +- Fix spotify bugs due to it's new UI changes ### 1.4.0 -- Add key shorcuts +- Add key shortcuts - Add seek 5s forward/backward for Youtube shortcuts - Add support for [Youtube Music](https://music.youtube.com/) - Add startPages settings diff --git a/icon/lmptm.png b/icon/lmptm.png deleted file mode 100644 index 6be3c3f..0000000 Binary files a/icon/lmptm.png and /dev/null differ diff --git a/media/btn1.4.png b/media/btn1.4.png new file mode 100644 index 0000000..e07eb46 Binary files /dev/null and b/media/btn1.4.png differ diff --git a/media/btn2.0.png b/media/btn2.0.png new file mode 100644 index 0000000..9a3a632 Binary files /dev/null and b/media/btn2.0.png differ diff --git a/media/capture2.0.png b/media/capture2.0.png new file mode 100644 index 0000000..a424827 Binary files /dev/null and b/media/capture2.0.png differ diff --git a/media/icon.svg b/media/icon.svg new file mode 100644 index 0000000..fd236ac --- /dev/null +++ b/media/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/lmptm.png b/media/lmptm.png new file mode 100644 index 0000000..4589810 Binary files /dev/null and b/media/lmptm.png differ diff --git a/media/lmptm.svg b/media/lmptm.svg new file mode 100644 index 0000000..2bc194b --- /dev/null +++ b/media/lmptm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/vscodeignore/soundcloud.svg b/media/vscodeignore/soundcloud.svg new file mode 100644 index 0000000..95a4ef6 --- /dev/null +++ b/media/vscodeignore/soundcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/vscodeignore/spotify.svg b/media/vscodeignore/spotify.svg new file mode 100644 index 0000000..44de73e --- /dev/null +++ b/media/vscodeignore/spotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/media/vscodeignore/youtube.svg b/media/vscodeignore/youtube.svg new file mode 100644 index 0000000..cd75eaa --- /dev/null +++ b/media/vscodeignore/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 0df0128..896664c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "letmeplaythemusic", "displayName": "Let Me Play The Music", - "description": "Control playback from the popular music sites", + "description": "Playback control buttons for the popular music sites", "homepage": "https://github.com/lanly-dev/VSCode-LMPTM", - "version": "1.4.0", + "version": "2.0.0", "publisher": "lanly-dev", "engines": { - "vscode": "^1.50.0" + "vscode": "^1.63.0" }, "extensionKind": [ "ui" @@ -15,24 +15,40 @@ "Other" ], "keywords": [ + "Browser", "Music", "Playback", - "Browser", - "Souncloud", + "SoundCloud", "Spotify", - "Youtube", - "Youtube Music" + "Youtube Music", + "Youtube" ], - "icon": "icon/lmptm.png", + "icon": "media/lmptm.png", "galleryBanner": { "color": "#000f33", "theme": "dark" }, "activationEvents": [ - "*" + "onStartupFinished" ], "main": "./dist/extension", "contributes": { + "viewsWelcome": [ + { + "view": "LMPTM", + "contents": "[Launch $(rocket)](command:lmptm.browserLaunch)" + } + ], + "views": { + "explorer": [ + { + "id": "LMPTM", + "name": "LMPTM", + "icon": "media/icon.svg", + "contextualTitle": "LMPTM" + } + ] + }, "configuration": [ { "title": "LMPTM", @@ -40,7 +56,7 @@ "lmptm.browserPath": { "type": "string", "default": null, - "markdownDescription": "Specify custom browser executable file path." + "description": "Specify custom browser executable file path." }, "lmptm.ignoreDisableSync": { "type": "boolean", @@ -50,7 +66,7 @@ "lmptm.incognitoMode": { "type": "boolean", "default": true, - "markdownDescription": "Specify whether to launch browser in incognito/private mode." + "description": "Specify whether to launch browser in incognito/private mode." }, "lmptm.startPages": { "type": "array", @@ -65,7 +81,7 @@ "lmptm.userData": { "type": "boolean", "default": false, - "markdownDescription": "Specify if the extension could store browser's user data, if enabled, user data directory setting is required." + "description": "Specify if the extension could store browser's user data, if enabled, user data directory setting is required." }, "lmptm.userDataDirectory": { "type": "string", @@ -85,7 +101,7 @@ }, { "key": "win+Alt+down", - "command": "lmptm.toggle" + "command": "lmptm.playPause" }, { "key": "win+Alt+x", @@ -98,36 +114,42 @@ ] }, "scripts": { - "vscode:prepublish": "webpack --mode production", + "vscode:prepublish": "webpack --mode production --devtool hidden-source-map", + "vsce-package": "vsce package", "test-compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "webpack": "webpack --mode development", - "webpack-dev": "webpack --mode development --watch", + "test-webpack": "webpack", + "ts-watch": "tsc -watch -p ./", + "wp-watch": "webpack --watch", "lint": "eslint .", "lint-fix": "eslint . --fix", - "copy-static-assets": "ts-node tasks.ts copy", - "clean-output": "ts-node tasks.ts clean" + "task-copy-static-assets": "ts-node tasks.ts copy", + "task-clean-output": "ts-node tasks.ts clean", + "copy": "npm run task-copy-static-assets", + "clean": "npm run task-clean-output" }, "devDependencies": { - "@types/node": "^14.11.8", - "@types/puppeteer-core": "^2.0.0", - "@types/shelljs": "^0.8.8", - "@types/vscode": "^1.50.0", - "@typescript-eslint/eslint-plugin": "^4.4.0", - "@typescript-eslint/parser": "^4.4.0", - "copy-webpack-plugin": "^6.2.1", - "eslint": "^7.11.0", + "@types/copy-webpack-plugin": "^8.0.1", + "@types/karma-chrome-launcher": "^3.1.1", + "@types/node": "^16.11.13", + "@types/puppeteer-core": "^5.4.0", + "@types/shelljs": "^0.8.9", + "@types/vscode": "^1.63.0", + "@typescript-eslint/eslint-plugin": "^5.7.0", + "@typescript-eslint/parser": "^5.7.0", + "copy-webpack-plugin": "^10.1.0", + "css-minimizer-webpack-plugin": "^3.2.0", + "eslint": "^8.4.1", "shelljs": "^0.8.4", - "terser": "^5.3.4", - "ts-loader": "^8.0.4", - "ts-node": "^9.0.0", - "typescript": "^4.0.3", - "webpack": "^4.44.2", - "webpack-cli": "^3.3.12" + "ts-loader": "^9.2.6", + "ts-node": "^10.4.0", + "typescript": "^4.5.4", + "vsce": "^2.5.1", + "webpack": "^5.65.0", + "webpack-cli": "^4.9.1" }, "dependencies": { "karma-chrome-launcher": "^3.1.0", - "puppeteer-core": "^5.3.1" + "puppeteer-core": "^13.0.0" }, "repository": { "type": "git", diff --git a/src/browser.ts b/src/browser.ts index bffa979..57d79e6 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,30 +1,25 @@ -import * as fs from 'fs' import * as path from 'path' import * as puppeteer from 'puppeteer-core' import * as vscode from 'vscode' import { Buttons } from './buttons' +import { TreeviewProvider } from './treeview' import { WhichChrome } from './whichChrome' +import { Entry } from './interfaces' +import { HTTPResponse } from 'puppeteer-core' -const seekMsg = 'Seeking backward/forward function is only work for Youtube videos' - +const SEEK_MSG = 'Seeking backward/forward function is only work for Youtube videos. 💡' +const STATE_MSG = 'Please select the tab/page that either in playing or paused. 💡' export class Browser { public static activeBrowser: Browser | undefined public static cssPath: string public static jsPath: string - public static launched: boolean = false - public static uiHtmlPath: string - public static playButtonCss = { - soundcloud: '.playControl', - spotify: '.control-button--circled', - youtube: '.ytp-play-button', - ytmusic: '#play-pause-button' - } + public static launched = false private buttons: Buttons private currentBrowser: puppeteer.Browser private incognitoContext: puppeteer.BrowserContext - private pages: puppeteer.Page[] | undefined - private selectedMusicPageBrand: string | undefined + private pagesStatus: Entry[] + private selectedMusicBrand: string | undefined private selectedPage: puppeteer.Page | undefined public static launch(buttons: Buttons, context: vscode.ExtensionContext) { @@ -43,26 +38,29 @@ export class Browser { let cPath = vscode.workspace.getConfiguration().get('lmptm.browserPath') if (!cPath) cPath = WhichChrome.getPaths().Chrome || WhichChrome.getPaths().Chromium + if (!cPath) { - vscode.window.showInformationMessage('Missing Browser! 🤔') + vscode.window.showInformationMessage('No Chromium or Chrome browser found. 🤔') return } - const links: any = vscode.workspace.getConfiguration().get('lmptm.startPages') - if(links.length) { + const links: string[] | undefined = vscode.workspace.getConfiguration().get('lmptm.startPages') + if (links && links.length) { let invalid = false links.forEach((e: string) => { try { new URL(e) } catch (err) { invalid = true return - }}) - if (invalid){ - vscode.window.showErrorMessage('You may have an invalid url on startPages setting! 🤔') + } + }) + if (invalid) { + vscode.window.showErrorMessage('You may have an invalid url on startPages setting. 🤔') return } } Browser.launched = true + // console.log('###########################################') puppeteer.launch({ args, defaultViewport: null, @@ -71,17 +69,16 @@ export class Browser { ignoreDefaultArgs: iArgs }).then(async (browser: puppeteer.Browser) => { buttons.setStatusButtonText('Running $(browser)') - Browser.cssPath = path.join(context.extensionPath, 'dist', 'scripts', 'style.css') - Browser.jsPath = path.join(context.extensionPath, 'dist', 'scripts', 'script.js') - Browser.uiHtmlPath = fs.readFileSync(path.join(context.extensionPath, 'dist', 'scripts', 'ui.html'), 'utf8') + Browser.cssPath = path.join(context.extensionPath, 'dist', 'inject', 'style.css') + Browser.jsPath = path.join(context.extensionPath, 'dist', 'inject', 'script.js') const defaultPages = await browser.pages() defaultPages[0].close() // evaluateOnNewDocument won't on this page - Browser.activeBrowser = new Browser(browser, buttons, await browser.createIncognitoBrowserContext()) - Browser.launched = false - - }, error => { + const b = new Browser(browser, buttons, await browser.createIncognitoBrowserContext()) + Browser.activeBrowser = b + TreeviewProvider.refresh() + }, (error: { message: string }) => { vscode.window.showErrorMessage(error.message) - vscode.window.showInformationMessage('Missing Chrome? 🤔') + vscode.window.showInformationMessage('Browser launch failed. 😲') Browser.launched = false }) } @@ -90,59 +87,40 @@ export class Browser { constructor(browser: puppeteer.Browser, buttons: Buttons, incognitoContext: puppeteer.BrowserContext) { this.buttons = buttons this.currentBrowser = browser - this.pages = undefined - this.selectedPage = undefined + this.pagesStatus = [] this.incognitoContext = incognitoContext - this.currentBrowser.on('targetcreated', target => this.update('page_created', target)) - this.currentBrowser.on('targetchanged', target => this.update('page_changed', target)) - // this.currentBrowser.on('targetdestroyed', target => this.update('page_destroyed',target)) + this.currentBrowser.on('targetcreated', async (target: puppeteer.Target) => this.update('page_created', await target.page())) + this.currentBrowser.on('targetchanged', async (target: puppeteer.Target) => this.update('page_changed', await target.page())) + // this.currentBrowser.on('targetdestroyed', target => this.update('page_destroyed', target)) this.currentBrowser.on('disconnected', () => { this.buttons.setStatusButtonText('Launch $(rocket)') Browser.activeBrowser = undefined - this.buttons.dipslayPlayback(false) + this.buttons.displayPlayback(false) + TreeviewProvider.refresh() + Browser.launched = false + // console.debug('CLOSE') }) + // this.currentBrowser.process().once('close', () => console.debug('CLOSE!!!!!!!!')) this.launchPages() } - play() { + // Toggle + async playPause() { if (!this.selectedPage) return - switch (this.selectedMusicPageBrand) { - case 'soundcloud': - case 'ytmusic': + const { state } = await this.getPlaybackState(this.selectedPage) + switch (this.selectedMusicBrand) { + case 'soundcloud': case 'spotify': case 'ytmusic': this.selectedPage.keyboard.press('Space') break - case 'spotify': - // @ts-ignore - this.selectedPage.evaluate(() => spotifyAction('play')) - break case 'youtube': this.selectedPage.keyboard.press('k') - break } - this.buttons.setPlayButton('pause') - } - - pause() { - if (!this.selectedPage) return - switch (this.selectedMusicPageBrand) { - case 'soundcloud': - case 'ytmusic': - this.selectedPage.keyboard.press('Space') - break - case 'spotify': - // @ts-ignore - this.selectedPage.evaluate(() => spotifyAction('pause')) - break - case 'youtube': - this.selectedPage.keyboard.press('k') - break - } - this.buttons.setPlayButton('play') + this.buttons.setPlayButtonLabel(state) } async skip() { if (!this.selectedPage) return - switch (this.selectedMusicPageBrand) { + switch (this.selectedMusicBrand) { case 'soundcloud': await this.selectedPage.keyboard.down('ShiftLeft') await this.selectedPage.keyboard.press('ArrowRight') @@ -159,14 +137,12 @@ export class Browser { break case 'ytmusic': await this.selectedPage.keyboard.press('j') - break } - this.changeEventCheck() } async back() { if (!this.selectedPage) return - switch (this.selectedMusicPageBrand) { + switch (this.selectedMusicBrand) { case 'soundcloud': await this.selectedPage.keyboard.down('ShiftLeft') await this.selectedPage.keyboard.press('ArrowLeft') @@ -181,37 +157,26 @@ export class Browser { break case 'ytmusic': await this.selectedPage.keyboard.press('j') - break } - this.changeEventCheck() } async forward() { if (!this.selectedPage) return - switch (this.selectedMusicPageBrand) { + switch (this.selectedMusicBrand) { case 'youtube': await this.selectedPage.keyboard.press('ArrowRight') break - default: { vscode.window.showInformationMessage(seekMsg) } + default: vscode.window.showInformationMessage(SEEK_MSG) } - this.changeEventCheck() } async backward() { if (!this.selectedPage) return - switch (this.selectedMusicPageBrand) { + switch (this.selectedMusicBrand) { case 'youtube': await this.selectedPage.keyboard.press('ArrowLeft') break - default: { vscode.window.showInformationMessage(seekMsg) } - } - this.changeEventCheck() - } - - async toggle() { - if (this.selectedPage) { - const pStt = await this.getPlayingStatus(this.selectedPage) - pStt.status === 'play' ? this.pause() : this.play() + default: vscode.window.showInformationMessage(SEEK_MSG) } } @@ -219,18 +184,35 @@ export class Browser { return this.selectedPage?.title() } + getPagesStatus() { + return this.pagesStatus + } + + async pickTab(index: number) { + if (!this.pagesStatus) return + await this.tabOrderUpdate() + const { page, state } = this.pagesStatus[index] + if (state === 'none') { + vscode.window.showInformationMessage(STATE_MSG) + return + } + this.update('page_selected:tab', page) + } + + // ↓↓↓↓ Private methods ↓↓↓↓ + private async launchPages() { - const links: any = vscode.workspace.getConfiguration().get('lmptm.startPages') - if (links.length) { - const p: any = [] + const links: string[] | undefined = vscode.workspace.getConfiguration().get('lmptm.startPages') + if (links && links.length) { + const p: Promise[] = [] links.forEach(async (e: string) => { const pg = await this.newPage() await pg.setDefaultNavigationTimeout(0) - p.push(pg.goto(e)) + await pg.goto(e) }) - await Promise.all(p) + await Promise.all(p) // need to wait? } - this.pages = await this.currentBrowser.pages() + TreeviewProvider.refresh() } private async newPage() { @@ -239,223 +221,213 @@ export class Browser { else return this.currentBrowser.newPage() } - // The button doesn't show up on the 1st launch - private injectHtml(page: puppeteer.Page) { - page.evaluate(uiHtmlPath => { - do { - // @ts-ignore - if (!window['injected']) { - const div = document.createElement('div') - div.innerHTML = uiHtmlPath - document.getElementsByTagName('body')[0].appendChild(div) - // @ts-ignore - window['injected'] = true - } - } while (!document.getElementsByTagName('body')[0]) - }, Browser.uiHtmlPath) + private resetFloatButton() { + // @ts-ignore + this.selectedPage?.evaluate(() => reset()) } - private addScripts(page: puppeteer.Page) { - page.addStyleTag({ path: Browser.cssPath }) - page.addScriptTag({ path: Browser.jsPath }) + private clickFloatButton(thePage: puppeteer.Page) { + // @ts-ignore + thePage.evaluate(() => click()) } - private async setupPageWatcher(page: puppeteer.Page) { - page.evaluateOnNewDocument(uiHtmlPath => { - window.onload = () => { - // @ts-ignore - if (!window['injected']) { - const div = document.createElement('div') - div.innerHTML = uiHtmlPath - document.getElementsByTagName('body')[0].appendChild(div) - // @ts-ignore - window['injected'] = true - } + private async tabOrderUpdate() { + // Puppeteer doesn't keep track pages' order? + const pages = await this.currentBrowser.pages() + const newPagesStatus = [] + for (const [i, p] of pages.entries()) { + for (const e of this.pagesStatus) { + if (p !== e.page) continue + e.index = i + newPagesStatus.push(e) } - }, Browser.uiHtmlPath) + } + this.pagesStatus = newPagesStatus + TreeviewProvider.refresh() + } - page.removeAllListeners('close') - page.on('close', async () => { - await new Promise(resolve => setTimeout(() => resolve(), 1000)) - if (Browser.activeBrowser) this.update('page_closed', page.target()) - }) + // Get from saved + private getPlaybackState(page: puppeteer.Page) { + for (const e of this.pagesStatus) + if (e.page === page) return e + // when not found + return this._getPlaybackState(page) + } - // @ts-ignore - if (!page._pageBindings.has('pageSelected')) { - page.exposeFunction('pageSelected', async e => { - this.update('pageSelected', page.target()) - if (page !== this.selectedPage) { - if (this.selectedPage) this.resetButton() - this.selectedPage = page - this.selectedMusicPageBrand = e.brand - this.setupMusicPage() - this.buttons.dipslayPlayback(true) - this.buttons.setStatusButtonText(await this.selectedPage.title()) - this.changeEventCheck() - } - }) - } + // Need this? + private async _getPlaybackState(page: puppeteer.Page) { + const pageBrand = this.musicBrandCheck(page.url()) + const state = await page.evaluate(() => navigator.mediaSession.playbackState) + return { brand: pageBrand, state } + } + + private musicBrandCheck(url: string) { + if (url.includes('soundcloud.com')) return 'soundcloud' + else if (url.includes('open.spotify.com')) return 'spotify' + else if (url.includes('www.youtube.com/watch')) return 'youtube' + else if (url.includes('music.youtube.com')) return 'ytmusic' + else return 'other' } - private setupMusicPage() { - const page = this.selectedPage + private async update(event: string, page: puppeteer.Page | null) { + // console.debug('$$$$$$$$$', event) if (!page) return - const brand = this.selectedMusicPageBrand - if (!brand) return - // @ts-ignore - if (!page._pageBindings.has('onPlayingChangeEvent')) { - page.exposeFunction('onPlayingChangeEvent', () => { - this.update('play_event', page.target()) - }) + let extra + if (event.includes('page_selected')) { + [event, extra] = event.split(':') + if (!extra) throw new Error('page_selected event needs source - tab|button') + } + else if (event.includes('playback_change')) { + [event, extra] = event.split(':') + if (!extra) throw new Error('playback_change event needs state - playing|paused|none') } - page.evaluate(playButtonCss => { - const target = document.querySelector(playButtonCss) - // @ts-ignore - const observer = new MutationObserver(() => onPlayingChangeEvent()) - observer.observe(target, { attributes: true }) - // @ts-ignore - }, Browser.playButtonCss[brand]) - - if (brand === 'spotify') { - page.evaluate(() => { - const id = setInterval(() => { - if (document.querySelectorAll('.now-playing .cover-art-image')[0]) { - const target = document.querySelectorAll('.now-playing .cover-art-image')[0] - // @ts-ignore - const observer = new MutationObserver(() => onPlayingChangeEvent()) - observer.observe(target, { attributes: true }) - clearInterval(id) - } - }, 3000) - }) + switch (event) { + case 'page_changed': await this.pageChanged(page); break + case 'page_closed': this.pageClosed(page); break + case 'page_created': await this.pageCreated(page); break + case 'page_selected': await this.pageSelected(page, extra); break + case 'playback_changed': await this.playbackChanged(page, extra); break + default: vscode.window.showErrorMessage(`Unknown event - ${event}`) } + TreeviewProvider.refresh() } - private async updatePages() { - this.pages = await this.currentBrowser.pages() - return this.pages[0] - } + private async pageChanged(page: puppeteer.Page) { + await page.waitForNetworkIdle() // this somehow prevents navigation error + const pageURL = page.url() + const brand = this.musicBrandCheck(pageURL) - private resetButton() { - // @ts-ignore - this.selectedPage?.evaluate(() => reset()) - } + // Spotify needs bypass CSP + // won't stick to another site (if go to another URL) after set + if (brand === 'spotify') this.spotifyBypassCSP(page) - private async changeEventCheck() { - if (!this.selectedPage) return - const pStatus = await this.getPlayingStatus(this.selectedPage) - if (pStatus.brand !== 'other') { - this.selectedMusicPageBrand = pStatus.brand - this.buttons.setPlayButton(pStatus.status) - if(this.selectedPage) - this.buttons.setStatusButtonText(await this.selectedPage.title()) - } else { - if (this.selectedPage.url().includes('www.youtube.com')) this.resetButton() // See line 400 - this.buttons.setStatusButtonText('Running $(browser)') - this.buttons.dipslayPlayback(false) - this.selectedPage = undefined - this.selectedMusicPageBrand = undefined - } - } - - private async getPlayingStatus(page: puppeteer.Page) { - const pageBrand = this.musicBrandCheck(page.url()) + const title = await page.title() - if (pageBrand === 'other' || !this.selectedPage) return { brand: pageBrand, status: '' } - - else if (pageBrand === 'soundcloud') { - const element = await this.selectedPage.$(Browser.playButtonCss.soundcloud) - const text = await this.selectedPage.evaluate(element => element.getAttribute('title'), element) - const stt = text.includes('Play') ? 'play' : 'pause' - return { brand: pageBrand, status: stt } - - } else if (pageBrand === 'spotify') { - const element = await this.selectedPage.$(Browser.playButtonCss.spotify) - const text = await this.selectedPage.evaluate(element => element.getAttribute('title'), element) - const stt = text.includes('Play') ? 'play' : 'pause' - return { brand: pageBrand, status: stt } - - } else if (pageBrand === 'youtube') { - const element = await this.selectedPage.$(Browser.playButtonCss.youtube) - const text = await this.selectedPage.evaluate(element => element.getAttribute('aria-label'), element) - if (!text) return { brand: pageBrand, status: 'play' } // When replay - const stt = text.includes('Play') ? 'play' : 'pause' - return { brand: pageBrand, status: stt } - - } else if (pageBrand === 'ytmusic') { - const element = await this.selectedPage.$(Browser.playButtonCss.ytmusic) - const text = await this.selectedPage.evaluate(element => element.getAttribute('aria-label'), element) - if (!text) return { brand: pageBrand, status: 'play' } - const stt = text.includes('Play') ? 'play' : 'pause' - return { brand: pageBrand, status: stt } - - } else return { brand: pageBrand, status: '' } + for (const [i, e] of this.pagesStatus.entries()) { + if (e.page !== page) continue + if (this.selectedPage === page) this.selectedMusicBrand = brand + if (brand === 'other') { + this.pagesStatus[i].picked = false + this.pagesStatus[i].state = 'none' + this.closingHelper() + } + this.pagesStatus[i].brand = brand + this.pagesStatus[i].title = title + } } - private musicBrandCheck(url: string) { - if (url.includes('soundcloud.com')) return 'soundcloud' - else if (url.includes('open.spotify.com')) return 'spotify' - else if (url.includes('www.youtube.com/watch')) return 'youtube' - else if (url.includes('music.youtube.com')) return 'ytmusic' - else return 'other' + private pageClosed(page: puppeteer.Page) { + if (page === this.selectedPage) this.closingHelper() + this.pagesStatus.forEach((e, i, arr) => { + if (e.page !== page) return + arr.splice(i, 1) + }) } - private async closeEventUpdate() { - this.buttons.setPlayButton('play') - this.buttons.dipslayPlayback(false) + private closingHelper() { + this.buttons.displayPlayback(false) this.buttons.setStatusButtonText('Running $(browser)') this.selectedPage = undefined - this.selectedMusicPageBrand = undefined + this.selectedMusicBrand = undefined } - private async update(event: string, target: puppeteer.Target) { - const page = await target.page() - if (!page) return + private async pageCreated(page: puppeteer.Page) { + const pageURL = page.url() + const brand = pageURL === 'about:blank' ? 'other' : this.musicBrandCheck(pageURL) - if (event === 'page_closed') { - if (page === this.selectedPage) this.closeEventUpdate() - else this.updatePages() - } + // Spotify needs bypass CSP + if (brand === 'spotify') this.spotifyBypassCSP(page) - else if (event === 'page_created') { - if ((this.musicBrandCheck(page.url()) === 'spotify')) { - this.setPageBypassCSP(page, 'true') - page.goto(page.url()) - } else this.setPageBypassCSP(page, 'false') - await this.setupPageWatcher(page) - - page.on('load', async () => { - if (this.musicBrandCheck(page.url()) === 'spotify') await this.checkSpotifyCSP(page) - this.injectHtml(page) - this.addScripts(page) - if (!(this.musicBrandCheck(page.url()) === 'other')) this.setupMusicPage() - }) + let title = pageURL === 'about:blank' ? pageURL : await page.title() + if (title === '') title = 'New Tab' + + const pages = await this.currentBrowser.pages() + for (const [index, p] of pages.entries()) { + if (p !== page) continue + this.pagesStatus.splice(index, 0, { page, brand, index, state: 'none', picked: false, title }) } - else if (event === 'page_changed') { - await page.waitForNavigation() - this.changeEventCheck() + page.on('load', async () => { + page.addStyleTag({ path: Browser.cssPath }) + page.addScriptTag({ path: Browser.jsPath }) + }) + + page.removeAllListeners('close') + page.on('close', async () => { + // console.debug('page on CLOSE') + await new Promise(resolve => setTimeout(() => resolve(), 1000)) + if (Browser.activeBrowser) this.update('page_closed', page) + }) + + // @ts-ignore + if (!page._pageBindings.has('pageSelected')) + page.exposeFunction('pageSelected', () => this.update('page_selected:button', page)) + + // @ts-ignore + if (!page._pageBindings.has('playbackChanged')) + page.exposeFunction('playbackChanged', (state: string) => this.update(`playback_changed:${state}`, page)) + + } + + private async pageSelected(page: puppeteer.Page, source: string) { + this.buttons.displayPlayback(true) + + // Not sure why it can't detect or wait - the error below + // rejected promise not handled within 1 second: + // Error: Execution context is not available in detached frame "about:blank" + // (are you trying to evaluate?) + + if (this.selectedPage) { + const { state } = await this.getPlaybackState(this.selectedPage) + if (this.selectedPage === page) { + await this.playPause() + return + } else { + this.pagesStatus.forEach(e => e.page === this.selectedPage ? e.picked = false : null) + if (source === 'button' && state === 'playing') await this.playPause() + this.resetFloatButton() + } } - else if (event === 'play_event') this.changeEventCheck() + for (const [i, e] of this.pagesStatus.entries()) { + if (e.page !== page) continue + if (source === 'tab') { + this.clickFloatButton(e.page) // this will trigger page_select:button + break + } else { + this.selectedPage = e.page + this.pagesStatus[i].picked = true + this.selectedMusicBrand = this.musicBrandCheck(page.url()) + const title = await this.selectedPage.title() + this.pagesStatus[i].title = title + this.buttons.setStatusButtonText(title) + } + } } - private async setPageBypassCSP(page: puppeteer.Page, flag: string) { - if (page.url() === 'about:blank') return - page.setBypassCSP(flag === 'true') - await page.evaluate(theFlag => sessionStorage.setItem('bypassCSP', theFlag), flag) + private async playbackChanged(page: puppeteer.Page, state: string) { + for (const [i, e] of this.pagesStatus.entries()) { + if (e.page !== page) continue + if (this.selectedPage === page) { + this.buttons.setPlayButtonLabel(state) + this.buttons.setStatusButtonText(await this.selectedPage.title()) + } + this.pagesStatus[i].state = state + this.pagesStatus[i].title = await page.title() + } } - private async checkSpotifyCSP(page: puppeteer.Page) { - const cspFlag = await page.evaluate(() => sessionStorage.getItem('bypassCSP')) - if (cspFlag === 'true') return - await this.setPageBypassCSP(page, 'true') - await page.reload() + private async spotifyBypassCSP(page: puppeteer.Page) { + page.setBypassCSP(true) + // for debugging + await page.evaluate(() => sessionStorage.setItem('bypassCSP', 'true')) + // this doesn't trigger page_changed again -> no infinity loop + page.goto(page.url()) } // private async sleep(ms: number = 1000) { // await new Promise(resolve => setTimeout(() => resolve(), ms)) // } -} \ No newline at end of file +} diff --git a/src/buttons.ts b/src/buttons.ts index 9d6e8d0..6ea6ab9 100644 --- a/src/buttons.ts +++ b/src/buttons.ts @@ -17,8 +17,8 @@ export class Buttons { this.backButton.text = '$(chevron-left)' this.statusButton.text = 'Launch $(rocket)' - this.statusButton.command = 'lmptm.browserlaunch' - this.playButton.command = 'lmptm.play' + this.statusButton.command = 'lmptm.browserLaunch' + this.playButton.command = 'lmptm.playPause' this.backButton.command = 'lmptm.back' this.skipButton.command = 'lmptm.skip' @@ -28,7 +28,7 @@ export class Buttons { setStatusButtonText(text: string) { if (text === 'Launch $(rocket)') { this.statusButton.text = text - this.statusButton.command = 'lmptm.browserlaunch' + this.statusButton.command = 'lmptm.browserLaunch' } else if (text === 'Running $(browser)') { this.statusButton.text = text this.statusButton.command = undefined @@ -41,16 +41,11 @@ export class Buttons { } } - setPlayButton(text: string) { - if (text === 'play') { - this.playButton.text = '$(play)' - this.playButton.command = 'lmptm.play' - } else { - this.playButton.text = '$(primitive-square)' - this.playButton.command = 'lmptm.pause' - } + setPlayButtonLabel(label: MediaSessionPlaybackState) { + this.playButton.text = label === 'playing' ? '$(primitive-square)' : '$(play)' } - dipslayPlayback(flag: boolean) { + + displayPlayback(flag: boolean) { if (flag) { this.playButton.show() this.skipButton.show() diff --git a/src/extension.ts b/src/extension.ts index cd9c6eb..6521bbd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,19 +1,21 @@ import { commands, window, ExtensionContext } from 'vscode' -import { Buttons } from './buttons' import { Browser } from './browser' +import { Buttons } from './buttons' +import { Entry } from './interfaces' +import { TreeviewProvider } from './treeview' export function activate(context: ExtensionContext) { const buttons = new Buttons() + const fn = ['playPause', 'skip', 'back', 'forward', 'backward'] as const + const rc = commands.registerCommand + TreeviewProvider.create() + const disposables = fn.map(n => rc(`lmptm.${n}`, () => Browser.activeBrowser?.[n]())) + context.subscriptions.concat(disposables) context.subscriptions.concat([ - commands.registerCommand('lmptm.browserlaunch', () => Browser.launch(buttons, context)), - commands.registerCommand('lmptm.play', () => Browser.activeBrowser?.play()), - commands.registerCommand('lmptm.pause', () => Browser.activeBrowser?.pause()), - commands.registerCommand('lmptm.skip', () => Browser.activeBrowser?.skip()), - commands.registerCommand('lmptm.back', () => Browser.activeBrowser?.back()), - commands.registerCommand('lmptm.forward', () => Browser.activeBrowser?.forward()), - commands.registerCommand('lmptm.backward', () => Browser.activeBrowser?.backward()), - commands.registerCommand('lmptm.toggle', () => Browser.activeBrowser?.toggle()), - commands.registerCommand('lmptm.showTitle', showTitle) + rc('lmptm.browserLaunch', () => Browser.launch(buttons, context)), + rc('lmptm.tvRefresh', () => TreeviewProvider.refresh()), + rc('lmptm.showTitle', showTitle), + rc('lmptm.click', selection => click(selection)) ]) } @@ -23,4 +25,8 @@ async function showTitle() { else window.showErrorMessage('Failed to retrieve title') } -export function deactivate() {} +function click(selection: Entry) { + Browser.activeBrowser?.pickTab(selection.index) +} + +// export function deactivate() { } diff --git a/src/inject/script.js b/src/inject/script.js new file mode 100644 index 0000000..0948fad --- /dev/null +++ b/src/inject/script.js @@ -0,0 +1,217 @@ +'use strict' +const style = 'background:deepskyblue;padding:1px 2px;border-radius:2px' +const log = (text, ...rest) => console.log(`%c${text}`, style, ...rest) +log(`LMPTM's script injected successfully!`) + +const PICK_MSG = '⛏️ Pick?' +const playButtonAttrs = { + soundcloud: { + css: '.playControl', + cssCover: '.m-visible' + }, + spotify: { + css: 'button[data-testid="control-button-playpause"]', + cssAll5Btns: '.player-controls button', + cssTitle: 'div[data-testid="now-playing-widget"]', + play: 'M4.018 14L14.41 8 4.018 2z' + }, + youtube: { css: '.ytp-play-button' }, + ytmusic: { css: '#play-pause-button' } +} + +let observer +const btnPick = document.createElement('button') +btnPick.innerHTML = PICK_MSG +btnPick.className = 'btn-pick-float' +document.body.appendChild(btnPick) +btnPick.addEventListener('click', click) + +// Duplicate tabs solution +// TODO: need note +let clear = false +let loadCount = sessionStorage.getItem('load') +loadCount === null ? (loadCount = 0) : loadCount++ +sessionStorage.setItem('load', loadCount) +let unloadCount = sessionStorage.getItem('unload') +unloadCount = unloadCount === null ? 0 : parseInt(unloadCount) +if (loadCount === unloadCount) verifyPage() + +window.addEventListener('beforeunload', () => { + if (clear) { + sessionStorage.removeItem('load') + sessionStorage.removeItem('unload') + return + } + sessionStorage.setItem('unload', unloadCount + 1) +}) + +function click() { + const btnPick = document.querySelector('.btn-pick-float') + const href = window.location.href + let brand + + if (href.includes('soundcloud.com')) { + if (!document.querySelector('.m-visible')) { + btnPick.disabled = true + return void showInfo(btnPick, 'soundcloud') + } + brand = 'soundcloud' + } else if (href.includes('open.spotify.com')) { + if (!navigator.mediaSession.metadata.title) { + btnPick.disabled = true + return void showInfo(btnPick, 'spotify') + } + brand = 'spotify' + } else if (href.includes('www.youtube.com')) { + if (!href.includes('/watch')) { + btnPick.disabled = true + return void showInfo(btnPick, 'youtube') + } + brand = 'youtube' + } else if (href.includes('music.youtube.com')) { + const e = 'ytmusic-app-layout[player-visible_] > [slot=player-bar]' + if (!document.querySelectorAll(e)[0]) { + btnPick.disabled = true + return void showInfo(btnPick, 'ytmusic') + } + brand = 'ytmusic' + } else { + btnPick.className = 'btn-pick-float error' + btnPick.innerHTML = 'Never mind! 😓' + btnPick.disabled = true + btnTimeoutReset(btnPick) + } + + if (brand) { + window.pageSelected() + sessionStorage.setItem('lmptm', brand) + changeBtnAttr(brand) + clear = true + } +} + +// For supporting other sites later? +// function setupMediaSession() { +// const proxyHandler = { +// get(target, prop) { +// log('get', prop) +// const value = target[prop] +// if (typeof value === 'function') { +// const fn = value.bind(target) +// return fn +// } +// return value +// }, +// set(target, prop, value) { +// target[prop] = value +// log('set', prop) +// if (prop[0] === 'playbackState') window.playbackChanged() +// return true +// } +// } +// Object.defineProperty(navigator, 'mediaSession', { +// // eslint-disable-next-line no-undef +// value: new Proxy(navigator.mediaSession, proxyHandler) +// }) +// } + +function setupObserver(brand) { + const { css } = playButtonAttrs[brand] + if (observer) observer.disconnect() + const targetE = document.querySelector(css) + let state = getPlaybackState(brand, css) + if (brand === 'soundcloud' && state === 'none') state = 'paused' + window.playbackChanged(state) + + observer = new MutationObserver(() => { + const state = getPlaybackState(brand) + window.playbackChanged(state) + }) + if (targetE) observer.observe(targetE, { attributes: true }) + + // Spotify doesn't fire playbackChanged when skip/back a song + if (brand === 'spotify') { + const { cssTitle } = playButtonAttrs.spotify + const targetE2 = document.querySelector(cssTitle) + // Observer can watch multiple elements :) + if (targetE2) observer.observe(targetE2, { attributes: true }) + } +} + +function getPlaybackState(brand) { + // spotify doesn't update the mediaSession.playbackState + if (brand === 'spotify') { + const { css, play } = playButtonAttrs.spotify + const d = document.querySelector(`${css} svg path:last-child`).getAttribute('d') + const state = d === play ? 'paused' : 'playing' + return state + } else return navigator.mediaSession.playbackState +} + +function verifyPage() { + const brand = sessionStorage.getItem('lmptm') + const href = window.location.href + if (!brand) return + if (brand === 'spotify' && href.includes('open.spotify.com')) changeBtnAttr(brand) + else if (brand === 'soundcloud' && href.includes('soundcloud.com')) changeBtnAttr(brand) + else if (brand === 'youtube' && href.includes('www.youtube.com/watch')) changeBtnAttr(brand) + else if (brand === 'ytmusic' && href.includes('music.youtube.com/watch')) changeBtnAttr(brand) + else reset() +} + +function changeBtnAttr(brand) { + setupObserver(brand) + if (brand === 'ytmusic') brand = 'youtube' + btnPick.className = `btn-pick-float border-gray ${brand}` + btnPick.innerHTML = null +} + +function showInfo(btnPick, brand) { + btnPick.className = `btn-pick-float border-gray ${brand}-info` + let msg = 'Something is not right...' + switch (brand) { + case 'soundcloud': + msg = 'Please pick a song 😉' + break + case 'spotify': + msg = 'Please log in and make sure the playing queue is not empty 😉' + break + case 'youtube': + msg = 'Please pick a video 😉' + break + case 'ytmusic': + msg = 'Please make sure the playing queue is not empty 😉' + break + } + btnPick.innerHTML = msg + btnTimeoutReset(btnPick) +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function spotifyAction(action) { + const { cssAll5Btns } = playButtonAttrs.spotify + const actionBtn = document.querySelectorAll(cssAll5Btns) + switch (action) { + case 'skip': + actionBtn[3].click() + break + case 'back': + actionBtn[1].click() + break + } +} + +function btnTimeoutReset(btnPick) { + setTimeout(() => { + btnPick.innerHTML = '⛏️ Pick?' + btnPick.className = 'btn-pick-float' + btnPick.disabled = false + }, 3000) +} + +function reset() { + const btnPick = document.querySelector('.btn-pick-float') + btnPick.innerHTML = '⛏️ Pick?' + btnPick.className = 'btn-pick-float' + sessionStorage.removeItem('lmptm') +} diff --git a/src/inject/style.css b/src/inject/style.css new file mode 100644 index 0000000..6c61be8 --- /dev/null +++ b/src/inject/style.css @@ -0,0 +1,86 @@ +.btn-pick-float { + position: fixed; + right: 10px; + bottom: 10px; + transition: 200ms; + z-index: 2147483646; + border-radius: 5px; + border-color: deepskyblue; + box-shadow: 1px 1px dodgerblue; + background-color: deepskyblue; + cursor: pointer; + width: 75px; + height: 30px; + text-align: center; + color: black; +} +.border-gray { + border-color: lightgray; + box-shadow: 1px 1px gray; +} +.soundcloud { + background: url('data:image/svg+xml, '); + background-color: #f50; + background-position: center; + background-repeat: no-repeat; + background-size: 1.5em; + width: 2em; + height: 1.5em; + font-size: 2.5em; +} +.soundcloud-info { + background-color: #f50; + width: 8em; + height: 3.5em; + color: white !important; + font-size: 1.2em; +} +.spotify { + background: url('data:image/svg+xml, '); + background-color: black; + background-position: center; + background-repeat: no-repeat; + background-size: 1em; + width: 1.5em; + height: 1.5em; + font-size: 2em; +} +.spotify-info { + background-color: black; + width: 20em; + height: 3.5em; + color: #1ed761; + font-size: 1em; +} +.youtube { + background: url('data:image/svg+xml, '); + background-color: white; + background-position: center; + background-repeat: no-repeat; + background-size: 1.5em; + width: 2em; + height: 1.5em; + font-size: 3em; +} +.youtube-info { + background-color: white; + width: 8em; + height: 3.5em; + color: red; + font-size: 1.5em; +} +.ytmusic-info { + background-color: white; + width: 15em; + height: 3.5em; + color: red; + font-size: 1.5em; +} +.error { + border-color: orangered; + box-shadow: 1px 1px orangered; + background-color: red; + cursor: progress; + width: 10em; + color: white; +} diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 0000000..b9a56e3 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,9 @@ +import { Page } from 'puppeteer-core' +export interface Entry { + brand: string + index: number + page: Page + picked: boolean + state: MediaSessionPlaybackState + title: string +} diff --git a/src/media/soundcloud.svg b/src/media/soundcloud.svg deleted file mode 100644 index 1afc2b6..0000000 --- a/src/media/soundcloud.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/media/spotify.svg b/src/media/spotify.svg deleted file mode 100644 index d55deb8..0000000 --- a/src/media/spotify.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/media/youtube.svg b/src/media/youtube.svg deleted file mode 100644 index ed00f43..0000000 --- a/src/media/youtube.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/scripts/script.js b/src/scripts/script.js deleted file mode 100644 index daa38f3..0000000 --- a/src/scripts/script.js +++ /dev/null @@ -1,155 +0,0 @@ -const btnPick = document.querySelector('.btn-pick-float') -btnPick.addEventListener('click',check) - -// Duplicate tab solution -let clear = false -let loadCount = sessionStorage.getItem('load') -if (loadCount === null) loadCount = 0 -else loadCount++ -sessionStorage.setItem('load',loadCount) -let unloadCount = sessionStorage.getItem('unload') -if (unloadCount === null) unloadCount = 0 -else unloadCount = parseInt(unloadCount) - -window.addEventListener('beforeunload',() => { - if (clear) { - sessionStorage.removeItem('load') - sessionStorage.removeItem('unload') - return - } - sessionStorage.setItem('unload',unloadCount + 1) -}) - -let flagPick = false -if (loadCount === unloadCount) verifyPage() - -function check() { - const btnPick = document.querySelector('.btn-pick-float') - - if (window.location.href.includes('soundcloud.com')) { - if (!document.querySelectorAll('.m-visible')[0]) { - btnPick.disabled = true - return void soundcloudInfo(btnPick) - } - window.pageSelected({ brand: 'soundcloud' }) - sessionStorage.setItem('lmptm','soundcloud') - soundcloud(btnPick) - clear = true - - } else if (window.location.href.includes('open.spotify.com')) { - if (!document.querySelectorAll('.now-playing .cover-art-image')[0]) { - btnPick.disabled = true - return void spotifyInfo(btnPick) - } - window.pageSelected({ brand: 'spotify' }) - sessionStorage.setItem('lmptm','spotify') - spotify(btnPick) - clear = true - - } else if (window.location.href.includes('www.youtube.com')) { - if (!window.location.href.includes('/watch')) { - btnPick.disabled = true - return void youtubeInfo(btnPick) - } - window.pageSelected({ brand: 'youtube' }) - sessionStorage.setItem('lmptm','youtube') - youtube(btnPick) - clear = true - - } else if (window.location.href.includes('music.youtube.com')) { - const e = 'ytmusic-app-layout[player-visible_] > [slot=player-bar]' - if (!document.querySelectorAll(e)[0]) { - btnPick.disabled = true - return void ytmusicInfo(btnPick) - } - window.pageSelected({ brand: 'ytmusic' }) - sessionStorage.setItem('lmptm','ytmusic') - youtube(btnPick) - clear = true - - } else { - btnPick.className = 'btn-pick-float error' - btnPick.innerHTML = ' Nevermind! 😓' - btnPick.disabled = true - btnTimeoutReset(btnPick) - } -} - -function verifyPage() { - const data = sessionStorage.getItem('lmptm') - if (!data) return - if (data === 'spotify' && window.location.href.includes('open.spotify.com')) spotify(btnPick) - else if (data === 'soundcloud' && window.location.href.includes('soundcloud.com')) soundcloud(btnPick) - else if (data === 'youtube' && window.location.href.includes('www.youtube.com/watch')) youtube(btnPick) - else if (data === 'ytmusic' && window.location.href.includes('music.youtube.com/watch')) ytmusic(btnPick) - else reset() -} - -function soundcloud(btnPick) { - btnPick.className = 'btn-pick-float soundcloud' - btnPick.innerHTML = '' -} - -function spotify(btnPick) { - btnPick.className = 'btn-pick-float spotify' - btnPick.innerHTML = '' -} - -function youtube(btnPick) { - btnPick.className = 'btn-pick-float youtube' - btnPick.innerHTML = '' -} - -function soundcloudInfo(btnPick) { - btnPick.className = 'btn-pick-float soundcloud-info' - btnPick.innerHTML = 'Please pick a song 😉' - btnTimeoutReset(btnPick) -} - -function spotifyInfo(btnPick) { - btnPick.className = 'btn-pick-float spotify-info' - btnPick.innerHTML = 'Please log in and make sure the playing queue is not empty! 😉' - btnTimeoutReset(btnPick) -} - -function youtubeInfo(btnPick) { - btnPick.className = 'btn-pick-float youtube-info' - btnPick.innerHTML = 'Please pick a video! 😉' - btnTimeoutReset(btnPick) -} - -function ytmusicInfo(btnPick) { - btnPick.className = 'btn-pick-float ytmusic-info' - btnPick.innerHTML = 'Please make sure the playing queue is not empty! 😉' - btnTimeoutReset(btnPick) -} - -function spotifyAction(action) { - switch (action) { - case 'play': - case 'pause': - (document.querySelector(".spoticon-play-16") || document.querySelector(".spoticon-pause-16")).click() - break - case 'skip': - document.querySelector('.spoticon-skip-forward-16').click() - break - case 'back': - document.querySelector('.spoticon-skip-back-16').click() - break - } -} - -function btnTimeoutReset(btnPick) { - setTimeout(() => { - btnPick.innerHTML = '⛏️ Pick?' - btnPick.className = 'btn-pick-float' - btnPick.disabled = false - },3000) -} - -function reset() { - const btnPick = document.querySelector('.btn-pick-float') - btnPick.innerHTML = '⛏️ Pick?' - btnPick.className = 'btn-pick-float' - sessionStorage.removeItem('lmptm') -} \ No newline at end of file diff --git a/src/scripts/style.css b/src/scripts/style.css deleted file mode 100644 index 3b3607a..0000000 --- a/src/scripts/style.css +++ /dev/null @@ -1,92 +0,0 @@ -.btn-pick-float { - /* box-shadow: 2px 2px 3px #999; */ - background-color: #25aff0; - border-radius: 5px; - width: 75px; - height: 30px; - right: 10px; - bottom: 10px; - color: black; - cursor: pointer; - position: fixed; - text-align: center; - transition: 200ms; - z-index: 2202; - box-shadow: - 1px 0px #3a587f, 0px 1px #4171ae, - 2px 1px #3a587f, 1px 2px #4171ae, - 3px 2px #3a587f, 2px 3px #4171ae, - 4px 3px #3a587f, 3px 4px #4171ae; -} - -.soundcloud { - background: url('data:image/svg+xml, '); - background-color: #f50; - background-position: center; - background-repeat: no-repeat; - background-size: 1.5em; - font-size: 2.5em; - height: 1.5em; - width: 2em; -} - -.soundcloud-info { - background-color: #f50; - color: white !important; - font-size: 1.2em; - height: 3.5em; - width: 8em; -} - -.spotify { - background: url('data:image/svg+xml, '); - background-color: black; - background-position: center; - background-repeat: no-repeat; - background-size: 1em; - font-size: 2.5em; - height: 1.5em; - width: 2em; -} - -.spotify-info { - background-color: black; - color: #1ed761; - font-size: 1em; - height: 3.5em; - width: 20em; -} - -.youtube { - background: url('data:image/svg+xml, '); - background-color: white; - background-position: center; - background-repeat: no-repeat; - background-size: 1.5em; - font-size: 3em; - height: 1.5em; - width: 2em; -} - -.youtube-info { - background-color: white; - color:red; - font-size: 1.5em; - height: 3.5em; - width: 8em; -} - -.ytmusic-info { - background-color: white; - color:red; - font-size: 1.5em; - height: 3.5em; - width: 15em; -} - -.error { - background-color: red; - color:white; - cursor: progress; - width: 10em; -} \ No newline at end of file diff --git a/src/scripts/ui.html b/src/scripts/ui.html deleted file mode 100644 index c5200a4..0000000 --- a/src/scripts/ui.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/treeview.ts b/src/treeview.ts new file mode 100644 index 0000000..0f9e259 --- /dev/null +++ b/src/treeview.ts @@ -0,0 +1,63 @@ +import { Browser } from './browser' +import { Event, EventEmitter, ThemeIcon, TreeDataProvider, TreeItem, window } from 'vscode' +import { Entry } from './interfaces' + +export class TreeviewProvider implements TreeDataProvider { + public static tvProvider: TreeviewProvider + + private _onDidChangeTreeData: EventEmitter = new EventEmitter() + readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event + + private browser: Browser | undefined + + public static create() { + const treeDataProvider = new TreeviewProvider() + window.createTreeView('LMPTM', { treeDataProvider }) + // const tv = window.createTreeView('LMPTM', { treeDataProvider }) + // tv.onDidChangeSelection(({ selection }) => {}) + this.tvProvider = treeDataProvider + } + + public static refresh() { + this.tvProvider.refresh() + } + + constructor() { + this.browser = Browser.activeBrowser + } + + getTreeItem(element: Entry): TreeItem { + return this.getItem(element) + } + + async getChildren(): Promise { + if (!this.browser) return + const details = this.browser.getPagesStatus() + // console.debug('@@@@@@@@', details) + if (!details) return + return details + } + + private getItem(element: Entry) { + // console.debug(element) + + return new TabItem(element) + } + + refresh(): void { + this.browser = Browser.activeBrowser + this._onDidChangeTreeData.fire(null) + // console.debug('refresh') + } +} + +class TabItem extends TreeItem { + constructor(e: Entry) { + const { picked, state } = e + let title = e.title + if (picked) title = `⛏️ ${title}` + super(title) + if (state !== 'none') this.iconPath = new ThemeIcon(state === 'playing' ? 'primitive-square' : 'play') + this.command = { title: 'click', command: 'lmptm.click', arguments: [e] } + } +} diff --git a/src/whichChrome.ts b/src/whichChrome.ts index 2cf4305..1ec37e9 100644 --- a/src/whichChrome.ts +++ b/src/whichChrome.ts @@ -4,7 +4,6 @@ interface Paths { [key: string]: string } -// @ts-ignore import * as karmaChromeLauncher from 'karma-chrome-launcher' export class WhichChrome { @@ -12,7 +11,8 @@ export class WhichChrome { const chromePaths: Paths = {} Object.keys(karmaChromeLauncher).forEach(key => { if (key.indexOf('launcher:') !== 0) return - const info = karmaChromeLauncher[key] && karmaChromeLauncher[key][1] && karmaChromeLauncher[key][1].prototype + // @ts-ignore + const info = karmaChromeLauncher[key][1].prototype if (!info) return chromePaths[info.name] = info.DEFAULT_CMD[process.platform] || null }) diff --git a/tasks.ts b/tasks.ts index ec385f4..5ebb943 100644 --- a/tasks.ts +++ b/tasks.ts @@ -1,13 +1,11 @@ import * as shell from 'shelljs' -// clean out file console.log(`Run task ${process.argv[2]}`) if (process.argv[2] === 'clean') { - console.log('Remove "out" and "dist" directory') - shell.rm('-rf', 'out') + console.log('Remove "dist" directory') shell.rm('-rf', 'dist') -} else if (process.argv[2] === 'copy') { - console.log('Copy static assets to "out" directories') - shell.mkdir('-p', 'out') - shell.cp('-R', 'src/scripts', 'out/scripts') -} else console.log(`ಠ_ಠ What task is this? task ${process.argv[2]}`) \ No newline at end of file +} else if (process.argv[2] === 'copy') { // for Launch Extension ts-watch + console.log('Copy static assets to "dist" directories') + shell.mkdir('-p', 'dist') + shell.cp('-R', 'src/inject', 'dist/inject') +} else console.log(`ಠ_ಠ What task is this? task ${process.argv[2]}`) diff --git a/tsconfig.json b/tsconfig.json index 4e376b1..338bfd1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", - "outDir": "out", - "lib": ["es6", "dom"], + "target": "esnext", + "outDir": "dist", + "lib": ["esnext", "dom"], "sourceMap": true, "rootDir": "src", "strict": true /* enable all strict type-checking options */, @@ -12,5 +12,5 @@ "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, "noUnusedParameters": true /* Report errors on unused parameters. */ }, - "exclude": ["node_modules", "tasks.ts"] + "exclude": ["node_modules", "tasks.ts", "webpack.config.ts"] } diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index a79bc81..0000000 --- a/webpack.config.js +++ /dev/null @@ -1,74 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// @ts-check -'use strict' - -const path = require('path') -const CopyPlugin = require('copy-webpack-plugin') -const Terser = require('terser') - -/**@type {import('webpack').Configuration}*/ -const config = { - target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ - - // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ - entry: { - extension: './src/extension.ts', - }, - output: { - // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ - path: path.resolve(__dirname, 'dist'), - filename: '[name].js', - libraryTarget: 'commonjs2', - devtoolModuleFilenameTemplate: '../[resource-path]', - }, - // devtool: 'source-map', - externals: { - vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ - bufferutil: 'commonjs bufferutil', // https://github.com/websockets/ws/issues/1220#issuecomment-433066790 - 'utf-8-validate': 'commonjs utf-8-validate', - 'supports-color': 'commonjs supports-color', - }, - resolve: { - // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader - extensions: ['.ts', '.js', 'css'], - }, - module: { - rules: [ - { - test: /\.ts$/, - exclude: /node_modules/, - use: [ - { - loader: 'ts-loader', - options: { - compilerOptions: { - module: 'es6', // override `tsconfig.json` so that TypeScript emits native JavaScript modules. - }, - }, - }, - ], - }, - ], - }, - plugins: [ - new CopyPlugin({ - patterns: [ - { - from: 'src/scripts/script.js', - to: 'scripts', - async transform(content, path) { - return (await Terser.minify(content.toString())).code - }, - }, - { from: 'src/scripts/style.css', to: 'scripts' }, - { from: 'src/scripts/ui.html', to: 'scripts' }, - ], - }), - ], -} - -module.exports = config diff --git a/webpack.config.ts b/webpack.config.ts new file mode 100644 index 0000000..dfe72ce --- /dev/null +++ b/webpack.config.ts @@ -0,0 +1,50 @@ +//@ts-check +'use strict' +import { resolve } from 'path' +import * as CopyPlugin from 'copy-webpack-plugin' +import * as TerserPlugin from 'terser-webpack-plugin' +import * as CssMinimizerPlugin from 'css-minimizer-webpack-plugin' + +//@ts-check +export default { + mode: 'none', + target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ + entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ + output: { + // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ + path: resolve(resolve(), 'dist'), + filename: 'extension.js', + libraryTarget: 'commonjs2' + }, + devtool: 'source-map', + externals: { + vscode: 'commonjs vscode', // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ + bufferutil: 'commonjs bufferutil', // https://github.com/websockets/ws/issues/1220#issuecomment-433066790 + 'utf-8-validate': 'commonjs utf-8-validate' + }, + resolve: { + // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader + extensions: ['.ts', '.js', 'css'] + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: [{ loader: 'ts-loader' }] + } + ] + }, + plugins: [ + new CopyPlugin({ + patterns: [ + { from: 'src/inject/script.js', to: 'inject' }, + { from: 'src/inject/style.css', to: 'inject' } + ] + }) + ], + optimization: { + minimize: true, + minimizer: [new TerserPlugin({ extractComments: false }), new CssMinimizerPlugin()] + } +}