├── .gitattributes ├── .github ├── assets │ ├── screenshot-1.webp │ └── screenshot-2.webp └── workflows │ └── build.yaml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── build ├── entitlements.mac.plist ├── icon.icns ├── icon.ico └── icon.png ├── electron-builder-config.ts ├── electron.vite.config.ts ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── src ├── main │ ├── database │ │ ├── dbs.ts │ │ ├── migrations.ts │ │ └── migrations │ │ │ ├── emotes.ts │ │ │ ├── feeds.ts │ │ │ └── users.ts │ ├── feed.ts │ ├── index.ts │ ├── ipc.ts │ ├── twitch │ │ ├── emote.ts │ │ ├── query.ts │ │ ├── stream.ts │ │ └── user.ts │ ├── user.ts │ ├── utils.ts │ └── youtube │ │ ├── channel.ts │ │ ├── rss.ts │ │ └── video.ts ├── preload │ ├── index.d.ts │ └── index.ts ├── renderer │ ├── index.html │ └── src │ │ ├── App.svelte │ │ ├── app.css │ │ ├── env.d.ts │ │ ├── lib │ │ ├── components │ │ │ ├── Chat.svelte │ │ │ ├── FeedHeader.svelte │ │ │ ├── Grid.svelte │ │ │ ├── Notification.svelte │ │ │ ├── Sidebar.svelte │ │ │ ├── Titlebar.svelte │ │ │ └── players │ │ │ │ ├── Twitch.svelte │ │ │ │ └── YouTube.svelte │ │ ├── index.ts │ │ └── state │ │ │ └── View.svelte.ts │ │ ├── main.ts │ │ └── pages │ │ ├── Settings.svelte │ │ ├── Users.svelte │ │ ├── streams │ │ ├── Streams.svelte │ │ └── Watch.svelte │ │ └── videos │ │ ├── Videos.svelte │ │ └── Watch.svelte └── shared │ ├── enums.ts │ └── globals.d.ts ├── svelte.config.mjs ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── tsconfig.web.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/assets/screenshot-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kyagara/rt/2c6e0deee5d319dd0aec372d223b89fa9ea1544d/.github/assets/screenshot-1.webp -------------------------------------------------------------------------------- /.github/assets/screenshot-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kyagara/rt/2c6e0deee5d319dd0aec372d223b89fa9ea1544d/.github/assets/screenshot-2.webp -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | paths: 8 | - "**.ts" 9 | - "**.js" 10 | - "**.mjs" 11 | - "**.json" 12 | - "**.svelte" 13 | - ".github/workflows/build.yaml" 14 | workflow_dispatch: 15 | 16 | jobs: 17 | release: 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-latest, macos-latest, windows-latest] 22 | 23 | runs-on: ${{ matrix.os }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Install Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: 22 32 | cache: npm 33 | cache-dependency-path: "**/package-lock.json" 34 | 35 | - name: Install dependencies 36 | run: npm ci 37 | 38 | - name: Run lint and typecheck 39 | if: matrix.os == 'ubuntu-latest' 40 | run: npm run lint && npm run typecheck 41 | 42 | - name: Build for Linux 43 | if: matrix.os == 'ubuntu-latest' 44 | run: npm run build:linux 45 | 46 | - name: Build for macOS 47 | if: matrix.os == 'macos-latest' 48 | run: npm run build:mac 49 | 50 | - name: Build for Windows 51 | if: matrix.os == 'windows-latest' 52 | run: npm run build:win 53 | 54 | - name: Upload Linux installer 55 | if: matrix.os == 'ubuntu-latest' 56 | uses: actions/upload-artifact@v4 57 | with: 58 | name: linux 59 | path: ./dist/rt-*-linux.deb 60 | compression-level: 0 61 | 62 | - name: Upload macOS installer 63 | if: matrix.os == 'macos-latest' 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: mac 67 | path: ./dist/rt-*-mac.dmg 68 | compression-level: 0 69 | 70 | - name: Upload Windows installer 71 | if: matrix.os == 'windows-latest' 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: windows 75 | path: ./dist/rt-*-win.exe 76 | compression-level: 0 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .DS_Store 5 | .eslintcache 6 | electron.vite.config.*.mjs 7 | *.log* 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | .github 4 | out 5 | dist 6 | README.md 7 | LICENSE.md 8 | tsconfig.json 9 | tsconfig.*.json 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "semi": false, 5 | "trailingComma": "none", 6 | "printWidth": 100, 7 | "endOfLine": "lf", 8 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 9 | "overrides": [ 10 | { 11 | "files": "*.svelte", 12 | "options": { 13 | "parser": "svelte" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "html.customData": ["./node_modules/vidstack/vscode.html-data.json"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RT (name pending) 2 | 3 | > [!WARNING] 4 | > WIP! YouTube videos will run into 403 after some time, embedded videos are used by default for now. 5 | 6 | A Twitch and YouTube frontend app using Svelte and Electron. 7 | 8 | 9 | 10 | 18 | 26 | 27 |
11 | vinesauce playing Yoshi's Island 12 | 13 |
14 | vinesauce playing Yoshi's Island. 15 |
16 |
17 |
19 | YouTube feed 20 | 21 |
22 | YouTube feed with videos and streams. 23 |
24 |
25 |
28 | 29 | ## Features 30 | 31 | - Import YouTube subscriptions. (Accepts a `csv` file separated by Channel ID, URL, Title) 32 | - Add users to your stream and videos feeds. 33 | - Watch content in any of the available resolutions. 34 | - View Twitch chat with 7tv and BetterTTV emotes. 35 | - Block ads. 36 | - Open videos or streams directly in the app using `rt://` URLs. 37 | - Picture-in-picture (except for embedded videos). 38 | 39 | ## About 40 | 41 | Tested on Windows and Ubuntu. Not tested on macOS. 42 | 43 | ### Download 44 | 45 | > These are files from the latest successful build, they do not require a GitHub account to download. 46 | 47 | - [Windows](https://nightly.link/Kyagara/rt/workflows/build.yaml/main/windows.zip) 48 | - [Linux](https://nightly.link/Kyagara/rt/workflows/build.yaml/main/linux.zip) 49 | - [macOS](https://nightly.link/Kyagara/rt/workflows/build.yaml/main/mac.zip) 50 | 51 | You can check for possible older artifacts [here](https://github.com/Kyagara/rt/actions). 52 | 53 | ### Redirects 54 | 55 | On launch, a custom protocol handler is registered for `rt://` URLs, this allows you to open videos or streams directly in the app. 56 | 57 | If the app is not running, it will be started with the URL as an argument, if it is running, the URL will be opened in a new window. 58 | 59 | `YouTube`: 60 | 61 | - `rt://yt/{VIDEO_ID}` 62 | - `rt://youtube/{VIDEO_ID}` 63 | - `rt://www.youtube.com/watch?v={VIDEO_ID}` 64 | - `rt://youtu.be/{VIDEO_ID}` 65 | 66 | `Twitch`: 67 | 68 | - `rt://tw/{CHANNEL_NAME}` 69 | - `rt://twitch/{CHANNEL_NAME}` 70 | - `rt://www.twitch.tv/{CHANNEL_NAME}` 71 | 72 | If you are using extensions like [LibRedirect](https://github.com/libredirect/browser_extension), you can set a frontend for YouTube like Invidious and set the instance URL to `rt://`. The same can be done for Twitch, you can set the frontend to SafeTwitch and set the instance URL to `rt://`. 73 | 74 | ### Paths 75 | 76 | To store users, feeds and emotes, SQLite is used with [better-sqlite3](https://github.com/WiseLibs/better-sqlite3). 77 | 78 | Data (databases, window state, etc) and logs: 79 | 80 | - Windows: `%AppData%/com.rt.app` 81 | - Linux: `~/.config/com.rt.app` 82 | 83 | ### Frontends 84 | 85 | `YouTube`: 86 | 87 | The feed uses YouTube's rss feed to retrieve videos to avoid rate limits, this sadly does not contain video duration. 88 | 89 | The watch page will try to retrieve a YouTube player using [YouTube.js](https://github.com/LuanRT/YouTube.js), if it fails, it will use Vidstack's YouTube [provider](https://vidstack.io/docs/player/api/providers/youtube/) to play videos via embeds, this fallback has the drawbacks of not being able to play videos that disallows embedding and not being able to select a video quality. You have the option to switch between them. 90 | 91 | `Twitch`: 92 | 93 | The player uses a custom [hls.js](https://github.com/video-dev/hls.js/) loader that fetches and reads the playlists, this is what allows for ad blocking as the loader can detect ads and switch to a backup stream until ads are over, this was inspired by [TwitchAdSolutions](https://github.com/pixeltris/TwitchAdSolutions) method of switching streams. 94 | 95 | Uses GQL queries from the internal Twitch API to retrieve user data and stream playback. 96 | 97 | ## TODO 98 | 99 | - Tweak tsconfig and maybe add some more linting. 100 | - Themes. 101 | - Error handling. 102 | - Logging. 103 | - Maybe move to using classes. 104 | - Maybe build just flatpak for Linux. 105 | - YouTube: 106 | - Improve descriptions, links are not formatted properly and has a lot of extra lines. 107 | - Maybe add tabs for livestreams and shorts in the feed. 108 | - Buttons for downloading videos (maybe using `yt-dlp`) and thumbnails. 109 | - YouTube channel page with all videos/shorts/livestreams with pagination. 110 | - Input field in feed and page for searching videos/channels. 111 | -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kyagara/rt/2c6e0deee5d319dd0aec372d223b89fa9ea1544d/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kyagara/rt/2c6e0deee5d319dd0aec372d223b89fa9ea1544d/build/icon.ico -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kyagara/rt/2c6e0deee5d319dd0aec372d223b89fa9ea1544d/build/icon.png -------------------------------------------------------------------------------- /electron-builder-config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from 'electron-builder' 2 | 3 | const config: Configuration = { 4 | productName: 'rt', 5 | appId: 'com.rt.app', 6 | artifactName: 'rt-${version}-${os}.${ext}', 7 | 8 | npmRebuild: false, 9 | publish: null, 10 | electronLanguages: ['en-US'], 11 | asarUnpack: ['resources/**'], 12 | directories: { 13 | buildResources: 'build' 14 | }, 15 | files: [ 16 | '!**/.vscode/*', 17 | '!src/*', 18 | '!{.env,.env.*,.npmrc}', 19 | '!{.gitignore,.gitattributes,README.md}', 20 | '!{electron.vite.config.ts,electron-builder-config.ts,svelte.config.mjs,tailwind.config.js}', 21 | '!{.prettierignore,.prettierrc,eslint.config.mjs}', 22 | '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 23 | ], 24 | 25 | protocols: [ 26 | { 27 | name: 'rt', 28 | schemes: ['rt'], 29 | role: 'Viewer' 30 | } 31 | ], 32 | 33 | // Platforms 34 | 35 | win: { 36 | target: ['nsis'] 37 | }, 38 | nsis: { 39 | oneClick: false, 40 | shortcutName: '${productName}', 41 | uninstallDisplayName: '${productName}', 42 | createDesktopShortcut: 'always', 43 | deleteAppDataOnUninstall: true 44 | }, 45 | 46 | mac: { 47 | entitlementsInherit: 'build/entitlements.mac.plist', 48 | identity: null, 49 | notarize: false, 50 | target: ['dmg'] 51 | }, 52 | 53 | linux: { 54 | target: ['deb'], 55 | category: 'Utility' 56 | } 57 | } 58 | 59 | export default config 60 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | import tailwindcss from '@tailwindcss/vite' 4 | import { vite as vidstack } from 'vidstack/plugins' 5 | import path from 'node:path' 6 | 7 | export default defineConfig({ 8 | main: { 9 | plugins: [externalizeDepsPlugin()], 10 | resolve: { 11 | alias: { 12 | $shared: path.resolve(__dirname, './src/shared') 13 | } 14 | } 15 | }, 16 | preload: { 17 | plugins: [externalizeDepsPlugin()], 18 | resolve: { 19 | alias: { 20 | $shared: path.resolve(__dirname, './src/shared') 21 | } 22 | } 23 | }, 24 | renderer: { 25 | plugins: [vidstack(), svelte(), tailwindcss()], 26 | resolve: { 27 | alias: { 28 | $lib: path.resolve(__dirname, './src/renderer/src/lib'), 29 | $shared: path.resolve(__dirname, './src/shared') 30 | } 31 | } 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from '@electron-toolkit/eslint-config-ts' 2 | import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier' 3 | import eslintPluginSvelte from 'eslint-plugin-svelte' 4 | 5 | export default tseslint.config( 6 | { ignores: ['**/node_modules', '**/dist', '**/out', '**/build'] }, 7 | tseslint.configs.recommended, 8 | eslintPluginSvelte.configs['flat/recommended'], 9 | { 10 | files: ['**/*.svelte', '**/*.svelte.ts'], 11 | languageOptions: { 12 | parserOptions: { 13 | parser: tseslint.parser 14 | } 15 | } 16 | }, 17 | { 18 | files: ['**/*.svelte', '**/*.svelte.ts'], 19 | rules: { 20 | 'no-undef': 'off' 21 | } 22 | }, 23 | { 24 | files: ['**/*.svelte'], 25 | rules: { 26 | '@typescript-eslint/explicit-function-return-type': 'off' 27 | } 28 | }, 29 | eslintConfigPrettier 30 | ) 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.rt.app", 3 | "version": "0.1.0", 4 | "description": "An application for watching Twitch streams and YouTube videos.", 5 | "main": "./out/main/index.js", 6 | "license": "Apache-2.0", 7 | "homepage": "https://github.com/Kyagara/rt", 8 | "author": { 9 | "name": "Kyagara", 10 | "email": "noreply@example.com" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/Kyagara/rt.git" 15 | }, 16 | "scripts": { 17 | "format": "prettier --plugin prettier-plugin-svelte --write .", 18 | "lint": "eslint --cache .", 19 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 20 | "svelte-check": "svelte-check --tsconfig ./tsconfig.json", 21 | "typecheck": "npm run typecheck:node && npm run svelte-check", 22 | "start": "electron-vite preview", 23 | "dev": "electron-vite dev --watch", 24 | "build": "electron-vite build", 25 | "postinstall": "electron-builder install-app-deps", 26 | "build:unpack": "npm run build && electron-builder --dir", 27 | "build:win": "npm run build && electron-builder --win --config electron-builder-config.ts", 28 | "build:mac": "npm run build && electron-builder --mac --config electron-builder-config.ts", 29 | "build:linux": "npm run build && electron-builder --linux --config electron-builder-config.ts" 30 | }, 31 | "devDependencies": { 32 | "@electron-toolkit/eslint-config-prettier": "^3.0.0", 33 | "@electron-toolkit/eslint-config-ts": "^3.1.0", 34 | "@electron-toolkit/utils": "^4.0.0", 35 | "@electron-toolkit/tsconfig": "^1.0.1", 36 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 37 | "@tailwindcss/vite": "^4.1.4", 38 | "@types/better-sqlite3": "^7.6.13", 39 | "@types/node": "^22.13.13", 40 | "electron": "^35.2.1", 41 | "electron-builder": "^26.0.12", 42 | "electron-vite": "^3.1.0", 43 | "eslint": "^9.25.1", 44 | "eslint-plugin-svelte": "^3.5.1", 45 | "fast-xml-parser": "^5.2.1", 46 | "prettier": "^3.5.3", 47 | "prettier-plugin-svelte": "^3.3.3", 48 | "prettier-plugin-tailwindcss": "^0.6.11", 49 | "svelte": "^5.28.2", 50 | "svelte-check": "^4.1.6", 51 | "tailwindcss": "^4.1.4", 52 | "typescript": "^5.8.3", 53 | "vite": "^6.3.3", 54 | "dashjs": "^5.0.1", 55 | "hls.js": "^1.6.2", 56 | "vidstack": "^1.12.13", 57 | "simplebar": "^6.3.0", 58 | "youtubei.js": "^13.4.0" 59 | }, 60 | "dependencies": { 61 | "better-sqlite3": "^11.9.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/database/dbs.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import path from 'path' 3 | import SQLite, { type Database } from 'better-sqlite3' 4 | 5 | const dbPath = (name: string): string => path.join(app.getPath('userData'), `${name}.db`) 6 | 7 | let usersDB: Database 8 | let feedsDB: Database 9 | let emotesDB: Database 10 | 11 | export const initDatabases = (): void => { 12 | usersDB = new SQLite(dbPath('users')) 13 | feedsDB = new SQLite(dbPath('feeds')) 14 | emotesDB = new SQLite(dbPath('emotes')) 15 | 16 | for (const db of [usersDB, feedsDB, emotesDB]) { 17 | db.pragma('journal_mode = WAL') 18 | db.pragma('synchronous = NORMAL') 19 | } 20 | } 21 | 22 | export { usersDB, feedsDB, emotesDB } 23 | -------------------------------------------------------------------------------- /src/main/database/migrations.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'better-sqlite3' 2 | 3 | import { usersDB, feedsDB, emotesDB } from './dbs' 4 | import usersMigrations from './migrations/users' 5 | import feedsMigrations from './migrations/feeds' 6 | import emotesMigrations from './migrations/emotes' 7 | 8 | export interface Migration { 9 | version: number 10 | description: string 11 | up: (db: Database) => void 12 | } 13 | 14 | export function migrate(dbName: string, db: Database, migrations: Migration[]): void { 15 | const currentVersion = db.pragma('user_version', { simple: true }) as number 16 | 17 | const maxVersion = Math.max(...migrations.map((m) => m.version)) 18 | 19 | if (currentVersion === maxVersion) { 20 | return 21 | } 22 | 23 | const toApply = migrations 24 | .filter((m) => m.version > currentVersion && m.version <= maxVersion) 25 | .sort((a, b) => a.version - b.version) 26 | 27 | const upgrade = db.transaction(() => { 28 | for (const migration of toApply) { 29 | console.log(`[${dbName}] Applying: ${migration.description} (v${migration.version})`) 30 | 31 | if (typeof migration.up === 'string') { 32 | db.exec(migration.up) 33 | } else { 34 | migration.up(db) 35 | } 36 | 37 | db.pragma(`user_version = ${migration.version}`) 38 | } 39 | }) 40 | 41 | upgrade() 42 | console.log(`[${dbName}] Upgraded to v${maxVersion}`) 43 | } 44 | 45 | export function runMigrations(): void { 46 | migrate('users', usersDB, usersMigrations) 47 | migrate('feeds', feedsDB, feedsMigrations) 48 | migrate('emotes', emotesDB, emotesMigrations) 49 | } 50 | -------------------------------------------------------------------------------- /src/main/database/migrations/emotes.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'better-sqlite3' 2 | 3 | export default [ 4 | { 5 | version: 1, 6 | description: 'Create emotes table', 7 | up: (db: Database): void => { 8 | db.exec(` 9 | CREATE TABLE IF NOT EXISTS twitch ( 10 | username TEXT NOT NULL, 11 | name TEXT NOT NULL, 12 | url TEXT, 13 | width INTEGER, 14 | height INTEGER, 15 | PRIMARY KEY (username, name) 16 | ); 17 | `) 18 | } 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /src/main/database/migrations/feeds.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'better-sqlite3' 2 | 3 | export default [ 4 | { 5 | version: 1, 6 | description: 'Create feeds table', 7 | up: (db: Database): void => { 8 | db.exec(` 9 | CREATE TABLE IF NOT EXISTS twitch ( 10 | username TEXT PRIMARY KEY, 11 | started_at TEXT 12 | ); 13 | 14 | CREATE TABLE IF NOT EXISTS youtube ( 15 | id TEXT PRIMARY KEY, 16 | username TEXT NOT NULL, 17 | title TEXT, 18 | published_at INTEGER, 19 | view_count TEXT 20 | ); 21 | 22 | CREATE INDEX idx_title ON youtube (title); 23 | CREATE INDEX idx_published_at ON youtube (published_at); 24 | `) 25 | } 26 | } 27 | ] 28 | -------------------------------------------------------------------------------- /src/main/database/migrations/users.ts: -------------------------------------------------------------------------------- 1 | import { Database } from 'better-sqlite3' 2 | 3 | export default [ 4 | { 5 | version: 1, 6 | description: 'Create users table', 7 | up: (db: Database): void => { 8 | db.exec(` 9 | CREATE TABLE IF NOT EXISTS twitch ( 10 | id TEXT PRIMARY KEY, 11 | username TEXT NOT NULL, 12 | display_name TEXT, 13 | avatar BLOB 14 | ); 15 | 16 | CREATE TABLE IF NOT EXISTS youtube ( 17 | id TEXT PRIMARY KEY, 18 | username TEXT NOT NULL, 19 | display_name TEXT, 20 | avatar BLOB 21 | ); 22 | `) 23 | } 24 | } 25 | ] 26 | -------------------------------------------------------------------------------- /src/main/feed.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from '$shared/enums.js' 2 | import { usersDB, feedsDB } from './database/dbs.js' 3 | import { fetchLiveNow } from './twitch/stream.js' 4 | import { fetchFeedVideos } from './youtube/video.js' 5 | 6 | export function getFeed(platform: Platform, lastPublishedAt?: number): Feed { 7 | if (platform === Platform.Twitch) { 8 | try { 9 | const stmt = feedsDB.prepare('SELECT username, started_at FROM twitch') 10 | const rows = stmt.all() as LiveNow[] 11 | 12 | const feed: LiveNow[] = rows.map((row) => ({ 13 | username: row.username, 14 | started_at: row.started_at 15 | })) 16 | 17 | return { 18 | twitch: feed, 19 | youtube: null 20 | } 21 | } catch (err) { 22 | throw new Error(`Querying feed: ${err}`) 23 | } 24 | } 25 | 26 | if (platform === Platform.YouTube) { 27 | let sql: string 28 | if (lastPublishedAt) { 29 | sql = 30 | 'SELECT id, username, title, published_at, view_count FROM youtube WHERE published_at < ? ORDER BY published_at DESC LIMIT 50' 31 | } else { 32 | sql = 33 | 'SELECT id, username, title, published_at, view_count FROM youtube ORDER BY published_at DESC LIMIT 50' 34 | } 35 | 36 | const stmt = feedsDB.prepare(sql) 37 | const rows = lastPublishedAt 38 | ? (stmt.all(lastPublishedAt) as FeedVideo[]) 39 | : (stmt.all() as FeedVideo[]) 40 | 41 | const feed: FeedVideo[] = rows.map((row) => ({ 42 | id: row.id, 43 | username: row.username, 44 | title: row.title, 45 | published_at: row.published_at, 46 | view_count: row.view_count 47 | })) 48 | 49 | return { 50 | twitch: null, 51 | youtube: feed 52 | } 53 | } 54 | 55 | throw new Error(`Invalid platform '${platform}'`) 56 | } 57 | 58 | export async function refreshFeed(platform: Platform): Promise { 59 | if (platform === Platform.Twitch) { 60 | try { 61 | const getStmt = usersDB.prepare('SELECT username FROM twitch') 62 | const rows = getStmt.all() as { username: string }[] 63 | 64 | const usernames = rows.map((row) => row.username) 65 | 66 | const liveNow = await fetchLiveNow(usernames) 67 | 68 | feedsDB.prepare('DELETE FROM twitch').run() 69 | 70 | const insertStmt = feedsDB.prepare('INSERT INTO twitch (username, started_at) VALUES (?, ?)') 71 | 72 | const insertStreams = feedsDB.transaction((liveNow) => { 73 | for (const live of liveNow) { 74 | insertStmt.run(live.username, live.started_at) 75 | } 76 | }) 77 | 78 | insertStreams(liveNow) 79 | 80 | const feed: Feed = { 81 | twitch: liveNow, 82 | youtube: null 83 | } 84 | 85 | return feed 86 | } catch (err) { 87 | throw new Error(`Refreshing Twitch feed: ${err}`) 88 | } 89 | } 90 | 91 | if (platform === Platform.YouTube) { 92 | try { 93 | const stmt = usersDB.prepare('SELECT id FROM youtube') 94 | const rows = stmt.all() as { id: string }[] 95 | const channelIds = rows.map((row) => row.id) 96 | 97 | const videos = await fetchFeedVideos(channelIds) 98 | 99 | feedsDB.prepare('DELETE FROM youtube').run() 100 | 101 | const insertStmt = feedsDB.prepare( 102 | 'INSERT INTO youtube (id, username, title, published_at, view_count) VALUES (?, ?, ?, ?, ?)' 103 | ) 104 | 105 | const insertVideos = feedsDB.transaction((videos) => { 106 | for (const video of videos) { 107 | insertStmt.run( 108 | video.id, 109 | video.username, 110 | video.title, 111 | video.published_at, 112 | video.view_count 113 | ) 114 | } 115 | }) 116 | 117 | insertVideos(videos) 118 | 119 | const initialFeed = videos.slice(0, 50).sort((a, b) => b.published_at - a.published_at) 120 | 121 | const feed: Feed = { 122 | twitch: null, 123 | youtube: initialFeed 124 | } 125 | 126 | return feed 127 | } catch (err) { 128 | throw new Error(`Refreshing YouTube feed: ${err}`) 129 | } 130 | } 131 | 132 | throw new Error(`Invalid platform '${platform}'`) 133 | } 134 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, shell, BrowserWindow, ipcMain, Menu } from 'electron' 2 | import path, { join } from 'node:path' 3 | import { electronApp, optimizer, is } from '@electron-toolkit/utils' 4 | 5 | import { runMigrations } from './database/migrations' 6 | import { initDatabases } from './database/dbs' 7 | import { handleURL, upsertKeyValue } from './utils' 8 | 9 | initDatabases() 10 | runMigrations() 11 | import './ipc' 12 | 13 | function createWindow(url?: string): void { 14 | const window = new BrowserWindow({ 15 | width: 1200, 16 | height: 600, 17 | show: false, 18 | darkTheme: true, 19 | frame: false, 20 | webPreferences: { 21 | preload: join(__dirname, '../preload/index.js') 22 | } 23 | }) 24 | 25 | window.on('ready-to-show', () => { 26 | window.show() 27 | }) 28 | 29 | window.webContents.setWindowOpenHandler((details) => { 30 | shell.openExternal(details.url) 31 | return { action: 'deny' } 32 | }) 33 | 34 | const { view, path } = handleURL(url) 35 | 36 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 37 | window.loadURL(`${process.env['ELECTRON_RENDERER_URL']}?view=${view}&path=${path}`) 38 | } else { 39 | const query: Record = { view: view, path: path } 40 | window.loadFile(join(__dirname, '../renderer/index.html'), { query: query }) 41 | } 42 | 43 | window.on('resized', () => { 44 | window.webContents.send('titlebar:resized', window.isMaximized()) 45 | }) 46 | 47 | window.on('maximize', () => { 48 | window.webContents.send('titlebar:maximized', true) 49 | }) 50 | 51 | window.on('unmaximize', () => { 52 | window.webContents.send('titlebar:maximized', false) 53 | }) 54 | 55 | // https://pratikpc.medium.com/bypassing-cors-with-electron-ab7eaf331605 56 | window.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { 57 | const { requestHeaders, referrer } = details 58 | if (referrer === 'https://www.youtube-nocookie.com/') { 59 | callback({ 60 | requestHeaders 61 | }) 62 | 63 | return 64 | } 65 | 66 | upsertKeyValue(requestHeaders, 'Access-Control-Allow-Origin', ['*']) 67 | 68 | callback({ requestHeaders }) 69 | }) 70 | 71 | window.webContents.session.webRequest.onHeadersReceived((details, callback) => { 72 | const { responseHeaders, referrer } = details 73 | if (referrer === 'https://www.youtube-nocookie.com/') { 74 | callback({ 75 | responseHeaders 76 | }) 77 | 78 | return 79 | } 80 | 81 | upsertKeyValue(responseHeaders, 'Access-Control-Allow-Origin', ['*']) 82 | upsertKeyValue(responseHeaders, 'Access-Control-Allow-Headers', ['*']) 83 | 84 | callback({ 85 | responseHeaders 86 | }) 87 | }) 88 | } 89 | 90 | if (process.defaultApp) { 91 | if (process.argv.length >= 2) { 92 | app.setAsDefaultProtocolClient('rt', process.execPath, [path.resolve(process.argv[1])]) 93 | } 94 | } else { 95 | app.setAsDefaultProtocolClient('rt') 96 | } 97 | 98 | const gotTheLock = app.requestSingleInstanceLock() 99 | if (!gotTheLock) { 100 | app.quit() 101 | } else { 102 | Menu.setApplicationMenu(null) 103 | app.enableSandbox() 104 | electronApp.setAppUserModelId('rt') 105 | 106 | app.on('browser-window-created', (_, window) => { 107 | optimizer.watchWindowShortcuts(window) 108 | }) 109 | 110 | app.on('second-instance', (_event, url) => { 111 | createWindow(url.join()) 112 | }) 113 | 114 | app.on('activate', function () { 115 | if (BrowserWindow.getAllWindows().length === 0) { 116 | createWindow() 117 | } 118 | }) 119 | 120 | app.whenReady().then(() => { 121 | const url = process.argv.find((arg) => arg.startsWith('rt://')) 122 | createWindow(url) 123 | }) 124 | 125 | app.on('window-all-closed', () => { 126 | if (process.platform !== 'darwin') { 127 | app.quit() 128 | } 129 | }) 130 | } 131 | 132 | ipcMain.on('main:new-window', (_event, url: string) => { 133 | createWindow(url) 134 | }) 135 | -------------------------------------------------------------------------------- /src/main/ipc.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain } from 'electron' 2 | 3 | import { getUser, listUsers, addUser, removeUser } from './user' 4 | import { getFeed, refreshFeed } from './feed' 5 | import { fetchStream, fetchStreamInfo } from './twitch/stream' 6 | import { getUserEmotes } from './twitch/emote' 7 | import { fetchVideo } from './youtube/video' 8 | import { importSubscriptions } from './youtube/channel' 9 | 10 | ipcMain.on('titlebar:minimize', (event) => { 11 | const win = BrowserWindow.fromWebContents(event.sender) 12 | if (win) { 13 | win.minimize() 14 | } 15 | }) 16 | 17 | ipcMain.on('titlebar:maximize', (event) => { 18 | const win = BrowserWindow.fromWebContents(event.sender) 19 | if (win) { 20 | if (win.isMaximized()) { 21 | win.unmaximize() 22 | win.webContents.send('titlebar:event', 'unmaximize') 23 | } else { 24 | win.maximize() 25 | win.webContents.send('titlebar:event', 'maximize') 26 | } 27 | } 28 | }) 29 | 30 | ipcMain.on('titlebar:close', (event) => { 31 | const win = BrowserWindow.fromWebContents(event.sender) 32 | if (win) { 33 | win.close() 34 | } 35 | }) 36 | 37 | // User 38 | ipcMain.handle( 39 | 'user:add', 40 | async (_event, platform, username, id) => await addUser(platform, username, id) 41 | ) 42 | ipcMain.handle( 43 | 'user:get', 44 | async (_event, platform, username, id) => await getUser(platform, username, id) 45 | ) 46 | ipcMain.handle('user:list', async (_event, platform) => await listUsers(platform)) 47 | ipcMain.handle( 48 | 'user:remove', 49 | async (_event, platform, username) => await removeUser(platform, username) 50 | ) 51 | 52 | // Feed 53 | ipcMain.handle('feed:get', async (_event, platform, lastPublishedAt) => 54 | getFeed(platform, lastPublishedAt) 55 | ) 56 | ipcMain.handle('feed:refresh', async (_event, platform) => await refreshFeed(platform)) 57 | 58 | // Stream 59 | ipcMain.handle( 60 | 'stream:get', 61 | async (_event, username, backup) => await fetchStream(username, backup) 62 | ) 63 | ipcMain.handle('stream:emotes', async (_event, username) => await getUserEmotes(username)) 64 | ipcMain.handle('stream:info', async (_event, username) => await fetchStreamInfo(username)) 65 | 66 | // Video 67 | ipcMain.handle( 68 | 'video:get', 69 | async (_event, videoID, retrievePlayer) => await fetchVideo(videoID, retrievePlayer) 70 | ) 71 | ipcMain.handle('video:import', async () => await importSubscriptions()) 72 | -------------------------------------------------------------------------------- /src/main/twitch/emote.ts: -------------------------------------------------------------------------------- 1 | import { emotesDB } from '../database/dbs' 2 | import { SubscriptionProduct } from './query' 3 | 4 | const TWITCH_EMOTES_CDN: string = 'https://static-cdn.jtvnw.net/emoticons/v2' 5 | const SEVENTV_API: string = 'https://7tv.io/v3' 6 | const BETTERTV_API: string = 'https://api.betterttv.net/3' 7 | 8 | type EmoteDB = { 9 | name: string 10 | url: string 11 | width: number 12 | height: number 13 | } 14 | 15 | export async function getUserEmotes(username: string): Promise> { 16 | const query = 'SELECT name, url, width, height FROM twitch WHERE username = ?' 17 | 18 | const rows = emotesDB.prepare(query).all(username) as EmoteDB[] 19 | 20 | const emotes: Record = {} 21 | 22 | for (const row of rows) { 23 | const emote: Emote = { 24 | n: row.name, 25 | u: row.url, 26 | w: row.width, 27 | h: row.height 28 | } 29 | 30 | emotes[row.name] = emote 31 | } 32 | 33 | return emotes 34 | } 35 | 36 | export async function updateUserEmotes( 37 | username: string, 38 | emotes: Record 39 | ): Promise { 40 | if (!emotes || Object.keys(emotes).length === 0) { 41 | return 42 | } 43 | 44 | const query = 'DELETE FROM twitch WHERE username = ?' 45 | emotesDB.prepare(query).run(username) 46 | 47 | const insertStmt = emotesDB.prepare( 48 | 'INSERT INTO twitch (username, name, url, width, height) VALUES (?, ?, ?, ?, ?)' 49 | ) 50 | 51 | const insertEmotes = emotesDB.transaction((emotes: Record) => { 52 | Object.entries(emotes).forEach(([name, emote]) => { 53 | if (name === '' || username === '') return 54 | insertStmt.run(username, name, emote.u, emote.w, emote.h) 55 | }) 56 | }) 57 | 58 | insertEmotes(emotes) 59 | } 60 | 61 | export function parseSubscriptionProducts( 62 | subscriptionProducts: SubscriptionProduct[] 63 | ): Record { 64 | const userEmotes: Record = {} 65 | 66 | for (const product of subscriptionProducts) { 67 | for (const e of product.emotes) { 68 | const name = e.token 69 | const url = `${TWITCH_EMOTES_CDN}/${e.id}/default/dark/1.0` 70 | 71 | const emote: Emote = { 72 | n: name, 73 | u: url, 74 | w: 28, 75 | h: 28 76 | } 77 | 78 | userEmotes[name] = emote 79 | } 80 | } 81 | 82 | return userEmotes 83 | } 84 | 85 | type BetterTTVResponse = { 86 | channelEmotes: BetterTTVEmote[] 87 | sharedEmotes: BetterTTVEmote[] 88 | } 89 | 90 | type BetterTTVEmote = { 91 | id: string 92 | code: string 93 | width?: number 94 | height?: number 95 | } 96 | 97 | export async function fetchBetterTTVEmotes(id: string): Promise> { 98 | const response = await fetch(`${BETTERTV_API}/cached/users/twitch/${id}`) 99 | 100 | if (!response.ok) { 101 | throw new Error(`BetterTTV request returned ${response.status}`) 102 | } 103 | 104 | const json: BetterTTVResponse = await response.json() 105 | 106 | const rawEmotes = [...json.channelEmotes, ...json.sharedEmotes] 107 | 108 | const emotes: Record = {} 109 | 110 | for (const e of rawEmotes) { 111 | const name = e.code 112 | const url = `https://cdn.betterttv.net/emote/${e.id}/1x` 113 | const emote: Emote = { 114 | n: name, 115 | u: url, 116 | w: e.width ?? 28, 117 | h: e.height ?? 28 118 | } 119 | 120 | emotes[name] = emote 121 | } 122 | 123 | return emotes 124 | } 125 | 126 | type SevenTVResponse = { 127 | emote_set: { 128 | emotes: { 129 | name: string 130 | data: { 131 | host: { 132 | url: string 133 | files: { 134 | name: string 135 | width: number 136 | height: number 137 | format: string 138 | }[] 139 | } 140 | } 141 | }[] 142 | } 143 | } 144 | 145 | export async function fetch7TVEmotes(id: string): Promise> { 146 | const response = await fetch(`${SEVENTV_API}/users/twitch/${id}`) 147 | 148 | if (!response.ok) { 149 | throw new Error(`7TV request returned ${response.status}`) 150 | } 151 | 152 | const json: SevenTVResponse = await response.json() 153 | 154 | const emotes: Record = {} 155 | 156 | for (const e of json.emote_set.emotes) { 157 | const name = e.name 158 | const host = e.data.host 159 | 160 | // Assign a priority to each file format 161 | const priority = (format: string): number => { 162 | switch (format.toUpperCase()) { 163 | case 'AVIF': 164 | return 0 165 | case 'WEBP': 166 | return 1 167 | case 'PNG': 168 | return 2 169 | case 'GIF': 170 | return 3 171 | default: 172 | return -1 173 | } 174 | } 175 | 176 | // Find the file with the highest priority (lowest number) 177 | let bestPriority: number | null = null 178 | let bestFile: { 179 | name: string 180 | width: number 181 | height: number 182 | format: string 183 | } | null = null 184 | 185 | for (const file of host.files) { 186 | if (bestPriority === null || priority(file.format) > bestPriority) { 187 | bestPriority = priority(file.format) 188 | bestFile = file 189 | } 190 | } 191 | 192 | if (bestFile === null) { 193 | continue 194 | } 195 | 196 | const newEmote: Emote = { 197 | n: name, 198 | u: `https:${host.url}/${bestFile.name}`, 199 | w: bestFile.width, 200 | h: bestFile.height 201 | } 202 | 203 | emotes[name] = newEmote 204 | } 205 | 206 | return emotes 207 | } 208 | -------------------------------------------------------------------------------- /src/main/twitch/query.ts: -------------------------------------------------------------------------------- 1 | const GRAPHQL_API: string = 'https://gql.twitch.tv/gql' 2 | 3 | const CLIENT_ID: string = 'kimne78kx3ncx6brgo4mv6wki5h1ko' 4 | 5 | const TURBO_AND_SUB_UPSELL_QUERY_HASH: string = 6 | '5dbca380e47e37808c89479f51f789990ec653428a01b76c649ebe01afb3aa7e' 7 | 8 | const USE_LIVE_QUERY_HASH: string = 9 | '639d5f11bfb8bf3053b424d9ef650d04c4ebb7d94711d644afb08fe9a0fad5d9' 10 | 11 | export class TurboAndSubUpsellQuery { 12 | operationName: string 13 | variables: ChannelLoginVariable 14 | extensions: QueryExtensions 15 | 16 | constructor(username: string) { 17 | this.operationName = 'TurboAndSubUpsell' 18 | this.variables = ChannelLoginVariable.new(username) 19 | this.extensions = QueryExtensions.new(TURBO_AND_SUB_UPSELL_QUERY_HASH) 20 | } 21 | 22 | static new(username: string): TurboAndSubUpsellQuery { 23 | return new TurboAndSubUpsellQuery(username) 24 | } 25 | } 26 | 27 | export type TurboAndSubUpsellResponse = { 28 | data: { 29 | user: { 30 | id: string 31 | displayName: string 32 | profileImageURL: string 33 | subscriptionProducts: SubscriptionProduct[] 34 | } | null 35 | } 36 | } 37 | 38 | export type SubscriptionProduct = { 39 | emotes: { id: string; token: string }[] 40 | } 41 | 42 | export class UseLiveQuery { 43 | operationName: string 44 | variables: ChannelLoginVariable 45 | extensions: QueryExtensions 46 | 47 | constructor(username: string) { 48 | this.operationName = 'UseLive' 49 | this.variables = ChannelLoginVariable.new(username) 50 | this.extensions = QueryExtensions.new(USE_LIVE_QUERY_HASH) 51 | } 52 | 53 | static new(username: string): UseLiveQuery { 54 | return new UseLiveQuery(username) 55 | } 56 | } 57 | 58 | export type UseLiveResponse = { 59 | data: { 60 | user: { 61 | login: string 62 | stream: { 63 | createdAt: string 64 | } | null 65 | } 66 | } 67 | } 68 | 69 | export class StreamInfoQuery { 70 | query: string 71 | 72 | constructor(username: string) { 73 | this.query = `query { 74 | user(login: "${username}") { 75 | stream { 76 | title 77 | type 78 | viewersCount 79 | createdAt 80 | game { 81 | id 82 | name 83 | } 84 | } 85 | } 86 | }` 87 | } 88 | 89 | static new(username: string): StreamInfoQuery { 90 | return new StreamInfoQuery(username) 91 | } 92 | } 93 | 94 | export type StreamInfoResponse = { 95 | data: { 96 | user: { 97 | stream: { 98 | title: string 99 | type: string 100 | viewersCount: number 101 | createdAt: string 102 | game: { 103 | id: string 104 | name: string 105 | } | null 106 | } | null 107 | } | null 108 | } 109 | } 110 | 111 | export class PlaybackAccessTokenQuery { 112 | operationName: string 113 | query: string 114 | variables: PlaybackAccessTokenQueryVariables 115 | 116 | constructor(username: string, backup: boolean) { 117 | this.operationName = 'PlaybackAccessToken_Template' 118 | this.query = `query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!, $platform: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: $platform, playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isLive) { value signature authorization { isForbidden forbiddenReasonCode } __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: $platform, playerBackend: "mediaplayer", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}` 119 | this.variables = PlaybackAccessTokenQuery.variables(username, backup) 120 | } 121 | 122 | static new(username: string, backup: boolean): PlaybackAccessTokenQuery { 123 | return new PlaybackAccessTokenQuery(username, backup) 124 | } 125 | 126 | static variables(username: string, backup: boolean): PlaybackAccessTokenQueryVariables { 127 | return { 128 | login: username, 129 | isLive: true, 130 | isVod: false, 131 | vodID: '', 132 | playerType: backup ? 'autoplay' : 'site', 133 | platform: backup ? 'ios' : 'web' 134 | } 135 | } 136 | } 137 | 138 | type PlaybackAccessTokenQueryVariables = { 139 | login: string 140 | isLive: boolean 141 | isVod: boolean 142 | vodID: string 143 | playerType: string 144 | platform: string 145 | } 146 | 147 | export type PlaybackAccessTokenResponse = { 148 | data: { 149 | streamPlaybackAccessToken: { 150 | value: string 151 | signature: string 152 | authorization: { 153 | isForbidden: boolean 154 | forbiddenReasonCode: string 155 | } 156 | } 157 | } 158 | } 159 | 160 | // Some queries have this variable 161 | class ChannelLoginVariable { 162 | channelLogin: string 163 | 164 | constructor(username: string) { 165 | this.channelLogin = username 166 | } 167 | 168 | static new(username: string): ChannelLoginVariable { 169 | return new ChannelLoginVariable(username) 170 | } 171 | } 172 | 173 | // Every persistent query has these fields 174 | 175 | class QueryExtensions { 176 | persistedQuery: PersistedQuery 177 | 178 | constructor(hash: string) { 179 | this.persistedQuery = PersistedQuery.new(hash) 180 | } 181 | 182 | static new(hash: string): QueryExtensions { 183 | return new QueryExtensions(hash) 184 | } 185 | } 186 | 187 | class PersistedQuery { 188 | version: number 189 | sha256Hash: string 190 | 191 | constructor(hash: string) { 192 | this.version = 1 193 | this.sha256Hash = hash 194 | } 195 | 196 | static new(hash: string): PersistedQuery { 197 | return new PersistedQuery(hash) 198 | } 199 | } 200 | 201 | export async function sendQuery(body: string): Promise { 202 | return await fetch(GRAPHQL_API, { 203 | method: 'POST', 204 | body: body, 205 | headers: { 206 | 'Content-Type': 'application/json', 207 | 'Client-ID': CLIENT_ID 208 | } 209 | }) 210 | .then((res) => res.json()) 211 | .catch((err) => { 212 | throw new Error(`Requesting UseLive: ${err}`) 213 | }) 214 | } 215 | -------------------------------------------------------------------------------- /src/main/twitch/stream.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PlaybackAccessTokenQuery, 3 | PlaybackAccessTokenResponse, 4 | sendQuery, 5 | StreamInfoQuery, 6 | StreamInfoResponse, 7 | UseLiveQuery, 8 | UseLiveResponse 9 | } from './query' 10 | 11 | export async function fetchLiveNow(usernames: string[]): Promise { 12 | if (usernames.length === 0) { 13 | return [] 14 | } 15 | 16 | const query: UseLiveQuery[] = [] 17 | 18 | for (const username of usernames) { 19 | if (username.length === 0) { 20 | continue 21 | } 22 | 23 | query.push(UseLiveQuery.new(username)) 24 | } 25 | 26 | const response = await sendQuery(JSON.stringify(query)) 27 | 28 | const liveNow: LiveNow[] = [] 29 | 30 | for (const obj of response) { 31 | if (obj.data.user.stream === null) { 32 | continue 33 | } 34 | 35 | const stream = obj.data.user.stream 36 | const username = obj.data.user.login 37 | 38 | const live: LiveNow = { 39 | username: username, 40 | started_at: stream.createdAt 41 | } 42 | 43 | liveNow.push(live) 44 | } 45 | 46 | return liveNow 47 | } 48 | 49 | export async function fetchStream(username: string, backup: boolean): Promise { 50 | const gql = [UseLiveQuery.new(username), PlaybackAccessTokenQuery.new(username, backup)] 51 | 52 | const response = await sendQuery<[UseLiveResponse, PlaybackAccessTokenResponse]>( 53 | JSON.stringify(gql) 54 | ) 55 | 56 | const useLive = response[0] 57 | if (useLive.data.user.stream === null) { 58 | return '' 59 | } 60 | 61 | const streamPlayback = response[1].data.streamPlaybackAccessToken 62 | 63 | let url = `https://usher.ttvnw.net/api/channel/hls/${username}.m3u8` 64 | 65 | const randomNumber = Math.floor(Math.random() * 10_000_000) + 1_000_000 66 | 67 | if (backup) { 68 | url += `?platform=ios&supported_codecs=h264&player=twitchweb&fast_bread=true&p=${randomNumber}&sig=${streamPlayback.signature}&token=${streamPlayback.value}` 69 | } else { 70 | url += `?platform=web&supported_codecs=av1,h265,h264&allow_source=true&player=twitchweb&fast_bread=true&p=${randomNumber}&sig=${streamPlayback.signature}&token=${streamPlayback.value}` 71 | } 72 | 73 | return url 74 | } 75 | 76 | export async function fetchStreamInfo(username: string): Promise { 77 | const gql = StreamInfoQuery.new(username) 78 | 79 | const response = await sendQuery(JSON.stringify(gql)) 80 | 81 | if (!response.data.user) { 82 | throw new Error(`User '${username}' not found`) 83 | } 84 | 85 | if (!response.data.user.stream) { 86 | throw new Error(`User '${username}' is not live`) 87 | } 88 | 89 | const stream = response.data.user.stream 90 | 91 | let box_art = 'https://static-cdn.jtvnw.net/ttv-static/404_boxart-144x192.jpg' 92 | if (stream.game && stream.game.id) { 93 | const box_art_url = `https://static-cdn.jtvnw.net/ttv-boxart/${stream.game.id}-144x192.jpg` 94 | 95 | try { 96 | const response = await fetch(box_art_url) 97 | if (response.redirected) { 98 | box_art = `https://static-cdn.jtvnw.net/ttv-boxart/${stream.game.id}_IGDB-144x192.jpg` 99 | } else if (response.status === 200) { 100 | box_art = box_art_url 101 | } 102 | } catch (err) { 103 | console.error(`Error fetching box art: ${err}`) 104 | } 105 | } 106 | 107 | return { 108 | title: stream.title, 109 | game: stream.game ? stream.game.name : 'N/A', 110 | box_art: box_art, 111 | started_at: stream.createdAt, 112 | viewer_count: stream.viewersCount 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/main/twitch/user.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from '$shared/enums' 2 | import { downloadImage } from '../utils' 3 | import { fetch7TVEmotes, fetchBetterTTVEmotes, parseSubscriptionProducts } from './emote' 4 | import { sendQuery, TurboAndSubUpsellQuery, TurboAndSubUpsellResponse } from './query' 5 | 6 | export async function fetchTwitchUser( 7 | username: string 8 | ): Promise<{ user: User; emotes: Record }> { 9 | const gql = TurboAndSubUpsellQuery.new(username) 10 | 11 | const response = await sendQuery(JSON.stringify(gql)) 12 | 13 | if (!response.data.user) { 14 | throw new Error(`User '${username}' not found`) 15 | } 16 | 17 | const twitchUser = response.data.user 18 | 19 | const userEmotes = parseSubscriptionProducts(twitchUser.subscriptionProducts) 20 | 21 | const user_id = twitchUser.id 22 | 23 | const sevenTV = await fetch7TVEmotes(user_id) 24 | const betterTTV = await fetchBetterTTVEmotes(user_id) 25 | 26 | const emotes: Record = { ...userEmotes, ...sevenTV, ...betterTTV } 27 | 28 | const avatar = await downloadImage(twitchUser.profileImageURL) 29 | 30 | const user = { 31 | id: twitchUser.id, 32 | username, 33 | display_name: twitchUser.displayName, 34 | avatar, 35 | platform: Platform.Twitch 36 | } 37 | 38 | return { user, emotes } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/user.ts: -------------------------------------------------------------------------------- 1 | import type { Database, Statement } from 'better-sqlite3' 2 | 3 | import { usersDB, feedsDB, emotesDB } from './database/dbs.js' 4 | import { Platform } from '../shared/enums.js' 5 | import { fetchTwitchUser } from './twitch/user.js' 6 | import { updateUserEmotes } from './twitch/emote.js' 7 | import { fetchChannelByID, fetchChannelByName } from './youtube/channel.js' 8 | 9 | export async function getUser( 10 | platform: Platform, 11 | username?: string, 12 | id?: string 13 | ): Promise { 14 | const table = platform === Platform.YouTube ? 'youtube' : 'twitch' 15 | 16 | let stmt: Statement 17 | if (username) { 18 | stmt = usersDB.prepare(`SELECT id, username, avatar FROM ${table} WHERE username = ?`) 19 | } else if (id) { 20 | stmt = usersDB.prepare(`SELECT id, username, avatar FROM ${table} WHERE id = ?`) 21 | } else { 22 | throw new Error('Invalid parameters') 23 | } 24 | 25 | const user = stmt.get(username || id) as User 26 | return user 27 | } 28 | 29 | export async function listUsers(platform?: Platform): Promise { 30 | const users: User[] = [] 31 | 32 | function fetchPlatformUsers(platform: Platform): User[] { 33 | const table = platform === Platform.YouTube ? 'youtube' : 'twitch' 34 | const stmt = usersDB.prepare(`SELECT id, username, display_name, avatar FROM ${table}`) 35 | const rows = stmt.all() as User[] 36 | 37 | return rows.map((row) => ({ 38 | id: row.id, 39 | username: row.username, 40 | display_name: row.display_name, 41 | avatar: row.avatar, 42 | platform: platform 43 | })) 44 | } 45 | 46 | if (platform) { 47 | return fetchPlatformUsers(platform) 48 | } 49 | 50 | users.push(...fetchPlatformUsers(Platform.YouTube)) 51 | users.push(...fetchPlatformUsers(Platform.Twitch)) 52 | 53 | return users 54 | } 55 | 56 | export async function addUser( 57 | platform: Platform, 58 | username?: string, 59 | id?: string 60 | ): Promise { 61 | let newUser: User | null = null 62 | 63 | if (platform === Platform.Twitch) { 64 | if (!username) throw new Error('Username not provided') 65 | 66 | const { user, emotes } = await fetchTwitchUser(username) 67 | newUser = user 68 | 69 | updateUserEmotes(username, emotes) 70 | 71 | const stmt = usersDB.prepare( 72 | 'INSERT INTO twitch (id, username, display_name, avatar) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET avatar = ?, display_name = ?' 73 | ) 74 | 75 | stmt.run( 76 | newUser.id, 77 | newUser.username, 78 | newUser.display_name, 79 | newUser.avatar, 80 | newUser.avatar, 81 | newUser.display_name 82 | ) 83 | } 84 | 85 | if (platform === Platform.YouTube) { 86 | if (!username && !id) throw new Error('Username or ID not provided') 87 | 88 | if (id) { 89 | newUser = await fetchChannelByID(id) 90 | } else if (username) { 91 | newUser = await fetchChannelByName(username) 92 | } 93 | 94 | if (!newUser) throw new Error('User not found') 95 | 96 | const stmt = usersDB.prepare( 97 | 'INSERT INTO youtube (id, username, display_name, avatar) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET avatar = ?, display_name = ?' 98 | ) 99 | 100 | stmt.run( 101 | newUser.id, 102 | newUser.username, 103 | newUser.display_name, 104 | newUser.avatar, 105 | newUser.avatar, 106 | newUser.display_name 107 | ) 108 | } 109 | 110 | return newUser 111 | } 112 | 113 | export async function removeUser(platform: Platform, username: string): Promise { 114 | if (!username) throw new Error('Username not provided') 115 | 116 | const table = platform === Platform.YouTube ? 'youtube' : 'twitch' 117 | 118 | const deleteFrom = (db: Database, table: string): void => { 119 | db.prepare(`DELETE FROM ${table} WHERE username = ?`).run(username) 120 | } 121 | 122 | deleteFrom(usersDB, table) 123 | deleteFrom(feedsDB, table) 124 | if (platform === Platform.Twitch) { 125 | deleteFrom(emotesDB, table) 126 | } 127 | 128 | return username 129 | } 130 | -------------------------------------------------------------------------------- /src/main/utils.ts: -------------------------------------------------------------------------------- 1 | import { View } from '$shared/enums' 2 | 3 | const TWITCH_REGEX = /(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([a-zA-Z0-9_]+)/ 4 | 5 | const YOUTUBE_REGEX = 6 | /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|embed|shorts|watch)?\??v=|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/ 7 | 8 | export function handleURL(url?: string): { view: View; path: string } { 9 | if (!url) return { view: View.Videos, path: '' } 10 | 11 | if (url.startsWith('/videos/watch')) { 12 | return { view: View.Videos, path: url.replace('/videos/', '/') } 13 | } else if (url.startsWith('/streams/watch')) { 14 | return { view: View.Streams, path: url.replace('/streams/', '/') } 15 | } 16 | 17 | if (url.startsWith('rt://tw/') || url.startsWith('rt://twitch/')) { 18 | const username = url.replace(/^rt:\/\/(tw|twitch)\//, '').trim() 19 | if (!username) return { view: View.Streams, path: '' } 20 | return { view: View.Streams, path: `/watch?username=${username}` } 21 | } 22 | 23 | const twMatches = url.match(TWITCH_REGEX) 24 | if (twMatches && twMatches[1]) { 25 | const username = twMatches[1] 26 | if (!username) return { view: View.Streams, path: '' } 27 | return { view: View.Streams, path: `/watch?username=${username}` } 28 | } 29 | 30 | if (url.startsWith('rt://yt/') || url.startsWith('rt://youtube/')) { 31 | const videoID = url.replace(/^rt:\/\/(yt|youtube)\//, '').trim() 32 | if (!videoID) return { view: View.Videos, path: '' } 33 | return { view: View.Videos, path: `/watch?id=${videoID}` } 34 | } 35 | 36 | if (url.includes('www.youtube.com/feed/subscriptions')) { 37 | return { view: View.Videos, path: '' } 38 | } 39 | 40 | const ytMatches = url.match(YOUTUBE_REGEX) 41 | if (ytMatches && ytMatches[1]) { 42 | const videoID = ytMatches[1] 43 | if (!videoID) return { view: View.Videos, path: '' } 44 | return { view: View.Videos, path: `/watch?id=${videoID}` } 45 | } 46 | 47 | return { view: View.Videos, path: '' } 48 | } 49 | 50 | export async function downloadImage(url: string): Promise { 51 | if (url.length === 0) { 52 | console.error('Image URL is empty') 53 | return new Uint8Array() 54 | } 55 | 56 | const response = await fetch(url) 57 | 58 | if (!response.ok) { 59 | console.error(`Downloading image: ${url}`) 60 | return new Uint8Array() 61 | } 62 | 63 | const bytes = await response.arrayBuffer() 64 | 65 | return new Uint8Array(bytes) 66 | } 67 | 68 | export function upsertKeyValue( 69 | obj: Record | undefined, 70 | keyToChange: string, 71 | value: string[] 72 | ): void { 73 | if (!obj) { 74 | return 75 | } 76 | 77 | const keyToChangeLower = keyToChange.toLowerCase() 78 | for (const key of Object.keys(obj)) { 79 | if (key.toLowerCase() === keyToChangeLower) { 80 | obj[key] = value 81 | return 82 | } 83 | } 84 | 85 | obj[keyToChange] = value 86 | } 87 | -------------------------------------------------------------------------------- /src/main/youtube/channel.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from 'electron' 2 | import { readFile } from 'node:fs/promises' 3 | import { Innertube } from 'youtubei.js' 4 | 5 | import { usersDB } from '../database/dbs' 6 | import { downloadImage } from '../utils' 7 | import { Platform } from '$shared/enums' 8 | 9 | const yt = Innertube.create({ retrieve_player: false }) 10 | 11 | export async function fetchChannelByName(channelName: string): Promise { 12 | const client = await yt 13 | 14 | const results = await client.search(channelName, { type: 'channel' }) 15 | 16 | const channelResult = results.channels.find( 17 | (item) => item.author.name.toLowerCase() === channelName.toLowerCase() 18 | ) 19 | 20 | if (!channelResult) { 21 | throw new Error(`Channel '${channelName}' not found`) 22 | } 23 | 24 | const channelID = channelResult.author.id 25 | 26 | try { 27 | const channel = await client.getChannel(channelID) 28 | 29 | const avatar = await downloadImage( 30 | getThumbnailForSize(channel.metadata.avatar?.[0].url ?? '', 70) 31 | ) 32 | 33 | const display_name = channel.metadata.title ?? '' 34 | const base_url = channel.metadata.vanity_channel_url ?? '' 35 | const username = base_url.split('/').pop()?.replace('@', '') ?? '' 36 | 37 | return { 38 | id: channelID, 39 | username, 40 | display_name, 41 | platform: Platform.YouTube, 42 | avatar 43 | } 44 | } catch (err) { 45 | throw new Error(`Fetching channel '${channelName}': ${err}`) 46 | } 47 | } 48 | 49 | export async function fetchChannelByID(channelID: string): Promise { 50 | const client = await yt 51 | 52 | try { 53 | const channel = await client.getChannel(channelID) 54 | 55 | const avatar = await downloadImage( 56 | getThumbnailForSize(channel.metadata.avatar?.[0].url ?? '', 70) 57 | ) 58 | 59 | const display_name = channel.metadata.title ?? '' 60 | const base_url = channel.metadata.vanity_channel_url ?? '' 61 | const username = base_url.split('/').pop()?.replace('@', '') ?? '' 62 | 63 | return { 64 | id: channelID, 65 | username, 66 | display_name, 67 | platform: Platform.YouTube, 68 | avatar 69 | } 70 | } catch (err) { 71 | throw new Error(`Fetching channel '${channelID}': ${err}`) 72 | } 73 | } 74 | 75 | export async function importSubscriptions(): Promise { 76 | const filePaths = await dialog.showOpenDialog({ 77 | properties: ['openFile'], 78 | filters: [{ name: 'CSV', extensions: ['csv'] }] 79 | }) 80 | 81 | if (filePaths.canceled) return -1 82 | 83 | const filePath = filePaths.filePaths[0] 84 | 85 | const imported: string[] = [] 86 | 87 | try { 88 | const data = await readFile(filePath, { encoding: 'utf8' }) 89 | 90 | const lines = data.toString().split('\n') 91 | 92 | for (let i = 1; i < lines.length; i++) { 93 | const fields = lines[i].split(',') 94 | if (fields.length != 3) continue 95 | imported.push(fields[0]) 96 | } 97 | } catch (err) { 98 | throw new Error(`Reading subscriptions file: ${err}`) 99 | } 100 | 101 | const getStmt = usersDB.prepare('SELECT id, username, avatar FROM youtube') 102 | const rows = getStmt.all() as { id: string }[] 103 | 104 | const filtered = imported.filter((channelID) => !rows.some((row) => row.id === channelID)) 105 | 106 | const promises = filtered.map(async (channelID) => { 107 | try { 108 | const channel = await fetchChannelByID(channelID) 109 | return channel 110 | } catch (err) { 111 | console.error(`Importing channel '${channelID}': ${err}`) 112 | return 113 | } 114 | }) 115 | 116 | const channels = await Promise.all(promises) 117 | 118 | const insertStmt = usersDB.prepare( 119 | 'INSERT INTO youtube (id, username, display_name, avatar) VALUES (?, ?, ?, ?)' 120 | ) 121 | 122 | let i = 0 123 | const insertChannels = usersDB.transaction(async (channels: (User | undefined)[]) => { 124 | for (const channel of channels) { 125 | if (!channel || channel.username === '') return 126 | 127 | insertStmt.run(channel.id, channel.username, channel.display_name, channel.avatar) 128 | i++ 129 | } 130 | }) 131 | 132 | await insertChannels(channels) 133 | 134 | return i 135 | } 136 | 137 | function getThumbnailForSize(url: string, size: number): string { 138 | return url.replace(/=s\d+/, `=s${size}`) 139 | } 140 | -------------------------------------------------------------------------------- /src/main/youtube/rss.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from 'fast-xml-parser' 2 | 3 | const parser = new XMLParser({ 4 | ignoreAttributes: false 5 | }) 6 | 7 | export async function fetchVideos(channelID: string): Promise { 8 | if (!channelID) throw new Error('Channel ID not provided') 9 | 10 | const response = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelID}`) 11 | 12 | if (!response.ok) { 13 | throw new Error(`Requesting videos for channel '${channelID}' returned ${response.status}`) 14 | } 15 | 16 | const body = await response.text() 17 | 18 | const videos: FeedVideo[] = [] 19 | 20 | const jsonObj = parser.parse(body) 21 | const feed = jsonObj.feed || jsonObj 22 | const username = feed.title || '' 23 | 24 | const entries = Array.isArray(feed.entry) ? feed.entry : [feed.entry] 25 | 26 | for (const entry of entries) { 27 | const id = entry['yt:videoId'] 28 | const title = entry.title || '' 29 | const published_at = Date.parse(entry.published || '') / 1000 30 | 31 | const view_count = 32 | entry['media:group']?.['media:community']?.['media:statistics']?.['@_views'] ?? '0' 33 | 34 | if (!id || !username) continue 35 | 36 | videos.push({ 37 | id, 38 | username, 39 | title, 40 | published_at, 41 | view_count 42 | }) 43 | } 44 | 45 | return videos 46 | } 47 | -------------------------------------------------------------------------------- /src/main/youtube/video.ts: -------------------------------------------------------------------------------- 1 | import { Innertube } from 'youtubei.js' 2 | import { fetchVideos } from './rss' 3 | 4 | export async function fetchFeedVideos(channelIDs: string[]): Promise { 5 | if (!channelIDs || channelIDs.length === 0) return [] 6 | 7 | const videoPromises = channelIDs.map(async (channelId) => { 8 | const newVideos = await fetchVideos(channelId) 9 | if (!newVideos) { 10 | console.error(`Error fetching videos for channel '${channelId}'`) 11 | return [] 12 | } 13 | 14 | return newVideos 15 | }) 16 | 17 | const videoArrays = await Promise.all(videoPromises) 18 | const videos = videoArrays.flat() 19 | return videos 20 | } 21 | 22 | const ytPlayer = Innertube.create({ retrieve_player: true }) 23 | const yt = Innertube.create({ retrieve_player: false }) 24 | 25 | export async function fetchVideo( 26 | videoID: string, 27 | retrievePlayer: boolean 28 | ): Promise { 29 | if (!videoID) throw new Error('Video ID not provided') 30 | 31 | if (!retrievePlayer) { 32 | const client = await yt 33 | const videoInfo = await client.getInfo(videoID) 34 | 35 | const info = { 36 | published_date_txt: videoInfo.primary_info?.published.toString() ?? '', 37 | description: modifyRedirects(videoInfo.secondary_info?.description.toHTML() ?? ''), 38 | title: videoInfo.primary_info?.title.toString() ?? '', 39 | view_count: Number( 40 | videoInfo.primary_info?.view_count?.view_count 41 | .toString() 42 | .replace(' views', '') 43 | .replaceAll(',', '') ?? '0' 44 | ) 45 | } 46 | 47 | const channel: WatchPageVideoChannel = { 48 | id: videoInfo.secondary_info?.owner?.author.id ?? '', 49 | name: videoInfo.secondary_info?.owner?.author.name ?? '', 50 | avatar: videoInfo.secondary_info?.owner?.author.thumbnails[0].url ?? '' 51 | } 52 | 53 | return { 54 | id: videoID, 55 | isLive: false, 56 | live: {}, 57 | channel, 58 | info 59 | } 60 | } 61 | 62 | const client = await ytPlayer 63 | const videoInfo = await client.getInfo(videoID) 64 | 65 | const info = { 66 | published_date_txt: videoInfo.primary_info?.published.toString() ?? '', 67 | description: modifyRedirects(videoInfo.secondary_info?.description.toHTML() ?? ''), 68 | title: videoInfo.primary_info?.title.toString() ?? '', 69 | view_count: Number( 70 | videoInfo.primary_info?.view_count?.view_count 71 | .toString() 72 | .replace(' views', '') 73 | .replaceAll(',', '') ?? '0' 74 | ) 75 | } 76 | 77 | const channel: WatchPageVideoChannel = { 78 | id: videoInfo.secondary_info?.owner?.author.id ?? '', 79 | name: videoInfo.secondary_info?.owner?.author.name ?? '', 80 | avatar: videoInfo.secondary_info?.owner?.author.thumbnails[0].url ?? '' 81 | } 82 | 83 | const live: WatchPageVideoLive = {} 84 | 85 | let isLive = videoInfo.basic_info.is_live ?? false 86 | 87 | if (isLive) { 88 | if (videoInfo.streaming_data?.hls_manifest_url) { 89 | live.hls = videoInfo.streaming_data?.hls_manifest_url 90 | isLive = true 91 | } 92 | 93 | if (videoInfo.streaming_data?.dash_manifest_url) { 94 | live.dash = videoInfo.streaming_data?.dash_manifest_url 95 | isLive = true 96 | } 97 | 98 | if (!live.dash && !live.hls) { 99 | isLive = false 100 | } 101 | } 102 | 103 | const video: WatchPageVideo = { 104 | id: videoID, 105 | isLive, 106 | dash: isLive 107 | ? undefined 108 | : await videoInfo.toDash(undefined, undefined, { captions_format: 'vtt' }), 109 | live, 110 | info, 111 | channel 112 | } 113 | 114 | return video 115 | } 116 | 117 | const REDIRECT_REGEX = new RegExp( 118 | /(]*\bhref\s*=\s*)(['"])(https:\/\/www\.youtube\.com\/redirect\?[^'"]+)\2/gi 119 | ) 120 | 121 | function modifyRedirects(html: string): string { 122 | const attrs = ` style="text-decoration: underline; color: rgb(59, 130, 246); target='_blank'"` 123 | 124 | return html.replace(REDIRECT_REGEX, (all, prefix, quote, fullRedirectUrl) => { 125 | const m = fullRedirectUrl.match(/[?&]q=([^&]+)/) 126 | if (!m) return all 127 | 128 | const realUrl = decodeURIComponent(m[1]) 129 | return prefix + quote + realUrl + quote + attrs 130 | }) 131 | } 132 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | main: { 4 | newWindow: (route: string) => void 5 | } 6 | 7 | titlebar: { 8 | onResized: (callback: (isMaximized: boolean) => void) => void 9 | onMaximized: (callback: (maximized: boolean) => void) => void 10 | minimize: () => void 11 | maximize: () => void 12 | close: () => void 13 | } 14 | 15 | user: { 16 | add: (platform: Platform, username?: string, id?: string) => Promise 17 | get: (platform: Platform, username?: string, id?: string) => Promise 18 | list: (platform?: Platform) => Promise 19 | remove: (platform: Platform, username: string) => Promise 20 | } 21 | 22 | feed: { 23 | get: (platform: Platform, lastPublishedAt?: number) => Promise 24 | refresh: (platform: Platform) => Promise 25 | } 26 | 27 | stream: { 28 | get: (username: string, backup: boolean) => Promise 29 | emotes: (username: string) => Promise> 30 | info: (username: string) => Promise 31 | } 32 | 33 | video: { 34 | get: (videoID: string, retrievePlayer: boolean) => Promise 35 | import: () => Promise 36 | } 37 | } 38 | } 39 | 40 | export {} 41 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from 'electron' 2 | 3 | import { Platform } from '$shared/enums' 4 | 5 | try { 6 | contextBridge.exposeInMainWorld('main', { 7 | newWindow: (route: string) => ipcRenderer.send('main:new-window', route) 8 | }) 9 | 10 | contextBridge.exposeInMainWorld('titlebar', { 11 | onResized: (callback: (isMaximized: boolean) => void) => { 12 | ipcRenderer.on('titlebar:resized', (_event, isMaximized: boolean) => { 13 | callback(isMaximized) 14 | }) 15 | }, 16 | onMaximized: (callback: (isMaximized: boolean) => void) => { 17 | ipcRenderer.on('titlebar:maximized', (_event, isMaximized: boolean) => { 18 | callback(isMaximized) 19 | }) 20 | }, 21 | minimize: () => ipcRenderer.send('titlebar:minimize'), 22 | maximize: () => ipcRenderer.send('titlebar:maximize'), 23 | close: () => ipcRenderer.send('titlebar:close') 24 | }) 25 | 26 | contextBridge.exposeInMainWorld('user', { 27 | add: (platform: Platform, username?: string, id?: string) => 28 | ipcRenderer.invoke('user:add', platform, username, id), 29 | get: (platform: Platform, username?: string, id?: string) => 30 | ipcRenderer.invoke('user:get', platform, username, id), 31 | list: (platform?: Platform) => ipcRenderer.invoke('user:list', platform), 32 | remove: (platform: Platform, username: string) => 33 | ipcRenderer.invoke('user:remove', platform, username) 34 | }) 35 | 36 | contextBridge.exposeInMainWorld('feed', { 37 | get: (platform: Platform, lastPublishedAt?: number) => 38 | ipcRenderer.invoke('feed:get', platform, lastPublishedAt), 39 | refresh: (platform: Platform) => ipcRenderer.invoke('feed:refresh', platform) 40 | }) 41 | 42 | contextBridge.exposeInMainWorld('stream', { 43 | get: (username: string, backup: boolean) => ipcRenderer.invoke('stream:get', username, backup), 44 | emotes: (username: string) => ipcRenderer.invoke('stream:emotes', username), 45 | info: (username: string) => ipcRenderer.invoke('stream:info', username) 46 | }) 47 | 48 | contextBridge.exposeInMainWorld('video', { 49 | get: (videoID: string, retrievePlayer: boolean) => 50 | ipcRenderer.invoke('video:get', videoID, retrievePlayer), 51 | import: () => ipcRenderer.invoke('video:import') 52 | }) 53 | } catch (err) { 54 | console.error(err) 55 | } 56 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | rt 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderer/src/App.svelte: -------------------------------------------------------------------------------- 1 | 55 | 56 |
61 | 62 | 63 |
64 | 65 | 66 |
67 | {#if currentView.id === View.Videos} 68 | {#if currentView.route.startsWith('/videos/watch')} 69 | 70 | {:else} 71 | 72 | {/if} 73 | {:else if currentView.id === View.Streams} 74 | {#if currentView.route.startsWith('/streams/watch')} 75 | 76 | {:else} 77 | 78 | {/if} 79 | {:else if currentView.id === View.Users} 80 | 81 | {:else} 82 | 83 | {/if} 84 |
85 |
86 | 87 | 88 |
89 | 90 | 124 | -------------------------------------------------------------------------------- /src/renderer/src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | -------------------------------------------------------------------------------- /src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/Chat.svelte: -------------------------------------------------------------------------------- 1 | 187 | 188 |
189 |
190 |
191 | 192 | 200 |
201 | 202 |
203 | 204 |
209 | {#each messages as message (message.id)} 210 |
215 | {message.n}: 218 | 219 | {#each message.m as fragment, index (index)} 220 | {#if fragment.t === 0} 221 | {fragment.c} 222 | {:else if fragment.t === 1 && fragment.e} 223 | {fragment.e.n} 232 | {:else} 233 | 234 | openURL(fragment.c)} 237 | tabindex="-1" 238 | role="link" 239 | class="underline-blue-400 mx-2 cursor-pointer break-all text-blue-400 underline hover:text-blue-600" 240 | > 241 | {fragment.c} 242 | 243 | {/if} 244 | {/each} 245 |
246 | {/each} 247 |
248 | 249 | {#if !autoScroll} 250 | 264 | {/if} 265 |
266 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/FeedHeader.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 |
42 | 63 |
64 |
65 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/Grid.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | {@render children()} 8 |
9 |
10 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/Notification.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if visible} 23 | 32 | {/if} 33 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/Sidebar.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 113 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/Titlebar.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | {currentView.name} 21 | 22 |
23 | 24 |
25 | 38 | 39 | 61 | 62 | 75 |
76 |
77 | 78 | 87 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/players/Twitch.svelte: -------------------------------------------------------------------------------- 1 | 249 | 250 | 256 | 257 | 258 | 259 | 260 | 264 | 265 | 266 | -------------------------------------------------------------------------------- /src/renderer/src/lib/components/players/YouTube.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if usingEmbed} 14 | 21 | 22 | 23 | 24 | 25 | {:else} 26 | 27 | 28 | {#if player.isLive} 29 | {#if player.live.hls} 30 | 31 | {:else} 32 | 36 | {/if} 37 | {:else} 38 | 42 | {/if} 43 | 44 | 45 | 46 | 47 | {/if} 48 | -------------------------------------------------------------------------------- /src/renderer/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from '$shared/enums' 2 | 3 | const ytAvatarCache = new Map() 4 | const twAvatarCache = new Map() 5 | 6 | export function getAvatarUrl(platform: Platform, username: string, avatar: Uint8Array): string { 7 | const cache = platform === Platform.Twitch ? twAvatarCache : ytAvatarCache 8 | if (cache.has(username)) { 9 | return cache.get(username) 10 | } 11 | 12 | const blob = new Blob([avatar], { type: 'image/png' }) 13 | const url = URL.createObjectURL(blob) 14 | 15 | cache.set(username, url) 16 | return url 17 | } 18 | 19 | export function defaultSettings(): Settings { 20 | return { 21 | videos: { 22 | autoplay: true, 23 | useEmbed: true 24 | }, 25 | streams: { 26 | blockAds: true 27 | } 28 | } 29 | } 30 | 31 | export function timeAgo(timestamp: number): string { 32 | const now = Math.floor(Date.now() / 1000) 33 | const secondsAgo = now - timestamp 34 | 35 | if (secondsAgo <= 0) return 'just now' 36 | 37 | if (secondsAgo < 60) return `${secondsAgo} second${plural(secondsAgo)} ago` 38 | const minutesAgo = Math.floor(secondsAgo / 60) 39 | 40 | if (minutesAgo < 60) return `${minutesAgo} minute${plural(minutesAgo)} ago` 41 | const hoursAgo = Math.floor(minutesAgo / 60) 42 | 43 | if (hoursAgo < 24) return `${hoursAgo} hour${plural(hoursAgo)} ago` 44 | 45 | const daysAgo = Math.floor(hoursAgo / 24) 46 | if (daysAgo < 30) return `${daysAgo} day${plural(daysAgo)} ago` 47 | 48 | const monthsAgo = Math.floor(daysAgo / 30) 49 | if (monthsAgo < 12) return `${monthsAgo} month${plural(monthsAgo)} ago` 50 | 51 | const yearsAgo = Math.floor(monthsAgo / 12) 52 | return `${yearsAgo} year${plural(yearsAgo)} ago` 53 | } 54 | 55 | export function streamingFor(startedAt: string): string { 56 | const diff = new Date().getTime() - new Date(startedAt).getTime() 57 | const totalSeconds = Math.floor(diff / 1000) 58 | const hours = Math.floor(totalSeconds / 3600) 59 | const minutes = Math.floor((totalSeconds % 3600) / 60) 60 | const seconds = totalSeconds % 60 61 | 62 | const formattedMinutes = minutes.toString().padStart(2, '0') 63 | const formattedSeconds = seconds.toString().padStart(2, '0') 64 | 65 | return `${hours}:${formattedMinutes}:${formattedSeconds}` 66 | } 67 | 68 | function plural(number: number): string { 69 | if (number > 1) { 70 | return 's' 71 | } 72 | 73 | return '' 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/src/lib/state/View.svelte.ts: -------------------------------------------------------------------------------- 1 | import { View } from '$shared/enums' 2 | 3 | type CurrentView = { 4 | id: View 5 | name: string 6 | route: string 7 | } 8 | 9 | // eslint-disable-next-line prefer-const 10 | export let currentView: CurrentView = $state({ id: View.Videos, name: 'Videos', route: '/videos' }) 11 | 12 | export function changeView(newViewID: View, navigateURL = true, path?: string): void { 13 | switch (newViewID) { 14 | case View.Videos: 15 | localStorage.setItem('lastView', newViewID) 16 | 17 | currentView.id = View.Videos 18 | currentView.name = 'Videos' 19 | if (navigateURL) { 20 | navigate(`/videos${path ? `${path}` : ''}`) 21 | } 22 | break 23 | 24 | case View.Streams: 25 | localStorage.setItem('lastView', newViewID) 26 | 27 | currentView.id = View.Streams 28 | currentView.name = 'Streams' 29 | if (navigateURL) { 30 | navigate(`/streams${path ? `${path}` : ''}`) 31 | } 32 | break 33 | 34 | case View.Users: 35 | localStorage.setItem('lastView', newViewID) 36 | 37 | currentView.id = View.Users 38 | currentView.name = 'Users' 39 | if (navigateURL) { 40 | navigate('/users') 41 | } 42 | break 43 | 44 | case View.Settings: 45 | localStorage.setItem('lastView', newViewID) 46 | 47 | currentView.id = View.Settings 48 | currentView.name = 'Settings' 49 | if (navigateURL) { 50 | navigate('/settings') 51 | } 52 | break 53 | } 54 | } 55 | 56 | function navigate(route: string): void { 57 | window.history.pushState({}, '', route) 58 | currentView.route = route 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/src/main.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'svelte' 2 | 3 | import './app.css' 4 | 5 | import App from './App.svelte' 6 | 7 | const app = mount(App, { 8 | target: document.getElementById('app')! 9 | }) 10 | 11 | export default app 12 | -------------------------------------------------------------------------------- /src/renderer/src/pages/Settings.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 |
30 |
31 |
32 | 38 |
39 | 40 |
41 |
42 | Videos 43 |
44 |
45 | 46 |
47 |
48 | 54 | 55 | Autoplay 56 |
57 | 58 |
59 | 65 | 66 | Use embed 67 |
68 |
69 | 70 |
71 |
72 | Streams 73 |
74 |
75 | 76 |
77 |
81 | 87 | 88 | Block ads 89 |
90 |
91 |
92 |
93 | -------------------------------------------------------------------------------- /src/renderer/src/pages/Users.svelte: -------------------------------------------------------------------------------- 1 | 113 | 114 |
115 |
116 |
117 | 127 | 128 |
{ 130 | e.preventDefault() 131 | await addUser() 132 | }} 133 | > 134 | 145 |
146 | 147 | 158 | 159 | {#if filter === Platform.YouTube} 160 | 170 | {/if} 171 |
172 | 173 |
174 | 175 |
176 | {#if !loading && filteredUsers.filter((user) => user.platform === filter).length === 0} 177 | No users found 178 | {:else} 179 | 180 | {#each filteredUsers as user, index (index)} 181 |
182 | {'Avatar 188 | 189 |
190 | {user.display_name} 191 | 192 |
193 | 205 | 206 | 223 |
224 |
225 |
226 | {/each} 227 |
228 | {/if} 229 |
230 |
231 |
232 | -------------------------------------------------------------------------------- /src/renderer/src/pages/streams/Streams.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 |
46 |
47 | loading} /> 48 | 49 |
50 | 51 | {#if !loading && feed.length === 0} 52 | No streams found 53 | {:else} 54 | 55 | {#each feed as live_now, index (index)} 56 | 75 | {/each} 76 | 77 | {/if} 78 |
79 |
80 | -------------------------------------------------------------------------------- /src/renderer/src/pages/streams/Watch.svelte: -------------------------------------------------------------------------------- 1 | 121 | 122 |
123 |
124 |
129 | {#if loading} 130 |
133 |
136 |
137 | {:else if url} 138 | 139 |
140 | 141 |
142 | 143 | 146 | {:else} 147 |
150 | {`${username} is not live`} 151 |
152 | {/if} 153 |
154 | 155 | {#if !loading && url && movingMouse && !showChat} 156 | 168 | {/if} 169 |
170 | 171 | {#if !loading && streamInfo} 172 |
173 |
174 |
175 |

{streamInfo.title}

176 | 177 |
178 | 179 | {username} 180 | 181 | 182 | 188 |
189 |
190 | 191 |
192 | 193 | {formatTime(elapsedSeconds)} - {streamInfo.viewer_count} viewers 194 | 195 | 196 | 212 |
213 |
214 | 215 |
216 | {streamInfo.game} 217 | 218 | 219 | {streamInfo.game} 220 | 221 |
222 |
223 | {/if} 224 |
225 | -------------------------------------------------------------------------------- /src/renderer/src/pages/videos/Videos.svelte: -------------------------------------------------------------------------------- 1 | 114 | 115 |
116 |
117 | loading} /> 118 | 119 |
120 | 121 | {#if !loading && feed.length === 0} 122 | No videos found 123 | {:else} 124 | 125 | {#each feed as video, index (index)} 126 | 150 | {/each} 151 | 152 | {/if} 153 |
154 | 155 |
156 |
157 |
158 |
159 | -------------------------------------------------------------------------------- /src/renderer/src/pages/videos/Watch.svelte: -------------------------------------------------------------------------------- 1 | 104 | 105 |
106 | {#if loading} 107 |
110 |
113 |
114 | {:else} 115 |
116 | {#key usingEmbed} 117 | {#if player && player.id} 118 | 119 | {/if} 120 | {/key} 121 |
122 | 123 |
124 |
125 |
126 |
127 |

{player.info.title}

128 | 129 | 130 | {player.isLive ? 'Live now' : player.info.published_date_txt} - {player.info.view_count.toLocaleString()} 131 | views 132 | 133 |
134 | 135 |
136 | {player.channel.name} 143 | 144 | 145 | {player.channel.name} 146 | 147 | 148 | 154 |
155 |
156 | 157 |
158 | 159 |
160 | 176 | 177 | 193 |
194 |
195 | 196 |
197 | 198 |
199 | {#if player.info.description} 200 | 201 | {@html player.info.description} 202 | {:else} 203 | No description available 204 | {/if} 205 |
206 |
207 | {/if} 208 |
209 | -------------------------------------------------------------------------------- /src/shared/enums.ts: -------------------------------------------------------------------------------- 1 | export enum View { 2 | Videos = 'videos', 3 | Streams = 'streams', 4 | Users = 'users', 5 | Settings = 'settings' 6 | } 7 | 8 | export enum Platform { 9 | Twitch = 'twitch', 10 | YouTube = 'youtube' 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { Database } from 'better-sqlite3' 2 | 3 | declare global { 4 | type Migration = { 5 | version: number 6 | up(db: Database): void 7 | description: string 8 | } 9 | 10 | type Settings = { 11 | videos: { 12 | autoplay: boolean 13 | useEmbed: boolean 14 | } 15 | streams: { 16 | blockAds: boolean 17 | } 18 | } 19 | 20 | type User = { 21 | id: string 22 | // Used in links like 'twitch.tv/username' or youtube.com/@username' 23 | username: string 24 | display_name: string 25 | platform: Platform 26 | avatar: Uint8Array 27 | } 28 | 29 | type Feed = { 30 | twitch: LiveNow[] | null 31 | youtube: FeedVideo[] | null 32 | } 33 | 34 | type LiveNow = { 35 | username: string 36 | started_at: string 37 | } 38 | 39 | type StreamInfo = { 40 | title: string 41 | game: string 42 | box_art: string 43 | started_at: string 44 | viewer_count: number 45 | } 46 | 47 | type FeedVideo = { 48 | id: string 49 | username: string 50 | title: string 51 | published_at: number 52 | view_count: string 53 | } 54 | 55 | type WatchPageVideo = { 56 | id: string 57 | isLive: boolean 58 | live: WatchPageVideoLive 59 | channel: WatchPageVideoChannel 60 | info: WatchPageVideoInfo 61 | dash?: string 62 | } 63 | 64 | type WatchPageVideoChannel = { 65 | id: string 66 | name: string 67 | avatar: string 68 | } 69 | 70 | type WatchPageVideoLive = { 71 | hls?: string 72 | dash?: string 73 | } 74 | 75 | type WatchPageVideoInfo = { 76 | title: string 77 | description: string 78 | published_date_txt: string 79 | view_count: number 80 | } 81 | 82 | type ChatEvent = { 83 | event: 'message' 84 | data: ChatMessage 85 | } 86 | 87 | type ChatMessage = { 88 | id: number 89 | // Color 90 | c: string 91 | // First message, not used 92 | f: boolean 93 | // Name 94 | n: string 95 | // Fragments that make up the message 96 | m: MessageFragment[] 97 | } 98 | 99 | type MessageFragment = { 100 | // export type, 0 = text, 1 = emote, 2 = url 101 | t: number 102 | // Content 103 | c: string 104 | // Emote 105 | e: Emote 106 | } 107 | 108 | type Emote = { 109 | // Name 110 | n: string 111 | // URL 112 | u: string 113 | // Width 114 | w: number 115 | // Height 116 | h: number 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /svelte.config.mjs: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | preprocess: vitePreprocess() 5 | } 6 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export const content = ['./src/renderer/**/*.svelte'] 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.web.json" 9 | } 10 | ], 11 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": [ 4 | "./electron.vite.config.ts", 5 | "./src/main/**/*", 6 | "./src/preload/**/*", 7 | "./src/shared/**/*" 8 | ], 9 | "compilerOptions": { 10 | "composite": true, 11 | "moduleResolution": "bundler", 12 | "types": [ 13 | "electron-vite/node", 14 | "./src/shared/globals", 15 | ], 16 | "paths": { 17 | "$shared/*": [ 18 | "./src/shared/*" 19 | ], 20 | "$shared": [ 21 | "./src/shared" 22 | ], 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "./src/renderer/src/env.d.ts", 5 | "./src/renderer/src/**/*", 6 | "./src/renderer/src/**/*.svelte", 7 | "./src/renderer/src/**/*.svelte.ts", 8 | "./src/preload/*.d.ts", 9 | "./src/shared/**/*.ts", 10 | "./src/shared/**/*.d.ts" 11 | ], 12 | "compilerOptions": { 13 | "composite": true, 14 | "verbatimModuleSyntax": true, 15 | "useDefineForClassFields": true, 16 | "strict": false, 17 | "allowJs": true, 18 | "checkJs": true, 19 | "lib": [ 20 | "ESNext", 21 | "DOM", 22 | "DOM.Iterable" 23 | ], 24 | "types": [ 25 | "vidstack/svelte", 26 | "svelte", 27 | "./src/shared/globals", 28 | ], 29 | "paths": { 30 | "$lib/*": [ 31 | "./src/renderer/src/lib/*" 32 | ], 33 | "$lib": [ 34 | "./src/renderer/src/lib" 35 | ], 36 | "$shared/*": [ 37 | "./src/shared/*" 38 | ], 39 | "$shared": [ 40 | "./src/shared" 41 | ], 42 | } 43 | } 44 | } --------------------------------------------------------------------------------