├── public
├── favicon.ico
├── robots.txt
├── favicon_open.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon_closed.ico
├── favicon.svg
├── index.html
└── 404.html
├── .prettierrc
├── .vscode
└── settings.json
├── src
├── features
│ ├── home
│ │ ├── ScreenshotsSection.tsx
│ │ ├── HomePage.tsx
│ │ ├── QuickstartSection.tsx
│ │ └── FeaturesSection.tsx
│ ├── clips
│ │ ├── providers
│ │ │ ├── afreecaClip
│ │ │ │ ├── afreecaClipProvider.test.ts
│ │ │ │ └── afreecaClipProvider.ts
│ │ │ ├── kickClip
│ │ │ │ ├── kickClipProvider.test.ts
│ │ │ │ └── kickClipProvider.ts
│ │ │ ├── streamable
│ │ │ │ ├── streamable.test.ts
│ │ │ │ └── streamableProvider.ts
│ │ │ ├── youtube
│ │ │ │ ├── youtube.test.ts
│ │ │ │ └── youtubeProvider.ts
│ │ │ ├── twitchClip
│ │ │ │ ├── twitchClipProvider.test.ts
│ │ │ │ └── twitchClipProvider.ts
│ │ │ ├── twitchVod
│ │ │ │ ├── twitchVodProvider.test.ts
│ │ │ │ └── twitchVodProvider.ts
│ │ │ └── providers.ts
│ │ ├── queue
│ │ │ ├── QueuePage.tsx
│ │ │ ├── layouts
│ │ │ │ ├── SpotlightLayout.tsx
│ │ │ │ ├── ClassicLayout.tsx
│ │ │ │ └── FullscreenWithPopupLayout.tsx
│ │ │ ├── Queue.tsx
│ │ │ ├── PlayerTitle.tsx
│ │ │ ├── QueueControlPanel.tsx
│ │ │ ├── PlayerButtons.tsx
│ │ │ ├── AutoplayOverlay.tsx
│ │ │ ├── QueueQuickMenu.tsx
│ │ │ ├── VideoPlayer.tsx
│ │ │ └── Player.tsx
│ │ ├── customization
│ │ │ └── customization.ts
│ │ ├── history
│ │ │ └── HistoryPage.tsx
│ │ ├── Clip.tsx
│ │ ├── clipQueueMiddleware.ts
│ │ └── clipQueueSlice.ts
│ ├── settings
│ │ ├── models.ts
│ │ ├── settingsSlice.ts
│ │ └── SettingsModal.tsx
│ ├── twitchChat
│ │ ├── actions.ts
│ │ ├── chatCommands.ts
│ │ └── twitchChatMiddleware.ts
│ ├── analytics
│ │ ├── analytics.ts
│ │ └── analyticsMiddleware.tsx
│ ├── auth
│ │ ├── IfAuthenticated.tsx
│ │ ├── RequireAuth.tsx
│ │ ├── AuthPage.tsx
│ │ ├── twitchAuthApi.ts
│ │ └── authSlice.ts
│ └── migration
│ │ └── legacyMigration.ts
├── setupTests.ts
├── app
│ ├── hooks.ts
│ ├── AppLayout.tsx
│ ├── Router.tsx
│ ├── store.ts
│ ├── AppSkeleton.tsx
│ ├── AppMenu.tsx
│ ├── App.tsx
│ └── AppHeader.tsx
├── common
│ ├── components
│ │ ├── NavLink.tsx
│ │ ├── MyCredits.tsx
│ │ ├── BrandPlatforms.tsx
│ │ ├── ColorSchemeSwitch.tsx
│ │ └── BrandButton.tsx
│ ├── utils.ts
│ ├── apis
│ │ ├── streamableApi.ts
│ │ ├── youtubeApi.ts
│ │ ├── afreecaApi.ts
│ │ ├── kickApi.ts
│ │ └── twitchApi.ts
│ ├── models
│ │ ├── oembed.ts
│ │ ├── kick.ts
│ │ └── twitch.ts
│ └── logging.ts
├── reportWebVitals.ts
├── react-app-env.d.ts
├── index.tsx
└── index.scss
├── .env.sample
├── .editorconfig
├── .gitignore
├── tsconfig.json
├── LICENSE
├── package.json
├── .github
└── workflows
│ └── build-deploy.yml
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon_open.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/HEAD/public/favicon_open.ico
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon_closed.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakemiki/twitch-clip-queue/HEAD/public/favicon_closed.ico
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/features/home/ScreenshotsSection.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from "@mantine/core";
2 |
3 | function ScreenshotsSection() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
11 | export default ScreenshotsSection;
12 |
--------------------------------------------------------------------------------
/.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=
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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/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/settings/models.ts:
--------------------------------------------------------------------------------
1 | export type ColorScheme = 'light' | 'dark';
2 |
3 |
4 | export interface AllSettings {
5 | channel?: string;
6 | colorScheme?: ColorScheme;
7 | commandPrefix?: string;
8 |
9 | enabledProviders?: string[];
10 | ignoredChatters?: string;
11 |
12 | clipLimit?: number | null;
13 | layout?: string;
14 | initialQueueOpen?: 'true' | 'false';
15 | }
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
25 | .idea
26 |
--------------------------------------------------------------------------------
/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/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/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/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/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/twitchChat/actions.ts:
--------------------------------------------------------------------------------
1 | import { createAction } from '@reduxjs/toolkit';
2 |
3 | export interface Userstate {
4 | username: string;
5 | mod?: boolean;
6 | broadcaster?: boolean;
7 | subscriber?: boolean;
8 | vip?: boolean;
9 | }
10 |
11 | export const urlReceived = createAction<{ url: string; userstate: Userstate }>('twitchChat/urlReceived');
12 | export const urlDeleted = createAction('twitchChat/urlDeleted');
13 | export const userTimedOut = createAction('twitchChat/userTimedOut');
14 |
--------------------------------------------------------------------------------
/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/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/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/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/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | namespace NodeJS {
4 | interface ProcessEnv {
5 | REACT_APP_TWITCH_CLIENT_ID: string;
6 | REACT_APP_TWITCH_REDIRECT_URI: string;
7 | REACT_APP_BASEPATH: string;
8 | REACT_APP_LOG_LEVEL: string;
9 | REACT_APP_UMAMI_WEBSITE_ID: string;
10 | REACT_APP_UMAMI_SRC: string;
11 | REACT_APP_CLIP_PROVIDERS: string;
12 | }
13 | }
14 |
15 | declare module 'redux-persist-indexeddb-storage' {
16 | import { WebStorage } from 'redux-persist/es/types';
17 |
18 | const createStorage: (dbName: string) => WebStorage;
19 | export default createStorage;
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/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/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/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/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/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/features/home/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Container, Text, Title } from '@mantine/core';
2 | import MyCredits from '../../common/components/MyCredits';
3 | import FeaturesSection from './FeaturesSection';
4 | import QuickstartSection from './QuickstartSection';
5 | import ScreenshotsSection from './ScreenshotsSection';
6 |
7 | function HomePage() {
8 | return (
9 |
10 |
11 | Clip Queue
12 |
13 | Enqueue and play clips from your Twitch Chat using nothing more than your web browser
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | export default HomePage;
23 |
--------------------------------------------------------------------------------
/src/features/home/QuickstartSection.tsx:
--------------------------------------------------------------------------------
1 | import { Title, Text, Box } from "@mantine/core";
2 |
3 | function QuickstartSection() {
4 | return (
5 |
6 | Quickstart
7 |
8 | Simply Login with Twitch. You'll be redirected to Twitch and
9 | asked to allow the application to get your username and read chat in your name. Any information received from Twitch is not
10 | sent anywhere but Twitch. By default you'll join your channel's chat, but you can change the channel afterwards.
11 | The only thing left to do is to open the queue and wait for some clip links to be posted in chat.
12 |
13 |
14 | );
15 | }
16 |
17 | export default QuickstartSection;
18 |
--------------------------------------------------------------------------------
/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 | let Layout = ClassicLayout;
11 | switch (layout) {
12 | case 'classic':
13 | Layout = ClassicLayout;
14 | break;
15 | case 'spotlight':
16 | Layout = SpotlightLayout;
17 | break;
18 | case 'fullscreen':
19 | Layout = FullscreenWithPopupLayout;
20 | break;
21 | }
22 |
23 | return (
24 | <>
25 |
26 |
27 | >
28 | );
29 | }
30 |
31 | export default QueuePage;
32 |
--------------------------------------------------------------------------------
/src/common/apis/kickApi.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { KickClip } from '../models/kick';
3 |
4 | export const getClip = async (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 const getDirectUrl = async (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 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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/features/clips/queue/layouts/SpotlightLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Grid, Group } 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 SpotlightLayout() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {children}} />
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default SpotlightLayout;
29 |
--------------------------------------------------------------------------------
/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/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client';
2 | import { PersistGate } from 'redux-persist/integration/react';
3 | import { Provider } from 'react-redux';
4 | import App from './app/App';
5 | import AppSkeleton from './app/AppSkeleton';
6 | import { persistor, store } from './app/store';
7 | import { injectStore } from './common/apis/twitchApi';
8 | import reportWebVitals from './reportWebVitals';
9 |
10 | import './index.scss';
11 |
12 | injectStore(store);
13 |
14 | const root = createRoot(document.getElementById('root') as HTMLElement);
15 | root.render(
16 |
17 | } persistor={persistor}>
18 |
19 |
20 |
21 | );
22 |
23 | // If you want to start measuring performance in your app, pass a function
24 | // to log results (for example: reportWebVitals(console.log))
25 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
26 | reportWebVitals();
27 |
--------------------------------------------------------------------------------
/src/features/clips/customization/customization.ts:
--------------------------------------------------------------------------------
1 | import { formatISO, isFriday } from 'date-fns';
2 | import type { AppMiddlewareAPI } from '../../../app/store';
3 | import { currentClipForceReplaced, isOpenChanged } from '../clipQueueSlice';
4 |
5 | export function applyCustomizations(storeApi: AppMiddlewareAPI) {
6 | const {
7 | settings: { channel, initialQueueOpen },
8 | } = storeApi.getState();
9 |
10 | storeApi.dispatch(isOpenChanged(initialQueueOpen))
11 |
12 | const now = new Date();
13 |
14 | switch (channel?.toLowerCase()) {
15 | case 'wolfabelle': {
16 | if (isFriday(now)) {
17 | storeApi.dispatch(
18 | currentClipForceReplaced({
19 | title: 'ITS FRIDAY THEN, ITS SATURDAY, SUNDAY! GO MUFASA!',
20 | author: 'MUFASA',
21 | id: 'youtube:1TewCPi92ro',
22 | thumbnailUrl: 'https://i.ytimg.com/vi/1TewCPi92ro/hqdefault.jpg',
23 | submitters: ['TriPls'],
24 | timestamp: formatISO(now),
25 | status: 'watched',
26 | })
27 | );
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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/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/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/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/index.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --initial-background-color: #ffffff;
3 | --control-bar-bg: rgba(0, 0, 0, 0.5);
4 | --control-bar-duration: 0.3s;
5 | --control-bar-easing: cubic-bezier(0.5, 0, 0.2, 1);
6 | }
7 |
8 | @media (prefers-color-scheme: dark) {
9 | :root {
10 | --initial-background-color: #1a1b1e;
11 | }
12 | }
13 |
14 | html {
15 | background: var(--initial-background-color);
16 | }
17 |
18 | .video-js {
19 | overflow: hidden !important;
20 | }
21 |
22 | .vjs-control-bar {
23 | background-color: var(--control-bar-bg) !important;
24 | transition: transform var(--control-bar-duration) var(--control-bar-easing) !important;
25 | user-select: none !important;
26 | position: absolute !important;
27 | bottom: 0 !important;
28 | width: 100% !important;
29 | opacity: 1 !important;
30 | }
31 |
32 | .video-js .vjs-control-bar {
33 | transform: translateY(0) !important;
34 | }
35 |
36 | .video-js.vjs-paused .vjs-control-bar,
37 | .video-js.vjs-waiting .vjs-control-bar {
38 | transform: translateY(0) !important;
39 | opacity: 1 !important;
40 | transition: transform var(--control-bar-duration) var(--control-bar-easing) !important;
41 | }
42 |
43 | .video-js.vjs-user-inactive:not(.vjs-paused):not(.vjs-waiting) .vjs-control-bar {
44 | transform: translateY(100%) !important;
45 | opacity: 1 !important;
46 | transition: transform var(--control-bar-duration) var(--control-bar-easing) !important;
47 | }
48 |
49 | .video-js:not(:hover):not(.vjs-paused):not(.vjs-waiting) .vjs-control-bar {
50 | transform: translateY(100%) !important;
51 | }
52 |
53 | .video-js:hover .vjs-control-bar,
54 | .video-js.vjs-user-active .vjs-control-bar {
55 | transform: translateY(0) !important;
56 | }
57 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Twitch Clip Queue
11 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/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-helmet": "^6.1.11",
29 | "@types/react-router-dom": "^5.3.3",
30 | "@types/tmi.js": "^1.8.1",
31 | "@videojs/http-streaming": "^3.17.0",
32 | "axios": "^0.27.2",
33 | "date-fns": "^2.28.0",
34 | "gh-pages": "^4.0.0",
35 | "react": "^18.1.0",
36 | "react-dom": "^18.1.0",
37 | "react-helmet": "^6.1.0",
38 | "react-player": "^2.10.1",
39 | "react-redux": "^8.0.2",
40 | "react-router-dom": "^6.3.0",
41 | "react-scripts": "^5.0.1",
42 | "redux-persist": "^6.0.0",
43 | "redux-persist-indexeddb-storage": "^1.0.4",
44 | "sass": "^1.52.3",
45 | "tabler-icons-react": "^1.48.1",
46 | "tmi.js": "^1.8.5",
47 | "typescript": "^4.7.3",
48 | "video.js": "^8.22.0",
49 | "videojs-youtube": "^3.0.1",
50 | "web-vitals": "^2.1.4"
51 | },
52 | "eslintConfig": {
53 | "extends": [
54 | "react-app",
55 | "react-app/jest"
56 | ]
57 | },
58 | "browserslist": {
59 | "production": [
60 | ">0.2%",
61 | "not dead",
62 | "not op_mini all"
63 | ],
64 | "development": [
65 | "last 1 chrome version",
66 | "last 1 firefox version",
67 | "last 1 safari version"
68 | ]
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | getChannelUrl(id: string, clipInfo: Clip): string | undefined {
55 | return `https://kick.com/${clipInfo.author}/clips/${id}`;
56 | }
57 |
58 | getEmbedUrl(id: string): string | undefined {
59 | return this.getUrl(id);
60 | }
61 | async getAutoplayUrl(id: string): Promise {
62 | return await kickApi.getDirectUrl(id);
63 | }
64 | }
65 |
66 | const kickClipProvider = new KickClipProvider();
67 | export default kickClipProvider;
68 |
--------------------------------------------------------------------------------
/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 | getFallbackM3u8Url(id: string): string | undefined {
55 | return twitchApi.getFallbackM3u8Url(id);
56 | }
57 |
58 | getEmbedUrl(id: string): string | undefined {
59 | return `https://clips.twitch.tv/embed?clip=${id}&autoplay=true&parent=${window.location.hostname}`;
60 | }
61 |
62 | async getAutoplayUrl(id: string): Promise {
63 | return await twitchApi.getDirectUrl(id);
64 | }
65 |
66 | private extractIdFromPathname(pathname: string): string | undefined {
67 | const idStart = pathname.lastIndexOf('/');
68 | const id = pathname.slice(idStart).split('?')[0].slice(1);
69 |
70 | return id;
71 | }
72 | }
73 |
74 | const twitchClipProvider = new TwitchClipProvider();
75 | export default twitchClipProvider;
76 |
--------------------------------------------------------------------------------
/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/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/twitchChat/chatCommands.ts:
--------------------------------------------------------------------------------
1 | import type { AppMiddlewareAPI } from '../../app/store';
2 | import {
3 | autoplayChanged,
4 | currentClipSkipped,
5 | currentClipWatched,
6 | isOpenChanged,
7 | memoryPurged,
8 | queueCleared,
9 | } from '../clips/clipQueueSlice';
10 | import { addIgnoredChatter, removeIgnoredChatter, settingsChanged } from '../settings/settingsSlice';
11 | import { createLogger } from '../../common/logging';
12 | import { urlDeleted, Userstate } from './actions';
13 |
14 | const logger = createLogger('Chat Command');
15 |
16 | type Dispatch = AppMiddlewareAPI['dispatch'];
17 |
18 | interface ChatCommandPayload {
19 | command: string;
20 | args: string[];
21 | userstate: Userstate;
22 | }
23 |
24 | type CommmandFunction = (dispatch: Dispatch, args: string[]) => void;
25 |
26 | const commands: Record = {
27 | open: (dispatch) => dispatch(isOpenChanged(true)),
28 | close: (dispatch) => dispatch(isOpenChanged(false)),
29 | next: (dispatch) => dispatch(currentClipWatched()),
30 | skip: (dispatch) => dispatch(currentClipSkipped()),
31 | remove: (dispatch, [url]) => url && dispatch(urlDeleted(url)),
32 | clear: (dispatch) => dispatch(queueCleared()),
33 | purgememory: (dispatch) => dispatch(memoryPurged()),
34 | autoplay: (dispatch, [enabled]) => {
35 | if (['on', 'true', '1'].includes(enabled)) {
36 | dispatch(autoplayChanged(true));
37 | } else if (['off', 'false', '0'].includes(enabled)) {
38 | dispatch(autoplayChanged(false));
39 | }
40 | },
41 | limit: (dispatch, [limit]) => {
42 | if (!limit) {
43 | return;
44 | }
45 |
46 | if (limit === 'off' || limit === '0') {
47 | dispatch(settingsChanged({ clipLimit: null }));
48 | }
49 |
50 | const parsedLimit = Number.parseInt(limit);
51 |
52 | if (Number.isInteger(parsedLimit) && parsedLimit > 0) {
53 | dispatch(settingsChanged({ clipLimit: parsedLimit }));
54 | }
55 | },
56 | 'ignore+': (dispatch, [name]) => dispatch(addIgnoredChatter(name)),
57 | 'ignore-': (dispatch, [name]) => dispatch(removeIgnoredChatter(name)),
58 | };
59 |
60 | export function processCommand(dispatch: Dispatch, { command, args, userstate }: ChatCommandPayload) {
61 | if (!userstate.mod && !userstate.broadcaster) {
62 | return;
63 | }
64 |
65 | logger.info(`Received '${command}' command`, args);
66 |
67 | const commandFunc = commands[command];
68 |
69 | if (commandFunc) {
70 | commandFunc(dispatch, args);
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/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 { Helmet } from 'react-helmet';
7 | import { selectAccessToken, authenticateWithToken, selectAuthState } from '../features/auth/authSlice';
8 | import { colorSchemeToggled, selectColorScheme } from '../features/settings/settingsSlice';
9 | import { selectIsOpen } from '../features/clips/clipQueueSlice';
10 | import { useAppDispatch, useAppSelector } from './hooks';
11 | import Router from './Router';
12 |
13 | function App() {
14 | const dispatch = useAppDispatch();
15 | const accessToken = useAppSelector(selectAccessToken);
16 | const authState = useAppSelector(selectAuthState);
17 | const preferredColorScheme = useColorScheme();
18 | const colorScheme = useAppSelector((state) => selectColorScheme(state, preferredColorScheme));
19 | const isOpen = useAppSelector(selectIsOpen);
20 |
21 | useEffect(() => {
22 | if (accessToken) {
23 | dispatch(authenticateWithToken(accessToken));
24 | }
25 | // eslint-disable-next-line react-hooks/exhaustive-deps
26 | }, []);
27 |
28 | return (
29 | <>
30 | {authState === 'authenticated' && (
31 | <>
32 | {isOpen ? (
33 |
34 | Clip Queue - Open
35 |
36 |
37 | ) : (
38 |
39 | Clip Queue - Closed
40 |
41 |
42 | )}
43 | >
44 | )}
45 |
46 | dispatch(colorSchemeToggled(preferredColorScheme))}
49 | >
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | >
60 | );
61 | }
62 |
63 | export default App;
64 |
--------------------------------------------------------------------------------
/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/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/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/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 | const TWITCH_CLIPS_CDN = 'https://clips-media-assets2.twitch.tv';
12 |
13 | const twitchApiClient = axios.create({
14 | baseURL: 'https://api.twitch.tv/helix/',
15 | headers: {
16 | 'Client-ID': TWITCH_CLIENT_ID,
17 | },
18 | });
19 |
20 | const twitchGqlClient = axios.create({
21 | baseURL: 'https://gql.twitch.tv/gql',
22 | headers: {
23 | 'Client-Id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
24 | },
25 | });
26 |
27 | const getDirectUrl = async (id: string): Promise => {
28 | const data = [
29 | {
30 | operationName: 'VideoAccessToken_Clip',
31 | variables: {
32 | slug: id,
33 | },
34 | extensions: {
35 | persistedQuery: {
36 | version: 1,
37 | sha256Hash: '36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11',
38 | },
39 | },
40 | },
41 | ];
42 |
43 | const resp = await twitchGqlClient.post('', data);
44 | const [respData] = resp.data;
45 |
46 | if (!respData.data.clip) {
47 | throw new Error('Clip not found');
48 | }
49 |
50 | if (!respData.data.clip.videoQualities || respData.data.clip.videoQualities.length === 0) {
51 | throw new Error('No video qualities available');
52 | }
53 |
54 | const playbackAccessToken = respData.data.clip.playbackAccessToken;
55 | const url =
56 | respData.data.clip.videoQualities[0].sourceURL +
57 | '?sig=' +
58 | playbackAccessToken.signature +
59 | '&token=' +
60 | encodeURIComponent(playbackAccessToken.value);
61 |
62 | return url;
63 | };
64 |
65 | twitchApiClient.interceptors.request.use((request) => {
66 | const { token } = store?.getState().auth;
67 | if (token) {
68 | request.headers = { Authorization: `Bearer ${token}`, ...request.headers };
69 | }
70 |
71 | return request;
72 | });
73 |
74 | const getClip = async (id: string): Promise => {
75 | const { data } = await twitchApiClient.get<{ data: TwitchClip[] }>(`clips?id=${id}`);
76 |
77 | return data.data[0];
78 | };
79 |
80 | const getVideo = async (id: string): Promise => {
81 | const { data } = await twitchApiClient.get<{ data: TwitchVideo[] }>(`videos?id=${id}`);
82 |
83 | return data.data[0];
84 | };
85 |
86 | const getGame = async (id: string): Promise => {
87 | const { data } = await twitchApiClient.get<{ data: TwitchGame[] }>(`games?id=${id}`);
88 |
89 | return data.data[0];
90 | };
91 |
92 | const getFallbackM3u8Url = (id: string): string => {
93 | return `${TWITCH_CLIPS_CDN}/${id}/AT-cm%7C${id}.m3u8`;
94 | };
95 |
96 | const twitchApi = {
97 | getClip,
98 | getVideo,
99 | getGame,
100 | getDirectUrl,
101 | getFallbackM3u8Url,
102 | };
103 |
104 | export default twitchApi;
105 |
--------------------------------------------------------------------------------
/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/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