├── .editorconfig
├── .env.sample
├── .github
└── workflows
│ └── build-deploy.yml
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── public
├── 404.html
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── favicon.svg
├── index.html
└── robots.txt
├── src
├── app
│ ├── App.tsx
│ ├── AppHeader.tsx
│ ├── AppLayout.tsx
│ ├── AppMenu.tsx
│ ├── AppSkeleton.tsx
│ ├── Router.tsx
│ ├── hooks.ts
│ └── store.ts
├── common
│ ├── apis
│ │ ├── afreecaApi.ts
│ │ ├── kickApi.ts
│ │ ├── streamableApi.ts
│ │ ├── twitchApi.ts
│ │ └── youtubeApi.ts
│ ├── components
│ │ ├── BrandButton.tsx
│ │ ├── BrandPlatforms.tsx
│ │ ├── ColorSchemeSwitch.tsx
│ │ ├── MyCredits.tsx
│ │ └── NavLink.tsx
│ ├── logging.ts
│ ├── models
│ │ ├── kick.ts
│ │ ├── oembed.ts
│ │ └── twitch.ts
│ └── utils.ts
├── features
│ ├── analytics
│ │ ├── analytics.ts
│ │ └── analyticsMiddleware.tsx
│ ├── auth
│ │ ├── AuthPage.tsx
│ │ ├── IfAuthenticated.tsx
│ │ ├── RequireAuth.tsx
│ │ ├── authSlice.ts
│ │ └── twitchAuthApi.ts
│ ├── clips
│ │ ├── Clip.tsx
│ │ ├── clipQueueMiddleware.ts
│ │ ├── clipQueueSlice.ts
│ │ ├── customization
│ │ │ └── customization.ts
│ │ ├── history
│ │ │ └── HistoryPage.tsx
│ │ ├── providers
│ │ │ ├── afreecaClip
│ │ │ │ ├── afreecaClipProvider.test.ts
│ │ │ │ └── afreecaClipProvider.ts
│ │ │ ├── kickClip
│ │ │ │ ├── kickClipProvider.test.ts
│ │ │ │ └── kickClipProvider.ts
│ │ │ ├── providers.ts
│ │ │ ├── streamable
│ │ │ │ ├── streamable.test.ts
│ │ │ │ └── streamableProvider.ts
│ │ │ ├── twitchClip
│ │ │ │ ├── twitchClipProvider.test.ts
│ │ │ │ └── twitchClipProvider.ts
│ │ │ ├── twitchVod
│ │ │ │ ├── twitchVodProvider.test.ts
│ │ │ │ └── twitchVodProvider.ts
│ │ │ └── youtube
│ │ │ │ ├── youtube.test.ts
│ │ │ │ └── youtubeProvider.ts
│ │ └── queue
│ │ │ ├── AutoplayOverlay.tsx
│ │ │ ├── Player.tsx
│ │ │ ├── PlayerButtons.tsx
│ │ │ ├── PlayerTitle.tsx
│ │ │ ├── Queue.tsx
│ │ │ ├── QueueControlPanel.tsx
│ │ │ ├── QueuePage.tsx
│ │ │ ├── QueueQuickMenu.tsx
│ │ │ ├── VideoPlayer.tsx
│ │ │ └── layouts
│ │ │ ├── ClassicLayout.tsx
│ │ │ ├── FullscreenWithPopupLayout.tsx
│ │ │ └── SpotlightLayout.tsx
│ ├── home
│ │ ├── FeaturesSection.tsx
│ │ ├── HomePage.tsx
│ │ ├── QuickstartSection.tsx
│ │ └── ScreenshotsSection.tsx
│ ├── migration
│ │ └── legacyMigration.ts
│ ├── settings
│ │ ├── SettingsModal.tsx
│ │ ├── models.ts
│ │ └── settingsSlice.ts
│ └── twitchChat
│ │ ├── actions.ts
│ │ ├── chatCommands.ts
│ │ └── twitchChatMiddleware.ts
├── index.scss
├── index.tsx
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | indent_style = space
8 | indent_size = 2
9 | end_of_line = lf
10 | charset = utf-8
11 | trim_trailing_whitespace = true
12 | insert_final_newline = true
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | REACT_APP_TWITCH_CLIENT_ID=xxx
2 | REACT_APP_TWITCH_REDIRECT_URI=http://localhost:3000/twitch-clip-queue/auth
3 | REACT_APP_BASEPATH=/twitch-clip-queue/
4 |
5 | REACT_APP_LOG_LEVEL=warn
6 |
7 | REACT_APP_UMAMI_WEBSITE_ID=
8 | REACT_APP_UMAMI_SRC=
--------------------------------------------------------------------------------
/.github/workflows/build-deploy.yml:
--------------------------------------------------------------------------------
1 | name: Build & deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build:
13 | name: Build
14 | runs-on: ubuntu-latest
15 | env:
16 | REACT_APP_TWITCH_CLIENT_ID: ${{ secrets.REACT_APP_TWITCH_CLIENT_ID }}
17 | REACT_APP_UMAMI_SRC: ${{ secrets.REACT_APP_UMAMI_SRC }}
18 | REACT_APP_UMAMI_WEBSITE_ID: ${{ secrets.REACT_APP_UMAMI_WEBSITE_ID }}
19 | REACT_APP_TWITCH_REDIRECT_URI: https://jakemiki.github.io/twitch-clip-queue/auth
20 | REACT_APP_BASEPATH: /twitch-clip-queue/
21 | REACT_APP_LOG_LEVEL: warn
22 |
23 | steps:
24 | - name: Checkout code
25 | uses: actions/checkout@v4
26 |
27 | - name: Install Node.js
28 | uses: actions/setup-node@v4
29 | with:
30 | node-version: 16.x
31 |
32 | - name: Get npm cache directory
33 | id: npm-cache-dir
34 | run: |
35 | echo "::set-output name=dir::$(npm config get cache)"
36 |
37 | - name: Setup npm cache
38 | uses: actions/cache@v4
39 | id: npm-cache
40 | with:
41 | path: ${{ steps.npm-cache-dir.outputs.dir }}
42 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
43 | restore-keys: |
44 | ${{ runner.os }}-node-
45 |
46 | - name: Install NPM packages
47 | run: npm ci
48 |
49 | - name: Build project
50 | run: npm run build
51 |
52 | - name: Run tests
53 | run: npm run test
54 |
55 | - name: Upload production-ready build files
56 | uses: actions/upload-artifact@v4
57 | with:
58 | name: production-files
59 | path: ./build
60 |
61 | deploy:
62 | name: Deploy
63 | needs: build
64 | runs-on: ubuntu-latest
65 | if: github.ref == 'refs/heads/main'
66 |
67 | steps:
68 | - name: Download artifact
69 | uses: actions/download-artifact@v4
70 | with:
71 | name: production-files
72 | path: ./build
73 |
74 | - name: Deploy to gh-pages
75 | uses: peaceiris/actions-gh-pages@v4
76 | with:
77 | github_token: ${{ secrets.GITHUB_TOKEN }}
78 | publish_dir: ./build
79 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "es5",
5 | "printWidth": 120
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules\\typescript\\lib",
3 | "scss.lint.unknownAtRules": "ignore"
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 JakeMiki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitch-clip-queue",
3 | "version": "1.0.0",
4 | "private": true,
5 | "homepage": "/twitch-clip-queue/",
6 | "scripts": {
7 | "start": "react-scripts start",
8 | "build": "react-scripts build",
9 | "test": "react-scripts test",
10 | "eject": "react-scripts eject",
11 | "predeploy": "npm run build",
12 | "deploy": "gh-pages -d build"
13 | },
14 | "dependencies": {
15 | "@mantine/core": "^4.2.9",
16 | "@mantine/hooks": "^4.2.9",
17 | "@mantine/modals": "^4.2.9",
18 | "@mantine/notifications": "^4.2.9",
19 | "@reduxjs/toolkit": "^1.8.2",
20 | "@tabler/icons-react": "^3.31.0",
21 | "@testing-library/jest-dom": "^5.16.4",
22 | "@testing-library/react": "^13.3.0",
23 | "@testing-library/user-event": "^14.2.0",
24 | "@types/jest": "^28.1.1",
25 | "@types/node": "^17.0.42",
26 | "@types/react": "^18.0.12",
27 | "@types/react-dom": "^18.0.5",
28 | "@types/react-router-dom": "^5.3.3",
29 | "@types/tmi.js": "^1.8.1",
30 | "@videojs/http-streaming": "^3.17.0",
31 | "axios": "^0.27.2",
32 | "date-fns": "^2.28.0",
33 | "gh-pages": "^4.0.0",
34 | "react": "^18.1.0",
35 | "react-dom": "^18.1.0",
36 | "react-player": "^2.10.1",
37 | "react-redux": "^8.0.2",
38 | "react-router-dom": "^6.3.0",
39 | "react-scripts": "^5.0.1",
40 | "redux-persist": "^6.0.0",
41 | "redux-persist-indexeddb-storage": "^1.0.4",
42 | "sass": "^1.52.3",
43 | "tabler-icons-react": "^1.48.1",
44 | "tmi.js": "^1.8.5",
45 | "typescript": "^4.7.3",
46 | "video.js": "^8.22.0",
47 | "videojs-youtube": "^3.0.1",
48 | "web-vitals": "^2.1.4"
49 | },
50 | "eslintConfig": {
51 | "extends": [
52 | "react-app",
53 | "react-app/jest"
54 | ]
55 | },
56 | "browserslist": {
57 | "production": [
58 | ">0.2%",
59 | "not dead",
60 | "not op_mini all"
61 | ],
62 | "development": [
63 | "last 1 chrome version",
64 | "last 1 firefox version",
65 | "last 1 safari version"
66 | ]
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Twitch Clip Queue
11 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/31776c5c39f5a701ce9e546619a80ff9b8c852d8/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/31776c5c39f5a701ce9e546619a80ff9b8c852d8/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/31776c5c39f5a701ce9e546619a80ff9b8c852d8/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Clip Queue
11 |
12 |
13 |
35 |
36 |
37 | <% if (process.env.REACT_APP_UMAMI_SRC && process.env.REACT_APP_UMAMI_WEBSITE_ID) { %>
38 |
39 | <% } %>
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/app/App.tsx:
--------------------------------------------------------------------------------
1 | import { ColorSchemeProvider, LoadingOverlay, MantineProvider } from '@mantine/core';
2 | import { useColorScheme } from '@mantine/hooks';
3 | import { ModalsProvider } from '@mantine/modals';
4 | import { NotificationsProvider } from '@mantine/notifications';
5 | import { useEffect } from 'react';
6 | import { selectAccessToken, authenticateWithToken, selectAuthState } from '../features/auth/authSlice';
7 | import { colorSchemeToggled, selectColorScheme } from '../features/settings/settingsSlice';
8 | import { useAppDispatch, useAppSelector } from './hooks';
9 | import Router from './Router';
10 |
11 | function App() {
12 | const dispatch = useAppDispatch();
13 | const accessToken = useAppSelector(selectAccessToken);
14 | const authState = useAppSelector(selectAuthState);
15 | const preferredColorScheme = useColorScheme();
16 | const colorScheme = useAppSelector((state) => selectColorScheme(state, preferredColorScheme));
17 |
18 | useEffect(() => {
19 | if (accessToken) {
20 | dispatch(authenticateWithToken(accessToken));
21 | }
22 | // eslint-disable-next-line react-hooks/exhaustive-deps
23 | }, []);
24 |
25 | return (
26 | dispatch(colorSchemeToggled(preferredColorScheme))}
29 | >
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | export default App;
43 |
--------------------------------------------------------------------------------
/src/app/AppHeader.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIcon, ActionIconProps, Button, Group, Header, Space, Text, ThemeIcon } from '@mantine/core';
2 | import { PropsWithChildren } from 'react';
3 | import { DeviceTv } from 'tabler-icons-react';
4 | import ColorSchemeSwitch from '../common/components/ColorSchemeSwitch';
5 | import { NavLinkProps, useLocation } from 'react-router-dom';
6 | import NavLink from '../common/components/NavLink';
7 | import MyCredits from '../common/components/MyCredits';
8 | import IfAuthenticated from '../features/auth/IfAuthenticated';
9 | import { useAppDispatch } from './hooks';
10 | import { login } from '../features/auth/authSlice';
11 | import AppMenu from './AppMenu';
12 |
13 | export function TitleIcon() {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | export function TitleText() {
22 | return (
23 |
24 |
25 | Clip Queue
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | function NavBarIcon({ children, ...props }: PropsWithChildren>) {
33 | return (
34 |
35 | {children}
36 |
37 | );
38 | }
39 |
40 | function NavBarButton({ children, type, className, style, ...props }: PropsWithChildren) {
41 | return (
42 |
52 | );
53 | }
54 |
55 | function AppHeader({ noNav = false }: { noNav?: boolean }) {
56 | const dispatch = useAppDispatch();
57 | const location = useLocation();
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
65 |
66 | {!noNav && (
67 |
68 | Home
69 |
70 | Queue
71 | History
72 |
73 |
74 | )}
75 |
76 |
79 |
80 |
81 |
82 | }
83 | >
84 |
85 |
86 |
87 |
88 | );
89 | }
90 |
91 | export default AppHeader;
92 |
--------------------------------------------------------------------------------
/src/app/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | import { AppShell, Box } from '@mantine/core';
2 | import { PropsWithChildren } from 'react';
3 | import AppHeader from './AppHeader';
4 |
5 | function AppLayout({ children, noNav = false }: PropsWithChildren<{ noNav?: boolean }>) {
6 | return (
7 | }
11 | sx={{
12 | main: {
13 | height: '100vh',
14 | minHeight: '100vh',
15 | maxHeight: '100vh',
16 | },
17 | }}
18 | >
19 |
25 | {children}
26 |
27 |
28 | );
29 | }
30 |
31 | export default AppLayout;
32 |
--------------------------------------------------------------------------------
/src/app/AppMenu.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useMantineColorScheme,
3 | UnstyledButton,
4 | Avatar,
5 | Divider,
6 | Menu,
7 | Switch,
8 | Text,
9 | Group,
10 | ChevronIcon,
11 | } from '@mantine/core';
12 | import { useNavigate } from 'react-router-dom';
13 | import { MoonStars, Settings, Logout } from 'tabler-icons-react';
14 | import { selectUsername, selectProfilePictureUrl, logout } from '../features/auth/authSlice';
15 | import useSettingsModal from '../features/settings/SettingsModal';
16 | import { useAppDispatch, useAppSelector } from './hooks';
17 |
18 | function AppMenu() {
19 | const navigate = useNavigate();
20 | const dispatch = useAppDispatch();
21 | const username = useAppSelector(selectUsername);
22 | const profilePictureUrl = useAppSelector(selectProfilePictureUrl);
23 | const { colorScheme, toggleColorScheme } = useMantineColorScheme();
24 | const { openSettingsModal } = useSettingsModal();
25 |
26 | return (
27 |
72 | );
73 | }
74 |
75 | export default AppMenu;
76 |
--------------------------------------------------------------------------------
/src/app/AppSkeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AppShell,
3 | Box,
4 | Button,
5 | ColorSchemeProvider,
6 | Group,
7 | Header,
8 | LoadingOverlay,
9 | MantineProvider,
10 | Space,
11 | } from '@mantine/core';
12 | import { useColorScheme } from '@mantine/hooks';
13 | import { TitleIcon, TitleText } from './AppHeader';
14 |
15 | function AppSkeleton() {
16 | const preferredColorScheme = useColorScheme();
17 | return (
18 | {}}>
19 |
24 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | }
40 | sx={{
41 | main: {
42 | height: '100vh',
43 | minHeight: '100vh',
44 | maxHeight: '100vh',
45 | },
46 | }}
47 | >
48 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | export default AppSkeleton;
62 |
--------------------------------------------------------------------------------
/src/app/Router.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter, Navigate, Route, Routes, Outlet } from 'react-router-dom';
2 | import AuthPage from '../features/auth/AuthPage';
3 | import IfAuthenticated from '../features/auth/IfAuthenticated';
4 | import RequireAuth from '../features/auth/RequireAuth';
5 | import HistoryPage from '../features/clips/history/HistoryPage';
6 | import QueuePage from '../features/clips/queue/QueuePage';
7 | import HomePage from '../features/home/HomePage';
8 | import AppLayout from './AppLayout';
9 |
10 | function Router() {
11 | return (
12 |
13 |
14 | }>
18 |
19 |
20 | }
21 | />
22 |
25 |
26 |
27 | }
28 | >
29 | } />
30 |
34 |
35 |
36 | }
37 | />
38 |
42 |
43 |
44 | }
45 | />
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | export default Router;
53 |
--------------------------------------------------------------------------------
/src/app/hooks.ts:
--------------------------------------------------------------------------------
1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
2 |
3 | import type { RootState, AppDispatch } from './store';
4 |
5 | export const useAppDispatch = () => useDispatch();
6 | export const useAppSelector: TypedUseSelectorHook = useSelector;
7 |
--------------------------------------------------------------------------------
/src/app/store.ts:
--------------------------------------------------------------------------------
1 | import { configureStore, combineReducers, MiddlewareAPI } from '@reduxjs/toolkit';
2 | import { persistStore, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
3 | import createAnalyticsMiddleware from '../features/analytics/analyticsMiddleware';
4 | import authReducer from '../features/auth/authSlice';
5 | import createClipQueueMiddleware from '../features/clips/clipQueueMiddleware';
6 | import clipQueueReducer from '../features/clips/clipQueueSlice';
7 | import { tryMigrateLegacyData } from '../features/migration/legacyMigration';
8 | import settingsReducer from '../features/settings/settingsSlice';
9 | import createTwitchChatMiddleware from '../features/twitchChat/twitchChatMiddleware';
10 |
11 | const rootReducer = combineReducers({
12 | auth: authReducer,
13 | settings: settingsReducer,
14 | clipQueue: clipQueueReducer,
15 | });
16 |
17 | export const store = configureStore({
18 | reducer: rootReducer,
19 | middleware: (getDefaultMiddleware) =>
20 | getDefaultMiddleware({
21 | serializableCheck: {
22 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
23 | },
24 | }).concat(createTwitchChatMiddleware(), createClipQueueMiddleware(), createAnalyticsMiddleware()),
25 | });
26 |
27 |
28 | export const persistor = persistStore(store, undefined, () => {
29 | tryMigrateLegacyData(store.dispatch);
30 | });
31 |
32 | export type RootState = ReturnType;
33 | export type AppDispatch = typeof store.dispatch;
34 | export type AppThunkConfig = { dispatch: AppDispatch; state: RootState };
35 | export type AppMiddlewareAPI = MiddlewareAPI;
36 |
--------------------------------------------------------------------------------
/src/common/apis/afreecaApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { OEmbedVideoResponse } from '../models/oembed';
3 |
4 | const getClip = async (id: string): Promise => {
5 | try {
6 | const { data } = await axios({
7 | method: 'get',
8 | url: `https://openapi.afreecatv.com/oembed/embedinfo?vod_url=https://vod.afreecatv.com/player/${id}`,
9 | headers: {
10 | 'Content-Type': 'application/x-www-form-urlencoded',
11 | Accept: '*/*',
12 | },
13 | });
14 | return data;
15 | } catch {
16 | return undefined;
17 | }
18 | };
19 |
20 | const afreecaApi = {
21 | getClip,
22 | };
23 |
24 | export default afreecaApi;
25 |
--------------------------------------------------------------------------------
/src/common/apis/kickApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { KickClip } from '../models/kick';
3 |
4 | export async function getClip(id: string): Promise {
5 | if (id.length <= 0) {
6 | return;
7 | }
8 | try {
9 | const response = await axios.get(`https://kick.com/api/v2/clips/${id}`);
10 | return response.data.clip;
11 | } catch (e) {
12 | console.error('Failed to Get Kick clip:', id, e);
13 | return;
14 | }
15 | }
16 |
17 | export async function getDirectUrl(id: string): Promise {
18 | const clip = await getClip(id);
19 | if (!clip || !clip.video_url) {
20 | console.error('Invalid clip or missing playback URL');
21 | return;
22 | }
23 | return clip.video_url;
24 | }
25 |
26 | const kickApi = {
27 | getClip,
28 | getDirectUrl,
29 | };
30 |
31 | export default kickApi;
32 |
--------------------------------------------------------------------------------
/src/common/apis/streamableApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { OEmbedVideoResponse } from '../models/oembed';
3 |
4 | const getClip = async (id: string): Promise => {
5 | try {
6 | const { data } = await axios.get(`https://api.streamable.com/oembed.json?url=https://streamable.com/${id}`);
7 | return data;
8 | } catch {
9 | return undefined;
10 | }
11 | };
12 |
13 | const streamableApi = {
14 | getClip,
15 | };
16 |
17 | export default streamableApi;
18 |
--------------------------------------------------------------------------------
/src/common/apis/twitchApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import type { AppMiddlewareAPI } from '../../app/store';
3 | import { TwitchClip, TwitchGame, TwitchVideo } from '../models/twitch';
4 |
5 | let store: AppMiddlewareAPI;
6 | export const injectStore = (_store: AppMiddlewareAPI) => {
7 | store = _store;
8 | };
9 |
10 | const TWITCH_CLIENT_ID = process.env.REACT_APP_TWITCH_CLIENT_ID ?? '';
11 |
12 | const twitchApiClient = axios.create({
13 | baseURL: 'https://api.twitch.tv/helix/',
14 | headers: {
15 | 'Client-ID': TWITCH_CLIENT_ID,
16 | },
17 | });
18 |
19 | const twitchGqlClient = axios.create({
20 | baseURL: 'https://gql.twitch.tv/gql',
21 | headers: {
22 | 'Client-Id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
23 | },
24 | });
25 |
26 | const getDirectUrl = async (id: string): Promise => {
27 | const data = [
28 | {
29 | operationName: 'ClipsDownloadButton',
30 | variables: {
31 | slug: id,
32 | },
33 | extensions: {
34 | persistedQuery: {
35 | version: 1,
36 | sha256Hash: '6e465bb8446e2391644cf079851c0cb1b96928435a240f07ed4b240f0acc6f1b',
37 | },
38 | },
39 | },
40 | ];
41 |
42 | const resp = await twitchGqlClient.post('', data);
43 | const [respData] = resp.data;
44 | const playbackAccessToken = respData.data.clip.playbackAccessToken;
45 | const url =
46 | respData.data.clip.videoQualities[0].sourceURL +
47 | '?sig=' +
48 | playbackAccessToken.signature +
49 | '&token=' +
50 | encodeURIComponent(playbackAccessToken.value);
51 |
52 | return url;
53 | };
54 |
55 | twitchApiClient.interceptors.request.use((request) => {
56 | const { token } = store?.getState().auth;
57 | if (token) {
58 | request.headers = { Authorization: `Bearer ${token}`, ...request.headers };
59 | }
60 |
61 | return request;
62 | });
63 |
64 | const getClip = async (id: string): Promise => {
65 | const { data } = await twitchApiClient.get<{ data: TwitchClip[] }>(`clips?id=${id}`);
66 |
67 | return data.data[0];
68 | };
69 |
70 | const getVideo = async (id: string): Promise => {
71 | const { data } = await twitchApiClient.get<{ data: TwitchVideo[] }>(`videos?id=${id}`);
72 |
73 | return data.data[0];
74 | };
75 |
76 | const getGame = async (id: string): Promise => {
77 | const { data } = await twitchApiClient.get<{ data: TwitchGame[] }>(`games?id=${id}`);
78 |
79 | return data.data[0];
80 | };
81 |
82 | const twitchApi = {
83 | getClip,
84 | getVideo,
85 | getGame,
86 | getDirectUrl,
87 | };
88 |
89 | export default twitchApi;
90 |
--------------------------------------------------------------------------------
/src/common/apis/youtubeApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { OEmbedVideoResponse } from '../models/oembed';
3 |
4 | const getClip = async (id: string): Promise => {
5 | try {
6 | const { data } = await axios.get(
7 | `https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=${id}`
8 | );
9 | return data;
10 | } catch {
11 | return undefined;
12 | }
13 | };
14 |
15 | const youtubeApi = {
16 | getClip,
17 | };
18 |
19 | export default youtubeApi;
20 |
--------------------------------------------------------------------------------
/src/common/components/BrandButton.tsx:
--------------------------------------------------------------------------------
1 | import { useMantineColorScheme, Button } from '@mantine/core';
2 | import { PropsWithChildren } from 'react';
3 |
4 | function BrandButton({ children, icon, href }: PropsWithChildren<{ icon: JSX.Element; href: string }>) {
5 | const { colorScheme } = useMantineColorScheme();
6 | const isDark = colorScheme === 'dark';
7 |
8 | return (
9 |
30 | );
31 | }
32 |
33 | export default BrandButton;
34 |
--------------------------------------------------------------------------------
/src/common/components/BrandPlatforms.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IconBrandTwitch, IconBrandKick, IconBrandYoutube } from '@tabler/icons-react';
3 | import type { PlatformType } from '../utils';
4 |
5 | interface BrandPlatformsProps {
6 | platform: PlatformType;
7 | }
8 |
9 | const Platform: React.FC = ({ platform }) => {
10 | switch (platform) {
11 | case 'Twitch':
12 | return ;
13 | case 'Kick':
14 | return ;
15 | case 'YouTube':
16 | return ;
17 | case 'Afreeca':
18 | return null;
19 | case 'Streamable':
20 | return null;
21 | default:
22 | return null;
23 | }
24 | };
25 |
26 | export default Platform;
27 |
--------------------------------------------------------------------------------
/src/common/components/ColorSchemeSwitch.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIcon, PolymorphicComponentProps, useMantineColorScheme } from '@mantine/core';
2 | import { PropsWithChildren } from 'react';
3 | import { Sun, MoonStars } from 'tabler-icons-react';
4 |
5 | const LightModeIcon = Sun;
6 | const DarkModeIcon = MoonStars;
7 |
8 | function ColorSchemeSwitch({
9 | component = ActionIcon,
10 | children,
11 | ...props
12 | }: PropsWithChildren>) {
13 | const { colorScheme, toggleColorScheme } = useMantineColorScheme();
14 | const isDark = colorScheme === 'dark';
15 | const ModeIcon = isDark ? LightModeIcon : DarkModeIcon;
16 | const Component = component;
17 |
18 | return (
19 | toggleColorScheme()}>
20 | {children ? children : }
21 |
22 | );
23 | }
24 |
25 | export default ColorSchemeSwitch;
26 |
--------------------------------------------------------------------------------
/src/common/components/MyCredits.tsx:
--------------------------------------------------------------------------------
1 | import { Group, Text } from '@mantine/core';
2 | import { BrandTwitch, BrandGithub } from 'tabler-icons-react';
3 | import BrandButton from './BrandButton';
4 |
5 | function MyCredits() {
6 | return (
7 |
8 |
9 | by
10 | }>
11 | JakeMiki
12 |
13 | /
14 | }>
15 | SirMuffin9
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | export default MyCredits;
23 |
--------------------------------------------------------------------------------
/src/common/components/NavLink.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NavLink as NavLinkBase, NavLinkProps } from 'react-router-dom';
3 |
4 | const NavLink = React.forwardRef<
5 | HTMLAnchorElement,
6 | Omit & { activeStyle?: NavLinkProps['style'] }
7 | >(({ activeStyle, ...props }, ref) => );
8 |
9 | export default NavLink;
10 |
--------------------------------------------------------------------------------
/src/common/logging.ts:
--------------------------------------------------------------------------------
1 |
2 | enum LogLevel {
3 | debug = 0,
4 | info,
5 | warn,
6 | error,
7 | };
8 |
9 | type LogLevels = keyof typeof LogLevel;
10 |
11 | type LoggingFunction = (message: any, ...data: any[]) => void;
12 | interface Logger {
13 | debug: LoggingFunction;
14 | info: LoggingFunction;
15 | warn: LoggingFunction;
16 | error: LoggingFunction;
17 | }
18 |
19 | let globalLogLevel: LogLevel;
20 |
21 | const setLogLevel = (level: LogLevels) => globalLogLevel = LogLevel[level] ?? LogLevel.info;
22 |
23 | class ConsoleLogger implements Logger {
24 | constructor(private name: string) {}
25 |
26 | public debug(message: any, ...data: any[]): void {
27 | this.log(LogLevel.debug, message, ...data);
28 | }
29 |
30 | public info(message: any, ...data: any[]): void {
31 | this.log(LogLevel.info, message, ...data);
32 | }
33 |
34 | public warn(message: any, ...data: any[]): void {
35 | this.log(LogLevel.warn, message, ...data);
36 | }
37 |
38 | public error(message: any, ...data: any[]): void {
39 | this.log(LogLevel.error, message, ...data);
40 | }
41 |
42 | protected log(level: LogLevel, message: any, ...data: any[]): void {
43 | if (level < globalLogLevel) {
44 | return;
45 | }
46 |
47 | const messageWithName = `[${this.name}] ${message}`;
48 |
49 | switch (level) {
50 | case LogLevel.debug:
51 | console.debug(messageWithName, ...data);
52 | break;
53 | case LogLevel.info:
54 | console.info(messageWithName, ...data);
55 | break;
56 | case LogLevel.warn:
57 | console.warn(messageWithName, ...data);
58 | break;
59 | case LogLevel.error:
60 | console.error(messageWithName, ...data);
61 | break;
62 | }
63 | }
64 | }
65 |
66 | setLogLevel((process.env.REACT_APP_LOG_LEVEL) as LogLevels);
67 | (window as any).__setLogLevel = setLogLevel;
68 |
69 | export function createLogger(name: string): Logger {
70 | return new ConsoleLogger(name);
71 | }
72 |
--------------------------------------------------------------------------------
/src/common/models/kick.ts:
--------------------------------------------------------------------------------
1 | export interface KickCategory {
2 | id: number;
3 | name: string;
4 | slug: string;
5 | responsive: string;
6 | banner: string;
7 | parent_category: string;
8 | }
9 |
10 | export interface KickChannel {
11 | id: number;
12 | username: string;
13 | slug: string;
14 | profile_picture: string | null;
15 | }
16 |
17 | export interface KickClip {
18 | id: string;
19 | livestream_id: string;
20 | category_id: string;
21 | channel_id: number;
22 | user_id: number;
23 | title: string;
24 | clip_url: string;
25 | thumbnail_url: string;
26 | privacy: string;
27 | likes: number;
28 | liked: boolean;
29 | views: number;
30 | duration: number;
31 | started_at: string;
32 | created_at: string;
33 | is_mature: boolean;
34 | video_url: string;
35 | view_count: number;
36 | likes_count: number;
37 | is_live: boolean;
38 | category: KickCategory;
39 | creator: KickChannel;
40 | channel: KickChannel;
41 | }
42 |
--------------------------------------------------------------------------------
/src/common/models/oembed.ts:
--------------------------------------------------------------------------------
1 | interface OEmbedBaseResponse {
2 | type: string;
3 | version: string;
4 | title?: string;
5 | author_name?: string;
6 | author_url?: string;
7 | provider_name?: string;
8 | provider_url?: string;
9 | cache_age?: number;
10 | thumbnail_url?: string;
11 | thumbnail_width?: number;
12 | thumbnail_height?: number;
13 | }
14 |
15 | export interface OEmbedVideoResponse extends OEmbedBaseResponse {
16 | type: 'video';
17 | html: string;
18 | width: number;
19 | height: number;
20 | }
21 |
22 | export type OEmbedResponse = OEmbedBaseResponse | OEmbedVideoResponse;
23 |
--------------------------------------------------------------------------------
/src/common/models/twitch.ts:
--------------------------------------------------------------------------------
1 | export interface AuthInfo {
2 | access_token: string;
3 | token_type: string;
4 | scope: string;
5 | }
6 |
7 | export interface UserInfo {
8 | aud: string;
9 | azp: string;
10 | exp: string;
11 | iat: string;
12 | iss: string;
13 | sub: string;
14 |
15 | preferred_username?: string;
16 | picture?: string;
17 | }
18 |
19 | export interface TokenInfo {
20 | client_id: string;
21 | expires_in: number;
22 | login: string;
23 | scopes: string[];
24 | user_id: string;
25 | }
26 |
27 | export interface TwitchClip {
28 | id: string;
29 | url: string;
30 | embed_url: string;
31 | broadcaster_id: string;
32 | broadcaster_name: string;
33 | creator_id: string;
34 | creator_name: string;
35 | video_id: string;
36 | game_id: string;
37 | language: string;
38 | title: string;
39 | view_count: number;
40 | created_at: string;
41 | thumbnail_url: string;
42 | duration: number;
43 | }
44 |
45 | export interface TwitchVideo {
46 | id: string;
47 | url: string;
48 | embed_url: string;
49 | user_id: string;
50 | user_name: string;
51 | language: string;
52 | title: string;
53 | view_count: number;
54 | created_at: string;
55 | thumbnail_url: string;
56 | duration: number;
57 | }
58 |
59 | export interface TwitchGame {
60 | box_art_url: string;
61 | id: string;
62 | name: string;
63 | }
64 |
--------------------------------------------------------------------------------
/src/common/utils.ts:
--------------------------------------------------------------------------------
1 | export type PlatformType = 'Twitch' | 'Kick' | 'YouTube' | 'Afreeca' | 'Streamable' | undefined;
2 |
3 | export const getUrlFromMessage = (message: string) => {
4 | const urlStart = message.indexOf('http');
5 | if (urlStart >= 0) {
6 | const urlEnd = message.indexOf(' ', urlStart);
7 | const url = message.slice(urlStart, urlEnd > 0 ? urlEnd : undefined);
8 | return url;
9 | }
10 |
11 | return undefined;
12 | };
13 |
--------------------------------------------------------------------------------
/src/features/analytics/analytics.ts:
--------------------------------------------------------------------------------
1 | import { createLogger } from '../../common/logging';
2 |
3 | const logger = createLogger('Umami Event');
4 |
5 | export function trace(value: string, type = 'custom') {
6 | const umami = (window as any).umami;
7 | logger.debug(`${type}: ${value}`);
8 |
9 | try {
10 | if (umami) {
11 | if (type === 'view') {
12 | umami.trackView(`${process.env.REACT_APP_BASEPATH}${value}`);
13 | } else {
14 | umami.trackEvent(value, type);
15 | }
16 | }
17 | } catch {}
18 | }
19 |
--------------------------------------------------------------------------------
/src/features/analytics/analyticsMiddleware.tsx:
--------------------------------------------------------------------------------
1 | import { isAnyOf, Middleware } from '@reduxjs/toolkit';
2 | import { AppMiddlewareAPI, RootState } from '../../app/store';
3 | import { trace } from './analytics';
4 | import { authenticateWithToken } from '../auth/authSlice';
5 | import { clipDetailsFailed, clipDetailsReceived, clipStubReceived, currentClipWatched, isOpenChanged, queueCleared } from '../clips/clipQueueSlice';
6 | import { settingsChanged } from '../settings/settingsSlice';
7 |
8 | const createAnalyticsMiddleware = (): Middleware<{}, RootState> => {
9 | return (storeApi: AppMiddlewareAPI) => {
10 | return (next) => (action) => {
11 | if (
12 | isAnyOf(
13 | clipStubReceived,
14 | clipDetailsReceived,
15 | clipDetailsFailed,
16 | currentClipWatched,
17 | isOpenChanged,
18 | authenticateWithToken.fulfilled,
19 | settingsChanged,
20 | queueCleared
21 | )(action)
22 | ) {
23 | trace(action.type);
24 | }
25 | return next(action);
26 | };
27 | };
28 | };
29 |
30 | export default createAnalyticsMiddleware;
31 |
--------------------------------------------------------------------------------
/src/features/auth/AuthPage.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingOverlay } from '@mantine/core';
2 | import { useEffect, useState } from 'react';
3 | import { Navigate, useLocation } from 'react-router-dom';
4 | import { useAppDispatch, useAppSelector } from '../../app/hooks';
5 | import { selectAuthState, authenticateWithToken } from './authSlice';
6 |
7 | function AuthPage() {
8 | const location = useLocation();
9 | const dispatch = useAppDispatch();
10 | const [returnUrl, setReturnUrl] = useState('/');
11 | const authState = useAppSelector(selectAuthState);
12 |
13 | useEffect(() => {
14 | if (location.hash) {
15 | const { access_token, state } = location.hash
16 | .substring(1)
17 | .split('&')
18 | .reduce((authInfo, s) => {
19 | const parts = s.split('=');
20 | authInfo[parts[0]] = decodeURIComponent(decodeURIComponent(parts[1]));
21 | return authInfo;
22 | }, {} as Record) as { access_token: string; state: string };
23 |
24 | if (state) {
25 | setReturnUrl(state);
26 | }
27 |
28 | if (access_token) {
29 | dispatch(authenticateWithToken(access_token));
30 | }
31 | }
32 | // eslint-disable-next-line react-hooks/exhaustive-deps
33 | }, []);
34 |
35 | switch (authState) {
36 | case 'authenticating':
37 | return ;
38 | case 'authenticated':
39 | return ;
40 | case 'unauthenticated':
41 | return ;
42 | }
43 | }
44 |
45 | export default AuthPage;
46 |
--------------------------------------------------------------------------------
/src/features/auth/IfAuthenticated.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, ReactNode } from 'react';
2 | import { useAppSelector } from '../../app/hooks';
3 | import { selectAuthState } from './authSlice';
4 |
5 | function IfAuthenticated({ children, otherwise }: PropsWithChildren<{ otherwise?: ReactNode }>) {
6 | const isAuthenticated = useAppSelector(selectAuthState) === 'authenticated';
7 |
8 | if (isAuthenticated) {
9 | return <>{children}>;
10 | } else {
11 | return <>{otherwise}>;
12 | }
13 | }
14 |
15 | export default IfAuthenticated;
16 |
--------------------------------------------------------------------------------
/src/features/auth/RequireAuth.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react';
2 | import { useLocation } from 'react-router-dom';
3 | import { useAppDispatch, useAppSelector } from '../../app/hooks';
4 | import { login, selectAuthState } from './authSlice';
5 |
6 | function RequireAuth({ children }: PropsWithChildren<{}>) {
7 | const location = useLocation();
8 | const dispatch = useAppDispatch();
9 | const authState = useAppSelector(selectAuthState);
10 |
11 | switch (authState) {
12 | case 'authenticating':
13 | return <>>;
14 | case 'authenticated':
15 | return <>{children}>;
16 | case 'unauthenticated':
17 | dispatch(login(location.pathname));
18 | return <>>;
19 | }
20 | }
21 |
22 | export default RequireAuth;
23 |
--------------------------------------------------------------------------------
/src/features/auth/authSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
2 | import { persistReducer } from 'redux-persist';
3 | import storage from 'redux-persist-indexeddb-storage';
4 | import type { RootState, AppThunkConfig } from '../../app/store';
5 | import twitchAuthApi from './twitchAuthApi';
6 |
7 | interface AuthState {
8 | state: 'authenticated' | 'unauthenticated' | 'authenticating';
9 | token?: string;
10 | username?: string;
11 | profilePictureUrl?: string;
12 | scopes?: string[];
13 |
14 | revalidateTimeoutHandle?: NodeJS.Timeout;
15 | }
16 |
17 | const initialState: AuthState = {
18 | state: 'unauthenticated',
19 | scopes: [],
20 | };
21 |
22 | const authSlice = createSlice({
23 | name: 'auth',
24 | initialState,
25 | reducers: {},
26 | extraReducers: (builder) => {
27 | builder.addCase(authenticateWithToken.pending, (state) => {
28 | state.state = 'authenticating';
29 | });
30 | builder.addCase(authenticateWithToken.fulfilled, (state, { payload }) => {
31 | state.token = payload.token;
32 | state.username = payload.username;
33 | state.scopes = payload.scopes;
34 | state.state = 'authenticated';
35 | state.profilePictureUrl = payload.profilePictureUrl;
36 | state.revalidateTimeoutHandle = payload.revalidateTimeoutHandle;
37 | });
38 | builder.addCase(authenticateWithToken.rejected, (state) => {
39 | state.token = undefined;
40 | state.username = undefined;
41 | state.profilePictureUrl = undefined;
42 | state.scopes = [];
43 | state.state = 'unauthenticated';
44 | });
45 | builder.addCase(validateToken.fulfilled, (state, { payload }) => {
46 | state.revalidateTimeoutHandle = payload.revalidateTimeoutHandle;
47 | });
48 | builder.addCase(validateToken.rejected, (state) => {
49 | state.token = undefined;
50 | state.username = undefined;
51 | state.profilePictureUrl = undefined;
52 | state.scopes = [];
53 | state.state = 'unauthenticated';
54 | state.revalidateTimeoutHandle = undefined;
55 | });
56 | builder.addCase(logout.fulfilled, (state) => {
57 | state.token = undefined;
58 | state.username = undefined;
59 | state.profilePictureUrl = undefined;
60 | state.scopes = [];
61 | state.state = 'unauthenticated';
62 | state.revalidateTimeoutHandle = undefined;
63 | });
64 | },
65 | });
66 |
67 | export const login = createAsyncThunk('auth/login', async (returnUrl: string | undefined, thunkApi) => {
68 | twitchAuthApi.redirectToLogin(returnUrl);
69 | });
70 |
71 | export const authenticateWithToken = createAsyncThunk(
72 | 'auth/authenticateWithToken',
73 | async (token: string | undefined, thunkApi) => {
74 | if (!token) {
75 | return thunkApi.rejectWithValue('missing token');
76 | }
77 |
78 | const validateTokenResponse = await twitchAuthApi.validateToken(token);
79 | if (validateTokenResponse.status !== 200) {
80 | return thunkApi.rejectWithValue(validateTokenResponse.status);
81 | }
82 |
83 | const userInfoResponse = await twitchAuthApi.getUserInfo(token);
84 | if (userInfoResponse.status !== 200) {
85 | return thunkApi.rejectWithValue(userInfoResponse.status);
86 | }
87 |
88 | const revalidateTimeoutHandle = setTimeout(() => {
89 | thunkApi.dispatch(validateToken(token));
90 | }, 60 * 60 * 1000);
91 |
92 | return {
93 | token,
94 | username: userInfoResponse.data.preferred_username ?? validateTokenResponse.data.login,
95 | profilePictureUrl: userInfoResponse.data.picture,
96 | scopes: validateTokenResponse.data.scopes,
97 | revalidateTimeoutHandle,
98 | };
99 | }
100 | );
101 |
102 | export const validateToken = createAsyncThunk('auth/validateToken', async (token: string | undefined, thunkApi) => {
103 | if (!token) {
104 | return thunkApi.rejectWithValue('missing token');
105 | }
106 |
107 | const validateTokenResponse = await twitchAuthApi.validateToken(token);
108 | if (validateTokenResponse.status !== 200) {
109 | return thunkApi.rejectWithValue(validateTokenResponse.status);
110 | }
111 |
112 | const revalidateTimeoutHandle = setTimeout(() => {
113 | thunkApi.dispatch(validateToken(token));
114 | }, 60 * 60 * 1000);
115 |
116 | return { revalidateTimeoutHandle };
117 | });
118 |
119 | export const logout = createAsyncThunk(
120 | 'auth/logout',
121 | async (token: string | undefined = undefined, thunkApi) => {
122 | const { auth } = thunkApi.getState();
123 | if (!token) {
124 | token = auth.token;
125 | }
126 |
127 | if (auth.revalidateTimeoutHandle) {
128 | clearTimeout(auth.revalidateTimeoutHandle);
129 | }
130 |
131 | if (token) {
132 | await twitchAuthApi.revokeToken(token);
133 | }
134 | return true;
135 | }
136 | );
137 |
138 | export const selectAuthState = (state: RootState) => state.auth.state;
139 | export const selectAccessToken = (state: RootState) => state.auth.token;
140 | export const selectUsername = (state: RootState) => state.auth.username;
141 | export const selectProfilePictureUrl = (state: RootState) => state.auth.profilePictureUrl;
142 |
143 | const authReducer = persistReducer(
144 | {
145 | key: 'auth',
146 | storage: storage('twitch-clip-queue'),
147 | version: 1,
148 | whitelist: ['token'],
149 | },
150 | authSlice.reducer
151 | );
152 | export default authReducer;
153 |
--------------------------------------------------------------------------------
/src/features/auth/twitchAuthApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { TokenInfo, UserInfo } from '../../common/models/twitch';
3 |
4 | const TWITCH_CLIENT_ID = process.env.REACT_APP_TWITCH_CLIENT_ID;
5 | const TWITCH_REDIRECT_URI = process.env.REACT_APP_TWITCH_REDIRECT_URI;
6 |
7 | const defaultScopes = ['openid', 'chat:read'];
8 |
9 | const getLoginUrl = (state: string = ''): string => {
10 | return encodeURI(
11 | `https://id.twitch.tv/oauth2/authorize?client_id=${TWITCH_CLIENT_ID}` +
12 | `&redirect_uri=${TWITCH_REDIRECT_URI}` +
13 | `&response_type=token` +
14 | `&scope=${[...defaultScopes].join(' ')}` +
15 | `&claims={"userinfo":{"picture":null, "preferred_username":null}}` +
16 | `&state=${state}`
17 | );
18 | };
19 |
20 | const redirectToLogin = (state?: string): void => {
21 | window.location.assign(getLoginUrl(state));
22 | };
23 |
24 | const validateToken = async (token: string): Promise<{ data: TokenInfo; status: number }> => {
25 | const { data, status } = await axios.get(`https://id.twitch.tv/oauth2/validate`, {
26 | headers: {
27 | Authorization: `Bearer ${token}`,
28 | },
29 | });
30 |
31 | return { data, status };
32 | };
33 |
34 | const getUserInfo = async (token: string): Promise<{ data: UserInfo; status: number }> => {
35 | const { data, status } = await axios.get(`https://id.twitch.tv/oauth2/userinfo`, {
36 | headers: {
37 | Authorization: `Bearer ${token}`,
38 | },
39 | });
40 | return { data, status };
41 | };
42 |
43 | const revokeToken = async (token: string): Promise => {
44 | await axios.post(`https://id.twitch.tv/oauth2/revoke`, `client_id=${TWITCH_CLIENT_ID}&token=${token}`, {
45 | headers: {
46 | 'Content-Type': 'application/x-www-form-urlencoded',
47 | },
48 | });
49 | };
50 |
51 | const twitchAuthApi = {
52 | getLoginUrl,
53 | redirectToLogin,
54 | revokeToken,
55 | validateToken,
56 | getUserInfo,
57 | };
58 |
59 | export default twitchAuthApi;
60 |
--------------------------------------------------------------------------------
/src/features/clips/Clip.tsx:
--------------------------------------------------------------------------------
1 | import { ActionIcon, AspectRatio, Image, Box, Group, Skeleton, Stack, Text } from '@mantine/core';
2 | import { MouseEventHandler } from 'react';
3 | import { Trash } from 'tabler-icons-react';
4 | import { useAppSelector } from '../../app/hooks';
5 | import { selectClipById } from './clipQueueSlice';
6 | import type { PlatformType } from '../../common/utils';
7 | import Platform from '../../common/components/BrandPlatforms';
8 |
9 | interface ClipProps {
10 | clipId: string;
11 | platform: PlatformType;
12 | onClick?: MouseEventHandler;
13 | onCrossClick?: MouseEventHandler;
14 |
15 | className?: string;
16 | card?: boolean;
17 | }
18 |
19 | function Clip({ clipId, onClick, onCrossClick, className, card, platform }: ClipProps) {
20 | const { title, thumbnailUrl = '', author, submitters } = useAppSelector(selectClipById(clipId));
21 |
22 | return (
23 | ({
25 | position: 'relative',
26 | height: '100%',
27 | width: '100%',
28 | maxWidth: '100%',
29 | overflow: 'hidden',
30 | '& .clip--action-icon': { display: 'none' },
31 | '&:hover .clip--action-icon': { display: 'block' },
32 | '&:hover .clip--title': { color: onClick ? theme.colors.indigo[5] : undefined },
33 | })}
34 | >
35 |
52 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | {title}
68 |
69 |
70 |
71 |
79 |
80 | {author}
81 |
82 |
83 | {submitters?.[0] && (
84 |
85 | Submitted by {submitters[0]}
86 | {submitters.length > 1 && ` +${submitters.length - 1}`}
87 |
88 | )}
89 |
90 |
91 | {onCrossClick && (
92 |
99 |
100 |
101 | )}
102 |
103 | );
104 | }
105 |
106 | export default Clip;
107 |
--------------------------------------------------------------------------------
/src/features/clips/clipQueueMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { isAnyOf, Middleware } from '@reduxjs/toolkit';
2 | import { formatISO } from 'date-fns';
3 | import { REHYDRATE } from 'redux-persist';
4 | import { RootState, AppMiddlewareAPI } from '../../app/store';
5 | import { createLogger } from '../../common/logging';
6 | import { authenticateWithToken } from '../auth/authSlice';
7 | import { settingsChanged } from '../settings/settingsSlice';
8 | import { urlDeleted, urlReceived } from '../twitchChat/actions';
9 | import {
10 | clipStubReceived,
11 | queueClipRemoved,
12 | Clip,
13 | clipDetailsReceived,
14 | clipDetailsFailed,
15 | autoplayTimeoutHandleChanged,
16 | currentClipWatched,
17 | currentClipReplaced,
18 | currentClipSkipped,
19 | queueCleared,
20 | autoplayUrlFailed,
21 | autoplayUrlReceived,
22 | } from './clipQueueSlice';
23 | import { applyCustomizations } from './customization/customization';
24 | import clipProvider from './providers/providers';
25 |
26 | const logger = createLogger('ClipQueueMiddleware');
27 |
28 | const createClipQueueMiddleware = (): Middleware<{}, RootState> => {
29 | return (storeAPI: AppMiddlewareAPI) => {
30 | return (next) => (action) => {
31 | if (action.type === REHYDRATE && action.key === 'clipQueue' && action.payload) {
32 | clipProvider.setProviders(action.payload.providers);
33 | } else if (urlReceived.match(action)) {
34 | const { url, userstate } = action.payload;
35 | const sender = userstate.username;
36 | if (storeAPI.getState().clipQueue.isOpen) {
37 | const id = clipProvider.getIdFromUrl(url);
38 | if (id) {
39 | const clip: Clip | undefined = storeAPI.getState().clipQueue.byId[id];
40 |
41 | storeAPI.dispatch(clipStubReceived({ id, submitters: [sender], timestamp: formatISO(new Date()) }));
42 |
43 | if (!clip) {
44 | clipProvider
45 | .getClipById(id)
46 | .then((clip) => {
47 | if (clip) {
48 | storeAPI.dispatch(clipDetailsReceived(clip));
49 | } else {
50 | storeAPI.dispatch(clipDetailsFailed(id));
51 | }
52 | })
53 | .catch((e) => {
54 | logger.error(e);
55 | storeAPI.dispatch(clipDetailsFailed(id));
56 | });
57 | }
58 | }
59 | }
60 | } else if (urlDeleted.match(action)) {
61 | const id = clipProvider.getIdFromUrl(action.payload);
62 | if (id) {
63 | storeAPI.dispatch(queueClipRemoved(id));
64 | }
65 | } else if (settingsChanged.match(action)) {
66 | const { enabledProviders } = action.payload;
67 | if (enabledProviders) {
68 | clipProvider.setProviders(enabledProviders);
69 | }
70 | } else if (autoplayTimeoutHandleChanged.match(action)) {
71 | if (!action.payload.handle) {
72 | if (action.payload.set) {
73 | const delay = storeAPI.getState().clipQueue.autoplayDelay;
74 | const handle = setTimeout(() => {
75 | storeAPI.dispatch(currentClipWatched());
76 | }, delay);
77 | action.payload.handle = handle as any;
78 | } else {
79 | const handle = storeAPI.getState().clipQueue.autoplayTimeoutHandle;
80 | clearTimeout(handle);
81 | }
82 | }
83 | } else if (isAnyOf(currentClipWatched, currentClipReplaced, currentClipSkipped, queueCleared)(action)) {
84 | const handle = storeAPI.getState().clipQueue.autoplayTimeoutHandle;
85 | clearTimeout(handle);
86 |
87 | const { autoplay, queueIds } = storeAPI.getState().clipQueue;
88 | const nextId = queueIds[0];
89 | if (autoplay && nextId) {
90 | clipProvider
91 | .getAutoplayUrl(nextId)
92 | .then((url) => {
93 | if (url) {
94 | storeAPI.dispatch(autoplayUrlReceived(url));
95 | } else {
96 | storeAPI.dispatch(autoplayUrlFailed());
97 | }
98 | })
99 | .catch((e) => {
100 | logger.error(e);
101 | storeAPI.dispatch(autoplayUrlFailed());
102 | });
103 | }
104 | } else if (isAnyOf(autoplayUrlFailed)(action)) {
105 | const handle = storeAPI.getState().clipQueue.autoplayTimeoutHandle;
106 | clearTimeout(handle);
107 | } else if (authenticateWithToken.fulfilled.match(action)) {
108 | applyCustomizations(storeAPI);
109 | }
110 |
111 | return next(action);
112 | };
113 | };
114 | };
115 |
116 | export default createClipQueueMiddleware;
117 |
--------------------------------------------------------------------------------
/src/features/clips/clipQueueSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, createSelector, PayloadAction } from '@reduxjs/toolkit';
2 | import { persistReducer } from 'redux-persist';
3 | import storage from 'redux-persist-indexeddb-storage';
4 | import type { RootState } from '../../app/store';
5 | import { legacyDataMigrated } from '../migration/legacyMigration';
6 | import { settingsChanged } from '../settings/settingsSlice';
7 | import { userTimedOut } from '../twitchChat/actions';
8 | import type { PlatformType } from '../../common/utils';
9 | export interface Clip {
10 | id: string;
11 | submitters: string[];
12 |
13 | status?: 'watched' | 'removed';
14 | timestamp?: string;
15 |
16 | title?: string;
17 | author?: string;
18 | createdAt?: string;
19 | category?: string;
20 | url?: string;
21 | Platform?: PlatformType;
22 |
23 | thumbnailUrl?: string;
24 | }
25 |
26 | interface ClipQueueState {
27 | byId: Record;
28 |
29 | currentId?: string;
30 | queueIds: string[];
31 | historyIds: string[];
32 | watchedClipCount: number;
33 |
34 | isOpen: boolean;
35 |
36 | autoplay: boolean;
37 | autoplayDelay: number;
38 | clipLimit?: number | null;
39 | providers: string[];
40 | layout: string;
41 |
42 | autoplayTimeoutHandle?: number;
43 | autoplayUrl?: string;
44 | watchedHistory: string[];
45 | }
46 |
47 | const initialState: ClipQueueState = {
48 | byId: {},
49 | queueIds: [],
50 | historyIds: [],
51 | watchedClipCount: 0,
52 | providers: ['twitch-clip', 'twitch-vod', 'youtube'],
53 | layout: 'classic',
54 | isOpen: false,
55 | autoplay: false,
56 | autoplayDelay: 5000,
57 | watchedHistory: [],
58 | };
59 |
60 | const addClipToQueue = (state: ClipQueueState, clip: Clip) => {
61 | const id = clip.id;
62 | const submitter = clip.submitters[0];
63 |
64 | if (state.byId[id]) {
65 | let rememberedClip = state.byId[id];
66 | if (state.queueIds.includes(id)) {
67 | if (!rememberedClip.submitters.includes(submitter)) {
68 | rememberedClip = {
69 | ...rememberedClip,
70 | submitters: [...rememberedClip.submitters, submitter],
71 | };
72 | state.byId[id] = rememberedClip;
73 |
74 | const index = state.queueIds.indexOf(id);
75 | state.queueIds.splice(index, 1);
76 |
77 | const newIndex = state.queueIds.findIndex(
78 | (otherId) => state.byId[otherId].submitters.length < rememberedClip.submitters.length
79 | );
80 | if (newIndex > -1) {
81 | state.queueIds.splice(newIndex, 0, id);
82 | } else {
83 | state.queueIds.push(id);
84 | }
85 | }
86 |
87 | return;
88 | }
89 | if (rememberedClip.status) {
90 | return;
91 | }
92 | }
93 |
94 | if (!state.clipLimit || calculateTotalQueueLength(state.watchedClipCount, state.queueIds) < state.clipLimit) {
95 | state.queueIds.push(id);
96 | state.byId[id] = clip;
97 | }
98 | };
99 |
100 | const removeClipFromQueue = (state: ClipQueueState, id: string) => {
101 | if (state.currentId === id) {
102 | state.currentId = undefined;
103 | } else {
104 | const index = state.queueIds.indexOf(id);
105 | if (index > -1) {
106 | state.queueIds.splice(index, 1);
107 | }
108 | }
109 | };
110 |
111 | const addClipToHistory = (state: ClipQueueState, id?: string) => {
112 | if (!id) {
113 | return;
114 | }
115 |
116 | const clip = state.byId[id];
117 |
118 | if (clip) {
119 | state.historyIds.unshift(id);
120 | }
121 | };
122 |
123 | const advanceQueue = (state: ClipQueueState) => {
124 | state.currentId = state.queueIds.shift();
125 | if (state.currentId) {
126 | addClipToHistory(state, state.currentId);
127 | }
128 | };
129 |
130 | const updateClip = (state: ClipQueueState, id: string | undefined, clipUpdate: Partial) => {
131 | if (!id) {
132 | return;
133 | }
134 |
135 | const clip = state.byId[id];
136 | if (clip) {
137 | state.byId[id] = {
138 | ...clip,
139 | ...clipUpdate,
140 | };
141 | }
142 | };
143 |
144 | const clipQueueSlice = createSlice({
145 | name: 'clipQueue',
146 | initialState,
147 | reducers: {
148 | queueCleared: (state) => {
149 | state.queueIds.forEach((id) => {
150 | delete state.byId[id];
151 | });
152 | state.currentId = undefined;
153 | state.autoplayTimeoutHandle = undefined;
154 | state.queueIds = [];
155 | state.watchedClipCount = 0;
156 | state.watchedHistory = [];
157 | },
158 | memoryPurged: (state) => {
159 | const memory = state.byId;
160 | state.byId = {};
161 | state.historyIds = [];
162 |
163 | if (state.currentId) {
164 | state.byId[state.currentId] = memory[state.currentId];
165 | }
166 | state.queueIds.forEach((id) => {
167 | state.byId[id] = memory[id];
168 | });
169 | },
170 | currentClipWatched: (state) => {
171 | advanceQueue(state);
172 | if (state.currentId) {
173 | state.watchedClipCount += 1;
174 | state.watchedHistory.push(state.currentId);
175 | }
176 | updateClip(state, state.currentId, { status: 'watched' });
177 | state.autoplayTimeoutHandle = undefined;
178 | },
179 | previousClipWatched: (state) => {
180 | const currentId = state.currentId;
181 | if (currentId) {
182 | state.watchedHistory.pop();
183 | }
184 | const previousId = state.watchedHistory[state.watchedHistory.length - 1];
185 | if (previousId) {
186 | state.currentId = previousId;
187 | if (currentId) {
188 | state.queueIds.unshift(currentId);
189 | state.watchedClipCount -= 1;
190 | state.historyIds = state.historyIds.filter((id) => id !== currentId);
191 | }
192 | state.watchedHistory = state.watchedHistory.filter((id) => state.historyIds.includes(id));
193 | }
194 | },
195 |
196 | currentClipSkipped: (state) => {
197 | advanceQueue(state);
198 | updateClip(state, state.currentId, { status: 'watched' });
199 | state.autoplayTimeoutHandle = undefined;
200 | if (state.currentId) {
201 | state.watchedHistory.push(state.currentId);
202 | }
203 | },
204 | clipStubReceived: (state, { payload: clip }: PayloadAction) => addClipToQueue(state, clip),
205 | clipDetailsReceived: (state, { payload: clip }: PayloadAction) => {
206 | if (state.byId[clip.id]) {
207 | const submitters = state.byId[clip.id].submitters;
208 | updateClip(state, clip.id, {
209 | ...clip,
210 | submitters,
211 | });
212 | }
213 | },
214 | clipDetailsFailed: (state, { payload }: PayloadAction) => {
215 | removeClipFromQueue(state, payload);
216 | if (state.byId[payload]) {
217 | delete state.byId[payload];
218 | }
219 | },
220 | queueClipRemoved: (state, { payload }: PayloadAction) => {
221 | removeClipFromQueue(state, payload);
222 | addClipToHistory(state, payload);
223 | updateClip(state, payload, { status: 'removed' });
224 | },
225 | memoryClipRemoved: (state, { payload }: PayloadAction) => {
226 | removeClipFromQueue(state, payload);
227 | state.historyIds = state.historyIds.filter((id) => id !== payload);
228 | delete state.byId[payload];
229 | },
230 | currentClipReplaced: (state, { payload }: PayloadAction) => {
231 | const index = state.queueIds.indexOf(payload);
232 | if (index > -1) {
233 | state.queueIds.splice(index, 1);
234 |
235 | if (payload) {
236 | addClipToHistory(state, payload);
237 | state.watchedHistory.push(payload);
238 | }
239 |
240 | state.currentId = payload;
241 | state.watchedClipCount += 1;
242 | updateClip(state, state.currentId, { status: 'watched' });
243 | state.autoplayTimeoutHandle = undefined;
244 | }
245 | },
246 | currentClipForceReplaced: (state, { payload }: PayloadAction) => {
247 | state.byId[payload.id] = payload;
248 | state.currentId = payload.id;
249 | state.autoplayTimeoutHandle = undefined;
250 | state.watchedHistory.push(payload.id);
251 | },
252 | isOpenChanged: (state, { payload }: PayloadAction) => {
253 | state.isOpen = payload;
254 | if (payload) {
255 | state.watchedClipCount = 0;
256 | if (state.queueIds.length === 0) {
257 | state.watchedHistory = [];
258 | }
259 | }
260 | },
261 | autoplayChanged: (state, { payload }: PayloadAction) => {
262 | state.autoplay = payload;
263 | },
264 | autoplayTimeoutHandleChanged: (
265 | state,
266 | { payload }: PayloadAction<{ set: boolean; handle?: number | undefined }>
267 | ) => {
268 | state.autoplayTimeoutHandle = payload.handle;
269 | },
270 | autoplayUrlReceived: (state, { payload }: PayloadAction) => {
271 | state.autoplayUrl = payload;
272 | },
273 | autoplayUrlFailed: (state) => {
274 | state.autoplay = false;
275 | state.autoplayUrl = undefined;
276 | state.autoplayTimeoutHandle = undefined;
277 | },
278 | },
279 | extraReducers: (builder) => {
280 | builder.addCase(userTimedOut, (state, { payload }) => {
281 | for (const id of state.queueIds) {
282 | const clip = state.byId[id];
283 | state.byId[id] = {
284 | ...clip,
285 | submitters: clip.submitters.filter((submitter) => submitter.toLowerCase() !== payload),
286 | };
287 | }
288 |
289 | state.queueIds = state.queueIds.filter((id) => state.byId[id].submitters.length > 0);
290 | });
291 | builder.addCase(settingsChanged, (state, { payload }) => {
292 | if (payload.clipLimit !== undefined) {
293 | state.clipLimit = payload.clipLimit;
294 | }
295 | if (payload.enabledProviders) {
296 | state.providers = payload.enabledProviders;
297 | }
298 | if (payload.layout) {
299 | state.layout = payload.layout;
300 | }
301 | });
302 | builder.addCase(legacyDataMigrated, (state, { payload }) => {
303 | state.watchedClipCount = 0;
304 | state.autoplay = payload.autoplay;
305 | state.byId = payload.byIds;
306 | state.historyIds = payload.historyIds;
307 | state.queueIds = payload.queueIds;
308 |
309 | if (payload.providers) {
310 | state.providers = payload.providers;
311 | }
312 | if (payload.clipLimit) {
313 | state.clipLimit = payload.clipLimit;
314 | }
315 | });
316 | },
317 | });
318 |
319 | const selectByIds = (state: RootState) => state.clipQueue.byId;
320 |
321 | export const selectQueueIds = (state: RootState) => state.clipQueue.queueIds;
322 | export const selectCurrentId = (state: RootState) => state.clipQueue.currentId;
323 | export const selectHistoryIds = (state: RootState) => state.clipQueue.historyIds;
324 | export const selectWatchedCount = (state: RootState) => state.clipQueue.watchedClipCount;
325 | export const selectIsOpen = (state: RootState) => state.clipQueue.isOpen;
326 | export const selectAutoplayEnabled = (state: RootState) => state.clipQueue.autoplay;
327 | export const selectClipLimit = (state: RootState) => state.clipQueue.clipLimit;
328 | export const selectProviders = (state: RootState) => state.clipQueue.providers;
329 | export const selectLayout = (state: RootState) => state.clipQueue.layout;
330 | export const selectAutoplayTimeoutHandle = (state: RootState) => state.clipQueue.autoplayTimeoutHandle;
331 | export const selectAutoplayDelay = (state: RootState) => state.clipQueue.autoplayDelay;
332 | export const selectAutoplayUrl = (state: RootState) => state.clipQueue.autoplayUrl;
333 | export const selectClipById = (id: string) => (state: RootState) => state.clipQueue.byId[id];
334 | export const selectNextId = createSelector([selectQueueIds], (queueIds) => queueIds[0]);
335 | export const selectCurrentClip = createSelector([selectByIds, selectCurrentId], (byIds, id) =>
336 | id ? byIds[id] : undefined
337 | );
338 | export const selectNextClip = createSelector([selectByIds, selectNextId], (byIds, id) => byIds[id]);
339 |
340 | const calculateTotalQueueLength = (watchedCount: number, queueIds: string[]) => {
341 | return watchedCount + queueIds.length;
342 | };
343 | export const selectTotalQueueLength = createSelector([selectWatchedCount, selectQueueIds], calculateTotalQueueLength);
344 |
345 | export const selectClipHistoryIdsPage = createSelector(
346 | [selectHistoryIds, (_, page: number, perPage: number) => ({ page, perPage })],
347 | (historyIds, { page, perPage }) => ({
348 | clips: historyIds.slice((page - 1) * perPage, page * perPage),
349 | totalPages: Math.ceil(historyIds.length / perPage),
350 | })
351 | );
352 | export const selectHasPrevious = (state: RootState) => {
353 | const { watchedHistory, currentId, historyIds } = state.clipQueue;
354 | return currentId && historyIds && watchedHistory.length > 1;
355 | };
356 |
357 | export const {
358 | queueCleared,
359 | memoryPurged,
360 | currentClipWatched,
361 | currentClipSkipped,
362 | currentClipReplaced,
363 | currentClipForceReplaced,
364 | clipStubReceived,
365 | clipDetailsReceived,
366 | clipDetailsFailed,
367 | queueClipRemoved,
368 | memoryClipRemoved,
369 | isOpenChanged,
370 | autoplayChanged,
371 | autoplayTimeoutHandleChanged,
372 | autoplayUrlReceived,
373 | autoplayUrlFailed,
374 | previousClipWatched,
375 | } = clipQueueSlice.actions;
376 |
377 | const clipQueueReducer = persistReducer(
378 | {
379 | key: 'clipQueue',
380 | storage: storage('twitch-clip-queue'),
381 | version: 1,
382 | blacklist: ['isOpen'],
383 | },
384 | clipQueueSlice.reducer
385 | );
386 | export default clipQueueReducer;
387 |
--------------------------------------------------------------------------------
/src/features/clips/customization/customization.ts:
--------------------------------------------------------------------------------
1 | import { formatISO, isFriday } from 'date-fns';
2 | import type { AppMiddlewareAPI } from '../../../app/store';
3 | import { currentClipForceReplaced } from '../clipQueueSlice';
4 |
5 | export function applyCustomizations(storeApi: AppMiddlewareAPI) {
6 | const {
7 | settings: { channel },
8 | } = storeApi.getState();
9 |
10 | const now = new Date();
11 |
12 | switch (channel?.toLowerCase()) {
13 | case 'wolfabelle': {
14 | if (isFriday(now)) {
15 | storeApi.dispatch(
16 | currentClipForceReplaced({
17 | title: 'ITS FRIDAY THEN, ITS SATURDAY, SUNDAY! GO MUFASA!',
18 | author: 'MUFASA',
19 | id: 'youtube:1TewCPi92ro',
20 | thumbnailUrl: 'https://i.ytimg.com/vi/1TewCPi92ro/hqdefault.jpg',
21 | submitters: ['TriPls'],
22 | timestamp: formatISO(now),
23 | status: 'watched',
24 | })
25 | );
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/features/clips/history/HistoryPage.tsx:
--------------------------------------------------------------------------------
1 | import { Anchor, Center, Container, Grid, Pagination, Text } from '@mantine/core';
2 | import { useState } from 'react';
3 | import { useAppDispatch, useAppSelector } from '../../../app/hooks';
4 | import { memoryClipRemoved, selectClipHistoryIdsPage, selectClipById } from '../clipQueueSlice';
5 | import Clip from '../Clip';
6 | import clipProvider from '../providers/providers';
7 |
8 | function MemoryPage() {
9 | const dispatch = useAppDispatch();
10 | const [activePage, setPage] = useState(1);
11 | const { clips, totalPages } = useAppSelector((state) => selectClipHistoryIdsPage(state, activePage, 24));
12 | const clipObjects = useAppSelector((state) => clips.map((id) => selectClipById(id)(state)).filter(Boolean));
13 | return (
14 |
15 | {totalPages > 0 ? (
16 | <>
17 |
18 |
19 |
20 |
21 | {clipObjects.map((clip) => (
22 |
23 |
29 | {}}
34 | onCrossClick={(e) => {
35 | e.preventDefault();
36 | dispatch(memoryClipRemoved(clip!.id));
37 | }}
38 | />
39 |
40 |
41 | ))}
42 |
43 | >
44 | ) : (
45 | Clip history is empty.
46 | )}
47 |
48 | );
49 | }
50 |
51 | export default MemoryPage;
52 |
--------------------------------------------------------------------------------
/src/features/clips/providers/afreecaClip/afreecaClipProvider.test.ts:
--------------------------------------------------------------------------------
1 | import afreecaClipProvider from './afreecaClipProvider';
2 |
3 | describe('afreecaClipProvider', () => {
4 | it('gets clip info from vod.afreecatv.com url', () => {
5 | expect(afreecaClipProvider.getIdFromUrl('https://vod.afreecatv.com/player/92015748')).toEqual('92015748');
6 | });
7 | });
8 |
--------------------------------------------------------------------------------
/src/features/clips/providers/afreecaClip/afreecaClipProvider.ts:
--------------------------------------------------------------------------------
1 | import afreecaApi from '../../../../common/apis/afreecaApi';
2 | import type { Clip } from '../../clipQueueSlice';
3 | import type { ClipProvider } from '../providers';
4 | class AfreecaClipProvider implements ClipProvider {
5 | name = 'afreeca-clip';
6 |
7 | getIdFromUrl(url: string): string | undefined {
8 | let uri: URL;
9 | try {
10 | uri = new URL(url);
11 | } catch {
12 | return undefined;
13 | }
14 |
15 | if (uri.hostname.endsWith('vod.afreecatv.com')) {
16 | const idStart = uri.pathname.lastIndexOf('/') + 1;
17 | const id = uri.pathname.slice(idStart).split('?')[0];
18 |
19 | if (!id) {
20 | return undefined;
21 | }
22 |
23 | return id;
24 | }
25 |
26 | return undefined;
27 | }
28 |
29 | async getClipById(id: string): Promise {
30 | const clipInfo = await afreecaApi.getClip(id);
31 |
32 | return {
33 | id,
34 | title: clipInfo?.title ?? id,
35 | author: clipInfo?.author_name ?? 'afreeca',
36 | thumbnailUrl: clipInfo?.thumbnail_url,
37 | submitters: [],
38 | Platform: 'Afreeca',
39 | };
40 | }
41 |
42 | getUrl(id: string): string | undefined {
43 | return `https://vod.afreecatv.com/player/${id}`;
44 | }
45 |
46 | getEmbedUrl(id: string): string | undefined {
47 | return `https://vod.afreecatv.com/player/${id}/embed?showChat=false&autoPlay=true&mutePlay=false`;
48 | }
49 |
50 | async getAutoplayUrl(id: string): Promise {
51 | return this.getUrl(id);
52 | }
53 | }
54 |
55 | const afreecaClipProvider = new AfreecaClipProvider();
56 |
57 | export default afreecaClipProvider;
58 |
--------------------------------------------------------------------------------
/src/features/clips/providers/kickClip/kickClipProvider.test.ts:
--------------------------------------------------------------------------------
1 | import kickClipProvider from './kickClipProvider';
2 |
3 | describe('KickClipProvider', () => {
4 | it('gets clip info from www.Kick.com url', () => {
5 | expect(kickClipProvider.getIdFromUrl('https://kick.com/silent/clips/clip_01JNVX1HA6QA593VKT6WEE5ZQE')).toEqual(
6 | 'clip_01JNVX1HA6QA593VKT6WEE5ZQE'
7 | );
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/features/clips/providers/kickClip/kickClipProvider.ts:
--------------------------------------------------------------------------------
1 | import kickApi from '../../../../common/apis/kickApi';
2 | import type { Clip } from '../../clipQueueSlice';
3 | import type { ClipProvider } from '../providers';
4 |
5 | class KickClipProvider implements ClipProvider {
6 | name = 'kick-clip';
7 | private clipCache: Map = new Map();
8 |
9 | getIdFromUrl(url: string): string | undefined {
10 | try {
11 | const uri = new URL(url);
12 | if (uri.hostname === 'kick.com' || uri.hostname === 'www.kick.com') {
13 | const id = uri.searchParams.get('clip');
14 | if (id) {
15 | return id;
16 | }
17 | if (uri.pathname.includes('/clips/')) {
18 | const idStart = uri.pathname.lastIndexOf('/');
19 | return uri.pathname.slice(idStart).split('?')[0]?.slice(1);
20 | }
21 | }
22 | return undefined;
23 | } catch {
24 | return undefined;
25 | }
26 | }
27 |
28 | async getClipById(id: string): Promise {
29 | if (!id) return undefined;
30 |
31 | const clipInfo = await kickApi.getClip(id);
32 | const clipCache: Record = {};
33 | if (!clipInfo || !clipInfo.video_url) return undefined;
34 |
35 | clipCache[id] = clipInfo.video_url;
36 |
37 | return {
38 | id: clipInfo.id,
39 | title: clipInfo.title,
40 | author: clipInfo.channel.username,
41 | category: clipInfo.category.name,
42 | url: clipInfo.video_url,
43 | createdAt: clipInfo.created_at,
44 | thumbnailUrl: clipInfo.thumbnail_url.replace('%{width}x%{height}', '480x272'),
45 | submitters: [],
46 | Platform: 'Kick',
47 | };
48 | }
49 |
50 | getUrl(id: string): string | undefined {
51 | return this.clipCache.get(id);
52 | }
53 |
54 | getEmbedUrl(id: string): string | undefined {
55 | return this.getUrl(id);
56 | }
57 | async getAutoplayUrl(id: string): Promise {
58 | return await kickApi.getDirectUrl(id);
59 | }
60 | }
61 |
62 | const kickClipProvider = new KickClipProvider();
63 | export default kickClipProvider;
64 |
--------------------------------------------------------------------------------
/src/features/clips/providers/providers.ts:
--------------------------------------------------------------------------------
1 | import { createLogger } from '../../../common/logging';
2 | import { Clip } from '../clipQueueSlice';
3 | import afreecaClipProvider from './afreecaClip/afreecaClipProvider';
4 | import streamableProvider from './streamable/streamableProvider';
5 | import twitchClipProvider from './twitchClip/twitchClipProvider';
6 | import twitchVodProvider from './twitchVod/twitchVodProvider';
7 | import youtubeProvider from './youtube/youtubeProvider';
8 | import kickClipProvider from './kickClip/kickClipProvider';
9 | const logger = createLogger('CombinedClipProvider');
10 |
11 | export interface ClipProvider {
12 | name: string;
13 | getIdFromUrl(url: string): string | undefined;
14 | getClipById(id: string): Promise;
15 | getUrl(id: string): string | undefined;
16 | getEmbedUrl(id: string): string | undefined;
17 | getAutoplayUrl(id: string): Promise;
18 | }
19 |
20 | class CombinedClipProvider implements ClipProvider {
21 | name = 'combined';
22 | providers = {
23 | [twitchClipProvider.name]: twitchClipProvider,
24 | [twitchVodProvider.name]: twitchVodProvider,
25 | [youtubeProvider.name]: youtubeProvider,
26 | [streamableProvider.name]: streamableProvider,
27 | [afreecaClipProvider.name]: afreecaClipProvider,
28 | [kickClipProvider.name]: kickClipProvider,
29 | };
30 | enabledProviders: string[] = [];
31 |
32 | getIdFromUrl(url: string): string | undefined {
33 | for (const providerName of this.enabledProviders) {
34 | const provider = this.providers[providerName];
35 | if (provider) {
36 | const id = provider.getIdFromUrl(url);
37 | if (id) {
38 | return `${provider.name}:${id}`;
39 | }
40 | }
41 | }
42 | return undefined;
43 | }
44 |
45 | async getClipById(id: string): Promise {
46 | const [provider, idPart] = this.getProviderAndId(id);
47 | const clip = await provider?.getClipById(idPart);
48 |
49 | if (clip) {
50 | clip.id = id;
51 | }
52 |
53 | return clip;
54 | }
55 |
56 | getUrl(id: string): string | undefined {
57 | const [provider, idPart] = this.getProviderAndId(id);
58 | return provider?.getUrl(idPart);
59 | }
60 | getEmbedUrl(id: string): string | undefined {
61 | const [provider, idPart] = this.getProviderAndId(id);
62 | return provider?.getEmbedUrl(idPart);
63 | }
64 | async getAutoplayUrl(id: string): Promise {
65 | const [provider, idPart] = this.getProviderAndId(id);
66 | return await provider?.getAutoplayUrl(idPart);
67 | }
68 |
69 | setProviders(providers: string[]) {
70 | logger.info('setProviders', providers);
71 | this.enabledProviders = providers;
72 | }
73 |
74 | private getProviderAndId(id: string): [ClipProvider | undefined, string] {
75 | const [providerName, idPart] = id.split(':');
76 | const provider = this.providers[providerName];
77 |
78 | return [provider, idPart];
79 | }
80 | }
81 |
82 | const clipProvider = new CombinedClipProvider();
83 |
84 | export default clipProvider;
85 |
--------------------------------------------------------------------------------
/src/features/clips/providers/streamable/streamable.test.ts:
--------------------------------------------------------------------------------
1 | import streamableProvider from './streamableProvider';
2 |
3 | describe('StreamableProvider', () => {
4 | it('gets clip info from streamable.com url', () => {
5 | expect(streamableProvider.getIdFromUrl('https://streamable.com/vxfb7q')).toEqual('vxfb7q');
6 | });
7 |
8 | it('gets clip info from www.streamable.com url', () => {
9 | expect(streamableProvider.getIdFromUrl('https://www.streamable.com/vxfb7q')).toEqual('vxfb7q');
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/features/clips/providers/streamable/streamableProvider.ts:
--------------------------------------------------------------------------------
1 | import streamableApi from '../../../../common/apis/streamableApi';
2 | import type { Clip } from '../../clipQueueSlice';
3 | import type { ClipProvider } from '../providers';
4 |
5 | class StreamableProvider implements ClipProvider {
6 | name = 'streamable';
7 |
8 | getIdFromUrl(url: string): string | undefined {
9 | let uri: URL;
10 | try {
11 | uri = new URL(url);
12 | } catch {
13 | return undefined;
14 | }
15 |
16 | if (uri.hostname.endsWith('streamable.com')) {
17 | const idStart = uri.pathname.lastIndexOf('/') + 1;
18 | const id = uri.pathname.slice(idStart).split('?')[0];
19 |
20 | if (!id) {
21 | return undefined;
22 | }
23 |
24 | return id;
25 | }
26 |
27 | return undefined;
28 | }
29 |
30 | async getClipById(id: string): Promise {
31 | const clipInfo = await streamableApi.getClip(id);
32 |
33 | return {
34 | id,
35 | title: clipInfo?.title ?? id,
36 | author: clipInfo?.author_name ?? 'Streamable',
37 | thumbnailUrl: clipInfo?.thumbnail_url,
38 | submitters: [],
39 | Platform: 'Streamable',
40 | };
41 | }
42 |
43 | getUrl(id: string): string | undefined {
44 | return `https://streamable.com/${id}`;
45 | }
46 |
47 | getEmbedUrl(id: string): string | undefined {
48 | return `https://streamable.com/o/${id}`;
49 | }
50 |
51 | async getAutoplayUrl(id: string): Promise {
52 | return this.getUrl(id);
53 | }
54 | }
55 |
56 | const streamableProvider = new StreamableProvider();
57 |
58 | export default streamableProvider;
59 |
--------------------------------------------------------------------------------
/src/features/clips/providers/twitchClip/twitchClipProvider.test.ts:
--------------------------------------------------------------------------------
1 | import twitchClipProvider from './twitchClipProvider';
2 |
3 | describe('TwitchClipProvider', () => {
4 | it('gets clip info from www.twitch.tv url', () => {
5 | expect(
6 | twitchClipProvider.getIdFromUrl('https://www.twitch.tv/zerkaa/clip/SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh')
7 | ).toEqual('SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh');
8 | });
9 |
10 | it('gets clip info from www.twitch.tv url with query params', () => {
11 | expect(
12 | twitchClipProvider.getIdFromUrl(
13 | 'https://www.twitch.tv/zerkaa/clip/SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh?filter=clips&range=7d&sort=time'
14 | )
15 | ).toEqual('SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh');
16 | });
17 |
18 | it('gets clip info from twitch.tv url', () => {
19 | expect(
20 | twitchClipProvider.getIdFromUrl('https://twitch.tv/zerkaa/clip/SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh')
21 | ).toEqual('SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh');
22 | });
23 |
24 | it('gets clip info from m.twitch.tv url', () => {
25 | expect(
26 | twitchClipProvider.getIdFromUrl('https://m.twitch.tv/zerkaa/clip/SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh')
27 | ).toEqual('SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh');
28 | });
29 |
30 | it('gets clip info from twitch.tv url with query params', () => {
31 | expect(
32 | twitchClipProvider.getIdFromUrl(
33 | 'https://twitch.tv/zerkaa/clip/SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh?filter=clips&range=7d&sort=time'
34 | )
35 | ).toEqual('SneakyFineBillItsBoshyTime-JjCNavRWMDXzNTDh');
36 | });
37 |
38 | it('gets clip info from clips.twitch.tv url', () => {
39 | expect(
40 | twitchClipProvider.getIdFromUrl('https://clips.twitch.tv/BumblingDiligentPotPraiseIt-5B65rVgbKTmzruYt')
41 | ).toEqual('BumblingDiligentPotPraiseIt-5B65rVgbKTmzruYt');
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/features/clips/providers/twitchClip/twitchClipProvider.ts:
--------------------------------------------------------------------------------
1 | import twitchApi from '../../../../common/apis/twitchApi';
2 | import type { Clip } from '../../clipQueueSlice';
3 | import type { ClipProvider } from '../providers';
4 |
5 | class TwitchClipProvider implements ClipProvider {
6 | name = 'twitch-clip';
7 | getIdFromUrl(url: string): string | undefined {
8 | let uri: URL;
9 | try {
10 | uri = new URL(url);
11 | } catch {
12 | return undefined;
13 | }
14 |
15 | if (uri.hostname === 'clips.twitch.tv') {
16 | return this.extractIdFromPathname(uri.pathname);
17 | }
18 |
19 | if (uri.hostname.endsWith('twitch.tv')) {
20 | if (uri.pathname.includes('/clip/')) {
21 | return this.extractIdFromPathname(uri.pathname);
22 | }
23 | }
24 |
25 | return undefined;
26 | }
27 |
28 | async getClipById(id: string): Promise {
29 | if (!id) {
30 | return undefined;
31 | }
32 |
33 | const clipInfo = await twitchApi.getClip(id);
34 |
35 | if (!clipInfo) {
36 | return undefined;
37 | }
38 |
39 | return {
40 | id,
41 | author: clipInfo.broadcaster_name,
42 | title: clipInfo.title,
43 | submitters: [],
44 | thumbnailUrl: clipInfo.thumbnail_url?.replace('%{width}x%{height}', '480x272'),
45 | createdAt: clipInfo.created_at,
46 | Platform: 'Twitch',
47 | };
48 | }
49 |
50 | getUrl(id: string): string | undefined {
51 | return `https://clips.twitch.tv/${id}`;
52 | }
53 |
54 | getEmbedUrl(id: string): string | undefined {
55 | return `https://clips.twitch.tv/embed?clip=${id}&autoplay=true&parent=${window.location.hostname}`;
56 | }
57 |
58 | async getAutoplayUrl(id: string): Promise {
59 | return await twitchApi.getDirectUrl(id);
60 | }
61 |
62 | private extractIdFromPathname(pathname: string): string | undefined {
63 | const idStart = pathname.lastIndexOf('/');
64 | const id = pathname.slice(idStart).split('?')[0].slice(1);
65 |
66 | return id;
67 | }
68 | }
69 |
70 | const twitchClipProvider = new TwitchClipProvider();
71 | export default twitchClipProvider;
72 |
--------------------------------------------------------------------------------
/src/features/clips/providers/twitchVod/twitchVodProvider.test.ts:
--------------------------------------------------------------------------------
1 | import twitchVodProvider from './twitchVodProvider';
2 |
3 | describe('twitchVodProvider', () => {
4 | it('does not get clip info from www.twitch.tv/videos url without timestamp', () => {
5 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/videos/1409747385')).toEqual(undefined);
6 | });
7 |
8 | it('does not get clip info from www.twitch.tv/video url without timestamp', () => {
9 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/video/1409747385')).toEqual(undefined);
10 | });
11 |
12 | it('gets clip info from www.twitch.tv/videos url with timestamp', () => {
13 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/videos/1409747385?t=01h36m38s')).toEqual(
14 | '1409747385;01h36m38s'
15 | );
16 | });
17 |
18 | it('gets clip info from www.twitch.tv/video url with timestamp', () => {
19 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/video/1409747385?t=01h36m38s')).toEqual(
20 | '1409747385;01h36m38s'
21 | );
22 | });
23 |
24 | it('does not get clip info from twitch.tv/videos url without timestamp', () => {
25 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/videos/1409747385')).toEqual(undefined);
26 | });
27 |
28 | it('does not get clip info from twitch.tv/video url without timestamp', () => {
29 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/video/1409747385')).toEqual(undefined);
30 | });
31 |
32 | it('gets clip info from twitch.tv/videos url with timestamp', () => {
33 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/videos/1409747385?t=01h36m38s')).toEqual(
34 | '1409747385;01h36m38s'
35 | );
36 | });
37 |
38 | it('gets clip info from twitch.tv/video url with timestamp', () => {
39 | expect(twitchVodProvider.getIdFromUrl('https://www.twitch.tv/video/1409747385?t=01h36m38s')).toEqual(
40 | '1409747385;01h36m38s'
41 | );
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/src/features/clips/providers/twitchVod/twitchVodProvider.ts:
--------------------------------------------------------------------------------
1 | import twitchApi from '../../../../common/apis/twitchApi';
2 | import type { Clip } from '../../clipQueueSlice';
3 | import type { ClipProvider } from '../providers';
4 |
5 | class TwitchVodProvider implements ClipProvider {
6 | name = 'twitch-vod';
7 | getIdFromUrl(url: string): string | undefined {
8 | let uri: URL;
9 | try {
10 | uri = new URL(url);
11 | } catch {
12 | return undefined;
13 | }
14 |
15 | if (uri.hostname.endsWith('twitch.tv')) {
16 | if (uri.pathname.includes('/videos/') || uri.pathname.includes('/video/')) {
17 | return this.extractId(uri.pathname, uri.searchParams);
18 | }
19 | }
20 | return undefined;
21 | }
22 |
23 | async getClipById(id: string): Promise {
24 | if (!id) {
25 | return undefined;
26 | }
27 |
28 | const [idPart] = id.split(';');
29 |
30 | const clipInfo = await twitchApi.getVideo(idPart);
31 |
32 | if (!clipInfo) {
33 | return undefined;
34 | }
35 |
36 | return {
37 | id: id,
38 | author: clipInfo.user_name,
39 | title: clipInfo.title,
40 | submitters: [],
41 | thumbnailUrl: clipInfo.thumbnail_url?.replace('%{width}x%{height}', '480x272'),
42 | createdAt: clipInfo.created_at,
43 | Platform: 'Twitch',
44 | };
45 | }
46 |
47 | getUrl(id: string): string | undefined {
48 | const [idPart, startTime = ''] = id.split(';');
49 | return `https://twitch.tv/videos/${idPart}?t=${startTime}`;
50 | }
51 | getEmbedUrl(id: string): string | undefined {
52 | const [idPart, startTime = ''] = id.split(';');
53 |
54 | return `https://player.twitch.tv/?video=${idPart}&autoplay=true&parent=${window.location.hostname}&time=${startTime}`;
55 | }
56 | async getAutoplayUrl(id: string): Promise {
57 | return this.getUrl(id);
58 | }
59 |
60 | private extractId(pathname: string, searchParams: URLSearchParams): string | undefined {
61 | const idStart = pathname.lastIndexOf('/');
62 | const id = pathname.slice(idStart).split('?')[0].slice(1);
63 | const startTime = searchParams.get('t');
64 |
65 | if (!startTime) {
66 | return undefined;
67 | }
68 |
69 | return `${id};${startTime}`;
70 | }
71 | }
72 |
73 | const twitchVodProvider = new TwitchVodProvider();
74 | export default twitchVodProvider;
75 |
--------------------------------------------------------------------------------
/src/features/clips/providers/youtube/youtube.test.ts:
--------------------------------------------------------------------------------
1 | import youtubeProvider from './youtubeProvider';
2 |
3 | describe('youtubeProvider', () => {
4 | it('gets clip info from youtube.com url', () => {
5 | expect(youtubeProvider.getIdFromUrl('https://youtube.com/watch?v=1TewCPi92ro')).toEqual('1TewCPi92ro');
6 | });
7 |
8 | it('gets clip info from youtube.com url', () => {
9 | expect(youtubeProvider.getIdFromUrl('https://youtube.com/watch?v=1TewCPi92ro&t=30')).toEqual('1TewCPi92ro;30');
10 | });
11 |
12 | it('gets clip info from www.youtube.com url', () => {
13 | expect(youtubeProvider.getIdFromUrl('https://www.youtube.com/watch?v=1TewCPi92ro')).toEqual('1TewCPi92ro');
14 | });
15 |
16 | it('gets clip info from www.youtube.com url', () => {
17 | expect(youtubeProvider.getIdFromUrl('https://www.youtube.com/watch?v=1TewCPi92ro&t=30')).toEqual('1TewCPi92ro;30');
18 | });
19 |
20 | it('gets clip info from www.youtube.com url with timestamp in hours/minutes/seconds', () => {
21 | expect(youtubeProvider.getIdFromUrl('https://www.youtube.com/watch?v=1TewCPi92ro&t=1m42s1h')).toEqual('1TewCPi92ro;3702');
22 | });
23 |
24 | it('gets clip info from youtu.be url', () => {
25 | expect(youtubeProvider.getIdFromUrl('https://youtu.be/1TewCPi92ro')).toEqual('1TewCPi92ro');
26 | });
27 |
28 | it('gets clip info from youtu.be url', () => {
29 | expect(youtubeProvider.getIdFromUrl('https://youtu.be/1TewCPi92ro?t=30')).toEqual('1TewCPi92ro;30');
30 | });
31 |
32 | it('gets clip info from youtube.com/shorts url', () => {
33 | expect(youtubeProvider.getIdFromUrl('https://youtube.com/shorts/1TewCPi92ro')).toEqual('1TewCPi92ro');
34 | });
35 |
36 | it('gets clip info from www.youtube.com/shorts url', () => {
37 | expect(youtubeProvider.getIdFromUrl('https://www.youtube.com/shorts/1TewCPi92ro')).toEqual('1TewCPi92ro');
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/src/features/clips/providers/youtube/youtubeProvider.ts:
--------------------------------------------------------------------------------
1 | import youtubeApi from '../../../../common/apis/youtubeApi';
2 | import type { Clip } from '../../clipQueueSlice';
3 | import type { ClipProvider } from '../providers';
4 |
5 | class YoutubeProvider implements ClipProvider {
6 | name = 'youtube';
7 |
8 | getIdFromUrl(url: string): string | undefined {
9 | let uri: URL;
10 | try {
11 | uri = new URL(url);
12 | } catch {
13 | return undefined;
14 | }
15 |
16 | let id: string | undefined = undefined;
17 | if (uri.hostname === 'youtu.be' || uri.pathname.includes('shorts')) {
18 | const idStart = uri.pathname.lastIndexOf('/') + 1;
19 | id = uri.pathname.slice(idStart).split('?')[0];
20 | } else if (uri.hostname.endsWith('youtube.com')) {
21 | id = uri.searchParams.get('v') ?? undefined;
22 | }
23 |
24 | if (!id) {
25 | return undefined;
26 | }
27 |
28 | const startTime = uri.searchParams.get('t') ?? undefined;
29 |
30 | if (startTime) {
31 | const chunks = startTime.split(/([hms])/).filter((chunk) => chunk !== '');
32 | const magnitudes = chunks.filter((chunk) => chunk.match(/[0-9]+/)).map((chunk) => parseInt(chunk));
33 | const UNITS = ['h', 'm', 's'];
34 | const seenUnits = chunks.filter((chunk) => UNITS.includes(chunk));
35 |
36 | if (chunks.length === 1) {
37 | return `${id};${chunks[0]}`;
38 | } else {
39 | const normalizedStartTime = magnitudes.reduce((accum, magnitude, index) => {
40 | let conversionFactor = 0;
41 |
42 | if (seenUnits[index] === 'h') {
43 | conversionFactor = 3600;
44 | } else if (seenUnits[index] === 'm') {
45 | conversionFactor = 60;
46 | } else if (seenUnits[index] === 's') {
47 | conversionFactor = 1;
48 | }
49 |
50 | return accum + magnitude * conversionFactor;
51 | }, 0);
52 |
53 | return `${id};${normalizedStartTime}`;
54 | }
55 | } else {
56 | return id;
57 | }
58 | }
59 |
60 | async getClipById(id: string): Promise {
61 | if (!id) {
62 | return undefined;
63 | }
64 |
65 | const [idPart] = id.split(';');
66 |
67 | const clipInfo = await youtubeApi.getClip(idPart);
68 |
69 | if (!clipInfo) {
70 | return undefined;
71 | }
72 |
73 | return {
74 | id: idPart,
75 | title: clipInfo.title,
76 | author: clipInfo.author_name,
77 | thumbnailUrl: clipInfo.thumbnail_url,
78 | submitters: [],
79 | Platform: 'YouTube',
80 | };
81 | }
82 |
83 | getUrl(id: string): string | undefined {
84 | const [idPart, startTime = ''] = id.split(';');
85 | return `https://youtu.be/${idPart}?t=${startTime}`;
86 | }
87 |
88 | getEmbedUrl(id: string): string | undefined {
89 | const [idPart, startTime = ''] = id.split(';');
90 | return `https://www.youtube.com/embed/${idPart}?autoplay=1&start=${startTime}`;
91 | }
92 |
93 | async getAutoplayUrl(id: string): Promise {
94 | return this.getUrl(id);
95 | }
96 | }
97 |
98 | const youtubeProvider = new YoutubeProvider();
99 |
100 | export default youtubeProvider;
101 |
--------------------------------------------------------------------------------
/src/features/clips/queue/AutoplayOverlay.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Center, RingProgress, LoadingOverlay, Stack, Text } from '@mantine/core';
2 | import { useInterval } from '@mantine/hooks';
3 | import { useEffect, useState } from 'react';
4 | import { useAppSelector } from '../../../app/hooks';
5 | import { selectAutoplayDelay, selectNextId, selectQueueIds, selectClipById } from '../clipQueueSlice';
6 | import Clip from '../Clip';
7 |
8 | interface AutoplayOverlayProps {
9 | visible: boolean;
10 | onCancel?: () => void;
11 | }
12 |
13 | function AutoplayOverlay({ visible, onCancel }: AutoplayOverlayProps) {
14 | const delay = useAppSelector(selectAutoplayDelay);
15 | const nextClipId = useAppSelector(selectNextId);
16 | const overlayOn = visible && !!nextClipId;
17 |
18 | const intervalTime = 100;
19 | const step = 100 / (delay / intervalTime - 1);
20 |
21 | const [progress, setProgress] = useState(0);
22 | const interval = useInterval(() => setProgress((p) => p + step), intervalTime);
23 |
24 | const clipQueueIds = useAppSelector(selectQueueIds);
25 | const clips = useAppSelector((state) =>
26 | clipQueueIds.map((id) => selectClipById(id)(state)).filter((clip) => clip !== undefined)
27 | );
28 | const nextClip = clips.find((clip) => clip!.id === nextClipId);
29 |
30 | useEffect(() => {
31 | if (overlayOn) {
32 | interval.stop();
33 | interval.start();
34 | } else {
35 | setProgress(0);
36 | interval.stop();
37 | }
38 | return () => interval.stop();
39 | // eslint-disable-next-line
40 | }, [overlayOn]);
41 |
42 | if (!overlayOn) {
43 | return <>>;
44 | }
45 | return (
46 |
51 |
52 |
59 |
62 |
63 | )
64 | }
65 | />
66 |
67 |
68 | Next up
69 |
70 |
71 |
72 | }
73 | />
74 | );
75 | }
76 |
77 | export default AutoplayOverlay;
78 |
--------------------------------------------------------------------------------
/src/features/clips/queue/Player.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from '@mantine/core';
2 | import { useEffect, useState } from 'react';
3 | import { useAppDispatch, useAppSelector } from '../../../app/hooks';
4 | import type { Clip } from '../clipQueueSlice';
5 | import {
6 | autoplayTimeoutHandleChanged,
7 | selectAutoplayEnabled,
8 | selectAutoplayTimeoutHandle,
9 | selectCurrentClip,
10 | selectNextId,
11 | } from '../clipQueueSlice';
12 | import clipProvider from '../providers/providers';
13 | import AutoplayOverlay from './AutoplayOverlay';
14 | import VideoPlayer from './VideoPlayer';
15 |
16 | interface PlayerProps {
17 | className?: string;
18 | }
19 |
20 | const getPlayerComponent = (
21 | currentClip: Clip | undefined,
22 | videoSrc: string | undefined,
23 | autoplayEnabled: boolean,
24 | nextClipId: string | undefined,
25 | dispatch: ReturnType
26 | ) => {
27 | if (!currentClip) return null;
28 |
29 | const KickClip = currentClip.Platform === 'Kick';
30 |
31 | if (autoplayEnabled && currentClip.id) {
32 | return (
33 | nextClipId && dispatch(autoplayTimeoutHandleChanged({ set: true }))}
37 | />
38 | );
39 | }
40 |
41 | if (KickClip) {
42 | return ;
43 | }
44 |
45 | const embedUrl = clipProvider.getEmbedUrl(currentClip.id);
46 | return (
47 |
56 | );
57 | };
58 |
59 | function Player({ className }: PlayerProps) {
60 | const dispatch = useAppDispatch();
61 | const currentClip = useAppSelector(selectCurrentClip);
62 | const nextClipId = useAppSelector(selectNextId);
63 | const autoplayEnabled = useAppSelector(selectAutoplayEnabled);
64 | const autoplayTimeoutHandle = useAppSelector(selectAutoplayTimeoutHandle);
65 | const [videoSrc, setVideoSrc] = useState(undefined);
66 | const [error, setError] = useState(null);
67 |
68 | useEffect(() => {
69 | if (!currentClip) {
70 | setVideoSrc(undefined);
71 | setError(null);
72 | return;
73 | }
74 |
75 | setVideoSrc(undefined);
76 | let Flag = true;
77 |
78 | const fetchVideoUrl = async () => {
79 | try {
80 | const url = await clipProvider.getAutoplayUrl(currentClip.id);
81 | if (Flag) {
82 | setVideoSrc(url);
83 | setError(null);
84 | }
85 | } catch (err) {
86 | if (Flag) {
87 | setError('Failed to load video');
88 | setVideoSrc(undefined);
89 | }
90 | }
91 | };
92 |
93 | fetchVideoUrl();
94 |
95 | return () => {
96 | Flag = false;
97 | };
98 | }, [currentClip]);
99 |
100 | const player = getPlayerComponent(currentClip, videoSrc, autoplayEnabled, nextClipId, dispatch);
101 |
102 | return (
103 |
108 | {error ? {error}
: videoSrc || !autoplayEnabled ? player : }
109 | dispatch(autoplayTimeoutHandleChanged({ set: false }))}
112 | />
113 |
114 | );
115 | }
116 |
117 | export default Player;
118 |
--------------------------------------------------------------------------------
/src/features/clips/queue/PlayerButtons.tsx:
--------------------------------------------------------------------------------
1 | import { Group, Button, Switch } from '@mantine/core';
2 | import { PlayerSkipForward, PlayerTrackNext, PlayerTrackPrev } from 'tabler-icons-react';
3 | import { useAppDispatch, useAppSelector } from '../../../app/hooks';
4 | import {
5 | autoplayChanged,
6 | currentClipSkipped,
7 | selectAutoplayEnabled,
8 | selectClipLimit,
9 | selectNextId,
10 | } from '../clipQueueSlice';
11 | import { currentClipWatched, selectCurrentId, previousClipWatched, selectHasPrevious } from '../clipQueueSlice';
12 |
13 | function PlayerButtons({ className }: { className?: string }) {
14 | const dispatch = useAppDispatch();
15 | const currentClipId = useAppSelector(selectCurrentId);
16 | const nextClipId = useAppSelector(selectNextId);
17 | const clipLimit = useAppSelector(selectClipLimit);
18 | const autoplayEnabled = useAppSelector(selectAutoplayEnabled);
19 | const hasPrevious = useAppSelector(selectHasPrevious);
20 | return (
21 |
22 |
23 | dispatch(autoplayChanged(event.currentTarget.checked))}
27 | />
28 | {clipLimit && (
29 | }
32 | onClick={() => dispatch(currentClipSkipped())}
33 | disabled={!currentClipId}
34 | >
35 | Skip
36 |
37 | )}
38 |
39 | }
42 | onClick={() => dispatch(previousClipWatched())}
43 | disabled={!hasPrevious}
44 | >
45 | Previous
46 |
47 | }
49 | onClick={() => dispatch(currentClipWatched())}
50 | disabled={!currentClipId && !nextClipId}
51 | >
52 | Next
53 |
54 |
55 | );
56 | }
57 |
58 | export default PlayerButtons;
59 |
--------------------------------------------------------------------------------
/src/features/clips/queue/PlayerTitle.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Text } from '@mantine/core';
2 | import { formatDistanceToNow, parseISO } from 'date-fns';
3 | import { useAppSelector } from '../../../app/hooks';
4 | import { selectCurrentClip } from '../clipQueueSlice';
5 | import Platform from '../../../common/components/BrandPlatforms';
6 |
7 | interface PlayerTitleProps {
8 | className?: string;
9 | }
10 |
11 | const _nbsp = <> >;
12 |
13 | function PlayerTitle({ className }: PlayerTitleProps) {
14 | const currentClip = useAppSelector(selectCurrentClip);
15 |
16 | return (
17 |
18 |
19 | {currentClip?.title ?? _nbsp}
20 |
21 |
22 |
23 | {currentClip?.author ?? _nbsp}
24 | {currentClip?.category && (
25 | <>
26 | {' ('}
27 | {currentClip?.category}
28 | {')'}
29 | >
30 | )}
31 | {currentClip?.submitters[0] && (
32 | <>
33 | , submitted by {currentClip?.submitters[0]}
34 | {currentClip?.submitters.length > 1 && <> and {currentClip.submitters.length - 1} other(s)>}
35 | >
36 | )}
37 | {currentClip?.createdAt && <>, created {formatDistanceToNow(parseISO(currentClip?.createdAt))} ago>}
38 |
39 |
40 | );
41 | }
42 |
43 | export default PlayerTitle;
44 |
--------------------------------------------------------------------------------
/src/features/clips/queue/Queue.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren } from 'react';
2 | import { useAppDispatch, useAppSelector } from '../../../app/hooks';
3 | import { selectQueueIds, currentClipReplaced, queueClipRemoved, selectClipById } from '../clipQueueSlice';
4 | import Clip from '../Clip';
5 |
6 | interface QueueProps {
7 | card?: boolean;
8 | wrapper?: (props: PropsWithChildren<{}>) => JSX.Element;
9 | }
10 |
11 | function Queue({ wrapper, card }: QueueProps) {
12 | const dispatch = useAppDispatch();
13 | const clipQueueIds = useAppSelector(selectQueueIds);
14 | const Wrapper = wrapper ?? (({ children }) => <>{children}>);
15 | const clips = useAppSelector((state) =>
16 | clipQueueIds.map((id) => selectClipById(id)(state)).filter((clip) => clip !== undefined)
17 | );
18 |
19 | return (
20 | <>
21 | {clips.map((clip) => (
22 |
23 | dispatch(currentClipReplaced(clip!.id))}
29 | onCrossClick={() => dispatch(queueClipRemoved(clip!.id))}
30 | />
31 |
32 | ))}
33 | >
34 | );
35 | }
36 |
37 | export default Queue;
38 |
--------------------------------------------------------------------------------
/src/features/clips/queue/QueueControlPanel.tsx:
--------------------------------------------------------------------------------
1 | import { Group, Text, SegmentedControl, Stack } from '@mantine/core';
2 | import { useAppDispatch, useAppSelector } from '../../../app/hooks';
3 | import {
4 | isOpenChanged,
5 | selectClipLimit,
6 | selectIsOpen,
7 | selectQueueIds,
8 | selectTotalQueueLength,
9 | } from '../clipQueueSlice';
10 | import QueueQuickMenu from './QueueQuickMenu';
11 |
12 | interface QueueControlPanelProps {
13 | className?: string;
14 | }
15 |
16 | function QueueControlPanel({ className }: QueueControlPanelProps) {
17 | const dispatch = useAppDispatch();
18 | const isOpen = useAppSelector(selectIsOpen);
19 | const clipLimit = useAppSelector(selectClipLimit);
20 | const totalClips = useAppSelector(selectTotalQueueLength);
21 | const clipsLeft = useAppSelector(selectQueueIds).length;
22 |
23 | return (
24 |
25 |
26 |
27 | Queue
28 |
29 | dispatch(isOpenChanged(state === 'open'))}
38 | />
39 |
40 |
41 |
42 | {clipsLeft} of {totalClips}
43 | {clipLimit && `/${clipLimit}`} clips left
44 |
45 |
46 | );
47 | }
48 |
49 | export default QueueControlPanel;
50 |
--------------------------------------------------------------------------------
/src/features/clips/queue/QueuePage.tsx:
--------------------------------------------------------------------------------
1 | import { useAppSelector } from '../../../app/hooks';
2 | import { selectLayout } from '../clipQueueSlice';
3 | import ClassicLayout from './layouts/ClassicLayout';
4 | import FullscreenWithPopupLayout from './layouts/FullscreenWithPopupLayout';
5 | import SpotlightLayout from './layouts/SpotlightLayout';
6 |
7 | function QueuePage() {
8 | const layout = useAppSelector(selectLayout);
9 |
10 | switch (layout) {
11 | case 'classic':
12 | return ;
13 | case 'spotlight':
14 | return ;
15 | case 'fullscreen':
16 | return ;
17 | default:
18 | return ;
19 | }
20 | }
21 |
22 | export default QueuePage;
23 |
--------------------------------------------------------------------------------
/src/features/clips/queue/QueueQuickMenu.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, Badge, NumberInput, Button, Stack } from '@mantine/core';
2 | import { useModals } from '@mantine/modals';
3 | import { FormEvent, useState } from 'react';
4 | import { TrashX, Tallymarks } from 'tabler-icons-react';
5 | import { useAppDispatch, useAppSelector } from '../../../app/hooks';
6 | import { settingsChanged } from '../../settings/settingsSlice';
7 | import { queueCleared, selectClipLimit } from '../clipQueueSlice';
8 |
9 | function ClipLimitModal({ onSubmit }: { onSubmit: () => void }) {
10 | const dispatch = useAppDispatch();
11 | const clipLimit = useAppSelector(selectClipLimit);
12 | const [value, setValue] = useState(clipLimit);
13 |
14 | const submit = (event: FormEvent) => {
15 | dispatch(settingsChanged({ clipLimit: value || null }));
16 | onSubmit();
17 |
18 | event.preventDefault();
19 | };
20 |
21 | return (
22 |
42 | );
43 | }
44 |
45 | function QueueQuickMenu() {
46 | const modals = useModals();
47 | const dispatch = useAppDispatch();
48 | const clipLimit = useAppSelector(selectClipLimit);
49 |
50 | const openClipLimitModal = () => {
51 | const id = modals.openModal({
52 | title: 'Set clip limit',
53 | children: modals.closeModal(id)} />,
54 | });
55 | };
56 |
57 | return (
58 | <>
59 |
71 | >
72 | );
73 | }
74 |
75 | export default QueueQuickMenu;
76 |
--------------------------------------------------------------------------------
/src/features/clips/queue/VideoPlayer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import { setVolume } from '../../settings/settingsSlice';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import { RootState } from '../../../app/store';
5 | import videojs from 'video.js';
6 | import VideoJSPlayer from 'video.js/dist/types/player';
7 | import 'video.js/dist/video-js.css';
8 | import '@videojs/http-streaming';
9 | import 'videojs-youtube';
10 |
11 | interface VideoPlayerProps {
12 | src: string | undefined;
13 | onEnded?: () => void;
14 | }
15 |
16 | const VideoPlayer: React.FC = ({ src, onEnded }) => {
17 | const videoRef = useRef(null);
18 | const playerRef = useRef(null);
19 | const dispatch = useDispatch();
20 | const volume = useSelector((state: RootState) => state.settings.volume);
21 |
22 | useEffect(() => {
23 | if (!videoRef.current || !src) return;
24 |
25 | const YouTube = src.includes('youtube.com') || src.includes('youtu.be');
26 |
27 | playerRef.current = videojs(videoRef.current, {
28 | controls: true,
29 | autoplay: true,
30 | fluid: true,
31 | responsive: true,
32 | aspectRatio: '16:9',
33 | bigPlayButton: false,
34 | preload: 'auto',
35 | sources: YouTube
36 | ? [{ src, type: 'video/youtube' }]
37 | : [{ src, type: src.includes('.m3u8') ? 'application/x-mpegURL' : 'video/mp4' }],
38 | techOrder: YouTube ? ['youtube', 'html5'] : ['html5'],
39 | });
40 |
41 | const player = playerRef.current;
42 | if (player) {
43 | player.fill(true);
44 | player.volume(volume);
45 |
46 | player.on('volumechange', () => {
47 | const currentVolume = player.volume();
48 | dispatch(setVolume(currentVolume));
49 | });
50 |
51 | player.on('error', (e: any) => {
52 | console.error('Video player error:', e);
53 | });
54 |
55 | player.on('loadedmetadata', () => {
56 | if (!player.paused()) return;
57 | player.play()?.catch((err) => {
58 | console.warn('Play failed:', err);
59 | });
60 | });
61 |
62 | if (onEnded) {
63 | player.on('ended', onEnded);
64 | }
65 | }
66 |
67 | return () => {
68 | if (playerRef.current && !playerRef.current.isDisposed()) {
69 | try {
70 | playerRef.current.dispose();
71 | } catch (e) {
72 | console.warn('Error during Video.js dispose:', e);
73 | }
74 | playerRef.current = null;
75 | }
76 | };
77 | // eslint-disable-next-line react-hooks/exhaustive-deps
78 | }, [src]);
79 |
80 | if (!src) return null;
81 |
82 | return (
83 |
84 |
85 |
86 | );
87 | };
88 |
89 | export default VideoPlayer;
90 |
--------------------------------------------------------------------------------
/src/features/clips/queue/layouts/ClassicLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid, Group, Stack, ScrollArea } from '@mantine/core';
2 | import Player from '../Player';
3 | import PlayerButtons from '../PlayerButtons';
4 | import PlayerTitle from '../PlayerTitle';
5 | import Queue from '../Queue';
6 | import QueueControlPanel from '../QueueControlPanel';
7 |
8 | function ClassicLayout() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | div': { display: 'block !important' } }}>
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default ClassicLayout;
37 |
--------------------------------------------------------------------------------
/src/features/clips/queue/layouts/FullscreenWithPopupLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Container, Grid, Group, Portal, Stack } from '@mantine/core';
2 | import { randomId } from '@mantine/hooks';
3 | import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react';
4 | import AppLayout from '../../../../app/AppLayout';
5 | import Player from '../Player';
6 | import PlayerButtons from '../PlayerButtons';
7 | import PlayerTitle from '../PlayerTitle';
8 | import Queue from '../Queue';
9 | import QueueControlPanel from '../QueueControlPanel';
10 |
11 | function copyStyles(sourceDoc: Document, targetDoc: Document) {
12 | Array.from(sourceDoc.styleSheets).forEach((styleSheet) => {
13 | if (styleSheet.cssRules) {
14 | // for