├── .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 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | YouTube feed with videos and streams.
23 |
24 |
25 |
26 |
27 |
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 |
193 |
199 |
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 |
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 |
{
253 | simpleBarInstance.scrollTop = simpleBarInstance.scrollHeight
254 | }}
255 | >
256 |
257 | {#if tempMessages.length === 0}
258 | Chat paused
259 | {:else}
260 | {tempMessages.length} new messages
261 | {/if}
262 |
263 |
264 | {/if}
265 |
266 |
--------------------------------------------------------------------------------
/src/renderer/src/lib/components/FeedHeader.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
{
46 | await refreshFeed()
47 | setLastUpdated()
48 | }}
49 | disabled={loading()}
50 | class="flex items-center gap-2 border border-gray-600 bg-neutral-900 p-2 {!loading()
51 | ? 'cursor-pointer hover:bg-neutral-700'
52 | : ''}"
53 | >
54 | Updated: {lastUpdated}
55 |
56 |
62 |
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 |
30 | {notificationMessage}
31 |
32 | {/if}
33 |
--------------------------------------------------------------------------------
/src/renderer/src/lib/components/Sidebar.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
handleMouseWheelClick(event, View.Videos)}
23 | onclick={() => changeView(View.Videos)}
24 | disabled={currentView.route === '/videos'}
25 | class="flex w-full flex-col items-center py-2 {currentView.route === '/videos'
26 | ? 'opacity-50 duration-100 ease-in-out'
27 | : 'cursor-pointer hover:bg-neutral-600'}"
28 | >
29 |
35 |
36 |
37 |
handleMouseWheelClick(event, View.Streams)}
41 | onclick={() => changeView(View.Streams)}
42 | disabled={currentView.route === '/streams'}
43 | class="flex w-full flex-col items-center py-2 {currentView.route === '/streams'
44 | ? 'opacity-50 duration-100 ease-in-out'
45 | : 'cursor-pointer hover:bg-neutral-600'}"
46 | >
47 |
53 |
54 |
55 |
handleMouseWheelClick(event, View.Users)}
59 | onclick={() => changeView(View.Users)}
60 | disabled={currentView.id === View.Users}
61 | class="flex w-full flex-col items-center py-2 {currentView.id === View.Users
62 | ? 'opacity-50 duration-100 ease-in-out'
63 | : 'cursor-pointer hover:bg-neutral-600'}"
64 | >
65 |
71 |
72 |
73 |
handleMouseWheelClick(event, View.Settings)}
77 | onclick={() => changeView(View.Settings)}
78 | disabled={currentView.id === View.Settings}
79 | class="flex w-full flex-col items-center py-2 {currentView.id === View.Settings
80 | ? 'opacity-50 duration-100 ease-in-out'
81 | : 'cursor-pointer hover:bg-neutral-600'}"
82 | >
83 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
handleMouseWheelClick(event, currentView.id)}
101 | onclick={() => openNewWindow(currentView.id)}
102 | class="flex w-full cursor-pointer flex-col items-center py-2 hover:bg-neutral-600"
103 | >
104 |
110 |
111 |
112 |
113 |
--------------------------------------------------------------------------------
/src/renderer/src/lib/components/Titlebar.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 | {currentView.name}
21 |
22 |
23 |
24 |
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 |
36 | Reset settings
37 |
38 |
39 |
40 |
41 |
42 | Videos
43 |
44 |
45 |
46 |
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 |
124 | Twitch
125 | YouTube
126 |
127 |
128 |
146 |
147 |
158 |
159 | {#if filter === Platform.YouTube}
160 | await importSubscriptions()}
162 | transition:fade={{ duration: 50 }}
163 | disabled={loading}
164 | class="border border-gray-600 bg-neutral-900 px-4 py-1 {loading
165 | ? ''
166 | : 'cursor-pointer hover:bg-neutral-700'}"
167 | >
168 | Import subscriptions
169 |
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 |
188 |
189 |
190 |
{user.display_name}
191 |
192 |
193 |
await updateUser(user.username)}
202 | >
203 | Update
204 |
205 |
206 |
await removeUser(user.username)}
211 | >
212 |
222 |
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 | handleMouseWheelClick(event, live_now.username)}
58 | onclick={() => changeView(View.Streams, true, `/watch?username=${live_now.username}`)}
59 | class="flex cursor-pointer flex-col text-left hover:bg-neutral-800"
60 | >
61 |
68 |
69 |
70 | {live_now.username}
71 |
72 | {streamingFor(live_now.started_at)}
73 |
74 |
75 | {/each}
76 |
77 | {/if}
78 |
79 |
80 |
--------------------------------------------------------------------------------
/src/renderer/src/pages/streams/Watch.svelte:
--------------------------------------------------------------------------------
1 |
121 |
122 |
123 |
124 |
129 | {#if loading}
130 |
137 | {:else if url}
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 | {:else}
147 |
150 | {`${username} is not live`}
151 |
152 | {/if}
153 |
154 |
155 | {#if !loading && url && movingMouse && !showChat}
156 |
161 |
167 |
168 | {/if}
169 |
170 |
171 | {#if !loading && streamInfo}
172 |
173 |
174 |
175 |
{streamInfo.title}
176 |
177 |
178 |
179 | {username}
180 |
181 |
182 | await handleSubscription()}
185 | >
186 | {subscribed ? 'Remove user' : 'Add user'}
187 |
188 |
189 |
190 |
191 |
192 |
193 | {formatTime(elapsedSeconds)} - {streamInfo.viewer_count} viewers
194 |
195 |
196 |
201 |
211 |
212 |
213 |
214 |
215 |
216 |
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 | handleMouseWheelClick(event, video.id)}
128 | onclick={() => changeView(View.Videos, true, `/watch?id=${video.id}`)}
129 | class="flex cursor-pointer flex-col text-left hover:bg-neutral-800"
130 | >
131 |
138 |
139 |
140 |
141 | {video.title}
142 |
143 |
144 |
145 | {video.username}
146 | {getViewCount(video.view_count)} - {timeAgo(video.published_at)}
147 |
148 |
149 |
150 | {/each}
151 |
152 | {/if}
153 |
154 |
155 |
158 |
159 |
--------------------------------------------------------------------------------
/src/renderer/src/pages/videos/Watch.svelte:
--------------------------------------------------------------------------------
1 |
104 |
105 |
106 | {#if loading}
107 |
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 |
143 |
144 |
145 | {player.channel.name}
146 |
147 |
148 |
await handleSubscription()}
151 | >
152 | {subscribed ? 'Remove user' : 'Add user'}
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
165 |
175 |
176 |
177 |
182 |
192 |
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 | }
--------------------------------------------------------------------------------