├── .editorconfig ├── .eslintrc.js ├── .github ├── img │ └── screenshot1.gif └── workflows │ └── extension.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.js ├── LICENSE ├── README.md ├── mv3-hot-reload.config.js ├── package.json ├── src ├── @types │ └── global.d.ts ├── assets │ ├── chat.svg │ ├── down-arrow.svg │ ├── icon-off.png │ ├── icon-on.png │ └── refresh.svg ├── background.ts ├── components │ ├── App.vue │ ├── AppearanceSection.vue │ ├── BehaviorSection.vue │ ├── FilterSection.vue │ ├── GeneralSection.vue │ └── OthersSection.vue ├── content-script-iframe.css ├── content-script-iframe.ts ├── content-script.css ├── content-script.ts ├── icon.png ├── manifest.json ├── models │ ├── index.ts │ ├── message.ts │ └── settings.ts ├── options.html ├── options.ts ├── plugins │ └── vuetify.ts ├── popup.html ├── popup.ts ├── store │ ├── index.ts │ └── settings.ts └── utils │ ├── dom-helper.ts │ ├── flow-controller.ts │ ├── message-parser.ts │ ├── message-renderer.ts │ └── message-settings.ts ├── tsconfig.json ├── webpack.config.dev.js ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parserOptions: { 7 | sourceType: 'module', 8 | ecmaVersion: 2020, 9 | }, 10 | overrides: [ 11 | { 12 | files: ['*.ts', '*.vue'], 13 | extends: ['plugin:@typescript-eslint/recommended', 'prettier'], 14 | parserOptions: { 15 | parser: '@typescript-eslint/parser', 16 | }, 17 | extends: [ 18 | 'plugin:@typescript-eslint/recommended', 19 | 'plugin:vue/recommended', 20 | 'prettier', 21 | ], 22 | }, 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /.github/img/screenshot1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdiox/youtube-live-chat-flow/2bd6ca49377dbaf018da678e5921a6ec30b6e35a/.github/img/screenshot1.gif -------------------------------------------------------------------------------- /.github/workflows/extension.yml: -------------------------------------------------------------------------------- 1 | name: Web Extension CI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: '16' 17 | - run: yarn 18 | - run: yarn test 19 | - run: yarn package 20 | - uses: softprops/action-gh-release@v1 21 | with: 22 | draft: true 23 | files: dist/archive.zip 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 28 | node_modules 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # Chrome Extension 37 | app 38 | dist 39 | !.gitkeep 40 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 subdiox 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 | # Flow Chat for YouTube Live 2 | 3 | > Chrome Extension for Flow Chat Messages on YouTube Live. 4 | 5 | ## Features 6 | 7 | - Flow messages over the video. 8 | - Change color, size and speed for messages. 9 | - Show author and avatar on messages. 10 | - Show super chats and super stickers. 11 | - Move the chat input to bottom controls on the video. 12 | - Add helper menu buttons on the chat list. 13 | 14 | ## Screenshots 15 | 16 |  17 | 18 | ## Installation 19 | 20 | 1. Download `archive.zip` from [releases page](https://github.com/subdiox/youtube-live-chat-flow/releases) and unzip this file. 21 | 2. Open the Extension Management page by navigating to `chrome://extensions`. 22 | 3. Enable Developer Mode by clicking the toggle switch next to **Developer mode**. 23 | 4. Click the **LOAD UNPACKED** button and select the unpacked directory named `app`. 24 | 25 | ## Development 26 | 27 | ```bash 28 | # install dependencies 29 | yarn 30 | 31 | # watch files changed and reload extension 32 | yarn dev 33 | ``` 34 | -------------------------------------------------------------------------------- /mv3-hot-reload.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | port: 9012, 3 | directory: 'src', 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-live-chat-flow", 3 | "description": "Chrome Extension for Flow Chat Messages on YouTube Live.", 4 | "version": "0.1.3", 5 | "dependencies": { 6 | "@fiahfy/semaphore": "^0.0.2", 7 | "@types/node": "^18.14.0", 8 | "@typescript-eslint/eslint-plugin": "^5.52.0", 9 | "@typescript-eslint/parser": "^5.52.0", 10 | "color": "^4.2.3", 11 | "eslint-config-prettier": "^8.6.0", 12 | "eslint-plugin-vue": "^9.9.0", 13 | "vue": "^2.7.14", 14 | "vuetify": "^2.6.6", 15 | "vuex": "^3.6.2", 16 | "vuex-module-decorators": "^1.2.0", 17 | "vuex-persist": "^3.1.3" 18 | }, 19 | "devDependencies": { 20 | "@types/chrome": "^0.0.188", 21 | "@types/color": "^3.0.3", 22 | "concurrently": "^7.2.1", 23 | "copy-webpack-plugin": "^11.0.0", 24 | "css-loader": "^6.7.1", 25 | "eslint": "^8.17.0", 26 | "file-loader": "^6.2.0", 27 | "html-webpack-plugin": "^5.5.0", 28 | "husky": "^8.0.1", 29 | "mv3-hot-reload": "^0.2.7", 30 | "prettier": "^2.6.2", 31 | "sass": "~1.32.13", 32 | "sass-loader": "^13.0.0", 33 | "svg-inline-loader": "^0.8.2", 34 | "ts-loader": "^9.3.0", 35 | "typescript": "^4.7.3", 36 | "vue-loader": "^15.9.8", 37 | "vue-template-compiler": "^2.7.14", 38 | "vuetify-loader": "^1.7.3", 39 | "webpack": "^5.73.0", 40 | "webpack-cli": "^4.9.2" 41 | }, 42 | "private": true, 43 | "productName": "Flow Chat for YouTube Live", 44 | "scripts": { 45 | "build": "webpack", 46 | "dev": "concurrently npm:watch:*", 47 | "lint": "npm run lint:eslint && npm run lint:prettier", 48 | "lint:eslint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore .", 49 | "lint:prettier": "prettier -c --ignore-path .gitignore .", 50 | "package": "npm run build && mkdir -p dist && zip -r dist/archive.zip app", 51 | "prepare": "husky install", 52 | "test": "npm run lint", 53 | "watch:dist": "mv3-hot-reload", 54 | "watch:src": "webpack -w --config webpack.config.dev.js" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | // @see https://github.com/vuejs/vue-class-component/issues/219 2 | declare module '*.vue' { 3 | import Vue from 'vue' 4 | export default Vue 5 | } 6 | 7 | // @see https://github.com/microsoft/TypeScript-React-Starter/issues/12 8 | declare module '*.png' { 9 | const content: string 10 | export default content 11 | } 12 | 13 | declare module '*.svg' { 14 | const content: string 15 | export default content 16 | } 17 | -------------------------------------------------------------------------------- /src/assets/chat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/down-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icon-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdiox/youtube-live-chat-flow/2bd6ca49377dbaf018da678e5921a6ec30b6e35a/src/assets/icon-off.png -------------------------------------------------------------------------------- /src/assets/icon-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdiox/youtube-live-chat-flow/2bd6ca49377dbaf018da678e5921a6ec30b6e35a/src/assets/icon-on.png -------------------------------------------------------------------------------- /src/assets/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { readyStore } from '~/store' 2 | import iconOff from '~/assets/icon-off.png' 3 | import iconOn from '~/assets/icon-on.png' 4 | 5 | interface TabState { 6 | enabled: boolean 7 | following: boolean 8 | } 9 | 10 | const initialState = { enabled: true, following: true } 11 | let tabStates: { [tabId: number]: TabState } = {} 12 | 13 | const getSettings = async () => { 14 | const store = await readyStore() 15 | return JSON.parse(JSON.stringify(store.state.settings)) 16 | } 17 | 18 | const setIcon = async (tabId: number) => { 19 | const path = tabStates[tabId] && tabStates[tabId].enabled ? iconOn : iconOff 20 | await chrome.action.setIcon({ tabId, path }) 21 | } 22 | 23 | const contentLoaded = async () => { 24 | const settings = await getSettings() 25 | 26 | return { settings } 27 | } 28 | 29 | const iframeLoaded = async (tabId: number) => { 30 | const enabled = initialState.enabled 31 | const following = initialState.following 32 | tabStates = { ...tabStates, [tabId]: { enabled, following } } 33 | 34 | await setIcon(tabId) 35 | 36 | const settings = await getSettings() 37 | 38 | return { enabled, following, settings } 39 | } 40 | 41 | const toggleEnabled = async (tabId: number) => { 42 | const enabled = !(tabStates[tabId] && tabStates[tabId].enabled) 43 | initialState.enabled = enabled 44 | tabStates = { 45 | ...tabStates, 46 | [tabId]: { ...(tabStates[tabId] ?? {}), enabled }, 47 | } 48 | 49 | await setIcon(tabId) 50 | 51 | await chrome.tabs.sendMessage(tabId, { 52 | type: 'enabled-changed', 53 | data: { enabled }, 54 | }) 55 | } 56 | 57 | const toggleFollowing = async (tabId: number) => { 58 | const following = !(tabStates[tabId] && tabStates[tabId].following) 59 | initialState.following = following 60 | tabStates = { 61 | ...tabStates, 62 | [tabId]: { ...(tabStates[tabId] ?? {}), following }, 63 | } 64 | 65 | await setIcon(tabId) 66 | 67 | await chrome.tabs.sendMessage(tabId, { 68 | type: 'following-changed', 69 | data: { following }, 70 | }) 71 | } 72 | 73 | const settingsChanged = async () => { 74 | const settings = await getSettings() 75 | const tabs = await chrome.tabs.query({}) 76 | for (const tab of tabs) { 77 | try { 78 | tab.id && 79 | chrome.tabs.sendMessage(tab.id, { 80 | type: 'settings-changed', 81 | data: { settings }, 82 | }) 83 | } catch (e) {} // eslint-disable-line no-empty 84 | } 85 | } 86 | 87 | chrome.tabs.onUpdated.addListener(async (tabId, changeInfo) => { 88 | if (changeInfo.url) { 89 | await chrome.tabs.sendMessage(tabId, { type: 'url-changed' }) 90 | } 91 | }) 92 | 93 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 94 | const { type } = message 95 | const { tab } = sender 96 | switch (type) { 97 | case 'content-loaded': 98 | contentLoaded().then((data) => sendResponse(data)) 99 | return true 100 | case 'iframe-loaded': 101 | if (tab?.id) { 102 | iframeLoaded(tab.id).then((data) => sendResponse(data)) 103 | return true 104 | } 105 | return 106 | case 'control-button-clicked': 107 | if (tab?.id) { 108 | toggleEnabled(tab.id).then(() => sendResponse()) 109 | return true 110 | } 111 | return 112 | case 'menu-button-clicked': 113 | if (tab?.id) { 114 | toggleFollowing(tab.id).then(() => sendResponse()) 115 | return true 116 | } 117 | return 118 | case 'settings-changed': 119 | settingsChanged().then(() => sendResponse()) 120 | return true 121 | } 122 | }) 123 | -------------------------------------------------------------------------------- /src/components/App.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | General 6 | 7 | 8 | Appearance 9 | 10 | 11 | Behavior 12 | 13 | 14 | Filter 15 | 16 | 17 | Others 18 | 19 | 20 | 21 | Reset Settings to Default 22 | 23 | 24 | 25 | 26 | 27 | 28 | 40 | 41 | 46 | 47 | 52 | -------------------------------------------------------------------------------- /src/components/AppearanceSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Height 6 | 14 | 15 | 16 | 17 | Line Height 18 | 27 | 28 | 41 | 42 | 43 | 44 | 45 | Lines 46 | 55 | 56 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | Max Width (Infinite if set to 0) 75 | 83 | 84 | 96 | 97 | 98 | 99 | Opacity 100 | 109 | 110 | 122 | 123 | 124 | 125 | Show Background (for Non-paid Messages) 126 | 127 | 128 | Background Opacity 129 | 138 | 139 | 151 | 152 | 153 | 154 | Outline Ratio 155 | 164 | 165 | 178 | 179 | 180 | 181 | Emoji Style 182 | 189 | 190 | Extended Style 191 | 200 | 201 | 202 | 203 | 319 | -------------------------------------------------------------------------------- /src/components/BehaviorSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | Display Time 4 | 13 | 14 | 27 | 28 | 29 | 30 | Delay Time 31 | 40 | 41 | 54 | 55 | 56 | 57 | Max Lines 58 | 65 | 66 | 76 | 77 | 78 | 79 | Max Displays per second (Infinite if set to 0) 80 | 88 | 89 | 100 | 101 | 102 | 103 | Stack Directions 104 | 111 | 112 | Overflow Mode 113 | 120 | 121 | 122 | 123 | 197 | -------------------------------------------------------------------------------- /src/components/FilterSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | Filter Messages by 4 | 5 | Chat Filter for YouTube Live 6 | 7 | 8 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /src/components/GeneralSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | {{ authorType }} 10 | 11 | 18 | mdi-eye 19 | 20 | 27 | mdi-account-circle 28 | 29 | 33 | setColor(authorType, value)" 39 | /> 40 | 41 | setTemplate(authorType, value)" 48 | /> 49 | 50 | 55 | 56 | {{ getTitle(messageType) }} 57 | 58 | 65 | mdi-eye 66 | 67 | 68 | 69 | 70 | 71 | 114 | 115 | 137 | -------------------------------------------------------------------------------- /src/components/OthersSection.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 29 | -------------------------------------------------------------------------------- /src/content-script-iframe.css: -------------------------------------------------------------------------------- 1 | /* yt-live-chat-header-renderer */ 2 | yt-live-chat-header-renderer > #primary-content { 3 | min-width: 0; 4 | } 5 | yt-live-chat-header-renderer 6 | > #primary-content 7 | > #view-selector 8 | > yt-sort-filter-sub-menu-renderer { 9 | width: 100%; 10 | } 11 | 12 | /* .ylcf-menu-button */ 13 | .ylcf-menu-button > button > yt-icon > svg { 14 | pointer-events: none; 15 | display: block; 16 | width: 100%; 17 | height: 100%; 18 | } 19 | /* .ylcf-menu-button.ylcf-active-menu-button */ 20 | .ylcf-menu-button.ylcf-active-menu-button > button > yt-icon { 21 | color: #4387f1 !important; 22 | } 23 | 24 | #input-panel > yt-live-chat-message-input-renderer > #container { 25 | opacity: 1; 26 | } 27 | 28 | /* .ylcf-description */ 29 | .ylcf-description { 30 | flex: 1; 31 | display: flex; 32 | align-items: center; 33 | padding: 15px 0; 34 | } 35 | .ylcf-description > button { 36 | text-align: center; 37 | font-size: smaller; 38 | flex: 1; 39 | color: var(--yt-spec-text-secondary); 40 | background: none; 41 | border: none; 42 | outline: none; 43 | cursor: pointer; 44 | --webkit-appearance: none; 45 | } 46 | -------------------------------------------------------------------------------- /src/content-script-iframe.ts: -------------------------------------------------------------------------------- 1 | import FlowController from '~/utils/flow-controller' 2 | import chat from '~/assets/chat.svg' 3 | import downArrow from '~/assets/down-arrow.svg' 4 | import refresh from '~/assets/refresh.svg' 5 | import { querySelectorAsync } from '~/utils/dom-helper' 6 | 7 | const controller = new FlowController() 8 | let observer: MutationObserver | undefined 9 | 10 | const menuButtonConfigs = [ 11 | { 12 | svg: downArrow, 13 | title: 'Follow New Messages', 14 | className: 'ylcf-follow-button', 15 | onclick: async () => 16 | await chrome.runtime.sendMessage({ type: 'menu-button-clicked' }), 17 | isActive: () => controller.following, 18 | }, 19 | { 20 | svg: refresh, 21 | title: 'Reload Frame', 22 | className: 'ylcf-reload-button', 23 | onclick: () => window.location.reload(), 24 | isActive: () => false, 25 | }, 26 | ] 27 | 28 | const updateControlButton = () => { 29 | const button = parent.document.querySelector('.ylcf-control-button') 30 | button && button.setAttribute('aria-pressed', String(controller.enabled)) 31 | } 32 | 33 | const removeControlButton = () => { 34 | const button = parent.document.querySelector('.ylcf-control-button') 35 | button && button.remove() 36 | } 37 | 38 | const addControlButton = () => { 39 | removeControlButton() 40 | 41 | const controls = parent.document.querySelector( 42 | '.ytp-chrome-bottom .ytp-chrome-controls .ytp-right-controls' 43 | ) 44 | if (!controls) { 45 | return 46 | } 47 | 48 | const button = document.createElement('button') 49 | button.classList.add('ytp-button', 'ylcf-control-button') 50 | button.title = 'Flow messages' 51 | button.onclick = async () => 52 | await chrome.runtime.sendMessage({ type: 'control-button-clicked' }) 53 | button.innerHTML = chat 54 | 55 | // Change SVG viewBox 56 | const svg = button.querySelector('svg') 57 | if (svg) { 58 | svg.setAttribute('viewBox', '-8 -8 40 40') 59 | svg.setAttribute('height', '100%') 60 | svg.setAttribute('width', '100%') 61 | } 62 | 63 | controls.prepend(button) 64 | 65 | updateControlButton() 66 | } 67 | 68 | const updateMenuButtons = () => { 69 | for (const config of menuButtonConfigs) { 70 | const button = document.querySelector(`.${config.className}`) 71 | if (!button) { 72 | return 73 | } 74 | if (config.isActive()) { 75 | button.classList.add('ylcf-active-menu-button') 76 | } else { 77 | button.classList.remove('ylcf-active-menu-button') 78 | } 79 | } 80 | } 81 | 82 | const addMenuButtons = () => { 83 | const refIconButton = document.querySelector( 84 | '#chat-messages > yt-live-chat-header-renderer > yt-icon-button' 85 | ) 86 | if (!refIconButton) { 87 | return 88 | } 89 | 90 | for (const config of menuButtonConfigs) { 91 | const icon = document.createElement('yt-icon') 92 | icon.classList.add('yt-live-chat-header-renderer', 'style-scope') 93 | 94 | const iconButton = document.createElement('yt-icon-button') 95 | iconButton.id = 'overflow' 96 | iconButton.classList.add( 97 | 'yt-live-chat-header-renderer', 98 | 'style-scope', 99 | 'ylcf-menu-button', 100 | config.className 101 | ) 102 | iconButton.title = config.title 103 | iconButton.onclick = config.onclick 104 | iconButton.append(icon) 105 | 106 | refIconButton.parentElement?.insertBefore(iconButton, refIconButton) 107 | 108 | // insert svg after wrapper button appended 109 | icon.innerHTML = config.svg 110 | } 111 | 112 | updateMenuButtons() 113 | } 114 | 115 | const addVideoEventListener = () => { 116 | const video = parent.document.querySelector( 117 | 'ytd-watch-flexy video.html5-main-video' 118 | ) 119 | if (!video) { 120 | return 121 | } 122 | 123 | video.addEventListener('play', () => controller.play()) 124 | video.addEventListener('pause', () => controller.pause()) 125 | } 126 | 127 | const observe = async () => { 128 | await controller.observe() 129 | 130 | const itemList = await querySelectorAsync('#item-list.yt-live-chat-renderer') 131 | if (!itemList) { 132 | return 133 | } 134 | 135 | observer = new MutationObserver(async () => { 136 | await controller.observe() 137 | }) 138 | observer.observe(itemList, { childList: true }) 139 | } 140 | 141 | const disconnect = () => { 142 | controller.disconnect() 143 | observer?.disconnect() 144 | } 145 | 146 | const init = async () => { 147 | disconnect() 148 | controller.clear() 149 | removeControlButton() 150 | 151 | addVideoEventListener() 152 | addControlButton() 153 | addMenuButtons() 154 | 155 | await observe() 156 | } 157 | 158 | chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { 159 | const { type, data } = message 160 | switch (type) { 161 | case 'url-changed': 162 | init().then(() => sendResponse()) 163 | return true 164 | case 'enabled-changed': 165 | controller.enabled = data.enabled 166 | updateControlButton() 167 | return sendResponse() 168 | case 'following-changed': 169 | controller.following = data.following 170 | updateMenuButtons() 171 | return sendResponse() 172 | case 'settings-changed': 173 | controller.settings = data.settings 174 | return sendResponse() 175 | } 176 | }) 177 | 178 | document.addEventListener('DOMContentLoaded', async () => { 179 | const data = await chrome.runtime.sendMessage({ type: 'iframe-loaded' }) 180 | 181 | controller.enabled = data.enabled 182 | controller.following = data.following 183 | controller.settings = data.settings 184 | 185 | await init() 186 | 187 | window.addEventListener('unload', () => { 188 | disconnect() 189 | controller.clear() 190 | removeControlButton() 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /src/content-script.css: -------------------------------------------------------------------------------- 1 | /* .ylcf-input-injected */ 2 | .ylcf-input-injected .ytp-chrome-bottom > .ytp-chrome-controls { 3 | display: flex; 4 | } 5 | .ylcf-input-injected 6 | .ytp-chrome-bottom 7 | > .ytp-chrome-controls 8 | > .ytp-left-controls { 9 | flex: unset; 10 | } 11 | /* .ylcf-small-input */ 12 | .ylcf-small-input 13 | .ylcf-controller 14 | > #top 15 | > #input-container 16 | > yt-live-chat-author-chip { 17 | display: none; 18 | } 19 | /* .ylcf-focused-input */ 20 | .ylcf-focused-input .html5-video-player .ytp-chrome-bottom { 21 | opacity: 1 !important; 22 | } 23 | .ylcf-focused-input .html5-video-player .ytp-gradient-bottom { 24 | display: block !important; 25 | opacity: 1 !important; 26 | } 27 | /* .ylcf-focused-input.ylcf-grow-input */ 28 | .ylcf-focused-input.ylcf-grow-input 29 | .ytp-chrome-bottom 30 | > .ytp-chrome-controls 31 | > .ytp-left-controls, 32 | .ylcf-focused-input.ylcf-grow-input 33 | .ytp-chrome-bottom 34 | > .ytp-chrome-controls 35 | > .ytp-right-controls { 36 | width: 0 !important; 37 | } 38 | .ylcf-focused-input.ylcf-grow-input 39 | .ylcf-controller 40 | > #message-buttons 41 | > #send-button { 42 | display: none; 43 | } 44 | /* .ylcf-input-injected.ylcf-grow-input */ 45 | .ylcf-input-injected.ylcf-grow-input 46 | .ytp-chrome-bottom 47 | > .ytp-chrome-controls 48 | > .ytp-left-controls, 49 | .ylcf-input-injected.ylcf-grow-input 50 | .ytp-chrome-bottom 51 | > .ytp-chrome-controls 52 | > .ytp-right-controls { 53 | overflow: hidden; 54 | } 55 | 56 | /* .ylcf-flow-message */ 57 | .ylcf-flow-message { 58 | align-items: center; 59 | display: flex; 60 | font-weight: bold; 61 | left: 0; 62 | position: absolute; 63 | vertical-align: bottom; 64 | white-space: nowrap; 65 | box-sizing: border-box; 66 | user-select: none; 67 | } 68 | 69 | /* .ylcf-control-button */ 70 | .ylcf-control-button > svg { 71 | fill: white; 72 | } 73 | 74 | /* Hide .ytp-fullerscreen-edu-button if .ylcf-controller exists */ 75 | .ylcf-controller + .ytp-right-controls .ytp-fullerscreen-edu-button { 76 | display: none; 77 | } 78 | 79 | /* .ylcf-controller */ 80 | .ylcf-controller { 81 | position: relative; 82 | flex: 1; 83 | display: flex; 84 | align-items: center; 85 | } 86 | /* .ylcf-controller > #top */ 87 | .ylcf-controller > #top { 88 | display: flex; 89 | flex: 1; 90 | min-width: 0; 91 | align-items: center !important; 92 | height: 100%; 93 | margin: 0 8px 0 0 !important; 94 | } 95 | .ylcf-controller > #top > yt-img-shadow#avatar { 96 | border-radius: 50%; 97 | margin: 0 8px; 98 | margin-bottom: 1px; 99 | overflow: hidden; 100 | } 101 | .ylcf-controller > #top > yt-img-shadow#avatar > img { 102 | width: 28px; 103 | height: 28px; 104 | } 105 | .ylcf-controller > #top > #input-container { 106 | display: flex; 107 | flex: 1; 108 | min-width: 0; 109 | align-items: center; 110 | height: 100%; 111 | } 112 | .ylcf-controller > #top > #input-container > yt-live-chat-author-chip { 113 | display: flex; 114 | margin-right: 8px; 115 | max-width: 128px; 116 | overflow: hidden; 117 | text-overflow: ellipsis; 118 | white-space: nowrap; 119 | } 120 | .ylcf-controller 121 | > #top 122 | > #input-container 123 | > yt-live-chat-author-chip 124 | > #author-name { 125 | color: inherit; 126 | user-select: none; 127 | } 128 | .ylcf-controller 129 | > #top 130 | > #input-container 131 | > yt-live-chat-text-input-field-renderer { 132 | position: relative; 133 | display: flex; 134 | flex: 1; 135 | min-width: 0; 136 | align-items: center; 137 | height: 100%; 138 | margin-top: 0; 139 | } 140 | .ylcf-controller 141 | > #top 142 | > #input-container 143 | > yt-live-chat-text-input-field-renderer 144 | > #label { 145 | color: inherit; 146 | position: absolute; 147 | top: unset !important; 148 | left: 1px; 149 | right: 1px; 150 | padding-left: 4px; 151 | pointer-events: none; 152 | overflow: hidden; 153 | text-overflow: ellipsis; 154 | white-space: nowrap; 155 | } 156 | .ylcf-controller 157 | > #top 158 | > #input-container 159 | > yt-live-chat-text-input-field-renderer[has-text] 160 | > #label { 161 | opacity: 0; 162 | } 163 | .ylcf-controller 164 | > #top 165 | > #input-container 166 | > yt-live-chat-text-input-field-renderer 167 | > #input { 168 | flex: 1; 169 | min-width: 0; 170 | height: 32px; 171 | line-height: 32px; 172 | border: 1px solid #eee; 173 | border-radius: 4px; 174 | padding: 0 4px; 175 | outline: none; 176 | transition: border-color 0.5s; 177 | white-space: nowrap; 178 | } 179 | .ylcf-controller 180 | > #top 181 | > #input-container 182 | > yt-live-chat-text-input-field-renderer[focused] 183 | > #input { 184 | border-color: var( 185 | --yt-live-chat-text-input-field-active-underline-color, 186 | hsl(206.1, 79.3%, 52.7%) 187 | ); 188 | } 189 | /* .ylcf-controller > #message-buttons */ 190 | .ylcf-controller > #message-buttons { 191 | position: relative; 192 | display: flex; 193 | align-items: center; 194 | height: 100%; 195 | line-height: initial; 196 | } 197 | .ylcf-controller > #message-buttons > #count { 198 | margin-right: 0; 199 | color: inherit; 200 | user-select: none; 201 | } 202 | .ylcf-controller > #message-buttons > #send-button > yt-button-renderer { 203 | color: inherit !important; 204 | } 205 | .ylcf-controller 206 | > #message-buttons 207 | > #send-button 208 | > yt-button-renderer 209 | > .yt-button-renderer { 210 | width: 48px; 211 | height: 48px; 212 | align-items: center; 213 | justify-content: center; 214 | } 215 | .ylcf-controller 216 | > #message-buttons 217 | > #send-button 218 | > yt-button-renderer 219 | > .yt-button-renderer 220 | > yt-icon-button#button { 221 | color: inherit; 222 | display: block; 223 | height: 24px; 224 | width: 24px; 225 | padding: 0; 226 | } 227 | .ylcf-controller 228 | > #message-buttons 229 | > #send-button 230 | > yt-button-renderer 231 | > .yt-button-renderer 232 | > yt-icon-button#button[disabled] { 233 | cursor: initial; 234 | opacity: 0.5; 235 | } 236 | .ylcf-controller 237 | > #message-buttons 238 | > #send-button 239 | > yt-button-renderer 240 | > .yt-button-renderer 241 | > yt-icon-button#button 242 | paper-ripple { 243 | display: none; 244 | } 245 | .ylcf-controller > #message-buttons > #countdown { 246 | position: absolute; 247 | margin-right: 0; 248 | left: unset; 249 | right: calc((48px - 24px) / 2); 250 | width: 24px; 251 | height: 24px; 252 | } 253 | 254 | /* .ylcf-controller for fullscreen */ 255 | .html5-video-player.ytp-fullscreen .ylcf-controller > #top { 256 | margin: 0 16px 0 0 !important; 257 | } 258 | .html5-video-player.ytp-fullscreen 259 | .ylcf-controller 260 | > #top 261 | > yt-img-shadow#avatar { 262 | margin: 0 16px; 263 | } 264 | .html5-video-player.ytp-fullscreen 265 | .ylcf-controller 266 | > #top 267 | > yt-img-shadow#avatar 268 | > img { 269 | width: 32px; 270 | height: 32px; 271 | } 272 | .html5-video-player.ytp-fullscreen 273 | .ylcf-controller 274 | > #top 275 | > #input-container 276 | > yt-live-chat-author-chip { 277 | display: none; 278 | } 279 | .html5-video-player.ytp-fullscreen 280 | .ylcf-controller 281 | > #top 282 | > #input-container 283 | > yt-live-chat-text-input-field-renderer { 284 | font-size: unset; 285 | } 286 | .html5-video-player.ytp-fullscreen 287 | .ylcf-controller 288 | > #top 289 | > #input-container 290 | > yt-live-chat-text-input-field-renderer 291 | > #label { 292 | padding-left: 8px; 293 | } 294 | .html5-video-player.ytp-fullscreen 295 | .ylcf-controller 296 | > #top 297 | > #input-container 298 | > yt-live-chat-text-input-field-renderer 299 | > #input { 300 | height: 36px; 301 | line-height: 36px; 302 | padding: 0 8px; 303 | } 304 | .html5-video-player.ytp-fullscreen 305 | .ylcf-controller 306 | > #message-buttons 307 | > #send-button 308 | > yt-button-renderer 309 | > .yt-button-renderer { 310 | width: 54px; 311 | height: 54px; 312 | } 313 | .html5-video-player.ytp-fullscreen 314 | .ylcf-controller 315 | > #message-buttons 316 | > #send-button 317 | > yt-button-renderer 318 | > .yt-button-renderer 319 | > yt-icon-button#button { 320 | width: 36px; 321 | height: 36px; 322 | } 323 | .html5-video-player.ytp-fullscreen 324 | .ylcf-controller 325 | > #message-buttons 326 | > #countdown { 327 | right: calc((54px - 36px) / 2); 328 | width: 36px; 329 | height: 36px; 330 | } 331 | -------------------------------------------------------------------------------- /src/content-script.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from '~/models' 2 | import { querySelectorAsync } from '~/utils/dom-helper' 3 | 4 | let settings: Settings 5 | 6 | const isVideoUrl = () => new URL(location.href).pathname === '/watch' 7 | 8 | const waitCollapsed = async () => { 9 | const iframe = await querySelectorAsync('ytd-live-chat-frame') 10 | return new Promise((resolve) => { 11 | const expireTime = Date.now() + 1000 12 | const timer = window.setInterval(async () => { 13 | const collapsed = iframe?.hasAttribute('collapsed') ?? false 14 | if (collapsed || Date.now() > expireTime) { 15 | clearInterval(timer) 16 | resolve(collapsed) 17 | } 18 | }, 100) 19 | }) 20 | } 21 | 22 | const init = async () => { 23 | if (!isVideoUrl()) { 24 | return 25 | } 26 | 27 | if (!settings.chatVisible) { 28 | return 29 | } 30 | 31 | const collapsed = await waitCollapsed() 32 | if (!collapsed) { 33 | return 34 | } 35 | 36 | const button = await querySelectorAsync( 37 | '#show-hide-button a' 38 | ) 39 | button && button.click() 40 | } 41 | 42 | chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { 43 | const { type } = message 44 | switch (type) { 45 | case 'url-changed': 46 | init().then(() => sendResponse()) 47 | return true 48 | } 49 | }) 50 | 51 | document.addEventListener('DOMContentLoaded', async () => { 52 | const data = await chrome.runtime.sendMessage({ type: 'content-loaded' }) 53 | console.log(data) 54 | settings = data.settings 55 | await init() 56 | }) 57 | -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subdiox/youtube-live-chat-flow/2bd6ca49377dbaf018da678e5921a6ec30b6e35a/src/icon.png -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "icons": { 4 | "128": "icon.png" 5 | }, 6 | "background": { 7 | "service_worker": "background.js" 8 | }, 9 | "content_scripts": [ 10 | { 11 | "run_at": "document_start", 12 | "matches": ["https://www.youtube.com/*"], 13 | "all_frames": false, 14 | "js": ["content-script.js"], 15 | "css": ["content-script.css"] 16 | }, 17 | { 18 | "run_at": "document_start", 19 | "matches": ["https://www.youtube.com/live_chat*"], 20 | "all_frames": true, 21 | "js": ["content-script-iframe.js"], 22 | "css": ["content-script-iframe.css"] 23 | } 24 | ], 25 | "action": { 26 | "default_icon": "icon.png", 27 | "default_popup": "popup.html" 28 | }, 29 | "options_ui": { 30 | "page": "options.html", 31 | "open_in_tab": false 32 | }, 33 | "permissions": ["storage"], 34 | "host_permissions": ["https://www.youtube.com/*"] 35 | } 36 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from '~/models/message' 2 | export * from '~/models/settings' 3 | -------------------------------------------------------------------------------- /src/models/message.ts: -------------------------------------------------------------------------------- 1 | export type Message = { 2 | author?: string 3 | authorType?: string 4 | message?: string 5 | messageType?: string 6 | html?: string 7 | avatarUrl?: string 8 | stickerUrl?: string 9 | backgroundColor?: string 10 | purchaseAmount?: string 11 | } 12 | -------------------------------------------------------------------------------- /src/models/settings.ts: -------------------------------------------------------------------------------- 1 | export type AuthorType = 'guest' | 'member' | 'moderator' | 'owner' | 'you' 2 | export type MessageType = 'super-chat' | 'super-sticker' | 'membership' 3 | export type EmojiStyle = 'image' | 'text' | 'none' 4 | export type HeightType = 'flexible' | 'fixed' 5 | export type StackDirection = 'top_to_bottom' | 'bottom_to_top' 6 | export type Overflow = 'overlay' | 'hidden' 7 | export type Styles = { [authorType in AuthorType]: Style } 8 | export type Visibilities = { [type in AuthorType | MessageType]: boolean } 9 | 10 | export type Template = 11 | | 'one-line-without-author' 12 | | 'one-line-with-author' 13 | | 'two-line' 14 | 15 | export type Style = { 16 | avatar: boolean 17 | color: string 18 | template: Template 19 | } 20 | 21 | export type Settings = { 22 | background: boolean 23 | backgroundOpacity: number 24 | chatVisible: boolean 25 | delayTime: number 26 | displayTime: number 27 | emojiStyle: EmojiStyle 28 | extendedStyle: string 29 | heightType: HeightType 30 | lineHeight: number 31 | lines: number 32 | maxDisplays: number 33 | maxLines: number 34 | maxWidth: number 35 | opacity: number 36 | outlineRatio: number 37 | overflow: Overflow 38 | stackDirection: StackDirection 39 | styles: Styles 40 | visibilities: Visibilities 41 | } 42 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from '~/components/App.vue' 3 | import vuetify from '~/plugins/vuetify' 4 | 5 | new Vue({ 6 | el: '#app', 7 | render: (createElement) => createElement(App), 8 | vuetify, 9 | }) 10 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify/lib' 3 | 4 | Vue.use(Vuetify) 5 | 6 | export default new Vuetify() 7 | -------------------------------------------------------------------------------- /src/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/popup.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from '~/components/App.vue' 3 | import vuetify from '~/plugins/vuetify' 4 | 5 | new Vue({ 6 | el: '#app', 7 | render: (createElement) => createElement(App), 8 | vuetify, 9 | }) 10 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import VuexPersistence from 'vuex-persist' 4 | import { getModule } from 'vuex-module-decorators' 5 | import settings from '~/store/settings' 6 | 7 | Vue.use(Vuex) 8 | 9 | const vuexPersist = new VuexPersistence({ 10 | storage: chrome.storage.local as any, // eslint-disable-line @typescript-eslint/no-explicit-any 11 | asyncStorage: true, 12 | restoreState: async (key, storage) => { 13 | const result = await storage?.get(key) 14 | const json = result[key] 15 | 16 | let state = {} 17 | try { 18 | state = JSON.parse(json) 19 | } catch (e) {} // eslint-disable-line no-empty 20 | 21 | return { 22 | ...state, 23 | __storageReady: true, 24 | } 25 | }, 26 | saveState: async (key, state, storage) => { 27 | const json = JSON.stringify(state) 28 | await storage?.set({ [key]: json }) 29 | }, 30 | }) 31 | 32 | const createStore = () => 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | new Vuex.Store({ 35 | state: {}, 36 | modules: { 37 | settings, 38 | }, 39 | plugins: [ 40 | vuexPersist.plugin, 41 | (store) => { 42 | store.subscribe( 43 | async () => 44 | await chrome.runtime.sendMessage({ type: 'settings-changed' }) 45 | ) 46 | }, 47 | ], 48 | }) 49 | 50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 51 | export const readyStore = async () => { 52 | const store = createStore() 53 | // @see https://github.com/championswimmer/vuex-persist#how-to-know-when-async-store-has-been-replaced 54 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 | await (store as any).restored 56 | return store 57 | } 58 | 59 | export const settingsStore = getModule(settings, createStore()) 60 | -------------------------------------------------------------------------------- /src/store/settings.ts: -------------------------------------------------------------------------------- 1 | import { Module, VuexModule, Mutation } from 'vuex-module-decorators' 2 | import { 3 | AuthorType, 4 | EmojiStyle, 5 | HeightType, 6 | MessageType, 7 | Overflow, 8 | Settings, 9 | StackDirection, 10 | Style, 11 | } from '~/models' 12 | 13 | const initialState: Settings = { 14 | background: false, 15 | backgroundOpacity: 0.4, 16 | chatVisible: true, 17 | delayTime: 0, 18 | displayTime: 5, 19 | emojiStyle: 'image', 20 | extendedStyle: '', 21 | heightType: 'flexible', 22 | lineHeight: 64, 23 | lines: 12, 24 | maxDisplays: 0, 25 | maxLines: 0, 26 | maxWidth: 200, 27 | opacity: 0.8, 28 | outlineRatio: 0.015, 29 | overflow: 'overlay', 30 | stackDirection: 'top_to_bottom', 31 | styles: { 32 | guest: { 33 | avatar: false, 34 | color: '#ffffff', 35 | template: 'one-line-without-author', 36 | }, 37 | member: { 38 | avatar: true, 39 | color: '#ccffcc', 40 | template: 'one-line-without-author', 41 | }, 42 | moderator: { 43 | avatar: true, 44 | color: '#ccccff', 45 | template: 'two-line', 46 | }, 47 | owner: { 48 | avatar: true, 49 | color: '#ffffcc', 50 | template: 'two-line', 51 | }, 52 | you: { 53 | avatar: true, 54 | color: '#ffcccc', 55 | template: 'one-line-with-author', 56 | }, 57 | }, 58 | visibilities: { 59 | guest: true, 60 | member: true, 61 | moderator: true, 62 | owner: true, 63 | you: true, 64 | 'super-chat': true, 65 | 'super-sticker': true, 66 | membership: true, 67 | }, 68 | } 69 | 70 | @Module({ name: 'settings' }) 71 | export default class SettingsModule extends VuexModule { 72 | background = initialState.background 73 | backgroundOpacity = initialState.backgroundOpacity 74 | chatVisible = true 75 | delayTime = initialState.delayTime 76 | displayTime = initialState.displayTime 77 | emojiStyle = initialState.emojiStyle 78 | extendedStyle = initialState.extendedStyle 79 | heightType = initialState.heightType 80 | lineHeight = initialState.lineHeight 81 | lines = initialState.lines 82 | maxDisplays = initialState.maxDisplays 83 | maxLines = initialState.maxLines 84 | maxWidth = initialState.maxWidth 85 | opacity = initialState.opacity 86 | outlineRatio = initialState.outlineRatio 87 | overflow = initialState.overflow 88 | stackDirection = initialState.stackDirection 89 | styles = initialState.styles 90 | visibilities = initialState.visibilities 91 | 92 | @Mutation 93 | updateStyle({ 94 | authorType, 95 | ...params 96 | }: { authorType: AuthorType } & Partial