├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore └── commit-msg ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.ts ├── preview-head.html └── preview.ts ├── .stylelintrc.json ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.cjs ├── package.json ├── pnpm-lock.yaml ├── public ├── _locales │ ├── en │ │ └── messages.json │ ├── zh_HK │ │ └── messages.json │ └── zh_TW │ │ └── messages.json └── icon.png ├── screenshot.jpg ├── src ├── _variable.scss ├── app │ └── live-chat-overlay │ │ ├── app.tsx │ │ ├── index.module.scss │ │ └── index.tsx ├── background.ts ├── components │ ├── chat-flow │ │ ├── author-chip │ │ │ ├── index.module.scss │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── chat-item-renderer │ │ │ └── index.tsx │ │ ├── debug-overlay │ │ │ ├── index.module.scss │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── index.module.scss │ │ ├── index.tsx │ │ ├── message-flower │ │ │ ├── index.module.scss │ │ │ └── index.tsx │ │ ├── message-parts-renderer │ │ │ ├── emoji-part-renderer.tsx │ │ │ ├── index.tsx │ │ │ └── text-part-renderer.tsx │ │ ├── pinned-message │ │ │ ├── index.module.scss │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ ├── super-chat-sticker │ │ │ ├── index.module.scss │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ └── two-lines-message │ │ │ ├── index.module.scss │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ ├── font-awesome.tsx │ ├── player-control │ │ ├── btn-tooltip │ │ │ ├── index.module.scss │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── message-settings-btn │ │ │ └── index.tsx │ │ ├── speed-slider │ │ │ ├── index.module.scss │ │ │ ├── index.tsx │ │ │ ├── slider.module.scss │ │ │ └── slider.tsx │ │ └── toggle-btn │ │ │ └── index.tsx │ └── popup │ │ ├── index.tsx │ │ ├── message-settings-input-form │ │ ├── global-settings-form.tsx │ │ ├── index.module.scss │ │ ├── index.tsx │ │ └── message-settings-form.tsx │ │ ├── message-settings-popup │ │ ├── index.module.scss │ │ └── index.tsx │ │ └── message-settings-type-select │ │ ├── index.module.scss │ │ └── index.tsx ├── constants.ts ├── content-script.ts ├── contexts │ ├── index.ts │ └── root-store.tsx ├── definitions │ └── youtube.d.ts ├── hooks │ ├── index.ts │ └── use-rect.ts ├── live-chat-content-script.ts ├── live-chat-fetch-interceptor.ts ├── manifest.json ├── models │ ├── chat-item │ │ ├── index.ts │ │ ├── mapper │ │ │ ├── helpers.ts │ │ │ └── index.ts │ │ └── types.ts │ └── settings │ │ ├── index.ts │ │ └── types.ts ├── scss.d.ts ├── services │ ├── fetch-interceptor │ │ └── index.ts │ └── index.ts ├── solid-js.d.ts ├── stores │ ├── chat-item │ │ ├── helpers.ts │ │ └── index.ts │ ├── debug-info │ │ ├── index.ts │ │ └── types.ts │ ├── index.ts │ ├── settings │ │ ├── const.ts │ │ ├── index.ts │ │ └── migrations.ts │ └── ui │ │ ├── index.ts │ │ └── types.ts └── utils │ ├── index.ts │ ├── logger.ts │ ├── metrics.ts │ └── youtube.ts ├── tests ├── tsconfig.json └── unit │ ├── models │ └── chat-item │ │ └── mapper │ │ └── helpers │ │ ├── map-live-chat-membership-item-renderer.test.ts │ │ ├── map-live-chat-paid-message-item-renderer.test.ts │ │ ├── map-live-chat-paid-sticker-item-renderer.test.ts │ │ ├── map-live-chat-text-message-renderer.test.ts │ │ └── map-pinned-live-chat-text-message-renderer.test.ts │ └── utils │ ├── catch-with-fallback.test.ts │ ├── clamp.test.ts │ ├── defaults-deep.test.ts │ ├── is-nil.test.ts │ └── metrics │ └── update-metrics.test.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | commitlint.config.js 2 | .eslintrc.js 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'xo', 4 | 'xo/browser', 5 | 'xo-typescript', 6 | 'plugin:solid/typescript', 7 | 'plugin:import/recommended', 8 | 'plugin:import/typescript', 9 | 'plugin:prettier/recommended', 10 | 'plugin:storybook/recommended', 11 | ], 12 | plugins: ['solid'], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | project: './tsconfig.json', 16 | tsconfigRootDir: __dirname, 17 | }, 18 | rules: { 19 | '@typescript-eslint/no-unused-vars': [ 20 | 'warn', 21 | { argsIgnorePattern: '^_' }, 22 | ], 23 | 'unicorn/no-array-callback-reference': 'off', 24 | 'node/file-extension-in-import': 'off', 25 | '@typescript-eslint/no-implicit-any-catch': 'off', 26 | // using 'useUnknownInCatchVariables' in tsconfig by default 27 | 'import/order': [ 28 | 'error', 29 | { 30 | groups: [ 31 | 'builtin', 32 | 'external', 33 | 'internal', 34 | ['parent', 'sibling'], 35 | ], 36 | pathGroups: [ 37 | { 38 | pattern: 'react', 39 | group: 'external', 40 | position: 'before', 41 | }, 42 | { 43 | pattern: '@/**', 44 | group: 'internal', 45 | position: 'before', 46 | }, 47 | ], 48 | pathGroupsExcludedImportTypes: ['react', '@/**'], 49 | 'newlines-between': 'always', 50 | alphabetize: { 51 | order: 'asc', 52 | caseInsensitive: true, 53 | }, 54 | }, 55 | ], 56 | '@typescript-eslint/naming-convention': 'off', 57 | '@typescript-eslint/ban-types': [ 58 | 'error', 59 | { 60 | extendDefaults: true, 61 | }, 62 | ], 63 | }, 64 | settings: { 65 | 'import/resolver': { 66 | node: { 67 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'], 68 | }, 69 | typescript: { 70 | alwaysTryTypes: true, 71 | }, 72 | }, 73 | }, 74 | overrides: [ 75 | { 76 | files: ['tests/**'], 77 | extends: ['plugin:vitest/recommended'], 78 | parserOptions: { 79 | project: './tests/tsconfig.json', 80 | tsconfigRootDir: __dirname, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 'v*' 4 | 5 | name: Create Release 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-20.04 11 | 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@v3 15 | - uses: pnpm/action-setup@v2 16 | name: Install pnpm 17 | - name: Use Node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version-file: '.nvmrc' 21 | cache: 'pnpm' 22 | - name: Install dependencies 23 | run: | 24 | pnpm i --frozen-lockfile 25 | - name: Build Project 26 | run: pnpm build 27 | - uses: actions/upload-artifact@v3 28 | with: 29 | name: built-extension 30 | path: dist 31 | 32 | deploy-to-chrome: 33 | name: Deploy to Chrome Web Store 34 | needs: build 35 | runs-on: ubuntu-20.04 36 | steps: 37 | - uses: actions/download-artifact@v3 38 | with: 39 | name: built-extension 40 | path: dist 41 | - run: cd dist && npx chrome-webstore-upload-cli upload --auto-publish 42 | env: 43 | EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }} 44 | CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }} 45 | CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }} 46 | REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | name: Test 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ubuntu-20.04 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v3 19 | - uses: pnpm/action-setup@v2 20 | name: Install pnpm 21 | - name: Use Node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version-file: '.nvmrc' 25 | cache: 'pnpm' 26 | - name: Install dependencies 27 | run: | 28 | pnpm i --frozen-lockfile 29 | - name: Check Dependency Vulnerabilities 30 | run: | 31 | pnpm audit 32 | - name: Lint & Test 33 | run: | 34 | pnpm lint && pnpm type-check && pnpm test:unit 35 | - name: Build 36 | run: | 37 | pnpm build 38 | - name: Build Storybook 39 | run: | 40 | pnpm storybook:build 41 | commitlint: 42 | name: Commit Lint 43 | runs-on: ubuntu-20.04 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | steps: 47 | - name: Checkout Code with All Commits 48 | uses: actions/checkout@v3 49 | with: 50 | fetch-depth: 0 51 | - name: Run commitlint 52 | uses: wagoid/commitlint-github-action@v5 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.tsbuildinfo 4 | /sample 5 | /storybook-static 6 | 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | echo '$ commit-msg' 5 | echo '$ pnpm commitlint -e $1' 6 | pnpm commitlint -e $1 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.11.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "arrowParens": "always", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "overrides": [ 11 | { 12 | "files": "*.yml", 13 | "options": { 14 | "tabWidth": 2 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/html-vite'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-interactions', 9 | ], 10 | framework: { 11 | name: '@storybook/html-vite', 12 | options: {}, 13 | }, 14 | docs: { 15 | autodocs: 'tag', 16 | }, 17 | viteFinal: (config) => { 18 | // we don't need to build the script for storybook 19 | config.plugins = config.plugins?.filter( 20 | (plugin) => (plugin as any).name !== 'web-extension:manifest', 21 | ); 22 | return config; 23 | }, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 6 | 12 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from 'solid-js/web'; 3 | 4 | export const decorators = [ 5 | (Story) => { 6 | const solidRoot = document.createElement('div'); 7 | 8 | render(Story, solidRoot); 9 | 10 | return solidRoot; 11 | }, 12 | ]; 13 | 14 | /** Autogenerated by Storybook */ 15 | export const parameters = { 16 | actions: { argTypesRegex: '^on[A-Z].*' }, 17 | controls: { 18 | matchers: { 19 | color: /(background|color)$/i, 20 | date: /Date$/, 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard-scss", 4 | "stylelint-config-recess-order", 5 | "stylelint-prettier/recommended" 6 | ], 7 | "overrides": [ 8 | { 9 | "files": ["**/*.scss"], 10 | "customSyntax": "postcss-scss" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "stylelint.vscode-stylelint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "editor.formatOnSave": true, 5 | "cSpell.words": ["stylelint"], 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "prettier.prettierPath": "./node_modules/prettier", 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": true, 10 | "source.fixAll.stylelint": true 11 | }, 12 | "stylelint.validate": ["css", "scss"], 13 | "eslint.validate": ["javascript"] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hin Wong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Live Chat Overlay 2 | 3 | Web Extension to overlay live chat on Youtube Livestreams. 4 | 5 | Inspired by [Youtube Live Chat Flow](https://github.com/fiahfy/youtube-live-chat-flow) 6 | 7 | ## Install 8 | 9 | Go to [Chrome Web Store](https://chrome.google.com/webstore/detail/live-chat-overlay/ahaklfidpffmhjhlkgakjgbkppdoaemo) to install with your browser (Google Chrome / Microsoft Edge). 10 | 11 | ## Screenshot 12 | 13 | ![Screenshot](screenshot.jpg) 14 | 15 | ## License 16 | 17 | Read [LICENSE](LICENSE) file. 18 | 19 | ## Development 20 | 21 | - You could use [`nvm`](https://github.com/nvm-sh/nvm) to make sure the node.js version is correct for this project. The node.js version is stated in [package.json](package.json) 22 | - Run `pnpm start` to start webpack server in development environment. Then you can import '/dist' folder as extension. 23 | - Run `pnpm build:prod` to build in optimized settings. 24 | - Run `pnpm storybook` to start [storybook](https://storybook.js.org/). 25 | - Please follow [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. [Commitlint](https://github.com/conventional-changelog/commitlint) is used to check for incorrect format. 26 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live-chat-overlay", 3 | "version": "2.15.5", 4 | "description": "Web Extension for Overlay Live Chat on Youtube", 5 | "main": "dist/content-script.js", 6 | "repository": "git@github.com:thwonghin/live-chat-overlay.git", 7 | "author": "Hin Wong ", 8 | "license": "MIT", 9 | "type": "module", 10 | "engines": { 11 | "node": ">=20.11.0" 12 | }, 13 | "scripts": { 14 | "test:unit": "vitest run", 15 | "lint": "pnpm lint:src && pnpm lint:test && pnpm lint:style", 16 | "lint:script": "eslint --ext ts,tsx", 17 | "lint:src": "pnpm lint:script src", 18 | "lint:test": "pnpm lint:script tests", 19 | "lint:style": "stylelint './src/**/*.scss'", 20 | "lint:fix": "pnpm lint:src --fix && pnpm lint:style --fix && pnpm lint:test --fix", 21 | "type-check": "tsc --noEmit && tsc -p tests --noEmit", 22 | "clean": "rm -rf dist .storybook/dist", 23 | "dev": "vite dev", 24 | "build": "vite build", 25 | "release": "pnpx standard-version", 26 | "storybook": "storybook dev -p 6006", 27 | "storybook:build": "storybook build --quiet" 28 | }, 29 | "devDependencies": { 30 | "@commitlint/cli": "^17.8.1", 31 | "@commitlint/config-conventional": "^17.8.1", 32 | "@storybook/addon-essentials": "^7.6.9", 33 | "@storybook/addon-interactions": "^7.6.9", 34 | "@storybook/addon-links": "^7.6.9", 35 | "@storybook/blocks": "^7.6.9", 36 | "@storybook/html": "^7.6.9", 37 | "@storybook/html-vite": "^7.6.9", 38 | "@storybook/testing-library": "^0.2.2", 39 | "@types/chrome": "^0.0.246", 40 | "@types/node": "^20.11.5", 41 | "@typescript-eslint/eslint-plugin": "^6.19.0", 42 | "@typescript-eslint/parser": "^6.19.0", 43 | "autoprefixer": "^10.4.17", 44 | "eslint": "^8.56.0", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-config-xo": "^0.43.1", 47 | "eslint-config-xo-typescript": "^1.0.1", 48 | "eslint-import-resolver-typescript": "^3.6.1", 49 | "eslint-plugin-import": "^2.29.1", 50 | "eslint-plugin-prettier": "^5.1.3", 51 | "eslint-plugin-solid": "^0.13.1", 52 | "eslint-plugin-storybook": "^0.6.15", 53 | "eslint-plugin-vitest": "^0.3.20", 54 | "husky": "8.0.3", 55 | "jsdom": "^22.1.0", 56 | "lodash": "^4.17.21", 57 | "postcss-scss": "^4.0.9", 58 | "prettier": "^3.2.4", 59 | "storybook": "^7.6.9", 60 | "stylelint": "^15.11.0", 61 | "stylelint-config-recess-order": "^4.4.0", 62 | "stylelint-config-standard-scss": "^11.1.0", 63 | "stylelint-order": "^6.0.4", 64 | "stylelint-prettier": "^4.1.0", 65 | "type-fest": "^4.9.0", 66 | "typescript": "^5.3.3", 67 | "typescript-plugin-css-modules": "^5.0.2", 68 | "vite": "^4.5.1", 69 | "vite-plugin-solid": "^2.8.0", 70 | "vite-plugin-web-extension": "^3.2.0", 71 | "vite-tsconfig-paths": "^4.3.1", 72 | "vitest": "^0.34.6" 73 | }, 74 | "browserslist": [ 75 | "Chrome >= 88" 76 | ], 77 | "dependencies": { 78 | "@felte/solid": "^1.2.11", 79 | "@fortawesome/fontawesome-svg-core": "^6.5.1", 80 | "@fortawesome/free-solid-svg-icons": "^6.5.1", 81 | "@kobalte/core": "^0.11.2", 82 | "@solid-primitives/scheduled": "^1.4.2", 83 | "solid-js": "^1.8.11" 84 | }, 85 | "packageManager": "pnpm@8.6.0", 86 | "resolutions": { 87 | "tough-cookie": "^4.1.3", 88 | "postcss": "^8.4.31" 89 | }, 90 | "volta": { 91 | "node": "20.11.0" 92 | }, 93 | "pnpm": { 94 | "auditConfig": { 95 | "ignoreCves": [ 96 | "CVE-2023-28155" 97 | ] 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /public/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Live Chat Overlay" 4 | }, 5 | "extensionDescription": { 6 | "message": "Overlay live chat on top of the livestream video on YouTube." 7 | }, 8 | "toggleButtonHideTitle": { 9 | "message": "Hide Chat" 10 | }, 11 | "toggleButtonShowTitle": { 12 | "message": "Show Chat" 13 | }, 14 | "messageSettingsButtonTitle": { 15 | "message": "Message Theme Settings" 16 | }, 17 | "globalSettingsTitle": { 18 | "message": "Global" 19 | }, 20 | "messageSettingsTitle": { 21 | "message": "Message" 22 | }, 23 | "fontSizeInputLabel": { 24 | "message": "Font Size Related" 25 | }, 26 | "fontScaleMethodInputLabel": { 27 | "message": "Scale?" 28 | }, 29 | "fontScaleMethodHelperText": { 30 | "message": "When turned on, the text size will change based on the player's height. If turned off, the text will stay the same size." 31 | }, 32 | "globalOpacityInputLabel": { 33 | "message": "Global Opacity" 34 | }, 35 | "messageSettingsColorInputLabel": { 36 | "message": "Text Color" 37 | }, 38 | "messageSettingsBgColorInputLabel": { 39 | "message": "Background Color" 40 | }, 41 | "messageSettingsWeightInputLabel": { 42 | "message": "Font Weight" 43 | }, 44 | "messageSettingsOpacityInputLabel": { 45 | "message": "Opacity" 46 | }, 47 | "messageSettingsStrokeColorInputLabel": { 48 | "message": "Stroke Color" 49 | }, 50 | "messageSettingsStrokeWidthInputLabel": { 51 | "message": "Stroke Width" 52 | }, 53 | "messageSettingsNumberOfLinesInputLabel": { 54 | "message": "Max Line Number" 55 | }, 56 | "messageSettingsDisplayAuthorInputLabel": { 57 | "message": "Display Author Method" 58 | }, 59 | "applyButtonText": { 60 | "message": "Apply" 61 | }, 62 | "resetButtonText": { 63 | "message": "Reset" 64 | }, 65 | "restoreButtonText": { 66 | "message": "Restore to Default" 67 | }, 68 | "messageTypeSelectLabel": { 69 | "message": "Message Type Settings:" 70 | }, 71 | "colorInputHelperText": { 72 | "message": "Please enter HTML colors for color fields." 73 | }, 74 | "moderatorMessageType": { 75 | "message": "Moderator" 76 | }, 77 | "memberMessageType": { 78 | "message": "Member" 79 | }, 80 | "guestMessageType": { 81 | "message": "Guest" 82 | }, 83 | "ownerMessageType": { 84 | "message": "Owner" 85 | }, 86 | "verifiedMessageType": { 87 | "message": "Verified" 88 | }, 89 | "membershipMessageType": { 90 | "message": "New Membership" 91 | }, 92 | "superChatMessageType": { 93 | "message": "Super Chat" 94 | }, 95 | "pinnedMessageType": { 96 | "message": "Pinned" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /public/_locales/zh_HK/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Live Chat Overlay" 4 | }, 5 | "extensionDescription": { 6 | "message": "在 YouTube 的直播畫面上顯示即時聊天" 7 | }, 8 | "toggleButtonHideTitle": { 9 | "message": "隱藏評論" 10 | }, 11 | "toggleButtonShowTitle": { 12 | "message": "顯示評論" 13 | }, 14 | "messageSettingsButtonTitle": { 15 | "message": "評論主題設定" 16 | }, 17 | "globalSettingsTitle": { 18 | "message": "廣域設定" 19 | }, 20 | "messageSettingsTitle": { 21 | "message": "個別設定" 22 | }, 23 | "fontSizeInputLabel": { 24 | "message": "字體大小相關" 25 | }, 26 | "fontScaleMethodInputLabel": { 27 | "message": "自動縮放?" 28 | }, 29 | "fontScaleMethodHelperText": { 30 | "message": "當開啟時,文字大小將根據播放器的高度而變化。如果關閉,文字將保持相同大小。" 31 | }, 32 | "globalOpacityInputLabel": { 33 | "message": "廣域透明度" 34 | }, 35 | "messageSettingsColorInputLabel": { 36 | "message": "字體顏色" 37 | }, 38 | "messageSettingsBgColorInputLabel": { 39 | "message": "背景顏色" 40 | }, 41 | "messageSettingsWeightInputLabel": { 42 | "message": "字體粗度" 43 | }, 44 | "messageSettingsOpacityInputLabel": { 45 | "message": "透明度" 46 | }, 47 | "messageSettingsStrokeColorInputLabel": { 48 | "message": "字體邊緣顏色" 49 | }, 50 | "messageSettingsStrokeWidthInputLabel": { 51 | "message": "字體邊緣粗度" 52 | }, 53 | "messageSettingsNumberOfLinesInputLabel": { 54 | "message": "最大顯示行數" 55 | }, 56 | "messageSettingsDisplayAuthorInputLabel": { 57 | "message": "作者顯示方式" 58 | }, 59 | "applyButtonText": { 60 | "message": "套用" 61 | }, 62 | "resetButtonText": { 63 | "message": "重置" 64 | }, 65 | "restoreButtonText": { 66 | "message": "恢復原始設定" 67 | }, 68 | "messageTypeSelectLabel": { 69 | "message": "設定評論類型:" 70 | }, 71 | "colorInputHelperText": { 72 | "message": "請於顏色相關的欄位輸入 HTML 顏色碼。" 73 | }, 74 | "moderatorMessageType": { 75 | "message": "管理員" 76 | }, 77 | "memberMessageType": { 78 | "message": "會員" 79 | }, 80 | "guestMessageType": { 81 | "message": "非會員" 82 | }, 83 | "ownerMessageType": { 84 | "message": "頻道擁有者" 85 | }, 86 | "verifiedMessageType": { 87 | "message": "已認證" 88 | }, 89 | "membershipMessageType": { 90 | "message": "新會員信息" 91 | }, 92 | "superChatMessageType": { 93 | "message": "超級留言" 94 | }, 95 | "pinnedMessageType": { 96 | "message": "置頂信息" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /public/_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "Live Chat Overlay" 4 | }, 5 | "extensionDescription": { 6 | "message": "在 YouTube 的直播畫面上顯示即時聊天" 7 | }, 8 | "toggleButtonHideTitle": { 9 | "message": "隱藏評論" 10 | }, 11 | "toggleButtonShowTitle": { 12 | "message": "顯示評論" 13 | }, 14 | "messageSettingsButtonTitle": { 15 | "message": "評論主題設定" 16 | }, 17 | "globalSettingsTitle": { 18 | "message": "廣域設定" 19 | }, 20 | "messageSettingsTitle": { 21 | "message": "個別設定" 22 | }, 23 | "fontSizeInputLabel": { 24 | "message": "字體大小相關" 25 | }, 26 | "fontScaleMethodInputLabel": { 27 | "message": "自動縮放?" 28 | }, 29 | "fontScaleMethodHelperText": { 30 | "message": "當開啟時,文字大小將根據播放器的高度而變化。如果關閉,文字將保持相同大小。" 31 | }, 32 | "globalOpacityInputLabel": { 33 | "message": "廣域透明度" 34 | }, 35 | "messageSettingsColorInputLabel": { 36 | "message": "字體顏色" 37 | }, 38 | "messageSettingsBgColorInputLabel": { 39 | "message": "背景顏色" 40 | }, 41 | "messageSettingsWeightInputLabel": { 42 | "message": "字體粗度" 43 | }, 44 | "messageSettingsOpacityInputLabel": { 45 | "message": "透明度" 46 | }, 47 | "messageSettingsStrokeColorInputLabel": { 48 | "message": "字體邊緣顏色" 49 | }, 50 | "messageSettingsStrokeWidthInputLabel": { 51 | "message": "字體邊緣粗度" 52 | }, 53 | "messageSettingsNumberOfLinesInputLabel": { 54 | "message": "最大顯示行數" 55 | }, 56 | "messageSettingsDisplayAuthorInputLabel": { 57 | "message": "作者顯示方式" 58 | }, 59 | "applyButtonText": { 60 | "message": "套用" 61 | }, 62 | "resetButtonText": { 63 | "message": "重置" 64 | }, 65 | "restoreButtonText": { 66 | "message": "恢復原始設定" 67 | }, 68 | "messageTypeSelectLabel": { 69 | "message": "設定評論類型:" 70 | }, 71 | "colorInputHelperText": { 72 | "message": "請於顏色相關的欄位輸入 HTML 顏色碼。" 73 | }, 74 | "moderatorMessageType": { 75 | "message": "管理員" 76 | }, 77 | "memberMessageType": { 78 | "message": "會員" 79 | }, 80 | "guestMessageType": { 81 | "message": "非會員" 82 | }, 83 | "ownerMessageType": { 84 | "message": "頻道擁有者" 85 | }, 86 | "verifiedMessageType": { 87 | "message": "已認證" 88 | }, 89 | "membershipMessageType": { 90 | "message": "新會員信息" 91 | }, 92 | "superChatMessageType": { 93 | "message": "超級留言" 94 | }, 95 | "pinnedMessageType": { 96 | "message": "置頂信息" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thwonghin/live-chat-overlay/819fa3a656fdaee290b64d644273da174f4e7605/public/icon.png -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thwonghin/live-chat-overlay/819fa3a656fdaee290b64d644273da174f4e7605/screenshot.jpg -------------------------------------------------------------------------------- /src/_variable.scss: -------------------------------------------------------------------------------- 1 | $youtube-big-mode-class: 'ytp-big-mode'; 2 | -------------------------------------------------------------------------------- /src/app/live-chat-overlay/app.tsx: -------------------------------------------------------------------------------- 1 | import ChatFlow from '@/components/chat-flow'; 2 | import PlayerControl from '@/components/player-control'; 3 | import PopupContainer from '@/components/popup'; 4 | 5 | type Props = Readonly<{ 6 | playerControlContainer: HTMLSpanElement; 7 | liveChatContainer: HTMLDivElement; 8 | }>; 9 | 10 | const App = (props: Props) => { 11 | return ( 12 | <> 13 | 14 | 17 | 20 | 21 | ); 22 | }; 23 | 24 | export default App; 25 | -------------------------------------------------------------------------------- /src/app/live-chat-overlay/index.module.scss: -------------------------------------------------------------------------------- 1 | .live-chat-container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | } 8 | 9 | .player-control-container { 10 | display: flex; 11 | align-items: center; 12 | 13 | > div { 14 | display: flex; 15 | align-items: center; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/live-chat-overlay/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'solid-js/web'; 2 | 3 | import { OVERLAY_CONTAINER_ID, PLAYER_CONTROL_CONTAINER_ID } from '@/constants'; 4 | import * as contexts from '@/contexts'; 5 | import type { RootStore } from '@/stores'; 6 | import { youtube } from '@/utils'; 7 | import { createError } from '@/utils/logger'; 8 | 9 | import App from './app'; 10 | import styles from './index.module.scss'; 11 | 12 | export async function injectLiveChatOverlay( 13 | store: RootStore, 14 | ): Promise<() => void> { 15 | const videoPlayerContainer = youtube.getVideoPlayerContainer(); 16 | if (!videoPlayerContainer) { 17 | throw createError('Video Player Container not found.'); 18 | } 19 | 20 | const rightControlEle = youtube.getRightControlEle(); 21 | if (!rightControlEle) { 22 | throw createError('Right Player Control not found.'); 23 | } 24 | 25 | rightControlEle.style.display = 'flex'; 26 | 27 | const liveChatContainer = document.createElement('div'); 28 | liveChatContainer.id = OVERLAY_CONTAINER_ID; 29 | liveChatContainer.className = styles['live-chat-container']!; 30 | videoPlayerContainer.append(liveChatContainer); 31 | 32 | const playerControlContainer = document.createElement('span'); 33 | playerControlContainer.id = PLAYER_CONTROL_CONTAINER_ID; 34 | playerControlContainer.className = styles['player-control-container']!; 35 | rightControlEle.prepend(playerControlContainer); 36 | 37 | const cleanupRender = render( 38 | () => ( 39 | 40 | 44 | 45 | ), 46 | liveChatContainer, 47 | ); 48 | 49 | return () => { 50 | cleanupRender(); 51 | playerControlContainer.remove(); 52 | liveChatContainer.remove(); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | chrome.webNavigation.onHistoryStateUpdated.addListener( 2 | async (details) => { 3 | await chrome.tabs.sendMessage(details.tabId, { 4 | message: 'urlChanged', 5 | newUrl: details.url, 6 | }); 7 | }, 8 | { 9 | url: [ 10 | { 11 | hostEquals: 'www.youtube.com', 12 | }, 13 | ], 14 | }, 15 | ); 16 | -------------------------------------------------------------------------------- /src/components/chat-flow/author-chip/index.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | padding: 1px 10px; 5 | } 6 | 7 | .author-avatar { 8 | width: 0.6em; 9 | height: 0.6em; 10 | border-radius: 0.3em; 11 | } 12 | 13 | .author-avatar-margin-right { 14 | margin-right: 10px; 15 | } 16 | 17 | .author-name { 18 | font-size: 0.6em; 19 | white-space: nowrap; 20 | } 21 | 22 | .author-name-margin-right { 23 | margin-right: 10px; 24 | } 25 | 26 | .donation { 27 | font-size: 0.6em; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/chat-flow/author-chip/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type JSXElement } from 'solid-js'; 2 | 3 | import { AuthorDisplayMethod } from '@/models/settings'; 4 | 5 | import AuthorChip from './index'; 6 | 7 | const settings = { title: 'AuthorChip' }; 8 | 9 | export default settings; 10 | 11 | const avatars = [ 12 | { 13 | url: 'https://placekitten.com/50/50', 14 | height: 50, 15 | width: 50, 16 | }, 17 | ]; 18 | const name = 'Author Name'; 19 | 20 | type ContainerProps = Readonly<{ 21 | children: JSXElement; 22 | }>; 23 | const Container: Component = (props) => { 24 | return
{props.children}
; 25 | }; 26 | 27 | export const WithAllDisplay: Component = () => ( 28 | 29 | 34 | 35 | ); 36 | 37 | export const WithNameOnly: Component = () => ( 38 | 39 | 44 | 45 | ); 46 | 47 | export const WithAvatarOnly: Component = () => ( 48 | 49 | 54 | 55 | ); 56 | 57 | export const WithNone: Component = () => ( 58 | 59 | 64 | 65 | ); 66 | 67 | export const WithAllDisplayAndDonation: Component = () => ( 68 | 69 | 75 | 76 | ); 77 | 78 | export const WithNameOnlyAndDonation: Component = () => ( 79 | 80 | 86 | 87 | ); 88 | 89 | export const WithAvatarOnlyAndDonation: Component = () => ( 90 | 91 | 97 | 98 | ); 99 | 100 | export const WithNoneAndDonation: Component = () => ( 101 | 102 | 108 | 109 | ); 110 | -------------------------------------------------------------------------------- /src/components/chat-flow/author-chip/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, Show } from 'solid-js'; 2 | 3 | import type { Thumbnail } from '@/models/chat-item/types'; 4 | import { AuthorDisplayMethod, type MessageSettings } from '@/models/settings'; 5 | 6 | import styles from './index.module.scss'; 7 | 8 | type Props = Readonly<{ 9 | avatars: Thumbnail[]; 10 | name: string; 11 | authorDisplaySetting: MessageSettings['authorDisplay']; 12 | donationAmount?: string; 13 | }>; 14 | 15 | const AuthorChip: Component = (props) => { 16 | const isAvatarShown = () => 17 | props.authorDisplaySetting === AuthorDisplayMethod.ALL || 18 | props.authorDisplaySetting === AuthorDisplayMethod.AVATAR_ONLY; 19 | 20 | const isNameShown = () => 21 | props.authorDisplaySetting === AuthorDisplayMethod.ALL || 22 | props.authorDisplaySetting === AuthorDisplayMethod.NAME_ONLY; 23 | 24 | return ( 25 | 26 |
27 | 28 | {props.name} 39 | 40 | 41 | 49 | {props.name} 50 | 51 | 52 | 53 | 54 | {props.donationAmount} 55 | 56 | 57 |
58 |
59 | ); 60 | }; 61 | 62 | export default AuthorChip; 63 | -------------------------------------------------------------------------------- /src/components/chat-flow/chat-item-renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, Show } from 'solid-js'; 2 | 3 | import type { ChatItemModel } from '@/models/chat-item'; 4 | import { 5 | isMembershipItem, 6 | isNormalChatItem, 7 | isPinnedItem, 8 | isSuperChatItem, 9 | isSuperStickerItem, 10 | } from '@/models/chat-item/mapper'; 11 | import { 12 | type MembershipItem, 13 | type NormalChatItem, 14 | type PinnedChatItem, 15 | type SuperChatItem, 16 | type SuperStickerItem, 17 | } from '@/models/chat-item/types'; 18 | import { type MessageSettings } from '@/models/settings'; 19 | 20 | import PinnedMessage from '../pinned-message'; 21 | import SuperChatSticker from '../super-chat-sticker'; 22 | import TwoLinesMessage from '../two-lines-message'; 23 | 24 | type Props = Readonly<{ 25 | onRender?: (ele: HTMLElement) => void; 26 | chatItem: ChatItemModel; 27 | messageSettings: MessageSettings; 28 | onClickClose?: (event: MouseEvent) => void; 29 | }>; 30 | 31 | const ChatItemRenderer: Component = (props) => { 32 | function handleRender(ele: HTMLElement) { 33 | props.onRender?.(ele); 34 | } 35 | 36 | return ( 37 | <> 38 | 39 | 44 | 45 | 46 | 51 | 52 | 53 | 58 | 59 | 60 | 65 | 66 | 67 | 72 | 73 | 74 | ); 75 | }; 76 | 77 | export default ChatItemRenderer; 78 | -------------------------------------------------------------------------------- /src/components/chat-flow/debug-overlay/index.module.scss: -------------------------------------------------------------------------------- 1 | .debug-container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | height: 100%; 7 | } 8 | 9 | .debug-text { 10 | font-size: 20px; 11 | color: #ff0; 12 | white-space: nowrap; 13 | -webkit-text-stroke-color: #000; 14 | -webkit-text-stroke-width: 1px; 15 | } 16 | 17 | .metrics-container { 18 | position: absolute; 19 | top: 0; 20 | left: 0; 21 | width: 100%; 22 | height: 100%; 23 | text-align: right; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/chat-flow/debug-overlay/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | 3 | import { DebugOverlayLayout } from '.'; 4 | 5 | const settings = { title: 'DebugOverlay' }; 6 | 7 | export default settings; 8 | 9 | export const DebugOverlay: Component = () => { 10 | return ( 11 |
12 | 43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/chat-flow/debug-overlay/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, For, Index, Show } from 'solid-js'; 2 | 3 | import { useStore } from '@/contexts/root-store'; 4 | import type { Metrics } from '@/utils/metrics'; 5 | 6 | import styles from './index.module.scss'; 7 | 8 | type RoundedMetrics = Readonly<{ 9 | min: string; 10 | max: string; 11 | avg: string; 12 | count: number; 13 | latest: string; 14 | }>; 15 | 16 | function roundMetrics(metrics: Metrics): RoundedMetrics { 17 | return { 18 | min: metrics.min.toFixed(2), 19 | max: metrics.max.toFixed(2), 20 | avg: metrics.avg.toFixed(2), 21 | count: metrics.count, 22 | latest: metrics.latest.toFixed(2), 23 | }; 24 | } 25 | 26 | function renderMetrics(metrics: RoundedMetrics): string[] { 27 | return [ 28 | `min: ${metrics.min}, max: ${metrics.max}`, 29 | `avg: ${metrics.avg}, count: ${metrics.count}`, 30 | `latest: ${metrics.latest}`, 31 | ]; 32 | } 33 | 34 | type DebugOverlayLayoutProps = Readonly<{ 35 | chatItemsCountByLineNumber: Record; 36 | processXhrMetrics: RoundedMetrics; 37 | enqueuedChatItemCount: number; 38 | processChatEventMetrics: RoundedMetrics; 39 | processChatEventQueueLength: number; 40 | outdatedRemovedChatEventCount: number; 41 | cleanedChatItemCount: number; 42 | liveChatDelay: RoundedMetrics; 43 | debugIntervalInSeconds: number; 44 | }>; 45 | 46 | export const DebugOverlayLayout: Component = ( 47 | props, 48 | ) => { 49 | const cleanSpeed = () => 50 | props.cleanedChatItemCount / props.debugIntervalInSeconds; 51 | const enqueueSpeed = () => 52 | props.enqueuedChatItemCount / props.debugIntervalInSeconds; 53 | const dequeueSpeed = () => 54 | props.processChatEventMetrics.count / props.debugIntervalInSeconds; 55 | 56 | return ( 57 | <> 58 |
59 |

Message Count By Position:

60 | 61 | {([lineNumber, count]) => ( 62 |

{`${ 63 | Number(lineNumber) + 1 64 | }: ${count}`}

65 | )} 66 |
67 |
68 |

Enqueue Speed:

69 |

{enqueueSpeed()}

70 |

Dequeue Speed:

71 |

{dequeueSpeed()}

72 |

Clean Speed:

73 |

{cleanSpeed()}

74 |
75 |
76 | 77 |

78 | Process response metrics (μs): 79 |

80 | 81 | {(item) =>

{item()}

} 82 |
83 |
84 |
85 |

86 | {`Response Chat Event Queue Length: ${props.processChatEventQueueLength}`} 87 |

88 | 89 |

90 | Process chat event metrics (μs): 91 |

92 | 93 | {(item) =>

{item()}

} 94 |
95 |
96 |
97 |

98 | {`Removed Outdated Chat Event: ${props.outdatedRemovedChatEventCount}`} 99 |

100 | 101 |

Live Chat Delay (s):

102 | 103 | {(item) =>

{item()}

} 104 |
105 |
106 |

107 | {`Cleaned Chat Item: ${props.cleanedChatItemCount}`} 108 |

109 |
110 | 111 | ); 112 | }; 113 | 114 | const DebugOverlay: Component = () => { 115 | const store = useStore(); 116 | 117 | const chatItemsCountByLineNumber = () => { 118 | const grouped: Record = {}; 119 | Object.values(store.chatItemStore.state.normalChatItems).forEach( 120 | (item) => { 121 | if (item.lineNumber !== undefined) { 122 | grouped[item.lineNumber] = 123 | (grouped[item.lineNumber] ?? 0) + 1; 124 | } 125 | }, 126 | ); 127 | 128 | return grouped; 129 | }; 130 | 131 | return ( 132 | 161 | ); 162 | }; 163 | 164 | export default DebugOverlay; 165 | -------------------------------------------------------------------------------- /src/components/chat-flow/index.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | overflow: hidden; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/chat-flow/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Component, 3 | For, 4 | Show, 5 | type JSX, 6 | createEffect, 7 | createMemo, 8 | } from 'solid-js'; 9 | 10 | import { useStore } from '@/contexts/root-store'; 11 | import type { ChatItemModel } from '@/models/chat-item'; 12 | 13 | import ChatItemRenderer from './chat-item-renderer'; 14 | import DebugOverlay from './debug-overlay'; 15 | import styles from './index.module.scss'; 16 | import MessageFlower from './message-flower'; 17 | 18 | type Props = { 19 | liveChatContainer: HTMLDivElement; 20 | }; 21 | 22 | const ChatFlow: Component = (props) => { 23 | const store = useStore(); 24 | 25 | function handleRemoveMessage(chatItem: ChatItemModel) { 26 | store.chatItemStore.removeStickyChatItemById(chatItem.value.id); 27 | } 28 | 29 | function handleRenderChatItem( 30 | chatItem: ChatItemModel, 31 | element: HTMLElement, 32 | ) { 33 | store.chatItemStore.assignChatItemEle(chatItem.value.id, element); 34 | } 35 | 36 | const messageFlowDimensionPx = createMemo(() => 37 | store.uiStore.messageFlowDimensionPx(), 38 | ); 39 | 40 | const containerStyle = (): JSX.CSSProperties => { 41 | const dimensionInPx = messageFlowDimensionPx(); 42 | 43 | return { 44 | top: `${dimensionInPx.top}px`, 45 | left: `${dimensionInPx.left}px`, 46 | width: `${dimensionInPx.width}px`, 47 | height: `${dimensionInPx.height}px`, 48 | }; 49 | }; 50 | 51 | createEffect(() => { 52 | props.liveChatContainer.style.height = `${store.uiStore.state.playerState.height}px`; 53 | }); 54 | 55 | return ( 56 |
63 |
71 | 76 | {(chatItem) => { 77 | return ( 78 | 89 | { 95 | handleRenderChatItem(chatItem, ele); 96 | }} 97 | /> 98 | 99 | ); 100 | }} 101 | 102 | 107 | {(chatItem) => ( 108 | { 114 | handleRemoveMessage(chatItem); 115 | }} 116 | /> 117 | )} 118 | 119 |
120 | 121 | 122 | 123 |
124 | ); 125 | }; 126 | 127 | export default ChatFlow; 128 | -------------------------------------------------------------------------------- /src/components/chat-flow/message-flower/index.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | top: 0; 4 | display: flex; 5 | flex-direction: row; 6 | white-space: nowrap; 7 | transition-timing-function: linear; 8 | transition-property: transform; 9 | will-change: transform; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/chat-flow/message-flower/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type JSXElement } from 'solid-js'; 2 | 3 | import { useStore } from '@/contexts/root-store'; 4 | 5 | import styles from './index.module.scss'; 6 | 7 | type Props = Readonly<{ 8 | shouldFlow: boolean; 9 | children: JSXElement; 10 | top: number; 11 | containerWidth: number; 12 | width?: number; 13 | }>; 14 | 15 | const MessageFlower: Component = (props) => { 16 | const store = useStore(); 17 | 18 | return ( 19 |
34 | {props.children} 35 |
36 | ); 37 | }; 38 | 39 | export default MessageFlower; 40 | -------------------------------------------------------------------------------- /src/components/chat-flow/message-parts-renderer/emoji-part-renderer.tsx: -------------------------------------------------------------------------------- 1 | import { createMemo, type Component } from 'solid-js'; 2 | 3 | import type { EmojiPart } from '@/models/chat-item/types'; 4 | 5 | type Props = Readonly<{ 6 | emojiPart: EmojiPart; 7 | }>; 8 | 9 | const EmojiPartRenderer: Component = (props) => { 10 | const thumbnail = createMemo(() => props.emojiPart.thumbnails.at(-1)); 11 | return ( 12 | 17 | ); 18 | }; 19 | 20 | export default EmojiPartRenderer; 21 | -------------------------------------------------------------------------------- /src/components/chat-flow/message-parts-renderer/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, For } from 'solid-js'; 2 | 3 | import { 4 | isEmojiMessagePart, 5 | isTextMessagePart, 6 | } from '@/models/chat-item/mapper'; 7 | import type { MessagePart } from '@/models/chat-item/types'; 8 | import { assertNever } from '@/utils'; 9 | 10 | import EmojiPartRenderer from './emoji-part-renderer'; 11 | import TextPartRenderer from './text-part-renderer'; 12 | 13 | type Props = Readonly<{ 14 | class?: string; 15 | classList?: Record; 16 | messageParts: MessagePart[]; 17 | }>; 18 | 19 | const MessagePartsRenderer: Component = (props) => { 20 | return ( 21 | 22 | 23 | {(part) => { 24 | if (isTextMessagePart(part)) { 25 | return ; 26 | } 27 | 28 | if (isEmojiMessagePart(part)) { 29 | return ; 30 | } 31 | 32 | return assertNever(part); 33 | }} 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default MessagePartsRenderer; 40 | -------------------------------------------------------------------------------- /src/components/chat-flow/message-parts-renderer/text-part-renderer.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | 3 | import type { TextPart } from '@/models/chat-item/types'; 4 | 5 | type Props = Readonly<{ 6 | textPart: TextPart; 7 | }>; 8 | 9 | const TextPartRenderer: Component = (props) => { 10 | return <>{props.textPart.text}; 11 | }; 12 | 13 | export default TextPartRenderer; 14 | -------------------------------------------------------------------------------- /src/components/chat-flow/pinned-message/index.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | align-items: center; 4 | margin-top: 0.2em; 5 | cursor: pointer; 6 | border-radius: 5px; 7 | } 8 | 9 | %icon { 10 | width: 1em; 11 | height: 0.5em !important; 12 | padding-right: 10px; 13 | padding-left: 10px; 14 | } 15 | 16 | .icon { 17 | @extend %icon; 18 | } 19 | 20 | .message { 21 | display: inline; 22 | padding: 1px 10px; 23 | font-size: 0.8em; 24 | 25 | img { 26 | width: 1em; 27 | height: 1em; 28 | vertical-align: middle; 29 | } 30 | } 31 | 32 | .message-truncated { 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | white-space: nowrap; 36 | } 37 | 38 | .close-icon { 39 | @extend %icon; 40 | 41 | margin-left: auto; 42 | 43 | /* Youtube disabled all svg pointer events */ 44 | pointer-events: all !important; 45 | } 46 | -------------------------------------------------------------------------------- /src/components/chat-flow/pinned-message/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type JSXElement } from 'solid-js'; 2 | 3 | import type { PinnedChatItem } from '@/models/chat-item/types'; 4 | import { AuthorDisplayMethod, type MessageSettings } from '@/models/settings'; 5 | 6 | import PinnedMessage from '.'; 7 | 8 | const settings = { title: 'PinnedMessage' }; 9 | export default settings; 10 | 11 | const avatars = [ 12 | { 13 | url: 'https://placekitten.com/50/50', 14 | height: 50, 15 | width: 50, 16 | }, 17 | ]; 18 | const authorName = 'Author Name'; 19 | 20 | const pinnedMessage: PinnedChatItem = { 21 | id: 'pinned-chat', 22 | authorBadges: [], 23 | messageParts: [{ text: 'This is a pinned message' }], 24 | avatars, 25 | videoTimestampMs: 0, 26 | authorName, 27 | chatType: 'pinned', 28 | authorType: 'owner', 29 | }; 30 | 31 | type ContainerProps = Readonly<{ 32 | children: JSXElement; 33 | }>; 34 | 35 | const Container: Component = (props) => ( 36 |
37 | {props.children} 38 |
39 | ); 40 | 41 | const messageSettings: MessageSettings = { 42 | color: 'white', 43 | weight: 700, 44 | opacity: 0.8, 45 | bgColor: '#224072', 46 | strokeColor: 'black', 47 | strokeWidth: 0.03, 48 | numberOfLines: 1, 49 | authorDisplay: AuthorDisplayMethod.ALL, 50 | isSticky: true, 51 | }; 52 | 53 | export const PinnedChatMessage: Component = () => ( 54 | 55 | { 59 | console.log('clicked close'); 60 | }} 61 | /> 62 | 63 | ); 64 | 65 | export const PinnedVeryLongChatMessage: Component = () => ( 66 | 67 | { 78 | console.log('clicked close'); 79 | }} 80 | /> 81 | 82 | ); 83 | 84 | export const PinnedVeryLongChatMessageWithImage: Component = () => ( 85 | 86 | { 111 | console.log('clicked close'); 112 | }} 113 | /> 114 | 115 | ); 116 | -------------------------------------------------------------------------------- /src/components/chat-flow/pinned-message/index.tsx: -------------------------------------------------------------------------------- 1 | import { faThumbtack, faTimes } from '@fortawesome/free-solid-svg-icons'; 2 | import { type Component, createSignal } from 'solid-js'; 3 | 4 | import FontAwesomeIcon from '@/components/font-awesome'; 5 | import type { PinnedChatItem } from '@/models/chat-item/types'; 6 | import { type MessageSettings } from '@/models/settings'; 7 | 8 | import styles from './index.module.scss'; 9 | import AuthorChip from '../author-chip'; 10 | import MessagePartsRenderer from '../message-parts-renderer'; 11 | 12 | type Props = Readonly<{ 13 | chatItem: PinnedChatItem; 14 | messageSettings: MessageSettings; 15 | onClickClose?: (event: MouseEvent) => void; 16 | }>; 17 | 18 | const PinnedMessage: Component = (props) => { 19 | const [isExpended, setIsExpended] = createSignal(false); 20 | const handleClick = (event: MouseEvent) => { 21 | event.preventDefault(); 22 | event.stopPropagation(); 23 | setIsExpended((state) => !state); 24 | }; 25 | 26 | const handleClickClose = (event: MouseEvent) => { 27 | event.preventDefault(); 28 | event.stopPropagation(); 29 | props.onClickClose?.(event); 30 | }; 31 | 32 | return ( 33 |
46 | 47 | 52 | 59 | 64 |
65 | ); 66 | }; 67 | 68 | export default PinnedMessage; 69 | -------------------------------------------------------------------------------- /src/components/chat-flow/super-chat-sticker/index.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | padding: 1px 10px; 6 | margin-top: 0.2em; 7 | border-radius: 5px; 8 | } 9 | 10 | .message { 11 | display: flex; 12 | align-items: center; 13 | justify-content: flex-end; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/chat-flow/super-chat-sticker/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type JSXElement } from 'solid-js'; 2 | 3 | import type { SuperStickerItem } from '@/models/chat-item/types'; 4 | import { AuthorDisplayMethod, type MessageSettings } from '@/models/settings'; 5 | 6 | import SuperChatSticker from '.'; 7 | 8 | const settings = { title: 'SuperChatSticker' }; 9 | export default settings; 10 | 11 | const avatars = [ 12 | { 13 | url: 'https://placekitten.com/50/50', 14 | height: 50, 15 | width: 50, 16 | }, 17 | ]; 18 | const authorName = 'Author Name'; 19 | const stickers = [ 20 | { 21 | url: 'https://placekitten.com/200/200', 22 | height: 200, 23 | width: 200, 24 | }, 25 | ]; 26 | 27 | const superStickerItem: SuperStickerItem = { 28 | id: 'super-chat-sticker', 29 | stickers, 30 | avatars, 31 | videoTimestampMs: 0, 32 | donationAmount: 'HK$ 100.00', 33 | authorName, 34 | color: 'red', 35 | chatType: 'super-sticker', 36 | }; 37 | 38 | const messageSettings: MessageSettings = { 39 | color: 'white', 40 | weight: 700, 41 | opacity: 0.8, 42 | bgColor: 'transparent', 43 | strokeColor: 'black', 44 | strokeWidth: 0.03, 45 | numberOfLines: 2, 46 | authorDisplay: AuthorDisplayMethod.ALL, 47 | isSticky: false, 48 | }; 49 | 50 | type ContainerProps = { 51 | children: JSXElement; 52 | }; 53 | 54 | const Container: Component = (props) => ( 55 |
56 | {props.children} 57 |
58 | ); 59 | 60 | export const TwoLinesSuperChatSticker: Component = () => ( 61 | 62 | 66 | 67 | ); 68 | 69 | export const OneLineSuperChatSticker: Component = () => ( 70 | 71 | 78 | 79 | ); 80 | 81 | export const TwoLinesSuperChatStickerWithoutAuthorDisplay: Component = () => ( 82 | 83 | 90 | 91 | ); 92 | 93 | export const OneLineSuperChatStickerWithoutAuthorDisplay: Component = () => ( 94 | 95 | 103 | 104 | ); 105 | -------------------------------------------------------------------------------- /src/components/chat-flow/super-chat-sticker/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, onMount } from 'solid-js'; 2 | 3 | import type { SuperStickerItem } from '@/models/chat-item/types'; 4 | import type { MessageSettings } from '@/models/settings'; 5 | 6 | import styles from './index.module.scss'; 7 | import AuthorChip from '../author-chip'; 8 | 9 | type Props = Readonly<{ 10 | onRender?: (ele: HTMLElement) => void; 11 | messageSettings: MessageSettings; 12 | chatItem: SuperStickerItem; 13 | }>; 14 | 15 | const SuperChatSticker: Component = (props) => { 16 | let ref: HTMLDivElement | undefined; 17 | onMount(() => { 18 | if (ref) { 19 | props.onRender?.(ref); 20 | } 21 | }); 22 | 23 | const imageSize = () => `${0.8 * props.messageSettings.numberOfLines}em`; 24 | 25 | return ( 26 |
40 | 46 | 47 | 54 | 55 |
56 | ); 57 | }; 58 | 59 | export default SuperChatSticker; 60 | -------------------------------------------------------------------------------- /src/components/chat-flow/two-lines-message/index.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | margin-top: 0.2em; 4 | border-radius: 5px; 5 | } 6 | 7 | .message { 8 | display: flex; 9 | align-items: center; 10 | padding: 1px 10px; 11 | font-size: 0.8em; 12 | 13 | img { 14 | width: 1em; 15 | height: 1em; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/chat-flow/two-lines-message/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type JSXElement } from 'solid-js'; 2 | 3 | import type { 4 | MembershipItem, 5 | NormalChatItem, 6 | SuperChatItem, 7 | } from '@/models/chat-item/types'; 8 | import { AuthorDisplayMethod, type MessageSettings } from '@/models/settings'; 9 | 10 | import TwoLinesMessage from '.'; 11 | 12 | const settings = { title: 'TwoLinesMessage' }; 13 | export default settings; 14 | 15 | const avatars = [ 16 | { 17 | url: 'https://placekitten.com/50/50', 18 | height: 50, 19 | width: 50, 20 | }, 21 | ]; 22 | const authorName = 'Author Name'; 23 | 24 | const superChatItem: SuperChatItem = { 25 | id: 'super-chat-message', 26 | messageParts: [{ text: 'This is a super chat message' }], 27 | avatars, 28 | videoTimestampMs: 0, 29 | donationAmount: 'HK$ 100.00', 30 | authorName, 31 | color: 'red', 32 | chatType: 'super-chat', 33 | }; 34 | 35 | const membershipItem: MembershipItem = { 36 | id: 'membership', 37 | authorBadges: [], 38 | messageParts: [{ text: 'Someone becomes a member.' }], 39 | avatars, 40 | videoTimestampMs: 0, 41 | authorName, 42 | chatType: 'membership', 43 | }; 44 | 45 | const normalMessageItem: NormalChatItem = { 46 | id: 'super-chat-sticker', 47 | authorBadges: [], 48 | messageParts: [{ text: 'This is a normal message' }], 49 | avatars, 50 | videoTimestampMs: 0, 51 | authorName, 52 | chatType: 'normal', 53 | authorType: 'owner', 54 | }; 55 | 56 | const messageSettings: MessageSettings = { 57 | color: 'white', 58 | weight: 700, 59 | opacity: 0.8, 60 | bgColor: 'black', 61 | strokeColor: 'black', 62 | strokeWidth: 0.03, 63 | numberOfLines: 2, 64 | authorDisplay: AuthorDisplayMethod.ALL, 65 | isSticky: false, 66 | }; 67 | 68 | type ContainerProps = Readonly<{ 69 | children: JSXElement; 70 | }>; 71 | 72 | const Container: Component = (props) => ( 73 |
74 | {props.children} 75 |
76 | ); 77 | 78 | export const TwoLinesSuperChatMessage: Component = () => ( 79 | 80 | 84 | 85 | ); 86 | 87 | export const OneLineSuperChatMessage: Component = () => ( 88 | 89 | 96 | 97 | ); 98 | 99 | export const TwoLinesMembershipMessage: Component = () => ( 100 | 101 | 105 | 106 | ); 107 | 108 | export const OneLineMembershipMessage: Component = () => ( 109 | 110 | 117 | 118 | ); 119 | 120 | export const TwoLinesNormalMessage: Component = () => ( 121 | 122 | 126 | 127 | ); 128 | 129 | export const OneLineNormalMessage: Component = () => ( 130 | 131 | 138 | 139 | ); 140 | 141 | export const TwoLinesSuperChatMessageWithoutAuthorDisplay: Component = () => ( 142 | 143 | 150 | 151 | ); 152 | 153 | export const OneLineSuperChatMessageWithoutAuthorDisplay: Component = () => ( 154 | 155 | 163 | 164 | ); 165 | 166 | export const TwoLinesMembershipMessageWithoutAuthorDisplay: Component = () => ( 167 | 168 | 175 | 176 | ); 177 | 178 | export const OneLineMembershipMessageWithoutAuthorDisplay: Component = () => ( 179 | 180 | 188 | 189 | ); 190 | 191 | export const TwoLinesNormalMessageWithoutAuthorDisplay: Component = () => ( 192 | 193 | 200 | 201 | ); 202 | 203 | export const OneLineNormalMessageWithoutAuthorDisplay: Component = () => ( 204 | 205 | 213 | 214 | ); 215 | -------------------------------------------------------------------------------- /src/components/chat-flow/two-lines-message/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, onMount } from 'solid-js'; 2 | 3 | import { 4 | isMembershipItem, 5 | isNormalChatItem, 6 | isSuperChatItem, 7 | } from '@/models/chat-item/mapper'; 8 | import type { 9 | NormalChatItem, 10 | MembershipItem, 11 | SuperChatItem, 12 | } from '@/models/chat-item/types'; 13 | import { type MessageSettings } from '@/models/settings'; 14 | 15 | import styles from './index.module.scss'; 16 | import AuthorChip from '../author-chip'; 17 | import MessagePartsRenderer from '../message-parts-renderer'; 18 | 19 | type Props = Readonly<{ 20 | chatItem: NormalChatItem | MembershipItem | SuperChatItem; 21 | messageSettings: MessageSettings; 22 | onRender?: (ele: HTMLElement) => void; 23 | }>; 24 | 25 | const TwoLinesMessage: Component = (props) => { 26 | let ref: HTMLDivElement | undefined; 27 | onMount(() => { 28 | if (ref) { 29 | props.onRender?.(ref); 30 | } 31 | }); 32 | 33 | const actualNumberOfLines = () => 34 | props.chatItem.messageParts.length > 0 35 | ? props.messageSettings.numberOfLines 36 | : 1; 37 | 38 | const flexDirection = () => 39 | actualNumberOfLines() === 2 ? 'column' : 'row'; 40 | 41 | return ( 42 |
64 | 74 | 78 |
79 | ); 80 | }; 81 | 82 | export default TwoLinesMessage; 83 | -------------------------------------------------------------------------------- /src/components/font-awesome.tsx: -------------------------------------------------------------------------------- 1 | import { type IconDefinition, icon } from '@fortawesome/fontawesome-svg-core'; 2 | import { createMemo, type JSX, splitProps } from 'solid-js'; 3 | 4 | import '@fortawesome/fontawesome-svg-core/styles.css'; 5 | 6 | type Props = Readonly< 7 | JSX.SvgSVGAttributes & { 8 | icon: IconDefinition; 9 | } 10 | >; 11 | 12 | const FontAwesomeIcon = (props: Props) => { 13 | const [localProps, otherProps] = splitProps(props, [ 14 | 'icon', 15 | 'classList', 16 | 'width', 17 | ]); 18 | const faicon = createMemo(() => icon(localProps.icon)); 19 | 20 | return ( 21 | 38 | ); 39 | }; 40 | 41 | export default FontAwesomeIcon; 42 | -------------------------------------------------------------------------------- /src/components/player-control/btn-tooltip/index.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variable'; 2 | 3 | .tooltip { 4 | padding: 5px 9px; 5 | margin-bottom: 23px; 6 | font-size: 1.2rem; 7 | font-weight: 500; 8 | line-height: 15px; 9 | background-color: rgb(28 28 28 / 90%); 10 | border-radius: 4px; 11 | 12 | :global(.#{$youtube-big-mode-class}) & { 13 | font-size: 2rem; 14 | line-height: 22px; 15 | } 16 | } 17 | 18 | .trigger { 19 | display: flex !important; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: 30px !important; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/player-control/btn-tooltip/index.tsx: -------------------------------------------------------------------------------- 1 | import { type IconDefinition } from '@fortawesome/fontawesome-svg-core'; 2 | import { Tooltip } from '@kobalte/core'; 3 | import { type Component } from 'solid-js'; 4 | 5 | import FontAwesomeIcon from '@/components/font-awesome'; 6 | import { youtube } from '@/utils'; 7 | 8 | import styles from './index.module.scss'; 9 | 10 | export const ICON_WIDTH = (2 / 3) * (512 / 640) * 100; 11 | 12 | type Props = Readonly<{ 13 | title: string; 14 | onClickTrigger?: (event: MouseEvent) => void; 15 | icon: IconDefinition; 16 | iconWidth?: string; 17 | }>; 18 | 19 | const BtnTooltip: Component = (props) => { 20 | return ( 21 | 22 | 29 | 34 | 35 | 36 |

{props.title}

37 |
38 |
39 | ); 40 | }; 41 | 42 | export default BtnTooltip; 43 | -------------------------------------------------------------------------------- /src/components/player-control/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | import { Portal } from 'solid-js/web'; 3 | 4 | import MessageSettingsBtn from '@/components/player-control/message-settings-btn'; 5 | import SpeedSlider from '@/components/player-control/speed-slider'; 6 | import ToggleBtn from '@/components/player-control/toggle-btn'; 7 | 8 | type Props = Readonly<{ 9 | playerControlContainer: HTMLSpanElement; 10 | }>; 11 | 12 | const PlayerControl: Component = (props) => { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default PlayerControl; 23 | -------------------------------------------------------------------------------- /src/components/player-control/message-settings-btn/index.tsx: -------------------------------------------------------------------------------- 1 | import { faPalette } from '@fortawesome/free-solid-svg-icons'; 2 | import { type Component } from 'solid-js'; 3 | 4 | import { useStore } from '@/contexts/root-store'; 5 | 6 | import BtnTooltip from '../btn-tooltip'; 7 | 8 | const MessageSettingsBtn: Component = () => { 9 | const store = useStore(); 10 | function handleClick(event: MouseEvent) { 11 | event.preventDefault(); 12 | store.uiStore.togglePopup('message-settings'); 13 | } 14 | 15 | return ( 16 | 21 | ); 22 | }; 23 | 24 | export default MessageSettingsBtn; 25 | -------------------------------------------------------------------------------- /src/components/player-control/speed-slider/index.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variable'; 2 | 3 | .container { 4 | display: inline-block; 5 | width: 52px; 6 | height: 100%; 7 | cursor: pointer; 8 | outline: 0; 9 | :global(.#{$youtube-big-mode-class}) & { 10 | width: 78px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/player-control/speed-slider/index.tsx: -------------------------------------------------------------------------------- 1 | import { leadingAndTrailing, debounce } from '@solid-primitives/scheduled'; 2 | import { type Component, onCleanup } from 'solid-js'; 3 | 4 | import { useStore } from '@/contexts/root-store'; 5 | 6 | import styles from './index.module.scss'; 7 | import Slider from './slider'; 8 | 9 | const minValue = 3; 10 | const maxValue = 15; 11 | 12 | function reverse(value: number): number { 13 | return maxValue - value + minValue; 14 | } 15 | 16 | const SpeedSlider: Component = () => { 17 | const store = useStore(); 18 | 19 | const handleChange = leadingAndTrailing( 20 | debounce, 21 | (percentage: number) => { 22 | const value = reverse( 23 | (maxValue - minValue) * (percentage / 100) + minValue, 24 | ); 25 | store.settingsStore.setSettings('flowTimeInSec', value); 26 | }, 27 | 100, 28 | ); 29 | 30 | const percentage = () => 31 | ((reverse(store.settingsStore.settings.flowTimeInSec) - minValue) / 32 | (maxValue - minValue)) * 33 | 100; 34 | 35 | onCleanup(() => { 36 | handleChange.clear(); 37 | }); 38 | 39 | return ( 40 |
41 | 42 |
43 | ); 44 | }; 45 | 46 | export default SpeedSlider; 47 | -------------------------------------------------------------------------------- /src/components/player-control/speed-slider/slider.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variable'; 2 | 3 | .slider { 4 | position: relative; 5 | height: 100%; 6 | min-height: 36px; 7 | overflow: hidden; 8 | 9 | :global(.#{$youtube-big-mode-class}) & { 10 | min-height: 54px; 11 | } 12 | } 13 | 14 | .handle { 15 | position: absolute; 16 | top: 50%; 17 | width: 12px; 18 | height: 12px; 19 | margin-top: -6px; 20 | background: #fff; 21 | border-radius: 6px; 22 | 23 | :global(.#{$youtube-big-mode-class}) & { 24 | width: 18px; 25 | height: 18px; 26 | margin-top: -9px; 27 | border-radius: 9px; 28 | } 29 | 30 | &::before, 31 | &::after { 32 | position: absolute; 33 | top: 50%; 34 | left: 0; 35 | display: block; 36 | width: 64px; 37 | height: 3px; 38 | margin-top: -2px; 39 | content: ''; 40 | 41 | :global(.#{$youtube-big-mode-class}) & { 42 | width: 96px; 43 | height: 4px; 44 | margin-top: -2px; 45 | } 46 | } 47 | 48 | &::before { 49 | left: -58px; 50 | background: #fff; 51 | 52 | :global(.#{$youtube-big-mode-class}) & { 53 | left: -87px; 54 | } 55 | } 56 | 57 | &::after { 58 | left: 6px; 59 | background: rgb(255 255 255 / 20%); 60 | 61 | :global(.#{$youtube-big-mode-class}) & { 62 | left: 9px; 63 | background: rgb(255 255 255 / 20%); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/player-control/speed-slider/slider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Component, 3 | createEffect, 4 | createSignal, 5 | onCleanup, 6 | onMount, 7 | } from 'solid-js'; 8 | 9 | import { useRect } from '@/hooks'; 10 | import { clamp } from '@/utils'; 11 | 12 | import styles from './slider.module.scss'; 13 | 14 | type Props = Readonly<{ 15 | percentage: number; 16 | onChange: (percentage: number) => void; 17 | }>; 18 | 19 | const Slider: Component = (props) => { 20 | const [trackEle, setTrackEle] = createSignal(); 21 | const trackRect = useRect(trackEle); 22 | 23 | const [handleEle, setHandleEle] = createSignal(); 24 | const handleRect = useRect(handleEle); 25 | 26 | const maxWidth = () => { 27 | return trackRect().width - handleRect().width; 28 | }; 29 | 30 | const hasInitiated = () => trackRect().width > 0 && handleRect().width > 0; 31 | 32 | const [position, setPosition] = createSignal(0); 33 | 34 | const [isDragging, setIsDragging] = createSignal(false); 35 | 36 | const updatePosition = (e: MouseEvent) => { 37 | if (!hasInitiated()) { 38 | return; 39 | } 40 | 41 | const rect = trackEle()?.getBoundingClientRect(); 42 | if (!rect) { 43 | return; 44 | } 45 | 46 | const newPosition = clamp( 47 | e.clientX - rect.left - handleRect().width / 2, 48 | 0, 49 | maxWidth(), 50 | ); 51 | setPosition(newPosition); 52 | }; 53 | 54 | const handleMouseDown = (e: MouseEvent) => { 55 | setIsDragging(true); 56 | e.preventDefault(); 57 | }; 58 | 59 | const handleMouseUp = () => { 60 | if (!hasInitiated()) { 61 | return; 62 | } 63 | 64 | props.onChange((position() / maxWidth()) * 100); 65 | setIsDragging(false); 66 | }; 67 | 68 | const handleMouseMove = (e: MouseEvent) => { 69 | if (!isDragging() || !hasInitiated()) { 70 | return; 71 | } 72 | 73 | updatePosition(e); 74 | }; 75 | 76 | const handleClick = (e: MouseEvent) => { 77 | if (!hasInitiated()) { 78 | return; 79 | } 80 | 81 | updatePosition(e); 82 | e.preventDefault(); 83 | props.onChange((position() / maxWidth()) * 100); 84 | }; 85 | 86 | onMount(() => { 87 | window.addEventListener('mousemove', handleMouseMove); 88 | window.addEventListener('mouseup', handleMouseUp); 89 | }); 90 | 91 | onCleanup(() => { 92 | window.removeEventListener('mousemove', handleMouseMove); 93 | window.removeEventListener('mouseup', handleMouseUp); 94 | }); 95 | 96 | createEffect(() => { 97 | if (!hasInitiated() || isDragging()) { 98 | return; 99 | } 100 | 101 | setPosition((maxWidth() * props.percentage) / 100); 102 | }); 103 | 104 | return ( 105 |
117 |
124 |
125 | ); 126 | }; 127 | 128 | export default Slider; 129 | -------------------------------------------------------------------------------- /src/components/player-control/toggle-btn/index.tsx: -------------------------------------------------------------------------------- 1 | import { faCommentSlash, faComment } from '@fortawesome/free-solid-svg-icons'; 2 | import { type Component } from 'solid-js'; 3 | 4 | import { useStore } from '@/contexts/root-store'; 5 | 6 | import BtnTooltip from '../btn-tooltip'; 7 | 8 | const iconToBtnRatio = 2 / 3; 9 | const faCommentSlashHeight = 640; 10 | const faCommentHeight = 512; 11 | const withSlashIconRatio = 12 | iconToBtnRatio * (faCommentHeight / faCommentSlashHeight); 13 | 14 | const ToggleBtn: Component = () => { 15 | const store = useStore(); 16 | 17 | const width = () => { 18 | const ratio = store.settingsStore.settings.isEnabled 19 | ? withSlashIconRatio 20 | : iconToBtnRatio; 21 | 22 | return `${ratio * 100}%`; 23 | }; 24 | 25 | function handleClick(event: MouseEvent) { 26 | event.preventDefault(); 27 | store.settingsStore.setSettings('isEnabled', (s) => !s); 28 | } 29 | 30 | return ( 31 | 45 | ); 46 | }; 47 | 48 | export default ToggleBtn; 49 | -------------------------------------------------------------------------------- /src/components/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | import { Portal } from 'solid-js/web'; 3 | 4 | import { useStore } from '@/contexts/root-store'; 5 | 6 | import MessageSettingsPopup from './message-settings-popup'; 7 | 8 | type Props = Readonly<{ 9 | playerControlContainer: HTMLSpanElement; 10 | }>; 11 | 12 | const PopupContainer: Component = (props) => { 13 | const store = useStore(); 14 | 15 | return ( 16 | 17 | 23 | 24 | ); 25 | }; 26 | 27 | export default PopupContainer; 28 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-input-form/global-settings-form.tsx: -------------------------------------------------------------------------------- 1 | import { createForm } from '@felte/solid'; 2 | import { Button, Switch as KobalteSwitch } from '@kobalte/core'; 3 | import { createEffect, type Component, Switch, Match } from 'solid-js'; 4 | 5 | import { FontScaleMethod, type Settings } from '@/models/settings'; 6 | 7 | import styles from './index.module.scss'; 8 | 9 | export type GlobalSettings = Pick< 10 | Settings, 11 | | 'globalOpacity' 12 | | 'fontScaleMethod' 13 | | 'fontSizeFixed' 14 | | 'fontSizeScaled' 15 | | 'totalNumberOfLines' 16 | >; 17 | 18 | type Props = Readonly<{ 19 | settings: GlobalSettings; 20 | onSubmit: (value: GlobalSettings) => void; 21 | }>; 22 | 23 | const GlobalSettingsForm: Component = (props) => { 24 | const { form, reset, setInitialValues, setData, setFields, data } = 25 | createForm({ 26 | initialValues: props.settings, 27 | onSubmit: props.onSubmit, 28 | }); 29 | 30 | createEffect(() => { 31 | setInitialValues(props.settings); 32 | setData(props.settings); 33 | }); 34 | 35 | return ( 36 |
37 |
38 | 53 |
54 | 57 |
58 | { 63 | setFields( 64 | 'fontScaleMethod', 65 | isChecked 66 | ? FontScaleMethod.SCALED 67 | : FontScaleMethod.FIXED, 68 | ); 69 | }} 70 | > 71 | 72 | {chrome.i18n.getMessage('fontScaleMethodInputLabel')} 73 | 74 | 75 | 76 | 77 | 78 |
79 | 80 | 86 | {' '} 94 | % 95 | 96 | 101 | {' '} 108 | px 109 | 110 | 111 |
112 |
113 |

114 | {chrome.i18n.getMessage('fontScaleMethodHelperText')} 115 |

116 | 132 |
133 | 134 | {chrome.i18n.getMessage('applyButtonText')} 135 | 136 | 141 | {chrome.i18n.getMessage('resetButtonText')} 142 | 143 |
144 |
145 | ); 146 | }; 147 | 148 | export default GlobalSettingsForm; 149 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-input-form/index.module.scss: -------------------------------------------------------------------------------- 1 | .container-form { 2 | width: 100%; 3 | } 4 | 5 | %row { 6 | display: flex; 7 | justify-content: space-between; 8 | } 9 | 10 | .row { 11 | @extend %row; 12 | } 13 | 14 | .btn-row { 15 | @extend %row; 16 | 17 | margin-top: 8px; 18 | } 19 | 20 | %btn { 21 | display: inline-flex; 22 | align-items: center; 23 | justify-content: space-between; 24 | padding: 8px 12px; 25 | font-size: 14px; 26 | line-height: 1; 27 | color: var(--yt-spec-text-primary); 28 | cursor: pointer; 29 | background-color: transparent; 30 | border: none; 31 | border-radius: 18px; 32 | outline: none; 33 | 34 | &:hover { 35 | background-color: var(--yt-spec-menu-background); 36 | } 37 | } 38 | 39 | .btn { 40 | @extend %btn; 41 | } 42 | 43 | .btn-primary { 44 | @extend %btn; 45 | 46 | background-color: var(--yt-spec-call-to-action); 47 | 48 | &:hover { 49 | background-color: var(--yt-spec-call-to-action-inverse); 50 | } 51 | } 52 | 53 | %input { 54 | padding: 4px 8px; 55 | margin-top: 4px; 56 | font-size: 14px; 57 | color: var(--yt-spec-text-primary); 58 | background-color: var(--yt-spec-raised-background); 59 | border: none; 60 | border-bottom: solid 1px var(--yt-spec-text-primary); 61 | border-radius: 0; 62 | outline: none; 63 | 64 | &:focus { 65 | border-color: var(--yt-spec-themed-blue); 66 | } 67 | 68 | &:disabled { 69 | cursor: not-allowed; 70 | background-color: var(--yt-spec-menu-background); 71 | } 72 | } 73 | 74 | .form-label { 75 | display: inline-flex; 76 | flex-direction: column; 77 | flex-grow: 1; 78 | padding-bottom: 8px; 79 | font-size: 14px; 80 | 81 | > input { 82 | @extend %input; 83 | } 84 | } 85 | 86 | .tab { 87 | display: flex; 88 | flex-direction: column; 89 | width: 100%; 90 | } 91 | 92 | .tab-list { 93 | position: relative; 94 | display: flex; 95 | align-items: center; 96 | border-bottom: solid 1px white; 97 | } 98 | 99 | .tab-indicator { 100 | position: absolute; 101 | bottom: -1px; 102 | height: 2px; 103 | background-color: var(--yt-spec-themed-blue); 104 | transition: all 250ms; 105 | } 106 | 107 | .tab-trigger { 108 | display: inline-block; 109 | padding: 8px 16px; 110 | color: white; 111 | cursor: pointer; 112 | background-color: transparent; 113 | border-color: transparent; 114 | border-radius: 0; 115 | outline: none; 116 | } 117 | 118 | .tab-content { 119 | display: flex; 120 | flex-direction: column; 121 | padding-top: 16px; 122 | } 123 | 124 | .switch { 125 | display: inline-flex; 126 | align-items: center; 127 | } 128 | 129 | .switch-label { 130 | font-size: 11px; 131 | } 132 | 133 | .switch-control { 134 | display: inline-flex; 135 | align-items: center; 136 | width: 22px; 137 | height: 12px; 138 | margin-left: 4px; 139 | cursor: pointer; 140 | background-color: hsl(240deg 6% 90%); 141 | border: 1px solid hsl(240deg 5% 84%); 142 | border-radius: 6px; 143 | transition: 250ms background-color; 144 | } 145 | 146 | .switch-control[data-checked] { 147 | background-color: var(--yt-spec-themed-blue); 148 | border-color: var(--yt-spec-themed-blue); 149 | } 150 | 151 | .switch-thumb { 152 | width: 10px; 153 | height: 10px; 154 | background-color: white; 155 | border-radius: 5px; 156 | transition: 250ms transform; 157 | } 158 | 159 | .switch-thumb[data-checked] { 160 | transform: translateX(100%); 161 | } 162 | 163 | .font-size-input-row { 164 | flex-direction: row; 165 | align-items: center; 166 | width: 100%; 167 | margin-left: 8px; 168 | 169 | > input { 170 | @extend %input; 171 | 172 | width: 50px; 173 | margin-top: 0; 174 | } 175 | } 176 | 177 | .helper-text { 178 | margin: 8px 0; 179 | font-size: 11px; 180 | } 181 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-input-form/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from '@kobalte/core'; 2 | import { type Component, Index, Show, createSignal } from 'solid-js'; 3 | 4 | import { useStore } from '@/contexts/root-store'; 5 | import { useRect } from '@/hooks'; 6 | import { 7 | type MessageSettingsKey, 8 | type MessageSettings, 9 | messageSettingsKeys, 10 | } from '@/models/settings'; 11 | import { assertNever } from '@/utils'; 12 | 13 | import GlobalSettingsForm, { 14 | type GlobalSettings, 15 | } from './global-settings-form'; 16 | import styles from './index.module.scss'; 17 | import MessageSettingsForm from './message-settings-form'; 18 | import MessageSettingsTypeSelect from '../message-settings-type-select'; 19 | 20 | const DEFAULT_MESSAGE_SETTING_KEY = 'guest' as const; 21 | type TabKey = 'global' | 'message'; 22 | 23 | const MessageSettingsInputForm: Component = () => { 24 | const [firstTabRef, setFirstTabRef] = createSignal(); 25 | const [secondTabRef, setSecondTabRef] = createSignal(); 26 | 27 | const firstRect = useRect(firstTabRef); 28 | const secondRect = useRect(secondTabRef); 29 | 30 | const [value, setValue] = createSignal('global'); 31 | const indicatorWidth = () => { 32 | const selectedValue = value(); 33 | 34 | switch (selectedValue) { 35 | case 'global': 36 | return firstRect().width; 37 | case 'message': 38 | return secondRect().width; 39 | default: 40 | return assertNever(selectedValue); 41 | } 42 | }; 43 | 44 | const store = useStore(); 45 | 46 | const [selectedMessageType, setSelectedMessageType] = 47 | createSignal(DEFAULT_MESSAGE_SETTING_KEY); 48 | 49 | const handleSubmitGlobalSettings = (value: GlobalSettings) => { 50 | store.settingsStore.setSettings(value); 51 | }; 52 | 53 | function handleSubmitMessageSettings(messageSettings: MessageSettings) { 54 | store.settingsStore.setSettings( 55 | 'messageSettings', 56 | selectedMessageType(), 57 | messageSettings, 58 | ); 59 | } 60 | 61 | return ( 62 | 63 | 64 | 69 | {chrome.i18n.getMessage('globalSettingsTitle')} 70 | 71 | 76 | {chrome.i18n.getMessage('messageSettingsTitle')} 77 | 78 | 82 | 83 | 84 | 99 | 100 | 101 | 105 | 106 | {(item) => ( 107 | 108 | 118 | 119 | )} 120 | 121 | 122 | 123 | ); 124 | }; 125 | 126 | export default MessageSettingsInputForm; 127 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-input-form/message-settings-form.tsx: -------------------------------------------------------------------------------- 1 | import { createForm } from '@felte/solid'; 2 | import { Button } from '@kobalte/core'; 3 | import { createEffect, type Component } from 'solid-js'; 4 | 5 | import { type MessageSettings } from '@/models/settings'; 6 | 7 | import styles from './index.module.scss'; 8 | 9 | type Props = Readonly<{ 10 | messageSettings: MessageSettings; 11 | onSubmit: (messageSettings: MessageSettings) => void; 12 | isBackgroundColorEditable: boolean; 13 | }>; 14 | 15 | const MessageSettingsForm: Component = (props) => { 16 | const { form, reset, setInitialValues, setData } = createForm({ 17 | initialValues: props.messageSettings, 18 | onSubmit: props.onSubmit, 19 | }); 20 | 21 | createEffect(() => { 22 | setInitialValues(props.messageSettings); 23 | setData(props.messageSettings); 24 | }); 25 | 26 | return ( 27 |
28 |

29 | {chrome.i18n.getMessage('colorInputHelperText')} 30 |

31 |
32 | 42 | 57 |
58 |
59 | 71 | 88 |
89 |
90 | 104 | 119 |
120 |
121 | 122 | {chrome.i18n.getMessage('applyButtonText')} 123 | 124 | 129 | {chrome.i18n.getMessage('resetButtonText')} 130 | 131 |
132 |
133 | ); 134 | }; 135 | 136 | export default MessageSettingsForm; 137 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-popup/index.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../variable'; 2 | 3 | .container { 4 | right: 12px; 5 | bottom: 49px; 6 | z-index: 71; 7 | width: 340px; 8 | height: 380px; 9 | 10 | :global(.#{$youtube-big-mode-class}) & { 11 | right: 24px; 12 | bottom: 70px; 13 | } 14 | } 15 | 16 | .container-hidden { 17 | display: none; 18 | } 19 | 20 | .nest-container { 21 | width: 100%; 22 | height: 100%; 23 | } 24 | 25 | .content { 26 | width: 100%; 27 | height: 100%; 28 | padding: 16px !important; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | 3 | import MessageSettingsInputForm from '@/components/popup/message-settings-input-form'; 4 | import { youtube } from '@/utils'; 5 | 6 | import styles from './index.module.scss'; 7 | 8 | type Props = Readonly<{ 9 | isHidden: boolean; 10 | playerControlContainer: HTMLSpanElement; 11 | }>; 12 | 13 | const MessageSettingsPopup: Component = (props) => { 14 | function stopPropagation(event: KeyboardEvent): void { 15 | event.stopPropagation(); 16 | } 17 | 18 | return ( 19 |
27 |
33 |
39 | 40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default MessageSettingsPopup; 47 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-type-select/index.module.scss: -------------------------------------------------------------------------------- 1 | .form-label { 2 | display: inline-flex; 3 | flex-direction: column; 4 | margin-bottom: 8px; 5 | font-size: 14px; 6 | 7 | > div { 8 | margin-top: 4px; 9 | } 10 | } 11 | 12 | .trigger { 13 | display: inline-flex; 14 | align-items: center; 15 | justify-content: space-between; 16 | width: 150px; 17 | padding: 4px 8px; 18 | font-size: 14px; 19 | line-height: 1; 20 | color: var(--yt-spec-text-primary); 21 | cursor: pointer; 22 | background-color: var(--yt-spec-raised-background); 23 | border: var(--yt-spec-raised-background); 24 | border-radius: 18px; 25 | outline: none; 26 | transition: 27 | border-color 250ms, 28 | color 250ms; 29 | 30 | &:hover { 31 | background-color: var(--yt-spec-menu-background); 32 | border-color: var(--yt-spec-menu-background); 33 | } 34 | } 35 | 36 | .value { 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | white-space: nowrap; 40 | } 41 | 42 | @keyframes content-show { 43 | from { 44 | opacity: 0; 45 | transform: translateY(-8px); 46 | } 47 | 48 | to { 49 | opacity: 1; 50 | transform: translateY(0); 51 | } 52 | } 53 | 54 | @keyframes content-hide { 55 | from { 56 | opacity: 1; 57 | transform: translateY(0); 58 | } 59 | 60 | to { 61 | opacity: 0; 62 | transform: translateY(-8px); 63 | } 64 | } 65 | 66 | .content { 67 | background-color: var(--yt-spec-raised-background); 68 | border-radius: 18px; 69 | box-shadow: 70 | 0 4px 6px -1px rgb(0 0 0 / 10%), 71 | 0 2px 4px -2px rgb(0 0 0 / 10%); 72 | transform-origin: var(--kb-select-content-transform-origin); 73 | animation: content-hide 250ms ease-in forwards; 74 | } 75 | 76 | .content[data-expanded] { 77 | animation: content-show 250ms ease-out; 78 | } 79 | 80 | .listbox { 81 | overflow-y: auto; 82 | } 83 | 84 | .item { 85 | position: relative; 86 | display: flex; 87 | align-items: center; 88 | justify-content: space-between; 89 | padding: 8px; 90 | font-size: 14px; 91 | line-height: 1; 92 | color: var(--yt-spec-text-primary); 93 | cursor: pointer; 94 | user-select: none; 95 | border-radius: 18px; 96 | outline: none; 97 | 98 | &:hover { 99 | background-color: var(--yt-spec-menu-background); 100 | } 101 | } 102 | 103 | .item[data-highlighted] { 104 | background-color: var(--yt-spec-menu-background); 105 | outline: none; 106 | } 107 | -------------------------------------------------------------------------------- /src/components/popup/message-settings-type-select/index.tsx: -------------------------------------------------------------------------------- 1 | import { faAngleDown } from '@fortawesome/free-solid-svg-icons'; 2 | import { Select } from '@kobalte/core'; 3 | import { type Component } from 'solid-js'; 4 | 5 | import FontAwesomeIcon from '@/components/font-awesome'; 6 | import { type MessageSettingsKey } from '@/models/settings'; 7 | import { assertNever } from '@/utils'; 8 | import { createError } from '@/utils/logger'; 9 | 10 | import styles from './index.module.scss'; 11 | 12 | function getStringByMessageKey(key: MessageSettingsKey): string { 13 | switch (key) { 14 | case 'guest': 15 | return chrome.i18n.getMessage('guestMessageType'); 16 | case 'member': 17 | return chrome.i18n.getMessage('memberMessageType'); 18 | case 'verified': 19 | return chrome.i18n.getMessage('verifiedMessageType'); 20 | case 'moderator': 21 | return chrome.i18n.getMessage('moderatorMessageType'); 22 | case 'owner': 23 | return chrome.i18n.getMessage('ownerMessageType'); 24 | case 'you': 25 | throw createError('"you" type message Not supported'); 26 | case 'membership': 27 | return chrome.i18n.getMessage('membershipMessageType'); 28 | case 'super-chat': 29 | return chrome.i18n.getMessage('superChatMessageType'); 30 | case 'pinned': 31 | return chrome.i18n.getMessage('pinnedMessageType'); 32 | default: 33 | return assertNever(key); 34 | } 35 | } 36 | 37 | const supportedTypes: MessageSettingsKey[] = [ 38 | 'guest', 39 | 'member', 40 | 'verified', 41 | 'moderator', 42 | 'owner', 43 | 'membership', 44 | 'super-chat', 45 | 'pinned', 46 | ]; 47 | type Option = Readonly<{ 48 | value: MessageSettingsKey; 49 | label: string; 50 | }>; 51 | 52 | type Props = Readonly<{ 53 | defaultValue: MessageSettingsKey; 54 | onChange: (value: MessageSettingsKey) => void; 55 | }>; 56 | 57 | const MessageSettingsTypeSelect: Component = (props) => { 58 | const messageSettingsOptions = supportedTypes.map((type) => ({ 59 | value: type, 60 | label: getStringByMessageKey(type), 61 | })); 62 | 63 | const defaultValue = messageSettingsOptions.find( 64 | (option) => option.value === props.defaultValue, 65 | )!; 66 | 67 | return ( 68 | 101 | ); 102 | }; 103 | 104 | export default MessageSettingsTypeSelect; 105 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHAT_START_EVENT = 'chat-start'; 2 | export const LIVE_CHAT_API_INTERCEPT_EVENT = 'live-chat-api-intercept'; 3 | export const LIVE_CHAT_INIT_DATA = 'live-chat-init-data'; 4 | export const HAS_LIVE_CHAT = 'has-live-chat'; 5 | 6 | export const OVERLAY_CONTAINER_ID = 'live-chat-overlay-app-container'; 7 | export const PLAYER_CONTROL_CONTAINER_ID = 'live-chat-player-control-container'; 8 | -------------------------------------------------------------------------------- /src/content-script.ts: -------------------------------------------------------------------------------- 1 | import { waitForValue, youtube } from '@/utils'; 2 | 3 | import { injectLiveChatOverlay } from './app/live-chat-overlay'; 4 | import { CHAT_START_EVENT, LIVE_CHAT_API_INTERCEPT_EVENT } from './constants'; 5 | import { type InitData } from './definitions/youtube'; 6 | import { type ChatEventDetail } from './services/fetch-interceptor'; 7 | import { createRootStore } from './stores'; 8 | import { createError, logDebug, logInfo } from './utils/logger'; 9 | 10 | function getChatFrame() { 11 | const chatFrame = document.getElementById( 12 | 'chatframe', 13 | ) as HTMLIFrameElement | null; 14 | if (!chatFrame?.contentDocument) { 15 | throw createError('Missing chat frame'); 16 | } 17 | 18 | return { 19 | document: chatFrame.contentDocument, 20 | iframe: chatFrame, 21 | }; 22 | } 23 | 24 | async function getInitData(doc: Document): Promise { 25 | function getData() { 26 | const initialDataTag = [...doc.querySelectorAll('script')].find((tag) => 27 | tag.innerHTML.includes('window["ytInitialData"] ='), 28 | ); 29 | 30 | if (!initialDataTag) { 31 | return null; 32 | } 33 | 34 | const innerHtml = initialDataTag.innerHTML.trim(); 35 | const startIndex = innerHtml.indexOf('{"responseContext"'); 36 | return JSON.parse(innerHtml.slice(startIndex, -1)) as InitData; 37 | } 38 | 39 | return waitForValue(getData, () => createError('init data not found')); 40 | } 41 | 42 | async function init() { 43 | logInfo('initiating in main player page'); 44 | const chatFrame = getChatFrame(); 45 | 46 | function attachChatEvent(callback: (e: ChatEventDetail) => void) { 47 | function listener(e: Event) { 48 | callback((e as CustomEvent).detail); 49 | } 50 | 51 | chatFrame.iframe.contentWindow?.addEventListener( 52 | LIVE_CHAT_API_INTERCEPT_EVENT, 53 | listener, 54 | ); 55 | 56 | return () => { 57 | chatFrame.iframe.contentWindow?.removeEventListener( 58 | LIVE_CHAT_API_INTERCEPT_EVENT, 59 | listener, 60 | ); 61 | }; 62 | } 63 | 64 | logDebug('Getting init data'); 65 | const initData = await getInitData(chatFrame.document); 66 | logDebug('Finish getting init data', initData); 67 | 68 | logDebug('Waiting for player ready'); 69 | await youtube.waitForPlayerReady(); 70 | 71 | const videoPlayerEle = youtube.getVideoPlayerEle(); 72 | if (!videoPlayerEle) { 73 | throw createError('Video Player Ele not found'); 74 | } 75 | 76 | const videoEle = youtube.getVideoEle(); 77 | if (!videoEle) { 78 | throw createError('Video Ele not found'); 79 | } 80 | 81 | const store = createRootStore(videoEle, videoPlayerEle); 82 | logDebug('Initiating the store'); 83 | await store.init(initData, attachChatEvent); 84 | 85 | logDebug('Injecting the live chat overlay'); 86 | const cleanupLiveChat = await injectLiveChatOverlay(store); 87 | 88 | const handleMessage: Parameters< 89 | typeof chrome.runtime.onMessage.addListener 90 | >[0] = (request) => { 91 | if (request.message === 'urlChanged') { 92 | logInfo('cleaning up in main player page'); 93 | store.cleanup(); 94 | cleanupLiveChat(); 95 | chrome.runtime.onMessage.removeListener(handleMessage); 96 | } 97 | }; 98 | 99 | chrome.runtime.onMessage.addListener(handleMessage); 100 | } 101 | 102 | window.addEventListener(`${chrome.runtime.id}-${CHAT_START_EVENT}`, init); 103 | 104 | logInfo('injected script from', window.location.href); 105 | -------------------------------------------------------------------------------- /src/contexts/index.ts: -------------------------------------------------------------------------------- 1 | export * as rootStore from './root-store'; 2 | -------------------------------------------------------------------------------- /src/contexts/root-store.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type JSXElement, 3 | createContext, 4 | useContext, 5 | type Component, 6 | } from 'solid-js'; 7 | 8 | import type { RootStore } from '@/stores'; 9 | import { createError } from '@/utils/logger'; 10 | 11 | const StoreContext = createContext(); 12 | 13 | export const useStore = (): RootStore => { 14 | const store = useContext(StoreContext); 15 | if (!store) { 16 | throw createError('useStore must be used within a StoreProvider'); 17 | } 18 | 19 | return store; 20 | }; 21 | 22 | type Props = { 23 | store: RootStore; 24 | children: JSXElement; 25 | }; 26 | 27 | export const StoreProvider: Component = (props) => { 28 | return ( 29 | // eslint-disable-next-line solid/reactivity 30 | 31 | {props.children} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/definitions/youtube.d.ts: -------------------------------------------------------------------------------- 1 | type LiveChatReplayContinuationData = { 2 | timeUntilLastMessageMsec: number; 3 | continuation: string; 4 | }; 5 | 6 | type PlayerSeekContinuationData = { 7 | continuation: string; 8 | }; 9 | 10 | type InvalidationContinuationData = { 11 | continuation: string; 12 | timeoutMs: number; 13 | }; 14 | 15 | type TimedContinuationData = { 16 | continuation: string; 17 | timeoutMs: number; 18 | }; 19 | 20 | type Continuation = { 21 | liveChatReplayContinuationData?: LiveChatReplayContinuationData; 22 | playerSeekContinuationData?: PlayerSeekContinuationData; 23 | timedContinuationData?: TimedContinuationData; // Live 24 | invalidationContinuationData?: InvalidationContinuationData; // Member only Live 25 | }; 26 | 27 | type Thumbnail = { 28 | url: string; 29 | width: number; 30 | height: number; 31 | }; 32 | 33 | type AccessibilityData = { 34 | label: string; 35 | }; 36 | 37 | type Accessibility = { 38 | accessibilityData: AccessibilityData; 39 | }; 40 | 41 | type Image = { 42 | thumbnails: Thumbnail[]; 43 | accessibility: Accessibility; 44 | }; 45 | 46 | export type EmojiRun = { 47 | emoji: { 48 | emojiId: string; 49 | shortcuts: string[]; 50 | searchTerms: string[]; 51 | image: Image; 52 | isCustomEmoji: boolean; 53 | }; 54 | }; 55 | 56 | export type TextRun = { 57 | text: string; 58 | }; 59 | 60 | export type MessageRun = EmojiRun | TextRun; 61 | 62 | export type Message = { 63 | runs: MessageRun[]; 64 | }; 65 | 66 | type AuthorName = { 67 | simpleText: string; 68 | }; 69 | 70 | type AuthorPhoto = { 71 | thumbnails: Thumbnail[]; 72 | }; 73 | 74 | type WebCommandMetadata = { 75 | ignoreNavigation: boolean; 76 | }; 77 | 78 | type CommandMetadata = { 79 | webCommandMetadata: WebCommandMetadata; 80 | }; 81 | 82 | type LiveChatItemContextMenuEndpoint = { 83 | params: string; 84 | }; 85 | 86 | type ContextMenuEndpoint = { 87 | commandMetadata: CommandMetadata; 88 | liveChatItemContextMenuEndpoint: LiveChatItemContextMenuEndpoint; 89 | }; 90 | 91 | type CustomThumbnail = { 92 | thumbnails: Array<{ url: string }>; 93 | }; 94 | 95 | export type LiveChatAuthorBadgeRenderer = { 96 | customThumbnail?: CustomThumbnail; 97 | tooltip: string; 98 | accessibility: Accessibility; 99 | icon?: { 100 | iconType: string; 101 | }; 102 | }; 103 | 104 | export type AuthorBadge = { 105 | liveChatAuthorBadgeRenderer: LiveChatAuthorBadgeRenderer; 106 | }; 107 | 108 | type ContextMenuAccessibility = { 109 | accessibilityData: AccessibilityData; 110 | }; 111 | 112 | type TimestampText = { 113 | simpleText: string; 114 | }; 115 | 116 | export type LiveChatTextMessageRenderer = { 117 | message: Message; 118 | authorName?: AuthorName; 119 | authorPhoto: AuthorPhoto; 120 | contextMenuEndpoint: ContextMenuEndpoint; 121 | id: string; 122 | timestampUsec: string; 123 | authorBadges?: AuthorBadge[]; 124 | authorExternalChannelId: string; 125 | contextMenuAccessibility: ContextMenuAccessibility; 126 | timestampText?: TimestampText; 127 | }; 128 | 129 | type PurchaseAmountText = { 130 | simpleText: string; 131 | }; 132 | 133 | export type LiveChatPaidMessageRenderer = { 134 | id: string; 135 | timestampUsec: string; 136 | authorName?: AuthorName; 137 | authorPhoto: AuthorPhoto; 138 | purchaseAmountText: PurchaseAmountText; 139 | message?: Message; 140 | headerBackgroundColor: number; 141 | headerTextColor: number; 142 | bodyBackgroundColor: number; 143 | bodyTextColor: number; 144 | authorExternalChannelId: string; 145 | authorNameTextColor: number; 146 | contextMenuEndpoint: ContextMenuEndpoint; 147 | timestampColor: number; 148 | contextMenuAccessibility: ContextMenuAccessibility; 149 | timestampText: TimestampText; 150 | }; 151 | 152 | export type LiveChatMembershipItemRenderer = { 153 | id: string; 154 | timestampUsec: string; 155 | authorExternalChannelId: string; 156 | headerSubtext: Message; 157 | authorName?: AuthorName; 158 | authorPhoto: AuthorPhoto; 159 | authorBadges: AuthorBadge[]; 160 | message?: Message; 161 | contextMenuEndpoint: ContextMenuEndpoint; 162 | contextMenuAccessibility: ContextMenuAccessibility; 163 | }; 164 | 165 | export type Sticker = { 166 | thumbnails: Thumbnail[]; 167 | accessibility: Accessibility; 168 | }; 169 | 170 | export type LiveChatPaidStickerRenderer = { 171 | id: string; 172 | contextMenuEndpoint: ContextMenuEndpoint; 173 | contextMenuAccessibility: ContextMenuAccessibility; 174 | timestampUsec: string; 175 | authorPhoto: AuthorPhoto; 176 | authorName?: AuthorName; 177 | authorExternalChannelId: string; 178 | timestampText: TimestampText; 179 | sticker: Sticker; 180 | moneyChipBackgroundColor: number; 181 | moneyChipTextColor: number; 182 | purchaseAmountText: PurchaseAmountText; 183 | stickerDisplayWidth: number; 184 | stickerDisplayHeight: number; 185 | backgroundColor: number; 186 | authorNameTextColor: number; 187 | }; 188 | 189 | export type Item = { 190 | liveChatTextMessageRenderer?: LiveChatTextMessageRenderer; 191 | liveChatPaidMessageRenderer?: LiveChatPaidMessageRenderer; 192 | liveChatMembershipItemRenderer?: LiveChatMembershipItemRenderer; 193 | liveChatPaidStickerRenderer?: LiveChatPaidStickerRenderer; 194 | liveChatViewerEngagementMessageRenderer?: unknown; 195 | liveChatPlaceholderItemRenderer?: unknown; 196 | }; 197 | 198 | export type AddChatItemAction = { 199 | item?: Item; 200 | clientId: string; 201 | }; 202 | 203 | export type AddBannerToLiveChatCommand = { 204 | bannerRenderer: { 205 | liveChatBannerRenderer: { 206 | contents: { 207 | liveChatTextMessageRenderer?: LiveChatTextMessageRenderer; 208 | liveChatBannerPollRenderer?: unknown; 209 | }; 210 | }; 211 | }; 212 | }; 213 | 214 | type Amount = { 215 | simpleText: string; 216 | }; 217 | 218 | type Renderer = { 219 | liveChatPaidMessageRenderer: LiveChatPaidMessageRenderer; 220 | }; 221 | 222 | type ShowLiveChatItemEndpoint = { 223 | renderer: Renderer; 224 | }; 225 | 226 | type ShowItemEndpoint = { 227 | commandMetadata: CommandMetadata; 228 | showLiveChatItemEndpoint: ShowLiveChatItemEndpoint; 229 | }; 230 | 231 | export type LiveChatTickerPaidMessageItemRenderer = { 232 | id: string; 233 | amount: Amount; 234 | amountTextColor: number; 235 | startBackgroundColor: number; 236 | endBackgroundColor: number; 237 | authorPhoto: AuthorPhoto; 238 | durationSec: number; 239 | showItemEndpoint: ShowItemEndpoint; 240 | authorExternalChannelId: string; 241 | fullDurationSec: number; 242 | }; 243 | 244 | type Action = { 245 | addChatItemAction?: AddChatItemAction; 246 | addBannerToLiveChatCommand?: AddBannerToLiveChatCommand; 247 | }; 248 | 249 | export type ReplayAction = { 250 | replayChatItemAction?: { 251 | actions?: Action[]; 252 | videoOffsetTimeMsec: string; 253 | }; 254 | }; 255 | 256 | type LiveLiveChatContinuation = { 257 | continuations: Continuation[]; 258 | actions?: Action[]; 259 | }; 260 | 261 | type ReplayLiveChatContinuation = { 262 | continuations: Continuation[]; 263 | actions?: ReplayAction[]; 264 | }; 265 | 266 | type InitDataAttributes = { 267 | viewerName: string; 268 | }; 269 | 270 | type InitLiveChatContinuation = LiveLiveChatContinuation & InitDataAttributes; 271 | type InitReplayLiveChatContinuation = ReplayLiveChatContinuation & 272 | InitDataAttributes; 273 | 274 | type InitLiveContinuationContents = { 275 | liveChatContinuation: InitLiveChatContinuation; 276 | }; 277 | 278 | type InitReplayContinuationContents = { 279 | liveChatContinuation: InitReplayLiveChatContinuation; 280 | isReplay: true; 281 | }; 282 | 283 | export type LiveContinuationContents = { 284 | liveChatContinuation: LiveLiveChatContinuation; 285 | }; 286 | 287 | export type ReplayContinuationContents = { 288 | liveChatContinuation: ReplayLiveChatContinuation; 289 | }; 290 | 291 | export type LiveResponse = { 292 | continuationContents?: LiveContinuationContents; 293 | }; 294 | 295 | export type ReplayResponse = { 296 | continuationContents?: ReplayContinuationContents; 297 | }; 298 | 299 | export type YoutubeChatResponse = LiveResponse | ReplayResponse; 300 | 301 | export type LiveInitData = { 302 | continuationContents: InitLiveContinuationContents; 303 | }; 304 | 305 | export type ReplayInitData = { 306 | continuationContents: InitReplayContinuationContents; 307 | }; 308 | 309 | export type InitData = LiveInitData | ReplayInitData; 310 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-rect'; 2 | -------------------------------------------------------------------------------- /src/hooks/use-rect.ts: -------------------------------------------------------------------------------- 1 | import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; 2 | 3 | export type RectResult = { 4 | height: number; 5 | width: number; 6 | }; 7 | 8 | function getRect(element?: T): RectResult { 9 | let rect: RectResult = { 10 | height: 0, 11 | width: 0, 12 | }; 13 | if (element) rect = element.getBoundingClientRect(); 14 | return rect; 15 | } 16 | 17 | export function useRect( 18 | ele: Accessor, 19 | ): Accessor { 20 | const [rect, setRect] = createSignal(getRect(ele())); 21 | 22 | function handleResize() { 23 | setTimeout(() => { 24 | setRect(getRect(ele())); 25 | }); 26 | } 27 | 28 | createEffect(() => { 29 | const element = ele(); 30 | if (!element) { 31 | return; 32 | } 33 | 34 | const resizeObserver = new ResizeObserver(() => { 35 | handleResize(); 36 | }); 37 | resizeObserver.observe(element); 38 | 39 | handleResize(); 40 | 41 | onCleanup(() => { 42 | resizeObserver?.disconnect(); 43 | }); 44 | }); 45 | 46 | return rect; 47 | } 48 | -------------------------------------------------------------------------------- /src/live-chat-content-script.ts: -------------------------------------------------------------------------------- 1 | import { CHAT_START_EVENT } from './constants'; 2 | import { injectScript } from './utils'; 3 | import { logInfo } from './utils/logger'; 4 | 5 | function start() { 6 | logInfo('starting from live chat iframe'); 7 | injectScript(chrome.runtime.getURL('src/live-chat-fetch-interceptor.js')); 8 | const event = new CustomEvent(`${chrome.runtime.id}-${CHAT_START_EVENT}`); 9 | window.parent.window.dispatchEvent(event); 10 | } 11 | 12 | document.addEventListener('DOMContentLoaded', start); 13 | -------------------------------------------------------------------------------- /src/live-chat-fetch-interceptor.ts: -------------------------------------------------------------------------------- 1 | import { initInterceptor } from '@/services/fetch-interceptor'; 2 | 3 | import { LIVE_CHAT_API_INTERCEPT_EVENT } from './constants'; 4 | import { youtube } from './utils'; 5 | import { logInfo } from './utils/logger'; 6 | 7 | function init(): void { 8 | logInfo('injecting chat interceptor'); 9 | const revert = initInterceptor( 10 | LIVE_CHAT_API_INTERCEPT_EVENT, 11 | youtube.GET_LIVE_CHAT_URL, 12 | ); 13 | 14 | window.addEventListener('unload', revert); 15 | } 16 | 17 | init(); 18 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_extensionName__", 3 | "version": "1.0.0", 4 | "description": "__MSG_extensionDescription__", 5 | "homepage_url": "https://github.com/thwonghin/live-chat-overlay", 6 | "manifest_version": 3, 7 | "minimum_chrome_version": "88", 8 | "icons": { 9 | "128": "icon.png" 10 | }, 11 | "default_locale": "en", 12 | "permissions": ["storage", "webNavigation"], 13 | "content_security_policy": { 14 | "extension_pages": "script-src 'self'; object-src 'self'" 15 | }, 16 | "background": { 17 | "service_worker": "src/background.ts" 18 | }, 19 | "content_scripts": [ 20 | { 21 | "run_at": "document_start", 22 | "matches": ["https://www.youtube.com/*"], 23 | "exclude_globs": ["https://www.youtube.com/live_chat*"], 24 | "all_frames": true, 25 | "js": ["src/content-script.ts"] 26 | }, 27 | { 28 | "run_at": "document_start", 29 | "matches": ["https://www.youtube.com/live_chat*"], 30 | "all_frames": true, 31 | "js": ["src/live-chat-content-script.ts"] 32 | } 33 | ], 34 | "web_accessible_resources": [ 35 | { 36 | "resources": ["src/live-chat-fetch-interceptor.ts"], 37 | "matches": ["https://www.youtube.com/*"] 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/models/chat-item/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MapActionsParameters, 3 | mapAddChatItemActions, 4 | isSuperChatItem, 5 | } from './mapper'; 6 | import type { ChatItem } from './types'; 7 | import type { SettingsModel } from '../settings'; 8 | 9 | export type ChatItemModel = { 10 | element: HTMLElement | undefined; 11 | width: number | undefined; 12 | addTimestamp: number | undefined; 13 | lineNumber: number | undefined; 14 | getNumberOfLines: (settings: SettingsModel) => number; 15 | readonly value: ChatItem; 16 | }; 17 | 18 | export const createChatItemModel = (value: ChatItem): ChatItemModel => { 19 | const chatItemModel: ChatItemModel = { 20 | value, 21 | width: undefined, 22 | element: undefined, 23 | addTimestamp: undefined, 24 | lineNumber: undefined, 25 | getNumberOfLines(settings: SettingsModel): number { 26 | const messageSettings = settings.getMessageSettings(this.value); 27 | return isSuperChatItem(this.value) && 28 | this.value.messageParts.length === 0 29 | ? 1 30 | : messageSettings.numberOfLines; 31 | }, 32 | }; 33 | 34 | return chatItemModel; 35 | }; 36 | 37 | export const createChatItemModelFromAction = ( 38 | params: MapActionsParameters, 39 | ): ChatItemModel | undefined => { 40 | const chatItem = mapAddChatItemActions(params); 41 | 42 | if (!chatItem) { 43 | return undefined; 44 | } 45 | 46 | return createChatItemModel(chatItem); 47 | }; 48 | -------------------------------------------------------------------------------- /src/models/chat-item/mapper/helpers.ts: -------------------------------------------------------------------------------- 1 | import type * as liveChatResponse from '@/definitions/youtube'; 2 | import { assertNever, colorFromDecimal } from '@/utils'; 3 | 4 | import type { 5 | NormalChatItem, 6 | TextPart, 7 | EmojiPart, 8 | MessagePart, 9 | MembershipItem, 10 | SuperChatItem, 11 | SuperStickerItem, 12 | PinnedChatItem, 13 | } from '../types'; 14 | 15 | function getAuthorTypeFromBadges( 16 | authorBadges?: liveChatResponse.AuthorBadge[], 17 | ): NormalChatItem['authorType'] { 18 | if (!authorBadges) { 19 | return 'guest'; 20 | } 21 | 22 | const resolvedIconType = authorBadges 23 | .map((v) => v.liveChatAuthorBadgeRenderer.icon?.iconType) 24 | .find((iconType): iconType is string => Boolean(iconType)); 25 | 26 | if (!resolvedIconType) { 27 | return 'member'; 28 | } 29 | 30 | return resolvedIconType.toLowerCase() as NormalChatItem['authorType']; 31 | } 32 | 33 | function isTextMessageRun( 34 | run: liveChatResponse.MessageRun, 35 | ): run is liveChatResponse.TextRun { 36 | return 'text' in run; 37 | } 38 | 39 | function isEmojiMessageRun( 40 | run: liveChatResponse.MessageRun, 41 | ): run is liveChatResponse.EmojiRun { 42 | return 'emoji' in run; 43 | } 44 | 45 | function mapTextMessagePart(textRun: liveChatResponse.TextRun): TextPart { 46 | return { 47 | text: textRun.text, 48 | }; 49 | } 50 | 51 | function mapEmojiMessagePart(emojiRun: liveChatResponse.EmojiRun): EmojiPart { 52 | return { 53 | id: emojiRun.emoji.emojiId, 54 | thumbnails: emojiRun.emoji.image.thumbnails.map((v) => ({ 55 | url: v.url, 56 | height: v.height, 57 | width: v.width, 58 | })), 59 | shortcuts: emojiRun.emoji.shortcuts, 60 | }; 61 | } 62 | 63 | function mapMessagePart(messageRun: liveChatResponse.MessageRun): MessagePart { 64 | if (isTextMessageRun(messageRun)) { 65 | return mapTextMessagePart(messageRun); 66 | } 67 | 68 | if (isEmojiMessageRun(messageRun)) { 69 | return mapEmojiMessagePart(messageRun); 70 | } 71 | 72 | return assertNever(messageRun); 73 | } 74 | 75 | function mapAuthorBadges( 76 | rendererAuthorBadges?: liveChatResponse.AuthorBadge[], 77 | ): string[] { 78 | if (!rendererAuthorBadges) { 79 | return []; 80 | } 81 | 82 | return rendererAuthorBadges 83 | .filter((v) => Boolean(v.liveChatAuthorBadgeRenderer.customThumbnail)) 84 | .flatMap( 85 | (v) => 86 | v.liveChatAuthorBadgeRenderer.customThumbnail?.thumbnails.flatMap( 87 | (x) => x.url, 88 | ) ?? [], 89 | ); 90 | } 91 | 92 | export function calculateVideoTimestampMsFromLiveTimestamp(parameters: { 93 | currentTimestampMs: number; 94 | liveTimestampMs: number; 95 | playerTimestampMs: number; 96 | }): number { 97 | const startTimestamp = 98 | parameters.currentTimestampMs - parameters.playerTimestampMs; 99 | return parameters.liveTimestampMs - startTimestamp; 100 | } 101 | 102 | type MapLiveChatMembershipItemRendererParameters = { 103 | renderer: liveChatResponse.LiveChatMembershipItemRenderer; 104 | videoTimestampMs?: number; 105 | }; 106 | 107 | export function mapLiveChatMembershipItemRenderer({ 108 | renderer, 109 | videoTimestampMs, 110 | }: MapLiveChatMembershipItemRendererParameters): MembershipItem { 111 | const liveTimestampMs = Number(renderer.timestampUsec) / 1000; 112 | return { 113 | id: renderer.id, 114 | messageParts: ( 115 | renderer.message?.runs ?? 116 | renderer.headerSubtext?.runs ?? 117 | [] 118 | ).map(mapMessagePart), 119 | avatars: renderer.authorPhoto.thumbnails, 120 | authorName: renderer.authorName?.simpleText ?? '', 121 | chatType: 'membership', 122 | authorBadges: mapAuthorBadges(renderer.authorBadges), 123 | videoTimestampMs, 124 | liveTimestampMs, 125 | }; 126 | } 127 | 128 | type MapLiveChatPaidMessageItemRendererParameters = { 129 | renderer: liveChatResponse.LiveChatPaidMessageRenderer; 130 | videoTimestampMs?: number; 131 | }; 132 | 133 | export function mapLiveChatPaidMessageItemRenderer({ 134 | renderer, 135 | videoTimestampMs, 136 | }: MapLiveChatPaidMessageItemRendererParameters): SuperChatItem { 137 | const liveTimestampMs = Number(renderer.timestampUsec) / 1000; 138 | return { 139 | id: renderer.id, 140 | messageParts: (renderer.message?.runs ?? []).map(mapMessagePart), 141 | avatars: renderer.authorPhoto.thumbnails, 142 | videoTimestampMs, 143 | authorName: renderer.authorName?.simpleText ?? '', 144 | chatType: 'super-chat', 145 | donationAmount: renderer.purchaseAmountText.simpleText, 146 | color: colorFromDecimal(renderer.bodyBackgroundColor), 147 | liveTimestampMs, 148 | }; 149 | } 150 | 151 | type MapLiveChatPaidStickerRendererParameters = { 152 | renderer: liveChatResponse.LiveChatPaidStickerRenderer; 153 | videoTimestampMs?: number; 154 | }; 155 | 156 | export function mapLiveChatPaidStickerRenderer({ 157 | renderer, 158 | videoTimestampMs, 159 | }: MapLiveChatPaidStickerRendererParameters): SuperStickerItem { 160 | const liveTimestampMs = Number(renderer.timestampUsec) / 1000; 161 | return { 162 | id: renderer.id, 163 | avatars: renderer.authorPhoto.thumbnails, 164 | videoTimestampMs, 165 | authorName: renderer.authorName?.simpleText ?? '', 166 | chatType: 'super-sticker', 167 | donationAmount: renderer.purchaseAmountText.simpleText, 168 | color: colorFromDecimal(renderer.backgroundColor), 169 | stickers: renderer.sticker.thumbnails, 170 | liveTimestampMs, 171 | }; 172 | } 173 | 174 | type MapLiveChatTextMessageRendererParameters = { 175 | renderer: liveChatResponse.LiveChatTextMessageRenderer; 176 | videoTimestampMs?: number; 177 | }; 178 | 179 | export function mapLiveChatTextMessageRenderer({ 180 | renderer, 181 | videoTimestampMs, 182 | }: MapLiveChatTextMessageRendererParameters): NormalChatItem { 183 | const liveTimestampMs = Number(renderer.timestampUsec) / 1000; 184 | return { 185 | id: renderer.id, 186 | messageParts: renderer.message.runs.map(mapMessagePart), 187 | avatars: renderer.authorPhoto.thumbnails, 188 | videoTimestampMs, 189 | authorName: renderer.authorName?.simpleText ?? '', 190 | authorType: getAuthorTypeFromBadges(renderer.authorBadges), 191 | chatType: 'normal', 192 | authorBadges: mapAuthorBadges(renderer.authorBadges), 193 | liveTimestampMs, 194 | }; 195 | } 196 | 197 | export function mapPinnedLiveChatTextMessageRenderer({ 198 | renderer, 199 | videoTimestampMs, 200 | }: MapLiveChatTextMessageRendererParameters): PinnedChatItem { 201 | return { 202 | ...mapLiveChatTextMessageRenderer({ 203 | renderer, 204 | videoTimestampMs, 205 | }), 206 | chatType: 'pinned', 207 | }; 208 | } 209 | -------------------------------------------------------------------------------- /src/models/chat-item/mapper/index.ts: -------------------------------------------------------------------------------- 1 | import type * as liveChatResponse from '@/definitions/youtube'; 2 | 3 | import { 4 | mapLiveChatTextMessageRenderer, 5 | mapLiveChatPaidMessageItemRenderer, 6 | mapLiveChatMembershipItemRenderer, 7 | mapLiveChatPaidStickerRenderer, 8 | mapPinnedLiveChatTextMessageRenderer, 9 | } from './helpers'; 10 | import type { 11 | NormalChatItem, 12 | ChatItem, 13 | SuperStickerItem, 14 | SuperChatItem, 15 | MembershipItem, 16 | PinnedChatItem, 17 | MessagePart, 18 | TextPart, 19 | EmojiPart, 20 | } from '../types'; 21 | 22 | export type MapActionsParameters = { 23 | action: 24 | | liveChatResponse.AddChatItemAction 25 | | liveChatResponse.AddBannerToLiveChatCommand; 26 | videoTimestampMs?: number; 27 | }; 28 | 29 | export function mapAddChatItemActions({ 30 | action, 31 | videoTimestampMs, 32 | }: MapActionsParameters): ChatItem | undefined { 33 | if ('item' in action) { 34 | if (action.item?.liveChatPaidMessageRenderer) { 35 | return mapLiveChatPaidMessageItemRenderer({ 36 | renderer: action.item.liveChatPaidMessageRenderer, 37 | videoTimestampMs, 38 | }); 39 | } 40 | 41 | if (action.item?.liveChatPaidStickerRenderer) { 42 | return mapLiveChatPaidStickerRenderer({ 43 | renderer: action.item.liveChatPaidStickerRenderer, 44 | videoTimestampMs, 45 | }); 46 | } 47 | 48 | if (action.item?.liveChatMembershipItemRenderer) { 49 | return mapLiveChatMembershipItemRenderer({ 50 | renderer: action.item.liveChatMembershipItemRenderer, 51 | videoTimestampMs, 52 | }); 53 | } 54 | 55 | if (action.item?.liveChatTextMessageRenderer) { 56 | return mapLiveChatTextMessageRenderer({ 57 | renderer: action.item.liveChatTextMessageRenderer, 58 | videoTimestampMs, 59 | }); 60 | } 61 | 62 | if (action.item?.liveChatViewerEngagementMessageRenderer) { 63 | return undefined; 64 | } 65 | 66 | if (action.item?.liveChatPlaceholderItemRenderer) { 67 | return undefined; 68 | } 69 | } 70 | 71 | if ( 72 | 'bannerRenderer' in action && 73 | action.bannerRenderer.liveChatBannerRenderer.contents 74 | .liveChatTextMessageRenderer 75 | ) { 76 | return mapPinnedLiveChatTextMessageRenderer({ 77 | renderer: 78 | action.bannerRenderer.liveChatBannerRenderer.contents 79 | .liveChatTextMessageRenderer, 80 | videoTimestampMs, 81 | }); 82 | } 83 | 84 | return undefined; 85 | } 86 | 87 | export function isNormalChatItem( 88 | chatItem: ChatItem, 89 | ): chatItem is NormalChatItem { 90 | return chatItem.chatType === 'normal'; 91 | } 92 | 93 | export function isSuperChatItem(chatItem: ChatItem): chatItem is SuperChatItem { 94 | return chatItem.chatType === 'super-chat'; 95 | } 96 | 97 | export function isSuperStickerItem( 98 | chatItem: ChatItem, 99 | ): chatItem is SuperStickerItem { 100 | return chatItem.chatType === 'super-sticker'; 101 | } 102 | 103 | export function isMembershipItem( 104 | chatItem: ChatItem, 105 | ): chatItem is MembershipItem { 106 | return chatItem.chatType === 'membership'; 107 | } 108 | 109 | export function isPinnedItem(chatItem: ChatItem): chatItem is PinnedChatItem { 110 | return chatItem.chatType === 'pinned'; 111 | } 112 | 113 | export function isTextMessagePart(part: MessagePart): part is TextPart { 114 | return 'text' in part; 115 | } 116 | 117 | export function isEmojiMessagePart(part: MessagePart): part is EmojiPart { 118 | return 'shortcuts' in part; 119 | } 120 | -------------------------------------------------------------------------------- /src/models/chat-item/types.ts: -------------------------------------------------------------------------------- 1 | export type Thumbnail = { 2 | url: string; 3 | width: number; 4 | height: number; 5 | }; 6 | 7 | export type TextPart = { 8 | text: string; 9 | }; 10 | 11 | export type EmojiPart = { 12 | id: string; 13 | thumbnails: Thumbnail[]; 14 | shortcuts: string[]; 15 | }; 16 | 17 | export type MessagePart = TextPart | EmojiPart; 18 | 19 | export type NormalChatItem = { 20 | id: string; 21 | messageParts: MessagePart[]; 22 | avatars: Thumbnail[]; 23 | authorName: string; 24 | authorBadges: string[]; 25 | authorType: 'moderator' | 'member' | 'guest' | 'owner' | 'you' | 'verified'; 26 | chatType: 'normal'; 27 | liveTimestampMs?: number; 28 | videoTimestampMs?: number; 29 | }; 30 | 31 | export type SuperChatItem = Omit< 32 | NormalChatItem, 33 | 'authorType' | 'chatType' | 'authorBadges' 34 | > & { 35 | donationAmount: string; 36 | color: string; 37 | chatType: 'super-chat'; 38 | }; 39 | 40 | export type SuperStickerItem = Omit< 41 | SuperChatItem, 42 | 'chatType' | 'messageParts' 43 | > & { 44 | stickers: Thumbnail[]; 45 | chatType: 'super-sticker'; 46 | }; 47 | 48 | export type MembershipItem = Omit & { 49 | chatType: 'membership'; 50 | }; 51 | 52 | export type PinnedChatItem = Omit & { 53 | chatType: 'pinned'; 54 | }; 55 | 56 | export type ChatItem = 57 | | NormalChatItem 58 | | SuperChatItem 59 | | SuperStickerItem 60 | | MembershipItem 61 | | PinnedChatItem; 62 | -------------------------------------------------------------------------------- /src/models/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isMembershipItem, 3 | isNormalChatItem, 4 | isPinnedItem, 5 | isSuperChatItem, 6 | isSuperStickerItem, 7 | } from '@/models/chat-item/mapper'; 8 | import type { ChatItem } from '@/models/chat-item/types'; 9 | import { assertNever, defaultsDeep } from '@/utils'; 10 | 11 | import { 12 | type Settings, 13 | type MessageSettings, 14 | AuthorDisplayMethod, 15 | FontScaleMethod, 16 | } from './types'; 17 | 18 | export * from './types'; 19 | 20 | const commonMessageSettings: Readonly = Object.freeze({ 21 | color: 'white', 22 | weight: 700, 23 | opacity: 0.8, 24 | bgColor: 'transparent', 25 | strokeColor: 'black', 26 | strokeWidth: 0.03, 27 | numberOfLines: 1, 28 | authorDisplay: AuthorDisplayMethod.NONE, 29 | isSticky: false, 30 | } as const); 31 | 32 | export const defaultSettings: Readonly = Object.freeze({ 33 | isEnabled: true, 34 | flowTimeInSec: 10, 35 | totalNumberOfLines: 15, 36 | globalOpacity: 0.7, 37 | fontSizeFixed: 20, 38 | fontSizeScaled: 6.67, 39 | fontScaleMethod: FontScaleMethod.SCALED, 40 | messagePosition: { 41 | top: 0, 42 | bottom: 100, 43 | left: 0, 44 | right: 100, 45 | mode: 'ratio', 46 | }, 47 | messageSettings: { 48 | guest: commonMessageSettings, 49 | member: { 50 | ...commonMessageSettings, 51 | color: '#2ba640', 52 | }, 53 | you: commonMessageSettings, 54 | moderator: { 55 | ...commonMessageSettings, 56 | color: '#5e84f1', 57 | authorDisplay: AuthorDisplayMethod.ALL, 58 | }, 59 | owner: { 60 | ...commonMessageSettings, 61 | color: 'white', 62 | bgColor: '#ffd600', 63 | authorDisplay: AuthorDisplayMethod.ALL, 64 | }, 65 | verified: { 66 | ...commonMessageSettings, 67 | color: '#E9E9E9', 68 | bgColor: '#606060', 69 | authorDisplay: AuthorDisplayMethod.ALL, 70 | }, 71 | membership: { 72 | ...commonMessageSettings, 73 | bgColor: '#2ba640', 74 | numberOfLines: 1, 75 | authorDisplay: AuthorDisplayMethod.ALL, 76 | }, 77 | 'super-chat': { 78 | ...commonMessageSettings, 79 | numberOfLines: 2, 80 | authorDisplay: AuthorDisplayMethod.ALL, 81 | bgColor: '', 82 | }, 83 | pinned: { 84 | ...commonMessageSettings, 85 | numberOfLines: 1, 86 | authorDisplay: AuthorDisplayMethod.ALL, 87 | bgColor: '#224072', 88 | isSticky: true, 89 | }, 90 | }, 91 | } as const); 92 | 93 | export type SettingsModel = { 94 | setRawSettings(settings: Settings): SettingsModel; 95 | getMessageSettings(chatItem: ChatItem): MessageSettings; 96 | } & Settings; 97 | 98 | export const createSettingsModel = (): SettingsModel => { 99 | const settingsModel: SettingsModel = { 100 | ...defaultSettings, 101 | setRawSettings(settings: Settings) { 102 | Object.assign(settingsModel, defaultsDeep(settings, { ...this })); 103 | return settingsModel; 104 | }, 105 | getMessageSettings(chatItem: ChatItem): MessageSettings { 106 | const { messageSettings } = settingsModel; 107 | if (isNormalChatItem(chatItem)) { 108 | return messageSettings[chatItem.authorType]; 109 | } 110 | 111 | if (isMembershipItem(chatItem)) { 112 | return messageSettings.membership; 113 | } 114 | 115 | if (isSuperChatItem(chatItem) || isSuperStickerItem(chatItem)) { 116 | return messageSettings['super-chat']; 117 | } 118 | 119 | if (isPinnedItem(chatItem)) { 120 | return messageSettings.pinned; 121 | } 122 | 123 | return assertNever(chatItem); 124 | }, 125 | }; 126 | 127 | return settingsModel; 128 | }; 129 | -------------------------------------------------------------------------------- /src/models/settings/types.ts: -------------------------------------------------------------------------------- 1 | export enum AuthorDisplayMethod { 2 | AVATAR_ONLY = 'avatar-only', 3 | NAME_ONLY = 'name-only', 4 | ALL = 'all', 5 | NONE = 'none', 6 | } 7 | 8 | export enum FontScaleMethod { 9 | FIXED = 'fixed', 10 | SCALED = 'scaled', 11 | } 12 | 13 | export type MessageSettings = { 14 | color: string; 15 | weight: number; 16 | opacity: number; 17 | bgColor: string; 18 | strokeColor: string; 19 | strokeWidth: number; 20 | numberOfLines: number; 21 | authorDisplay: AuthorDisplayMethod; 22 | isSticky: boolean; 23 | }; 24 | 25 | export const messageSettingsKeys = [ 26 | 'moderator', 27 | 'member', 28 | 'guest', 29 | 'owner', 30 | 'you', 31 | 'verified', 32 | 'membership', 33 | 'super-chat', 34 | 'pinned', 35 | ] as const; 36 | 37 | export type MessageSettingsKey = (typeof messageSettingsKeys)[number]; 38 | 39 | export type MessagePosition = { 40 | top: number; 41 | left: number; 42 | bottom: number; 43 | right: number; 44 | mode: 'fixed' | 'ratio'; 45 | }; 46 | 47 | export type Settings = { 48 | isEnabled: boolean; 49 | totalNumberOfLines: number; 50 | flowTimeInSec: number; 51 | globalOpacity: number; 52 | messageSettings: Record; 53 | fontSizeFixed: number; 54 | fontSizeScaled: number; 55 | fontScaleMethod: FontScaleMethod; 56 | messagePosition: MessagePosition; 57 | }; 58 | -------------------------------------------------------------------------------- /src/scss.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.scss' { 2 | const classes: Record; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /src/services/fetch-interceptor/index.ts: -------------------------------------------------------------------------------- 1 | import { logDebug } from '@/utils/logger'; 2 | 3 | export type ChatEventDetail = { 4 | response: unknown; 5 | url: string; 6 | }; 7 | 8 | function getUrlFromFetchParam(url: RequestInfo | URL): string { 9 | if (typeof url === 'string') { 10 | return url; 11 | } 12 | 13 | if (url instanceof URL) { 14 | return url.toString(); 15 | } 16 | 17 | return url.url; 18 | } 19 | 20 | export function initInterceptor( 21 | eventName: string, 22 | urlPrefix: string, 23 | ): () => void { 24 | const originalFetch = window.fetch; 25 | 26 | window.fetch = async (url, ...args) => { 27 | const fetchResult = await originalFetch(url, ...args); 28 | const requestUrl = getUrlFromFetchParam(url); 29 | 30 | if (requestUrl.startsWith(urlPrefix)) { 31 | const clonedResult = fetchResult.clone(); 32 | 33 | setTimeout(async () => { 34 | const data = (await clonedResult.json()) as unknown; 35 | logDebug('Intercepted', requestUrl, data); 36 | const event = new CustomEvent(eventName, { 37 | detail: { 38 | response: data, 39 | url: requestUrl, 40 | }, 41 | }); 42 | window.dispatchEvent(event); 43 | }, 0); 44 | } 45 | 46 | return fetchResult; 47 | }; 48 | 49 | return () => { 50 | window.fetch = originalFetch; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * as fetchInterceptor from './fetch-interceptor'; 2 | -------------------------------------------------------------------------------- /src/solid-js.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-definitions */ 2 | import 'solid-js'; 3 | 4 | declare module 'solid-js' { 5 | namespace JSX { 6 | interface CustomEvents { 7 | // On:Name 8 | click: MouseEvent; 9 | keydown: KeyboardEvent; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/stores/chat-item/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ReplayContinuationContents, 3 | LiveContinuationContents, 4 | ReplayInitData, 5 | InitData, 6 | } from '@/definitions/youtube'; 7 | import { 8 | type ChatItemModel, 9 | createChatItemModelFromAction, 10 | } from '@/models/chat-item'; 11 | import { isNormalChatItem } from '@/models/chat-item/mapper'; 12 | import type { ChatItem } from '@/models/chat-item/types'; 13 | import { isNil, isNotNil } from '@/utils'; 14 | import { createError } from '@/utils/logger'; 15 | 16 | export enum Mode { 17 | LIVE = 'live', 18 | REPLAY = 'replay', 19 | } 20 | 21 | export function mapChatItemsFromReplayResponse( 22 | continuationContents: ReplayContinuationContents, 23 | ): ChatItemModel[] { 24 | return (continuationContents.liveChatContinuation.actions ?? []) 25 | .map((a) => a.replayChatItemAction) 26 | .filter(isNotNil) 27 | .flatMap((a) => { 28 | const actions = (a.actions ?? []) 29 | .map( 30 | (action) => 31 | action.addChatItemAction ?? 32 | action.addBannerToLiveChatCommand, 33 | ) 34 | .filter(isNotNil); 35 | 36 | const videoTimestampMs = Number(a.videoOffsetTimeMsec); 37 | const items = actions 38 | .map((action) => 39 | createChatItemModelFromAction({ 40 | action, 41 | videoTimestampMs, 42 | }), 43 | ) 44 | .filter(isNotNil); 45 | 46 | return items; 47 | }); 48 | } 49 | 50 | export function mapChatItemsFromLiveResponse( 51 | continuationContents: LiveContinuationContents, 52 | ): ChatItemModel[] { 53 | return (continuationContents.liveChatContinuation.actions ?? []) 54 | .map((v) => v.addChatItemAction ?? v.addBannerToLiveChatCommand) 55 | .filter(isNotNil) 56 | .map((action) => 57 | createChatItemModelFromAction({ 58 | action, 59 | }), 60 | ) 61 | .filter(isNotNil); 62 | } 63 | 64 | type TimeInfo = { 65 | playerTimestampMs: number; 66 | currentTimestampMs: number; 67 | }; 68 | 69 | type IsTimeToDispatchParameters = { 70 | timeInfo: TimeInfo; 71 | chatItem: ChatItem; 72 | }; 73 | 74 | export function isTimeToDispatch({ 75 | timeInfo, 76 | chatItem, 77 | }: IsTimeToDispatchParameters): boolean { 78 | if (chatItem.videoTimestampMs !== undefined) { 79 | return timeInfo.playerTimestampMs >= chatItem.videoTimestampMs; 80 | } 81 | 82 | if (chatItem.liveTimestampMs !== undefined) { 83 | return timeInfo.currentTimestampMs >= chatItem.liveTimestampMs; 84 | } 85 | 86 | throw createError(`No time info for chatItem ${chatItem.id}`); 87 | } 88 | 89 | export const MAX_CHAT_DISPLAY_DELAY_IN_SEC = 5; 90 | const REMOVABLE_AUTHOR_TYPES = ['guest', 'member']; 91 | 92 | function getOutdatedFactor(chatItem: ChatItem): number { 93 | if ( 94 | isNormalChatItem(chatItem) && 95 | REMOVABLE_AUTHOR_TYPES.includes(chatItem.authorType) 96 | ) { 97 | return 1; 98 | } 99 | 100 | return 3; 101 | } 102 | 103 | type IsOutdatedChatItemParameters = { 104 | chatItem: ChatItem; 105 | liveChatDelayMs: number; 106 | timeInfo: TimeInfo; 107 | }; 108 | 109 | export function isOutdatedChatItem({ 110 | chatItem, 111 | liveChatDelayMs, 112 | timeInfo, 113 | }: IsOutdatedChatItemParameters): boolean { 114 | const factor = getOutdatedFactor(chatItem); 115 | const delayMs = 116 | (MAX_CHAT_DISPLAY_DELAY_IN_SEC * 1000 + liveChatDelayMs) * factor; 117 | 118 | if (chatItem.videoTimestampMs !== undefined) { 119 | return chatItem.videoTimestampMs < timeInfo.playerTimestampMs - delayMs; 120 | } 121 | 122 | if (chatItem.liveTimestampMs !== undefined) { 123 | return chatItem.liveTimestampMs < timeInfo.currentTimestampMs - delayMs; 124 | } 125 | 126 | throw createError(`No time info for chatItem ${chatItem.id}`); 127 | } 128 | 129 | export function isReplayInitData( 130 | initData: InitData, 131 | ): initData is ReplayInitData { 132 | return 'isReplay' in initData.continuationContents.liveChatContinuation; 133 | } 134 | 135 | type HasSpaceInLineParameters = { 136 | lastMessageInLine: ChatItemModel; 137 | elementWidth: number; 138 | addTimestamp: number; 139 | flowTimeInSec: number; 140 | containerWidth: number; 141 | lineNumber: number; 142 | }; 143 | 144 | function hasSpaceInLine({ 145 | lastMessageInLine, 146 | elementWidth, 147 | addTimestamp, 148 | flowTimeInSec, 149 | containerWidth, 150 | }: HasSpaceInLineParameters): boolean { 151 | if (isNil(lastMessageInLine.addTimestamp)) { 152 | throw createError('Missing timestamp'); 153 | } 154 | 155 | const lastMessageFlowedTime = 156 | (addTimestamp - lastMessageInLine.addTimestamp) / 1000; 157 | const lastMessageWidth = lastMessageInLine.width; 158 | if (isNil(lastMessageWidth)) { 159 | throw createError('Unknown width'); 160 | } 161 | 162 | const lastMessageSpeed = 163 | (containerWidth + lastMessageWidth) / flowTimeInSec; 164 | const lastMessagePos = 165 | lastMessageSpeed * lastMessageFlowedTime - lastMessageWidth; 166 | 167 | const remainingTime = flowTimeInSec - lastMessageFlowedTime; 168 | 169 | const speed = (containerWidth + elementWidth) / flowTimeInSec; 170 | 171 | return speed * remainingTime < containerWidth && lastMessagePos > 0; 172 | } 173 | 174 | type GetLineNumberParameters = { 175 | chatItemsByLineNumber: Map; 176 | elementWidth: number; 177 | maxLineNumber: number; 178 | addTimestamp: number; 179 | flowTimeInSec: number; 180 | containerWidth: number; 181 | displayNumberOfLines: number; 182 | }; 183 | 184 | /** 185 | * Return the line number that is available to be inserted 186 | * given the current states. 187 | * 188 | * Return undefined if there is no place to be inserted 189 | */ 190 | export function getLineNumber({ 191 | chatItemsByLineNumber, 192 | elementWidth, 193 | maxLineNumber, 194 | addTimestamp, 195 | flowTimeInSec, 196 | containerWidth, 197 | displayNumberOfLines, 198 | }: GetLineNumberParameters): number | undefined { 199 | for ( 200 | let lineNumber = 0; 201 | lineNumber <= maxLineNumber - displayNumberOfLines; 202 | lineNumber += 1 203 | ) { 204 | if ( 205 | Array.from({ length: displayNumberOfLines }) 206 | .fill(null) 207 | .map((v, index) => index + lineNumber) 208 | .every((loopLineNumber) => { 209 | const lastMessageInLine = chatItemsByLineNumber 210 | .get(loopLineNumber) 211 | ?.at(-1); 212 | 213 | return ( 214 | !lastMessageInLine || 215 | hasSpaceInLine({ 216 | lastMessageInLine, 217 | elementWidth, 218 | addTimestamp, 219 | flowTimeInSec, 220 | containerWidth, 221 | lineNumber: loopLineNumber, 222 | }) 223 | ); 224 | }) 225 | ) { 226 | return lineNumber; 227 | } 228 | } 229 | 230 | return undefined; 231 | } 232 | -------------------------------------------------------------------------------- /src/stores/debug-info/index.ts: -------------------------------------------------------------------------------- 1 | import { createRoot, onCleanup, onMount } from 'solid-js'; 2 | import { type SetStoreFunction, createStore } from 'solid-js/store'; 3 | 4 | import { attachKeydownEventListener, noop } from '@/utils'; 5 | import { updateMetrics } from '@/utils/metrics'; 6 | 7 | import { type DebugInfo } from './types'; 8 | 9 | const getDefaultDebugInfo = (): Readonly => 10 | Object.freeze({ 11 | processXhrMetrics: { 12 | min: Number.MAX_SAFE_INTEGER, 13 | max: 0, 14 | avg: 0, 15 | count: 0, 16 | latest: 0, 17 | }, 18 | processChatEventMetrics: { 19 | min: Number.MAX_SAFE_INTEGER, 20 | max: 0, 21 | avg: 0, 22 | count: 0, 23 | latest: 0, 24 | }, 25 | enqueuedChatItemCount: 0, 26 | processChatEventQueueLength: 0, 27 | outdatedRemovedChatEventCount: 0, 28 | cleanedChatItemCount: 0, 29 | liveChatDelay: { 30 | min: Number.MAX_SAFE_INTEGER, 31 | max: 0, 32 | avg: 0, 33 | count: 0, 34 | latest: 0, 35 | }, 36 | debugStartTimeMs: 0, 37 | lastEventTimeMs: 0, 38 | }); 39 | 40 | type DebugInfoStoreState = DebugInfo & { 41 | isDebugging: boolean; 42 | }; 43 | 44 | export class DebugInfoStore { 45 | cleanup = noop; 46 | state: DebugInfoStoreState; 47 | 48 | private readonly setState: SetStoreFunction; 49 | 50 | constructor() { 51 | const [state, setState] = createStore< 52 | DebugInfo & { 53 | isDebugging: boolean; 54 | } 55 | >({ ...getDefaultDebugInfo(), isDebugging: false }); 56 | // eslint-disable-next-line solid/reactivity 57 | this.state = state; 58 | this.setState = setState; 59 | } 60 | 61 | init() { 62 | this.attachReactiveContext(); 63 | } 64 | 65 | resetMetrics() { 66 | this.setState(getDefaultDebugInfo()); 67 | } 68 | 69 | addProcessXhrBenchmark(value: number) { 70 | this.setState('processXhrMetrics', (s) => 71 | updateMetrics(s, value * 1000), 72 | ); 73 | this.updateLastEventTime(); 74 | } 75 | 76 | addProcessChatEventBenchmark(value: number) { 77 | this.setState('processChatEventMetrics', (s) => 78 | updateMetrics(s, value * 1000), 79 | ); 80 | this.updateLastEventTime(); 81 | } 82 | 83 | addLiveChatDelay(ms: number) { 84 | this.setState('liveChatDelay', (s) => updateMetrics(s, ms / 1000)); 85 | this.updateLastEventTime(); 86 | } 87 | 88 | addEnqueueChatItemCount(count: number) { 89 | this.setState('enqueuedChatItemCount', (s) => s + count); 90 | this.updateLastEventTime(); 91 | } 92 | 93 | updateProcessChatEventQueueLength(queueLength: number) { 94 | this.setState('processChatEventQueueLength', queueLength); 95 | this.updateLastEventTime(); 96 | } 97 | 98 | addOutdatedRemovedChatEventCount(count: number) { 99 | this.setState('outdatedRemovedChatEventCount', (s) => s + count); 100 | this.updateLastEventTime(); 101 | } 102 | 103 | addCleanedChatItemCount(count: number) { 104 | this.setState('cleanedChatItemCount', (s) => s + count); 105 | this.updateLastEventTime(); 106 | } 107 | 108 | private updateLastEventTime() { 109 | this.setState('lastEventTimeMs', performance.now()); 110 | } 111 | 112 | private readonly toggleIsDebugging = () => { 113 | this.setState((s) => { 114 | const newIsDebugging = !s.isDebugging; 115 | 116 | if (!newIsDebugging) { 117 | return { 118 | ...getDefaultDebugInfo(), 119 | isDebugging: newIsDebugging, 120 | }; 121 | } 122 | 123 | return { 124 | isDebugging: newIsDebugging, 125 | debugStartTimeMs: performance.now(), 126 | }; 127 | }); 128 | }; 129 | 130 | private attachReactiveContext() { 131 | createRoot((dispose) => { 132 | onMount(() => { 133 | const disposeKeyboardListener = attachKeydownEventListener({ 134 | withAlt: true, 135 | withCtrl: true, 136 | key: 'd', 137 | domToAttach: document.body, 138 | callback: this.toggleIsDebugging, 139 | }); 140 | onCleanup(() => { 141 | disposeKeyboardListener(); 142 | }); 143 | }); 144 | 145 | this.cleanup = dispose; 146 | }); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/stores/debug-info/types.ts: -------------------------------------------------------------------------------- 1 | import { type Metrics } from '@/utils/metrics'; 2 | 3 | export type DebugInfo = { 4 | processXhrMetrics: Metrics; 5 | processChatEventMetrics: Metrics; 6 | processChatEventQueueLength: number; 7 | enqueuedChatItemCount: number; 8 | outdatedRemovedChatEventCount: number; 9 | cleanedChatItemCount: number; 10 | liveChatDelay: Metrics; 11 | debugStartTimeMs: number; 12 | lastEventTimeMs: number; 13 | }; 14 | -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { type InitData } from '@/definitions/youtube'; 2 | import { type ChatEventDetail } from '@/services/fetch-interceptor'; 3 | 4 | import { ChatItemStore } from './chat-item'; 5 | import { DebugInfoStore } from './debug-info'; 6 | import { SettingsStore } from './settings'; 7 | import { UiStore } from './ui'; 8 | 9 | export type RootStore = { 10 | settingsStore: SettingsStore; 11 | debugInfoStore: DebugInfoStore; 12 | uiStore: UiStore; 13 | chatItemStore: ChatItemStore; 14 | cleanup: () => void; 15 | init: ( 16 | initData: InitData, 17 | attachChatEvent: (callback: (e: ChatEventDetail) => void) => () => void, 18 | ) => Promise; 19 | }; 20 | 21 | export const createRootStore = ( 22 | videoEle: HTMLVideoElement, 23 | videoPlayerEle: HTMLDivElement, 24 | ): RootStore => { 25 | const settingsStore = new SettingsStore(); 26 | const debugInfoStore = new DebugInfoStore(); 27 | const uiStore = new UiStore(videoPlayerEle, videoEle, settingsStore); 28 | const chatItemStore = new ChatItemStore( 29 | uiStore, 30 | settingsStore, 31 | debugInfoStore, 32 | ); 33 | 34 | function cleanup() { 35 | settingsStore.cleanup(); 36 | debugInfoStore.cleanup(); 37 | uiStore.cleanup(); 38 | chatItemStore.cleanup?.(); 39 | } 40 | 41 | async function init( 42 | initData: InitData, 43 | attachChatEvent: (callback: (e: ChatEventDetail) => void) => () => void, 44 | ) { 45 | await settingsStore.init(); 46 | debugInfoStore.init(); 47 | uiStore.init(); 48 | await chatItemStore.init(attachChatEvent, initData); 49 | } 50 | 51 | return { 52 | settingsStore, 53 | debugInfoStore, 54 | uiStore, 55 | chatItemStore, 56 | init, 57 | cleanup, 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/stores/settings/const.ts: -------------------------------------------------------------------------------- 1 | const prefix = 'live-chat-overlay'; 2 | 3 | export const SETTINGS_STORAGE_KEY = `${prefix}-settings`; 4 | export const MIGRATIONS_STORAGE_KEY = `${prefix}-migrations`; 5 | -------------------------------------------------------------------------------- /src/stores/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, createRoot } from 'solid-js'; 2 | import { type SetStoreFunction, createStore } from 'solid-js/store'; 3 | 4 | import { 5 | type Settings, 6 | type SettingsModel, 7 | createSettingsModel, 8 | } from '@/models/settings'; 9 | import { catchWithFallback, noop, promiseSeries } from '@/utils'; 10 | 11 | import { MIGRATIONS_STORAGE_KEY, SETTINGS_STORAGE_KEY } from './const'; 12 | import { migrations } from './migrations'; 13 | 14 | async function runMigrations() { 15 | const result = await chrome.storage.sync.get(MIGRATIONS_STORAGE_KEY); 16 | const currentMigrations = (result[MIGRATIONS_STORAGE_KEY] ?? 17 | []) as string[]; 18 | 19 | const missingMigrations = migrations.filter( 20 | (migration) => !currentMigrations.includes(migration.name), 21 | ); 22 | 23 | await promiseSeries( 24 | missingMigrations.map((migration) => async () => { 25 | await migration.run(); 26 | 27 | const getResult = await chrome.storage.sync.get( 28 | MIGRATIONS_STORAGE_KEY, 29 | ); 30 | const migrated = (getResult[MIGRATIONS_STORAGE_KEY] ?? 31 | []) as string[]; 32 | 33 | await chrome.storage.sync.set({ 34 | [MIGRATIONS_STORAGE_KEY]: [...migrated, migration.name], 35 | }); 36 | }), 37 | ); 38 | } 39 | 40 | async function loadFromStorage() { 41 | const storedSettings = await catchWithFallback(async () => { 42 | const result = await chrome.storage.sync.get(SETTINGS_STORAGE_KEY); 43 | return result[SETTINGS_STORAGE_KEY] as Settings; 44 | }, undefined); 45 | 46 | const settings = createSettingsModel(); 47 | 48 | if (storedSettings) { 49 | return settings.setRawSettings(storedSettings); 50 | } 51 | 52 | return settings; 53 | } 54 | 55 | export class SettingsStore { 56 | settings: SettingsModel; 57 | setSettings: SetStoreFunction; 58 | cleanup = noop; 59 | 60 | constructor() { 61 | const [state, setState] = createStore( 62 | createSettingsModel(), 63 | ); 64 | // eslint-disable-next-line solid/reactivity 65 | this.settings = state; 66 | this.setSettings = setState; 67 | } 68 | 69 | async init() { 70 | await runMigrations(); 71 | this.setSettings(await loadFromStorage()); 72 | this.attachReactiveContext(); 73 | } 74 | 75 | async updateSettingsInStorage(settings: SettingsModel) { 76 | return chrome.storage.sync.set({ 77 | [SETTINGS_STORAGE_KEY]: settings, 78 | }); 79 | } 80 | 81 | private attachReactiveContext() { 82 | createRoot((dispose) => { 83 | createEffect(() => { 84 | void this.updateSettingsInStorage(this.settings); 85 | }); 86 | 87 | this.cleanup = dispose; 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/stores/settings/migrations.ts: -------------------------------------------------------------------------------- 1 | import { SETTINGS_STORAGE_KEY } from './const'; 2 | import type { Settings } from '../../models/settings/types'; 3 | 4 | export const migrations: Array<{ 5 | name: string; 6 | run: () => Promise; 7 | }> = [ 8 | { 9 | name: 'MigrateLocalStorageToSyncStorage', 10 | async run(): Promise { 11 | const localStorageResult = 12 | await chrome.storage.local.get(SETTINGS_STORAGE_KEY); 13 | const localSettings = localStorageResult?.[SETTINGS_STORAGE_KEY] as 14 | | Settings 15 | | undefined; 16 | if (!localSettings) { 17 | return; 18 | } 19 | 20 | const syncStorageResult = 21 | await chrome.storage.sync.get(SETTINGS_STORAGE_KEY); 22 | const syncSettings = syncStorageResult?.[SETTINGS_STORAGE_KEY] as 23 | | Settings 24 | | undefined; 25 | 26 | // Avoid overriding sync settings 27 | if (!syncSettings) { 28 | await chrome.storage.sync.set({ 29 | [SETTINGS_STORAGE_KEY]: localSettings, 30 | }); 31 | } 32 | 33 | await chrome.storage.local.clear(); 34 | }, 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /src/stores/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { createRoot, onCleanup, onMount } from 'solid-js'; 2 | import { type SetStoreFunction, createStore } from 'solid-js/store'; 3 | 4 | import { FontScaleMethod } from '@/models/settings'; 5 | import { assertNever, noop } from '@/utils'; 6 | 7 | import type { PopupType } from './types'; 8 | import { type SettingsStore } from '../settings'; 9 | 10 | export type PlayerStateModel = { 11 | width: number; 12 | height: number; 13 | isPaused: boolean; 14 | readonly videoPlayerEle: HTMLDivElement; 15 | readonly videoEle: HTMLVideoElement; 16 | get videoCurrentTimeInSecs(): number; 17 | }; 18 | 19 | export type UiStoreState = { 20 | currentPopup?: PopupType; 21 | isDocumentVisible: boolean; 22 | playerState: PlayerStateModel; 23 | }; 24 | 25 | const VIDEO_EVENTS_TO_SUBSCRIBE: Array = [ 26 | 'seeking', 27 | 'pause', 28 | 'play', 29 | 'playing', 30 | 'seeked', 31 | ]; 32 | 33 | export class UiStore { 34 | state: UiStoreState; 35 | cleanup = noop; 36 | 37 | private readonly setState: SetStoreFunction; 38 | 39 | constructor( 40 | public videoPlayerEle: HTMLDivElement, 41 | private readonly videoEle: HTMLVideoElement, 42 | private readonly settingsStore: SettingsStore, 43 | ) { 44 | const [state, setState] = createStore({ 45 | currentPopup: undefined, 46 | isDocumentVisible: !document.hidden, 47 | playerState: { 48 | width: 0, 49 | height: 0, 50 | isPaused: false, 51 | videoPlayerEle, 52 | videoEle, 53 | get videoCurrentTimeInSecs() { 54 | return videoEle.currentTime; 55 | }, 56 | }, 57 | }); 58 | 59 | // eslint-disable-next-line solid/reactivity 60 | this.state = state; 61 | this.setState = setState; 62 | } 63 | 64 | lineHeight() { 65 | const playerHeight = this.state.playerState.height; 66 | const { fontScaleMethod } = this.settingsStore.settings; 67 | switch (fontScaleMethod) { 68 | case FontScaleMethod.FIXED: 69 | return this.settingsStore.settings.fontSizeFixed; 70 | case FontScaleMethod.SCALED: 71 | return ( 72 | (playerHeight * 73 | this.settingsStore.settings.fontSizeScaled) / 74 | 100 75 | ); 76 | default: 77 | return assertNever(fontScaleMethod); 78 | } 79 | } 80 | 81 | messageFlowDimensionPx(): { 82 | top: number; 83 | bottom: number; 84 | left: number; 85 | right: number; 86 | width: number; 87 | height: number; 88 | } { 89 | const { messagePosition } = this.settingsStore.settings; 90 | const { mode, top, left, bottom, right } = messagePosition; 91 | 92 | switch (mode) { 93 | case 'fixed': { 94 | const clampedBottom = Math.min( 95 | bottom, 96 | this.state.playerState.height, 97 | ); 98 | const clampedRight = Math.min( 99 | right, 100 | this.state.playerState.width, 101 | ); 102 | return { 103 | top, 104 | left, 105 | bottom: clampedBottom, 106 | right: clampedRight, 107 | height: clampedBottom - top, 108 | width: clampedRight - left, 109 | }; 110 | } 111 | 112 | case 'ratio': { 113 | const fixedTop = (this.state.playerState.height * top) / 100; 114 | const fixedBottom = 115 | (this.state.playerState.height * bottom) / 100; 116 | const fixedLeft = (this.state.playerState.width * left) / 100; 117 | const fixedRight = (this.state.playerState.width * right) / 100; 118 | 119 | return { 120 | top: fixedTop, 121 | bottom: fixedBottom, 122 | left: fixedLeft, 123 | right: fixedRight, 124 | height: fixedBottom - fixedTop, 125 | width: fixedRight - fixedLeft, 126 | }; 127 | } 128 | 129 | default: 130 | return assertNever(mode); 131 | } 132 | } 133 | 134 | maxNumberOfLines() { 135 | const messageFlowDimensionPx = this.messageFlowDimensionPx(); 136 | 137 | return Math.min( 138 | messageFlowDimensionPx.height / this.lineHeight(), 139 | this.settingsStore.settings.totalNumberOfLines, 140 | ); 141 | } 142 | 143 | init() { 144 | this.attachReactiveContext(); 145 | } 146 | 147 | togglePopup(type: PopupType) { 148 | this.setState('currentPopup', (s) => (type === s ? undefined : type)); 149 | } 150 | 151 | private readonly handleVideoStateChange = () => { 152 | this.setState('playerState', 'isPaused', this.videoEle.paused); 153 | }; 154 | 155 | private readonly handleResize = () => { 156 | const { width, height } = this.videoPlayerEle.getBoundingClientRect(); 157 | 158 | this.setState('playerState', { 159 | width, 160 | height, 161 | }); 162 | }; 163 | 164 | private attachReactiveContext() { 165 | createRoot((dispose) => { 166 | onMount(() => { 167 | const resizeObserver = new ResizeObserver(this.handleResize); 168 | resizeObserver.observe(this.videoPlayerEle); 169 | 170 | onCleanup(() => { 171 | resizeObserver.disconnect(); 172 | }); 173 | }); 174 | 175 | onMount(() => { 176 | VIDEO_EVENTS_TO_SUBSCRIBE.forEach((event) => { 177 | this.videoEle.addEventListener( 178 | event, 179 | this.handleVideoStateChange, 180 | ); 181 | }); 182 | 183 | onCleanup(() => { 184 | VIDEO_EVENTS_TO_SUBSCRIBE.forEach((event) => { 185 | this.videoEle.removeEventListener( 186 | event, 187 | this.handleVideoStateChange, 188 | ); 189 | }); 190 | }); 191 | }); 192 | 193 | onMount(() => { 194 | const handleVisibilityChange = () => { 195 | this.setState('isDocumentVisible', !document.hidden); 196 | }; 197 | 198 | document.addEventListener( 199 | 'visibilitychange', 200 | handleVisibilityChange, 201 | ); 202 | 203 | onCleanup(() => { 204 | document.removeEventListener( 205 | 'visibilitychange', 206 | handleVisibilityChange, 207 | ); 208 | }); 209 | }); 210 | 211 | this.cleanup = dispose; 212 | }); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/stores/ui/types.ts: -------------------------------------------------------------------------------- 1 | export type PopupType = 'message-settings'; 2 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { createError } from '@/utils/logger'; 2 | 3 | export * as youtube from './youtube'; 4 | 5 | // https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore 6 | // Lodash is not used due to it having `Function()` call that is blocked by CSP in web extensions 7 | 8 | export function isNil(value: any): value is null | undefined { 9 | // eslint-disable-next-line no-eq-null, eqeqeq 10 | return value == null; 11 | } 12 | 13 | export function isNotNil(value?: T | undefined): value is NonNullable { 14 | return !isNil(value); 15 | } 16 | 17 | export function clamp(value: number, min: number, max: number): number { 18 | return Math.max(Math.min(value, max), min); 19 | } 20 | 21 | export function mapValues( 22 | value: T, 23 | mapper: (value: T[K]) => U, 24 | ): Record { 25 | return Object.fromEntries( 26 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 27 | Object.entries(value).map(([key, value]) => [key, mapper(value)]), 28 | ); 29 | } 30 | 31 | export function defaultsDeep>( 32 | values: Partial, 33 | defaultValues: Partial, 34 | ): Partial { 35 | const result: Record = structuredClone(values); 36 | 37 | Object.entries(defaultValues).forEach(([key, value]) => { 38 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 39 | const originalValue = result[key]; 40 | if (originalValue === undefined) { 41 | if (typeof value === 'function') { 42 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 43 | result[key] = value; 44 | } else { 45 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 46 | result[key] = structuredClone(value); 47 | } 48 | } else if ( 49 | typeof originalValue === 'object' && 50 | originalValue !== null && 51 | !Array.isArray(originalValue) && 52 | value !== undefined 53 | ) { 54 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 55 | result[key] = defaultsDeep(originalValue, value); 56 | } 57 | }); 58 | 59 | return result as T; 60 | } 61 | 62 | // eslint-disable-next-line @typescript-eslint/no-empty-function 63 | export function noop() {} 64 | 65 | export function assertNever(type: never): never { 66 | throw createError(`Unknown object: ${type as string}`); 67 | } 68 | 69 | // Reference from Youtube livechat.js 70 | /* eslint-disable no-bitwise */ 71 | export function colorFromDecimal(decimal: number): string { 72 | return `rgba(${[ 73 | (decimal >> 16) & 255, 74 | (decimal >> 8) & 255, 75 | decimal & 255, 76 | ((decimal >> 24) & 255) / 255, 77 | ].join(',')})`; 78 | } 79 | /* eslint-enable no-bitwise */ 80 | 81 | export function injectScript(scriptSrc: string) { 82 | const scriptTag = document.createElement('script'); 83 | scriptTag.src = scriptSrc; 84 | scriptTag.addEventListener('load', () => { 85 | scriptTag.remove(); 86 | }); 87 | 88 | document.head.appendChild(scriptTag); 89 | } 90 | 91 | export async function catchWithFallback( 92 | func: () => Promise, 93 | fallbackValue: T, 94 | ): Promise { 95 | try { 96 | return await func(); 97 | } catch { 98 | return fallbackValue; 99 | } 100 | } 101 | 102 | export async function promiseSeries( 103 | promises: Array<() => Promise>, 104 | ): Promise { 105 | for (const promise of promises) { 106 | // eslint-disable-next-line no-await-in-loop 107 | await promise(); 108 | } 109 | } 110 | 111 | type UseKeyboardToggleParameters = { 112 | withAlt: boolean; 113 | withCtrl: boolean; 114 | key: string; 115 | domToAttach: HTMLElement; 116 | callback: () => void; 117 | }; 118 | 119 | export function attachKeydownEventListener({ 120 | withAlt, 121 | withCtrl, 122 | key, 123 | domToAttach, 124 | callback, 125 | }: UseKeyboardToggleParameters): () => void { 126 | const handleKeyDown = (ev: KeyboardEvent) => { 127 | if (withAlt && !ev.altKey) { 128 | return; 129 | } 130 | 131 | if (withCtrl && !ev.ctrlKey) { 132 | return; 133 | } 134 | 135 | if (key.toLowerCase() !== ev.key.toLowerCase()) { 136 | return; 137 | } 138 | 139 | callback(); 140 | }; 141 | 142 | domToAttach.addEventListener('keydown', handleKeyDown); 143 | 144 | return () => { 145 | domToAttach.removeEventListener('keydown', handleKeyDown); 146 | }; 147 | } 148 | 149 | export async function waitForValue( 150 | getValue: () => T | null | undefined, 151 | createError: () => Error, 152 | retryIntervalMs = 100, 153 | maxRetryMs = 600000, 154 | ): Promise { 155 | return new Promise((resolve, reject) => { 156 | const value = getValue(); 157 | if (value) { 158 | resolve(value); 159 | return; 160 | } 161 | 162 | let retryTimeMs = 0; 163 | const interval = setInterval(() => { 164 | const value = getValue(); 165 | if (value) { 166 | resolve(value); 167 | clearInterval(interval); 168 | } else { 169 | retryTimeMs += retryIntervalMs; 170 | if (retryTimeMs >= maxRetryMs) { 171 | reject(createError()); 172 | } 173 | } 174 | }, retryIntervalMs); 175 | }); 176 | } 177 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | const prefix = '[live-chat-overlay]'; 2 | 3 | export const logInfo = (...params: Parameters) => { 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 5 | console.info(prefix, ...params); 6 | }; 7 | 8 | export const logDebug = (...params: Parameters) => { 9 | if (import.meta.env.PROD) { 10 | return; 11 | } 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 14 | console.debug(prefix, ...params); 15 | }; 16 | 17 | export const createError = (msg: string) => { 18 | return new Error(`${prefix} ${msg}`); 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/metrics.ts: -------------------------------------------------------------------------------- 1 | type BenchmarkResult = { 2 | result: T; 3 | runtime?: number; 4 | }; 5 | 6 | export type Metrics = { 7 | min: number; 8 | max: number; 9 | avg: number; 10 | count: number; 11 | latest: number; 12 | }; 13 | 14 | export function benchmarkRuntime( 15 | callback: () => T, 16 | isDebugging: boolean, 17 | ): BenchmarkResult { 18 | const beforeTime = isDebugging ? performance.now() : 0; 19 | 20 | const result = callback(); 21 | 22 | return { 23 | result, 24 | runtime: isDebugging ? performance.now() - beforeTime : undefined, 25 | }; 26 | } 27 | 28 | export async function benchmarkRuntimeAsync( 29 | callback: () => Promise, 30 | isDebugging: boolean, 31 | ): Promise> { 32 | const beforeTime = isDebugging ? performance.now() : 0; 33 | 34 | const result = await callback(); 35 | 36 | return { 37 | result, 38 | runtime: isDebugging ? performance.now() - beforeTime : undefined, 39 | }; 40 | } 41 | 42 | export function updateMetrics(oldMetrics: Metrics, newValue: number): Metrics { 43 | const newCount = oldMetrics.count + 1; 44 | return { 45 | min: Math.min(oldMetrics.min, newValue), 46 | max: Math.max(oldMetrics.max, newValue), 47 | avg: (oldMetrics.avg * oldMetrics.count + newValue) / newCount, 48 | count: newCount, 49 | latest: newValue, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/youtube.ts: -------------------------------------------------------------------------------- 1 | import type { InitData, YoutubeChatResponse } from '@/definitions/youtube'; 2 | import { createError } from '@/utils/logger'; 3 | 4 | import { waitForValue } from '.'; 5 | 6 | export const CLASS_BIG_MODE = 'ytp-big-mode'; 7 | export const CLASS_PLAYER_CTL_BTN = 'ytp-button'; 8 | export const CLASS_POPUP = 'ytp-popup'; 9 | export const CLASS_PANEL = 'ytp-panel'; 10 | export const CLASS_PANEL_MENU = 'ytp-panel-menu'; 11 | export const CLASS_MENUITEM = 'ytp-menuitem'; 12 | export const CLASS_AUTOHIDE = 'ytp-autohide'; 13 | 14 | export const GET_LIVE_CHAT_URL = 15 | 'https://www.youtube.com/youtubei/v1/live_chat/get_live_chat'; 16 | export const GET_LIVE_CHAT_REPLAY_URL = `${GET_LIVE_CHAT_URL}_replay`; 17 | 18 | export function getVideoPlayerContainer(): HTMLElement | undefined { 19 | return ( 20 | document.querySelector( 21 | '#ytd-player .html5-video-container', 22 | ) ?? undefined 23 | ); 24 | } 25 | 26 | export function getVideoPlayerEle(): HTMLDivElement | undefined { 27 | return ( 28 | document.querySelector( 29 | '#ytd-player .html5-video-player', 30 | ) ?? undefined 31 | ); 32 | } 33 | 34 | export function getRightControlEle(): HTMLElement | undefined { 35 | return ( 36 | document.querySelector( 37 | '#ytd-player .ytp-right-controls', 38 | ) ?? undefined 39 | ); 40 | } 41 | 42 | export function getVideoEle(): HTMLVideoElement | undefined { 43 | return ( 44 | document.querySelector( 45 | '#ytd-player .html5-video-player video', 46 | ) ?? undefined 47 | ); 48 | } 49 | 50 | export async function waitForPlayerReady(): Promise { 51 | return waitForValue(getVideoPlayerEle, () => 52 | createError('Video player not found'), 53 | ); 54 | } 55 | 56 | export function isInitData( 57 | data: InitData | YoutubeChatResponse, 58 | ): data is InitData { 59 | return ( 60 | 'viewerName' in (data.continuationContents?.liveChatContinuation ?? {}) 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "..", 5 | "noEmit": true 6 | }, 7 | "include": ["./**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tests/unit/models/chat-item/mapper/helpers/map-live-chat-paid-message-item-renderer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import type { LiveChatPaidMessageRenderer } from '@/definitions/youtube'; 4 | import { mapLiveChatPaidMessageItemRenderer } from '@/models/chat-item/mapper/helpers'; 5 | 6 | function getFixture(): LiveChatPaidMessageRenderer { 7 | return { 8 | id: 'random-id', 9 | timestampUsec: '1591023793124462', 10 | authorName: { 11 | simpleText: 'Sample Author', 12 | }, 13 | authorPhoto: { 14 | thumbnails: [ 15 | { 16 | url: 'https://sample-author-avatar/small.jpg', 17 | width: 32, 18 | height: 32, 19 | }, 20 | { 21 | url: 'https://sample-author-avatar/large.jpg', 22 | width: 64, 23 | height: 64, 24 | }, 25 | ], 26 | }, 27 | purchaseAmountText: { 28 | simpleText: 'HK$1,500.00', 29 | }, 30 | message: { 31 | runs: [ 32 | { 33 | text: 'Test Message', 34 | }, 35 | { 36 | emoji: { 37 | emojiId: 'sample-emoji-id', 38 | shortcuts: [':text-emoji:'], 39 | searchTerms: ['text-emoji', 'emoji'], 40 | image: { 41 | thumbnails: [ 42 | { 43 | url: 'https://sample-image', 44 | width: 24, 45 | height: 24, 46 | }, 47 | { 48 | url: 'https://sample-image-larger', 49 | width: 48, 50 | height: 48, 51 | }, 52 | ], 53 | accessibility: { 54 | accessibilityData: { 55 | label: ':text-emoji:', 56 | }, 57 | }, 58 | }, 59 | isCustomEmoji: true, 60 | }, 61 | }, 62 | ], 63 | }, 64 | headerBackgroundColor: 4291821568, 65 | headerTextColor: 4294967295, 66 | bodyBackgroundColor: 4293271831, 67 | bodyTextColor: 4294967295, 68 | authorExternalChannelId: 'channel-id', 69 | authorNameTextColor: 3019898879, 70 | contextMenuEndpoint: { 71 | commandMetadata: { 72 | webCommandMetadata: { 73 | ignoreNavigation: true, 74 | }, 75 | }, 76 | liveChatItemContextMenuEndpoint: { 77 | params: 'some-endpoint', 78 | }, 79 | }, 80 | timestampColor: 2164260863, 81 | contextMenuAccessibility: { 82 | accessibilityData: { 83 | label: 'More option on comment', 84 | }, 85 | }, 86 | timestampText: { 87 | simpleText: '0:40', 88 | }, 89 | }; 90 | } 91 | 92 | describe('mapLiveChatPaidMessageItemRenderer', () => { 93 | it('should map to correct result', () => { 94 | const result = mapLiveChatPaidMessageItemRenderer({ 95 | renderer: getFixture(), 96 | videoTimestampMs: 1500, 97 | }); 98 | 99 | expect(result).toEqual({ 100 | id: 'random-id', 101 | messageParts: [ 102 | { 103 | text: 'Test Message', 104 | }, 105 | { 106 | thumbnails: [ 107 | { 108 | url: 'https://sample-image', 109 | width: 24, 110 | height: 24, 111 | }, 112 | { 113 | url: 'https://sample-image-larger', 114 | width: 48, 115 | height: 48, 116 | }, 117 | ], 118 | shortcuts: [':text-emoji:'], 119 | id: 'sample-emoji-id', 120 | }, 121 | ], 122 | avatars: [ 123 | { 124 | url: 'https://sample-author-avatar/small.jpg', 125 | width: 32, 126 | height: 32, 127 | }, 128 | { 129 | url: 'https://sample-author-avatar/large.jpg', 130 | width: 64, 131 | height: 64, 132 | }, 133 | ], 134 | liveTimestampMs: 1591023793124.462, 135 | videoTimestampMs: 1500, 136 | authorName: 'Sample Author', 137 | chatType: 'super-chat', 138 | color: 'rgba(230,33,23,1)', 139 | donationAmount: 'HK$1,500.00', 140 | }); 141 | }); 142 | 143 | it('should handle empty message correctly', () => { 144 | const sample = getFixture(); 145 | sample.message = undefined; 146 | const result = mapLiveChatPaidMessageItemRenderer({ 147 | renderer: sample, 148 | videoTimestampMs: 1500, 149 | }); 150 | 151 | expect(result).toEqual({ 152 | id: 'random-id', 153 | messageParts: [], 154 | avatars: [ 155 | { 156 | url: 'https://sample-author-avatar/small.jpg', 157 | width: 32, 158 | height: 32, 159 | }, 160 | { 161 | url: 'https://sample-author-avatar/large.jpg', 162 | width: 64, 163 | height: 64, 164 | }, 165 | ], 166 | liveTimestampMs: 1591023793124.462, 167 | videoTimestampMs: 1500, 168 | authorName: 'Sample Author', 169 | chatType: 'super-chat', 170 | color: 'rgba(230,33,23,1)', 171 | donationAmount: 'HK$1,500.00', 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /tests/unit/models/chat-item/mapper/helpers/map-live-chat-paid-sticker-item-renderer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import type { LiveChatPaidStickerRenderer } from '@/definitions/youtube'; 4 | import { mapLiveChatPaidStickerRenderer } from '@/models/chat-item/mapper/helpers'; 5 | 6 | function getFixture(): LiveChatPaidStickerRenderer { 7 | return { 8 | id: 'random-id', 9 | timestampUsec: '1591023793124462', 10 | authorName: { 11 | simpleText: 'Sample Author', 12 | }, 13 | authorPhoto: { 14 | thumbnails: [ 15 | { 16 | url: 'https://sample-author-avatar/small.jpg', 17 | width: 32, 18 | height: 32, 19 | }, 20 | { 21 | url: 'https://sample-author-avatar/large.jpg', 22 | width: 64, 23 | height: 64, 24 | }, 25 | ], 26 | }, 27 | sticker: { 28 | thumbnails: [ 29 | { 30 | url: 'https://sample-sticker/small.jpg', 31 | width: 32, 32 | height: 32, 33 | }, 34 | { 35 | url: 'https://sample-sticker/large.jpg', 36 | width: 64, 37 | height: 64, 38 | }, 39 | ], 40 | accessibility: { 41 | accessibilityData: { 42 | label: 'sticker label', 43 | }, 44 | }, 45 | }, 46 | moneyChipBackgroundColor: 4291821568, 47 | moneyChipTextColor: 4291821568, 48 | stickerDisplayHeight: 200, 49 | stickerDisplayWidth: 200, 50 | purchaseAmountText: { 51 | simpleText: 'HK$1,500.00', 52 | }, 53 | backgroundColor: 4291821568, 54 | authorExternalChannelId: 'channel-id', 55 | authorNameTextColor: 3019898879, 56 | contextMenuEndpoint: { 57 | commandMetadata: { 58 | webCommandMetadata: { 59 | ignoreNavigation: true, 60 | }, 61 | }, 62 | liveChatItemContextMenuEndpoint: { 63 | params: 'some-endpoint', 64 | }, 65 | }, 66 | contextMenuAccessibility: { 67 | accessibilityData: { 68 | label: 'More option on comment', 69 | }, 70 | }, 71 | timestampText: { 72 | simpleText: '0:40', 73 | }, 74 | }; 75 | } 76 | 77 | describe('mapLiveChatPaidStickerRenderer', () => { 78 | it('should map to correct result', () => { 79 | const result = mapLiveChatPaidStickerRenderer({ 80 | renderer: getFixture(), 81 | videoTimestampMs: 1500, 82 | }); 83 | 84 | expect(result).toEqual({ 85 | id: 'random-id', 86 | stickers: [ 87 | { 88 | url: 'https://sample-sticker/small.jpg', 89 | width: 32, 90 | height: 32, 91 | }, 92 | { 93 | url: 'https://sample-sticker/large.jpg', 94 | width: 64, 95 | height: 64, 96 | }, 97 | ], 98 | avatars: [ 99 | { 100 | url: 'https://sample-author-avatar/small.jpg', 101 | width: 32, 102 | height: 32, 103 | }, 104 | { 105 | url: 'https://sample-author-avatar/large.jpg', 106 | width: 64, 107 | height: 64, 108 | }, 109 | ], 110 | liveTimestampMs: 1591023793124.462, 111 | videoTimestampMs: 1500, 112 | authorName: 'Sample Author', 113 | chatType: 'super-sticker', 114 | color: 'rgba(208,0,0,1)', 115 | donationAmount: 'HK$1,500.00', 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/unit/utils/catch-with-fallback.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, vi, expect } from 'vitest'; 2 | 3 | import { catchWithFallback } from '@/utils'; 4 | 5 | describe('catchWithFallback', () => { 6 | describe('when the func does not throw error', () => { 7 | it('should return func returned value', async () => { 8 | const fn = vi.fn().mockResolvedValue('resolve'); 9 | 10 | const result = await catchWithFallback(fn, 'fallback'); 11 | 12 | expect(result).toBe('resolve'); 13 | }); 14 | }); 15 | 16 | describe('when the func throws error', () => { 17 | it('should return func returned value', async () => { 18 | const fn = vi.fn().mockRejectedValue('reject'); 19 | 20 | const result = await catchWithFallback(fn, 'fallback'); 21 | 22 | expect(result).toBe('fallback'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/unit/utils/clamp.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { clamp } from '@/utils'; 4 | 5 | type TestParameters = { 6 | min: number; 7 | max: number; 8 | expected: boolean; 9 | }; 10 | 11 | const value = 10; 12 | 13 | describe('clamp', () => { 14 | describe.each` 15 | min | max | expected 16 | ${-1} | ${1} | ${1} 17 | ${-1} | ${100} | ${10} 18 | ${11} | ${100} | ${11} 19 | ${-1} | ${-1} | ${-1} 20 | ${100} | ${100} | ${100} 21 | ${value} | ${value} | ${value} 22 | `( 23 | `when min is $min and max is $max given value = ${value}`, 24 | (params: TestParameters) => { 25 | it(`should return ${params.expected}`, () => { 26 | expect(clamp(value, params.min, params.max)).toBe( 27 | params.expected, 28 | ); 29 | }); 30 | }, 31 | ); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/unit/utils/defaults-deep.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { defaultsDeep } from '@/utils'; 4 | 5 | type TestParameters = { 6 | values: any; 7 | expected: any; 8 | }; 9 | 10 | const defaultValues = { 11 | p1: 'test', 12 | p2: { 13 | nested_p: 'test', 14 | nested_p2: { 15 | nested_p3: [], 16 | nested_p4: 1000, 17 | }, 18 | }, 19 | p3: 100, 20 | p4: null, 21 | }; 22 | 23 | describe('defaultsDeep', () => { 24 | describe.each` 25 | condition | values | expected 26 | ${'when the object is flat'} | ${{ p3: 10 }} | ${{ p1: 'test', p2: { nested_p: 'test', nested_p2: { nested_p3: [], nested_p4: 1000 } }, p3: 10, p4: null }} 27 | ${'when the object has array attribute'} | ${{ p4: [] }} | ${{ p1: 'test', p2: { nested_p: 'test', nested_p2: { nested_p3: [], nested_p4: 1000 } }, p3: 100, p4: [] }} 28 | ${'when the object has boolean value'} | ${{ p2: false }} | ${{ p1: 'test', p2: false, p3: 100, p4: null }} 29 | ${'when the object has null value'} | ${{ p2: null }} | ${{ p1: 'test', p2: null, p3: 100, p4: null }} 30 | ${'when the object has undefined value'} | ${{ p2: undefined }} | ${defaultValues} 31 | ${'when it is nested'} | ${{ p2: { nested_p: 'a key', nested_p2: { nested_p3: ['a value'] } } }} | ${{ p1: 'test', p2: { nested_p: 'a key', nested_p2: { nested_p3: ['a value'], nested_p4: 1000 } }, p3: 100, p4: null }} 32 | `(`when $condition`, (params: TestParameters) => { 33 | it(`should return correctly`, () => { 34 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 35 | expect(defaultsDeep(params.values, defaultValues)).toStrictEqual( 36 | params.expected, 37 | ); 38 | }); 39 | }); 40 | 41 | describe('when one of the default is a function', () => { 42 | it('should work without crashing', () => { 43 | // eslint-disable-next-line @typescript-eslint/no-empty-function 44 | const defaults = { set() {} }; 45 | const result = defaultsDeep({}, defaults); 46 | expect(result.set).toBe(defaults.set); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/unit/utils/is-nil.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { isNil } from '@/utils'; 4 | 5 | type TestParameters = { 6 | input: any; 7 | expected: boolean; 8 | }; 9 | 10 | describe('isNil', () => { 11 | describe.each` 12 | input | expected 13 | ${null} | ${true} 14 | ${undefined} | ${true} 15 | ${NaN} | ${false} 16 | ${false} | ${false} 17 | ${0} | ${false} 18 | ${''} | ${false} 19 | `('when the input is $input', (params: TestParameters) => { 20 | it(`should return ${params.expected}`, () => { 21 | expect(isNil(params.input)).toBe(params.expected); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/unit/utils/metrics/update-metrics.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { mapValues } from '@/utils'; 4 | import { updateMetrics, type Metrics } from '@/utils/metrics'; 5 | 6 | type TestParameters = { 7 | condition: string; 8 | min: number; 9 | max: number; 10 | avg: number; 11 | count: number; 12 | nextMin: number; 13 | nextMax: number; 14 | nextAvg: number; 15 | nextCount: number; 16 | value: number; 17 | }; 18 | 19 | describe('calculateMetrics', () => { 20 | describe.each` 21 | condition | min | max | avg | count | value | nextMin | nextMax | nextAvg | nextCount 22 | ${'when it is from initial state'} | ${Number.MAX_SAFE_INTEGER} | ${0} | ${0} | ${0} | ${10} | ${10} | ${10} | ${10} | ${1} 23 | ${'when it consume value between min and max'} | ${12} | ${100} | ${50} | ${20} | ${30} | ${12} | ${100} | ${49.05} | ${21} 24 | ${'when it consume value less than min'} | ${12} | ${100} | ${50} | ${20} | ${2} | ${2} | ${100} | ${47.71} | ${21} 25 | ${'when it consume value more than max'} | ${12} | ${100} | ${50} | ${20} | ${200} | ${12} | ${200} | ${57.14} | ${21} 26 | `('$condition', (parameters: TestParameters) => { 27 | it('should return correct result', () => { 28 | const nowMetrics: Metrics = { 29 | min: parameters.min, 30 | max: parameters.max, 31 | avg: parameters.avg, 32 | count: parameters.count, 33 | latest: parameters.value, 34 | }; 35 | 36 | const expectedResult: Metrics = { 37 | min: parameters.nextMin, 38 | max: parameters.nextMax, 39 | avg: parameters.nextAvg, 40 | count: parameters.nextCount, 41 | latest: parameters.value, 42 | }; 43 | 44 | const result = updateMetrics(nowMetrics, parameters.value); 45 | 46 | expect(mapValues(result, (value) => value.toFixed(2))).toEqual( 47 | mapValues(expectedResult, (value) => value.toFixed(2)), 48 | ); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": false, 6 | "downlevelIteration": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "useDefineForClassFields": true, 10 | "module": "ESNext", 11 | "moduleResolution": "Node", 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "baseUrl": ".", 18 | "noUncheckedIndexedAccess": true, 19 | "noPropertyAccessFromIndexSignature": true, 20 | "noImplicitOverride": true, 21 | "strictPropertyInitialization": true, 22 | "jsx": "preserve", 23 | "jsxImportSource": "solid-js", 24 | "types": ["vite/client", "chrome"], 25 | "paths": { 26 | "@/*": ["src/*"] 27 | }, 28 | "plugins": [ 29 | { 30 | "name": "typescript-plugin-css-modules", 31 | "options": { 32 | "noUncheckedIndexedAccess": true 33 | } 34 | } 35 | ] 36 | }, 37 | "include": ["./src"] 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts", ".storybook"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import webExtension, { readJsonFile } from 'vite-plugin-web-extension'; 3 | import solidPlugin from 'vite-plugin-solid'; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | import autoprefixer from 'autoprefixer'; 6 | 7 | type Manifest = { 8 | version: string; 9 | web_accessible_resources?: Array<{ 10 | resources?: string[]; 11 | }>; 12 | }; 13 | 14 | function readManifest(): Manifest { 15 | return readJsonFile('src/manifest.json'); 16 | } 17 | 18 | function generateManifest() { 19 | const manifest = readManifest(); 20 | const pkg = readJsonFile('package.json'); 21 | manifest.version = pkg.version; 22 | if (manifest.web_accessible_resources) { 23 | manifest.web_accessible_resources = 24 | manifest.web_accessible_resources.map(({ resources, ...rest }) => { 25 | return { 26 | ...rest, 27 | resources: [ 28 | ...resources.map((filePath) => { 29 | if (filePath.endsWith('.ts')) { 30 | return `${filePath.slice(0, -2)}js`; 31 | } 32 | return filePath; 33 | }), 34 | ], 35 | }; 36 | }); 37 | } 38 | 39 | return manifest; 40 | } 41 | 42 | export default defineConfig({ 43 | plugins: [ 44 | tsconfigPaths(), 45 | solidPlugin(), 46 | webExtension({ 47 | disableAutoLaunch: true, 48 | skipManifestValidation: true, 49 | manifest: generateManifest, 50 | scriptViteConfig: { 51 | css: { 52 | postcss: { 53 | plugins: [autoprefixer], 54 | }, 55 | }, 56 | build: { 57 | sourcemap: 58 | process.env.NODE_ENV === 'production' 59 | ? false 60 | : 'inline', 61 | }, 62 | }, 63 | additionalInputs: 64 | readManifest().web_accessible_resources?.flatMap( 65 | ({ resources }) => resources ?? [], 66 | ) ?? [], 67 | }), 68 | ], 69 | }); 70 | --------------------------------------------------------------------------------