├── preview.jpg ├── public ├── favicon.ico └── assets │ └── sprite.svg ├── remix.env.d.ts ├── .gitignore ├── postcss.config.js ├── .eslintrc.js ├── remix.config.js ├── tailwind.config.js ├── app ├── routes │ ├── index.tsx │ └── watch.tsx ├── components │ ├── icon.tsx │ ├── context.tsx │ ├── playlist.tsx │ ├── playlistMachine.ts │ ├── playerMachine.ts │ └── player.tsx ├── entry.client.tsx ├── utils │ └── index.ts ├── root.tsx ├── entry.server.tsx └── api │ └── index.ts ├── README.md ├── styles └── tailwind.css ├── prettier.config.js ├── tsconfig.json └── package.json /preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4lek/xstate-video-player/HEAD/preview.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/d4lek/xstate-video-player/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /app/styles/**/*.css 4 | /.cache 5 | /build 6 | /public/build 7 | .env 8 | 9 | .DS_Store -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | module.exports = { 3 | ignoredRouteFiles: ['**/.*'], 4 | feature: { 5 | v2_routeConvention: true, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./app/**/*.{js,ts,jsx,tsx}'], 4 | darkMode: 'class', 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [require('@tailwindcss/aspect-ratio')], 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import {LoaderArgs, redirect} from '@remix-run/node' 2 | import {fetchPlaylist} from '~/api' 3 | 4 | export const loader = async (args: LoaderArgs) => { 5 | let defaultPlaylist = await fetchPlaylist() 6 | return redirect(`/watch?list=${defaultPlaylist?.id}`) 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to Remix! 2 | 3 | - [Remix Docs](https://remix.run/docs) 4 | 5 | ## Development 6 | 7 | From your terminal: 8 | 9 | ```sh 10 | npm run dev 11 | ``` 12 | 13 | Preview: 14 | 15 |  16 | -------------------------------------------------------------------------------- /styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply flex min-h-screen flex-col antialiased bg-zinc-900; 8 | } 9 | } 10 | 11 | @layer components { 12 | .material-symbols-outlined { 13 | font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 48; 14 | font-size: 2rem; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSpacing: false, 4 | embeddedLanguageFormatting: 'auto', 5 | endOfLine: 'lf', 6 | htmlWhitespaceSensitivity: 'css', 7 | insertPragma: false, 8 | jsxBracketSameLine: false, 9 | jsxSingleQuote: false, 10 | printWidth: 80, 11 | proseWrap: 'always', 12 | quoteProps: 'as-needed', 13 | requirePragma: false, 14 | semi: false, 15 | singleQuote: true, 16 | tabWidth: 2, 17 | trailingComma: 'all', 18 | useTabs: false, 19 | } 20 | -------------------------------------------------------------------------------- /app/components/icon.tsx: -------------------------------------------------------------------------------- 1 | import type {SVGAttributes} from 'react' 2 | 3 | const icons = [ 4 | 'skip_previous', 5 | 'pause', 6 | 'play_arrow', 7 | 'skip_next', 8 | 'volume_off', 9 | 'volume_up', 10 | ] as const 11 | 12 | export type IconNames = typeof icons[number] 13 | 14 | type IconProps = SVGAttributes & { 15 | id: IconNames 16 | } 17 | 18 | export const Icon = ({id, width = 24, height = 24, ...props}: IconProps) => { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | function hydrate() { 6 | startTransition(() => { 7 | hydrateRoot( 8 | document, 9 | 10 | 11 | 12 | ); 13 | }); 14 | } 15 | 16 | if (typeof requestIdleCallback === "function") { 17 | requestIdleCallback(hydrate); 18 | } else { 19 | // Safari doesn't support requestIdleCallback 20 | // https://caniuse.com/requestidlecallback 21 | setTimeout(hydrate, 1); 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/utils/index.ts: -------------------------------------------------------------------------------- 1 | import {Observable} from 'rxjs' 2 | 3 | export const fromResizeEvent = (elem: T) => { 4 | return new Observable(subscriber => { 5 | const resizeObserver = new ResizeObserver(entries => { 6 | for (const entry of entries) { 7 | if (entry.contentBoxSize) { 8 | const contentBoxSize = entry.contentBoxSize[0] 9 | subscriber.next({ 10 | blockSize: contentBoxSize.blockSize, 11 | inlineSize: contentBoxSize.inlineSize, 12 | }) 13 | } 14 | } 15 | }) 16 | 17 | resizeObserver.observe(elem) 18 | return function unsubscribe() { 19 | resizeObserver.unobserve(elem) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build": "npm run build:css && remix build", 6 | "build:css": "tailwindcss -m -i ./styles/tailwind.css -o app/styles/tailwind.css", 7 | "dev": "concurrently \"npm run dev:css\" \"remix dev\"", 8 | "dev:css": "tailwindcss -w -i ./styles/tailwind.css -o app/styles/tailwind.css", 9 | "start": "remix-serve build", 10 | "typecheck": "tsc" 11 | }, 12 | "dependencies": { 13 | "@remix-run/node": "^1.12.0", 14 | "@remix-run/react": "^1.12.0", 15 | "@remix-run/serve": "^1.12.0", 16 | "@tailwindcss/aspect-ratio": "^0.4.2", 17 | "@xstate/react": "^3.1.2", 18 | "clsx": "^1.2.1", 19 | "fp-ts": "^2.13.1", 20 | "isbot": "^3.6.5", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "rxjs": "^7.8.0", 24 | "xstate": "^4.35.4" 25 | }, 26 | "devDependencies": { 27 | "@remix-run/dev": "^1.12.0", 28 | "@remix-run/eslint-config": "^1.12.0", 29 | "@types/react": "^18.0.25", 30 | "@types/react-dom": "^18.0.8", 31 | "concurrently": "^7.6.0", 32 | "eslint": "^8.27.0", 33 | "tailwindcss": "^3.2.6", 34 | "typescript": "^4.8.4" 35 | }, 36 | "engines": { 37 | "node": ">=14" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type {LinksFunction, MetaFunction} from '@remix-run/node' 2 | import { 3 | Links, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | } from '@remix-run/react' 10 | import type {PropsWithChildren} from 'react' 11 | import tailwindStyles from './styles/tailwind.css' 12 | 13 | export const links: LinksFunction = () => { 14 | return [ 15 | { 16 | rel: 'prefetch', 17 | as: 'image', 18 | type: 'image/svg+xml', 19 | href: '/assets/sprite.svg', 20 | }, 21 | {rel: 'stylesheet', href: tailwindStyles}, 22 | ] 23 | } 24 | 25 | export const meta: MetaFunction = () => ({ 26 | charset: 'utf-8', 27 | title: 'New Remix App', 28 | viewport: 'width=device-width,initial-scale=1', 29 | }) 30 | 31 | function Document({children}: PropsWithChildren) { 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {children} 41 | 42 | 43 | 44 | 45 | 46 | ) 47 | } 48 | 49 | export default function App() { 50 | return ( 51 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/components/context.tsx: -------------------------------------------------------------------------------- 1 | import {useSearchParams} from '@remix-run/react' 2 | import {useInterpret} from '@xstate/react' 3 | import {createContext, PropsWithChildren, useContext} from 'react' 4 | import {InterpreterFrom} from 'xstate' 5 | import type {Video} from '../api' 6 | import {playlistMachine} from './playlistMachine' 7 | 8 | type PlaylistProviderState = { 9 | playlistService: InterpreterFrom 10 | } 11 | const PlaylistContext = createContext({ 12 | playlistService: {} as InterpreterFrom, 13 | }) 14 | 15 | type PlaylistProviderProps = PropsWithChildren<{ 16 | title?: string 17 | videos: Video[] 18 | playing?: Video 19 | }> 20 | export const PlaylistProvider = ({ 21 | children, 22 | title, 23 | videos, 24 | playing, 25 | }: PlaylistProviderProps) => { 26 | let [searchParams, setSearchParams] = useSearchParams() 27 | let playlistService = useInterpret(playlistMachine, { 28 | context: {videos, playing, title}, 29 | actions: { 30 | syncSearchParams: context => { 31 | let prev = Object.fromEntries(searchParams.entries()) 32 | setSearchParams({...prev, v: context.playing?.id!}) 33 | }, 34 | }, 35 | }) 36 | 37 | return ( 38 | 39 | {children} 40 | 41 | ) 42 | } 43 | 44 | export const usePlaylistContext = () => { 45 | return useContext(PlaylistContext) 46 | } 47 | -------------------------------------------------------------------------------- /app/routes/watch.tsx: -------------------------------------------------------------------------------- 1 | import {PlaylistProvider} from '~/components/context' 2 | import {Player} from '~/components/player' 3 | import {Playlist} from '~/components/playlist' 4 | import {useLoaderData} from 'react-router' 5 | import type {LoaderArgs, SerializeFrom} from '@remix-run/node' 6 | import {json} from '@remix-run/node' 7 | import {fetchPlaylist} from '~/api' 8 | 9 | export const loader = async (args: LoaderArgs) => { 10 | let url = new URL(args.request.url) 11 | let listId = url.searchParams.get('list') 12 | let videoId = url.searchParams.get('v') 13 | let playlist = await fetchPlaylist(listId) 14 | let playing = playlist?.videos.find(video => video.id === videoId) 15 | return json({...playlist, playing}) 16 | } 17 | 18 | export default function Index() { 19 | let playlist = useLoaderData() as SerializeFrom 20 | 21 | if (!playlist) return Oops! No playlist with this id! 22 | 23 | return ( 24 | 25 | 26 | Brand 27 | 28 | 29 | 30 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /public/assets/sprite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { PassThrough } from "stream"; 2 | import type { EntryContext } from "@remix-run/node"; 3 | import { Response } from "@remix-run/node"; 4 | import { RemixServer } from "@remix-run/react"; 5 | import isbot from "isbot"; 6 | import { renderToPipeableStream } from "react-dom/server"; 7 | 8 | const ABORT_DELAY = 5000; 9 | 10 | export default function handleRequest( 11 | request: Request, 12 | responseStatusCode: number, 13 | responseHeaders: Headers, 14 | remixContext: EntryContext 15 | ) { 16 | return isbot(request.headers.get("user-agent")) 17 | ? handleBotRequest( 18 | request, 19 | responseStatusCode, 20 | responseHeaders, 21 | remixContext 22 | ) 23 | : handleBrowserRequest( 24 | request, 25 | responseStatusCode, 26 | responseHeaders, 27 | remixContext 28 | ); 29 | } 30 | 31 | function handleBotRequest( 32 | request: Request, 33 | responseStatusCode: number, 34 | responseHeaders: Headers, 35 | remixContext: EntryContext 36 | ) { 37 | return new Promise((resolve, reject) => { 38 | let didError = false; 39 | 40 | const { pipe, abort } = renderToPipeableStream( 41 | , 42 | { 43 | onAllReady() { 44 | const body = new PassThrough(); 45 | 46 | responseHeaders.set("Content-Type", "text/html"); 47 | 48 | resolve( 49 | new Response(body, { 50 | headers: responseHeaders, 51 | status: didError ? 500 : responseStatusCode, 52 | }) 53 | ); 54 | 55 | pipe(body); 56 | }, 57 | onShellError(error: unknown) { 58 | reject(error); 59 | }, 60 | onError(error: unknown) { 61 | didError = true; 62 | 63 | console.error(error); 64 | }, 65 | } 66 | ); 67 | 68 | setTimeout(abort, ABORT_DELAY); 69 | }); 70 | } 71 | 72 | function handleBrowserRequest( 73 | request: Request, 74 | responseStatusCode: number, 75 | responseHeaders: Headers, 76 | remixContext: EntryContext 77 | ) { 78 | return new Promise((resolve, reject) => { 79 | let didError = false; 80 | 81 | const { pipe, abort } = renderToPipeableStream( 82 | , 83 | { 84 | onShellReady() { 85 | const body = new PassThrough(); 86 | 87 | responseHeaders.set("Content-Type", "text/html"); 88 | 89 | resolve( 90 | new Response(body, { 91 | headers: responseHeaders, 92 | status: didError ? 500 : responseStatusCode, 93 | }) 94 | ); 95 | 96 | pipe(body); 97 | }, 98 | onShellError(err: unknown) { 99 | reject(err); 100 | }, 101 | onError(error: unknown) { 102 | didError = true; 103 | 104 | console.error(error); 105 | }, 106 | } 107 | ); 108 | 109 | setTimeout(abort, ABORT_DELAY); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /app/components/playlist.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import {useSelector} from '@xstate/react' 3 | import {usePlaylistContext} from './context' 4 | import {Link, useSearchParams} from '@remix-run/react' 5 | import {useEffect} from 'react' 6 | 7 | export const Playlist = () => { 8 | let [searchParams] = useSearchParams() 9 | let {playlistService} = usePlaylistContext() 10 | let context = useSelector(playlistService, ({context}) => context) 11 | let activeListId = searchParams.get('list') 12 | let activeVideoId = searchParams.get('v') 13 | let activeIndex = context.videos.findIndex( 14 | video => video.id === activeVideoId, 15 | ) 16 | 17 | // update active video on browser history navigation 18 | useEffect(() => { 19 | let optionalVideo = context.videos.find(video => video.id === activeVideoId) 20 | if (!optionalVideo) return 21 | if (optionalVideo.id === context.playing?.id) return 22 | playlistService.send({type: 'SELECT', video: optionalVideo}) 23 | }, [activeVideoId]) 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | {context.title} 32 | 33 | 34 | Playing: {activeIndex + 1}/{context.videos.length} 35 | 36 | 37 | 38 | {context.videos.map((item, index) => { 39 | let isActive = item.id === context.playing?.id 40 | let title = item.thumbnail 41 | .split('/') 42 | .at(-1) 43 | ?.replace('.jpg', '') 44 | .split(/(?=[A-Z])/) 45 | .join(' ') 46 | 47 | return ( 48 | 56 | 57 | 58 | 59 | 64 | 65 | 66 | 67 | {title} 68 | 69 | 70 | 71 | ) 72 | })} 73 | 74 | 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/api/index.ts: -------------------------------------------------------------------------------- 1 | export type Video = { 2 | id: string 3 | url: string 4 | thumbnail: string 5 | } 6 | 7 | export type PlaylistTable = { 8 | id: string 9 | title: string 10 | videos: Video[] 11 | } 12 | 13 | const playlistsDb: PlaylistTable[] = [ 14 | { 15 | id: '2b0d7b3dcb6d', 16 | title: 'Random short clips from google', 17 | videos: [ 18 | { 19 | id: '5d42bf2f-63b5-443b-ac95-2450d56ae4aa', 20 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', 21 | thumbnail: 22 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/BigBuckBunny.jpg', 23 | }, 24 | { 25 | id: 'f6147965-a8bc-46ce-917d-243c804ee7ed', 26 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', 27 | thumbnail: 28 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ElephantsDream.jpg', 29 | }, 30 | { 31 | id: '63a24217-e54b-4388-b603-f5ba41a81498', 32 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', 33 | thumbnail: 34 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerBlazes.jpg', 35 | }, 36 | { 37 | id: '06a5f1fe-6f9e-4a2d-a33e-665e5a58f6ab', 38 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4', 39 | thumbnail: 40 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg', 41 | }, 42 | { 43 | id: '880ddb7e-c956-4ef4-966a-1a7250932d59', 44 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4', 45 | thumbnail: 46 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerFun.jpg', 47 | }, 48 | { 49 | id: '57fcf5b6-aa14-483e-8e0e-4208d9d554db', 50 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4', 51 | thumbnail: 52 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerJoyrides.jpg', 53 | }, 54 | { 55 | id: 'b0dca8af-7e5d-4170-b3b3-f46091e2a578', 56 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4', 57 | thumbnail: 58 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerMeltdowns.jpg', 59 | }, 60 | { 61 | id: 'dddaf4dc-8423-46c3-a739-537ed533eba9', 62 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/Sintel.jpg', 63 | thumbnail: 64 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/Sintel.jpg', 65 | }, 66 | 67 | { 68 | id: '2b4575e1-1db8-47bd-8da9-6ec18ee980a2', 69 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4', 70 | thumbnail: 71 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/SubaruOutbackOnStreetAndDirt.jpg', 72 | }, 73 | { 74 | id: '5a19deda-2ea3-422b-9350-5770c37947fb', 75 | url: 'https://storage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', 76 | thumbnail: 77 | 'https://storage.googleapis.com/gtv-videos-bucket/sample/images/TearsOfSteel.jpg', 78 | }, 79 | ], 80 | }, 81 | ] 82 | 83 | export const fetchPlaylist = ( 84 | listId?: string | null, 85 | ): Promise => { 86 | if (!listId) return Promise.resolve(playlistsDb[0]) 87 | let optionalPlaylist = playlistsDb.find(p => p.id === listId) 88 | return new Promise(res => res(optionalPlaylist)) 89 | } 90 | -------------------------------------------------------------------------------- /app/components/playlistMachine.ts: -------------------------------------------------------------------------------- 1 | import * as O from 'fp-ts/Option' 2 | import * as F from 'fp-ts/function' 3 | import * as A from 'fp-ts/Array' 4 | import {ActorRefFrom, assign, createMachine, spawn} from 'xstate' 5 | import {Video} from '../api' 6 | import {createPlayerMachine} from './playerMachine' 7 | 8 | type PlayerMachineEvents = 9 | | {type: 'LOADED'} 10 | | {type: 'SELECT'; video: Video} 11 | | {type: 'AUTOPLAY'} 12 | | {type: 'LOOP'} 13 | | {type: 'SHUFFLE'} 14 | | {type: 'NEXT'} 15 | | {type: 'PREV'} 16 | 17 | export type PlaylistMachineContext = { 18 | autoplay: boolean 19 | videos: Video[] 20 | playerRef: ActorRefFrom> | null 21 | loop: boolean 22 | title: string 23 | muted: boolean 24 | playing: Video | null 25 | shuffle: boolean 26 | playbackRate: number 27 | } 28 | 29 | export const playlistMachine = createMachine< 30 | PlaylistMachineContext, 31 | PlayerMachineEvents 32 | >( 33 | { 34 | id: 'Playlist', 35 | initial: 'loading', 36 | context: { 37 | autoplay: false, 38 | videos: [], 39 | playing: null, 40 | muted: false, 41 | title: '', 42 | playerRef: null, 43 | loop: false, 44 | shuffle: false, 45 | playbackRate: 1, 46 | }, 47 | predictableActionArguments: true, 48 | preserveActionOrder: true, 49 | states: { 50 | loading: { 51 | entry: [ 52 | assign(context => { 53 | // allow to load with a specific video or default to first 54 | let video = context.playing ?? context.videos[0] 55 | return { 56 | ...context, 57 | playing: video, 58 | playerRef: spawn(createPlayerMachine(video), 'player'), 59 | } 60 | }), 61 | 'syncSearchParams', 62 | ], 63 | always: 'ready', 64 | }, 65 | ready: { 66 | on: { 67 | SELECT: { 68 | actions: assign((context, event) => { 69 | context.playerRef?.send({ 70 | type: 'CHANGE', 71 | video: event.video, 72 | }) 73 | 74 | return { 75 | ...context, 76 | playing: event.video, 77 | } 78 | }), 79 | }, 80 | AUTOPLAY: {}, 81 | LOOP: { 82 | actions: assign({ 83 | loop: ctx => true, 84 | }), 85 | }, 86 | SHUFFLE: {}, 87 | PREV: { 88 | actions: ['switchVideo', 'syncSearchParams'], 89 | }, 90 | NEXT: { 91 | actions: ['switchVideo', 'syncSearchParams'], 92 | }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | { 98 | actions: { 99 | syncSearchParams: () => {}, 100 | switchVideo: assign((context, event) => { 101 | // TODO: take care if loop is on 102 | return F.pipe( 103 | O.fromNullable(context.videos), 104 | O.chain(videos => 105 | F.pipe( 106 | videos, 107 | A.findIndex(video => video.id === context.playing?.id), 108 | O.map(index => { 109 | let selector = event.type === 'NEXT' ? index + 1 : index - 1 110 | return videos[selector] 111 | }), 112 | O.chain(video => (video ? O.some(video) : O.none)), 113 | ), 114 | ), 115 | O.fold( 116 | () => context, 117 | video => { 118 | context.playerRef?.send({ 119 | type: 'CHANGE', 120 | video: video, 121 | }) 122 | 123 | return { 124 | ...context, 125 | playing: video, 126 | } 127 | }, 128 | ), 129 | ) 130 | }), 131 | }, 132 | }, 133 | ) 134 | -------------------------------------------------------------------------------- /app/components/playerMachine.ts: -------------------------------------------------------------------------------- 1 | import {assign, createMachine, sendParent} from 'xstate' 2 | import {Video} from '../api' 3 | import {PlaylistMachineContext} from './playlistMachine' 4 | import * as O from 'fp-ts/Option' 5 | import * as F from 'fp-ts/function' 6 | 7 | type PlayerMachineEvents = 8 | | {type: 'LOADED'; videoRef: HTMLVideoElement} 9 | | {type: 'CHANGE'; video: Video} 10 | | {type: 'PLAY'} 11 | | {type: 'PAUSE'} 12 | | {type: 'RESUME'} 13 | | {type: 'NEXT'} 14 | | {type: 'PREV'} 15 | | {type: 'END'} 16 | | {type: 'RETRY'} 17 | | {type: 'TIME.UPDATE'; inlineSize: number; position: number} 18 | | {type: 'TRACK'} 19 | | {type: 'BUFFERING'} 20 | | {type: 'FORWARD'} 21 | | {type: 'BACKWARD'} 22 | | {type: 'ERROR'} 23 | | {type: 'SOUND'} 24 | | {type: 'MUTE'} 25 | | {type: 'PLAYBACK_RATE'; playbackRate: number} 26 | 27 | type PlayerMachineContext = { 28 | video: Video 29 | videoRef: HTMLVideoElement 30 | progress: number 31 | muted: boolean 32 | playbackRate: number 33 | } 34 | 35 | const initialContext: Omit< 36 | PlayerMachineContext, 37 | 'muted' | 'video' | 'playbackRate' 38 | > = { 39 | videoRef: {} as HTMLVideoElement, 40 | progress: 0, 41 | } 42 | 43 | export const createPlayerMachine = ( 44 | video: PlaylistMachineContext['videos'][0], 45 | ) => { 46 | return createMachine({ 47 | id: 'player', 48 | initial: 'loading', 49 | context: { 50 | ...initialContext, 51 | muted: false, 52 | playbackRate: 1, 53 | video, 54 | }, 55 | predictableActionArguments: true, 56 | preserveActionOrder: true, 57 | states: { 58 | loading: { 59 | on: { 60 | LOADED: { 61 | target: 'ready', 62 | actions: assign({ 63 | videoRef: (_context, event) => event.videoRef, 64 | }), 65 | }, 66 | }, 67 | }, 68 | ready: { 69 | initial: 'paused', 70 | states: { 71 | paused: { 72 | entry: [context => context.videoRef?.pause()], 73 | on: { 74 | PLAY: { 75 | target: 'playing', 76 | }, 77 | }, 78 | }, 79 | playing: { 80 | entry: [context => context.videoRef?.play()], 81 | on: { 82 | PAUSE: { 83 | target: 'paused', 84 | }, 85 | END: { 86 | target: 'ended', 87 | }, 88 | TRACK: { 89 | actions: assign({ 90 | progress: context => { 91 | return F.pipe( 92 | O.fromNullable(context.videoRef), 93 | O.map(x => (x.currentTime / x.duration) * 100), 94 | O.fold( 95 | () => 0, 96 | x => x, 97 | ), 98 | ) 99 | }, 100 | }), 101 | }, 102 | BUFFERING: 'buffering', 103 | FORWARD: {}, 104 | BACKWARD: {}, 105 | }, 106 | }, 107 | buffering: { 108 | on: { 109 | RESUME: 'playing', 110 | }, 111 | }, 112 | ended: {}, 113 | }, 114 | on: { 115 | MUTE: { 116 | actions: assign({ 117 | muted: context => !context.muted, 118 | }), 119 | }, 120 | SOUND: {}, 121 | 'TIME.UPDATE': { 122 | actions: assign({ 123 | progress: (context, event) => { 124 | return F.pipe( 125 | O.fromNullable(context.videoRef), 126 | O.map( 127 | ref => (ref.duration / event.inlineSize) * event.position, 128 | ), 129 | O.map(time => { 130 | // mutate videoRef current time 131 | context.videoRef.currentTime = time 132 | return time 133 | }), 134 | O.map(time => (time / context.videoRef.duration) * 100), 135 | O.fold( 136 | () => 0, 137 | progress => progress, 138 | ), 139 | ) 140 | }, 141 | }), 142 | }, 143 | PLAYBACK_RATE: { 144 | actions: assign({ 145 | playbackRate: ({videoRef}, event) => { 146 | videoRef.playbackRate = event.playbackRate 147 | return event.playbackRate 148 | }, 149 | }), 150 | }, 151 | PREV: { 152 | actions: sendParent('PREV'), 153 | }, 154 | NEXT: { 155 | actions: sendParent('NEXT'), 156 | }, 157 | ERROR: 'error', 158 | }, 159 | }, 160 | error: {}, 161 | }, 162 | on: { 163 | CHANGE: { 164 | target: 'loading', 165 | actions: assign((context, event) => { 166 | return { 167 | ...initialContext, 168 | video: event.video, 169 | } 170 | }), 171 | }, 172 | }, 173 | }) 174 | } 175 | -------------------------------------------------------------------------------- /app/components/player.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from 'react' 2 | import {useActor, useSelector} from '@xstate/react' 3 | import {usePlaylistContext} from './context' 4 | import {createPlayerMachine} from './playerMachine' 5 | import {ActorRefFrom} from 'xstate' 6 | import {Icon} from './icon' 7 | import {fromEvent, map} from 'rxjs' 8 | import {fromResizeEvent} from '../utils' 9 | import {useSearchParams} from '@remix-run/react' 10 | 11 | type VideoProps = { 12 | playerRef: ActorRefFrom> 13 | } 14 | export const Video = ({playerRef}: VideoProps) => { 15 | const videoEl = React.useRef(null) 16 | const [state, send] = useActor(playerRef) 17 | 18 | useEffect(() => { 19 | if (!videoEl.current) return 20 | send({type: 'LOADED', videoRef: videoEl.current}) 21 | }, [videoEl, send]) 22 | 23 | return ( 24 | 25 | {state.matches('ready.buffering') && ( 26 | 27 | Buffering 28 | 29 | )} 30 | { 37 | send({type: 'BUFFERING'}) 38 | }} 39 | onError={() => send('ERROR')} 40 | onLoadStart={() => console.log('onLoadStart')} 41 | onCanPlay={() => { 42 | console.log('onCanPlay') 43 | send('RESUME') 44 | }} 45 | onTimeUpdate={() => send('TRACK')} 46 | /> 47 | 48 | ) 49 | } 50 | 51 | const ControlsProgress = ({playerRef}: VideoProps) => { 52 | const ref = useRef(null) 53 | const [state, send] = useActor(playerRef) 54 | const [position, setPosition] = useState(0) 55 | const [size, setSize] = useState({ 56 | blockSize: 0, 57 | inlineSize: 0, 58 | }) 59 | 60 | useEffect(() => { 61 | if (!ref.current) return 62 | let sub = fromResizeEvent(ref.current).subscribe(setSize) 63 | return () => sub.unsubscribe() 64 | }, []) 65 | 66 | useEffect(() => { 67 | if (!ref.current) return 68 | let sub = fromEvent(ref.current, 'mousemove') 69 | .pipe(map(event => event.clientX - size.blockSize)) 70 | .subscribe(setPosition) 71 | return () => sub.unsubscribe() 72 | }, [size.blockSize]) 73 | 74 | return ( 75 | setPosition(0)} 79 | onClick={() => { 80 | send({type: 'TIME.UPDATE', inlineSize: size.inlineSize, position}) 81 | }} 82 | > 83 | 84 | 85 | 89 | 90 | 94 | 95 | 96 | 97 | ) 98 | } 99 | 100 | const Controls = ({playerRef}: VideoProps) => { 101 | const [state, send] = useActor(playerRef) 102 | 103 | return ( 104 | 105 | 106 | 107 | 108 | send('PREV')}> 109 | 110 | 111 | {['ready.playing', 'ready.buffering'].some(state.matches) ? ( 112 | send('PAUSE')}> 113 | 114 | 115 | ) : ( 116 | send('PLAY')}> 117 | 118 | 119 | )} 120 | send('NEXT')}> 121 | 122 | 123 | send('MUTE')}> 124 | 125 | 126 | 127 | 128 | { 131 | send({ 132 | type: 'PLAYBACK_RATE', 133 | playbackRate: Number(evt.target.value), 134 | }) 135 | }} 136 | > 137 | 0.50x 138 | 1x 139 | 1.25x 140 | 2x 141 | 142 | 143 | 144 | 145 | ) 146 | } 147 | 148 | export const Player = () => { 149 | const {playlistService} = usePlaylistContext() 150 | const playerRef = useSelector( 151 | playlistService, 152 | state => state.context.playerRef, 153 | ) 154 | const [state] = useActor(playerRef!) 155 | 156 | if (state.matches('error')) { 157 | return ( 158 | 159 | Broken URL! 160 | 161 | ) 162 | } 163 | 164 | return ( 165 | 166 | 167 | 168 | 169 | ) 170 | } 171 | --------------------------------------------------------------------------------
Brand
31 | {context.title} 32 |
34 | Playing: {activeIndex + 1}/{context.videos.length} 35 |
{title}