diff --git a/nextjs-uploader-and-player/README.md b/nextjs-uploader-and-player/README.md new file mode 100644 index 0000000..721ed96 --- /dev/null +++ b/nextjs-uploader-and-player/README.md @@ -0,0 +1 @@ +Check out our full example in the official Next.js Examples repository: [with-mux-video](https://github.com/vercel/next.js/tree/canary/examples/with-mux-video) diff --git a/remixjs-uploader-and-player/.env.example b/remixjs-uploader-and-player/.env.example new file mode 100644 index 0000000..c3941d4 --- /dev/null +++ b/remixjs-uploader-and-player/.env.example @@ -0,0 +1,2 @@ +MUX_TOKEN_ID= +MUX_TOKEN_SECRET= diff --git a/remixjs-uploader-and-player/.eslintrc.js b/remixjs-uploader-and-player/.eslintrc.js new file mode 100644 index 0000000..2061cd2 --- /dev/null +++ b/remixjs-uploader-and-player/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], +}; diff --git a/remixjs-uploader-and-player/.gitignore b/remixjs-uploader-and-player/.gitignore new file mode 100644 index 0000000..3f7bf98 --- /dev/null +++ b/remixjs-uploader-and-player/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/remixjs-uploader-and-player/README.md b/remixjs-uploader-and-player/README.md new file mode 100644 index 0000000..da0ff4b --- /dev/null +++ b/remixjs-uploader-and-player/README.md @@ -0,0 +1,69 @@ +# Mux Video + +> [!NOTE] +> This example will be removed from this repository once it is accepted and merged into the official Remix examples: [remix-run/examples#441](https://github.com/remix-run/examples/pull/441). + +This example features video uploading and playback in a Remix.js application. + +This example is useful if you want to build a platform that supports user-uploaded videos. For example: + +- Enabling user profile videos +- Accepting videos for a video contest promotion +- Allowing customers to upload screencasts that help with troubleshooting a bug +- Or even the next Youtube, TikTok, or Instagram + +## Preview + +Open this example on [CodeSandbox](https://codesandbox.com): + +[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/examples/tree/main/mux-video) + +## How to use + +### Step 1. Create an account in Mux + +All you need to run this example is a [Mux account](https://www.mux.com?utm_source=remix-examples&utm_medium=mux-video&utm_campaign=remix-examples). You can sign up for free. There are no upfront charges -- you get billed monthly only for what you use. + +Without entering a credit card on your Mux account all videos are in “test mode” which means they are watermarked and clipped to 10 seconds. If you enter a credit card all limitations are lifted and you get \$20 of free credit. The free credit should be plenty for you to test out and play around with everything. + +### Step 2. Set up environment variables + +Copy the `.env.example` file in this directory to `.env` (which will be ignored by Git): + +```bash +cp .env.example .env +``` + +Then, go to the [settings page](https://dashboard.mux.com/settings/access-tokens) in your Mux dashboard, get a new **API Access Token**. Use that token to set the variables in `.env.local`: + +- `MUX_TOKEN_ID` should be the `TOKEN ID` of your new token +- `MUX_TOKEN_SECRET` should be `TOKEN SECRET` + +At this point, you're good to `npm run dev`. + +## How it works + +Uploading and viewing a video takes four steps: + +1. **Upload a video**: Use the Mux [Direct Uploads API](https://docs.mux.com/api-reference#video/tag/direct-uploads?utm_source=remix-examples&utm_medium=mux-video&utm_campaign=remix-examples) to create an endpoint for [Mux Uploader React](https://docs.mux.com/guides/mux-uploader?utm_source=remix-examples&utm_medium=mux-video&utm_campaign=remix-examples). The user can then use Mux Uploader to upload a video. +1. **Exchange the `upload.id` for an `asset.id`**: Once the upload is complete, it will have a Mux asset associated with it. We can use the [Direct Uploads API](https://docs.mux.com/api-reference#video/tag/direct-uploads?utm_source=remix-examples&utm_medium=mux-video&utm_campaign=remix-examples) to check for that asset. +1. **Use the `asset.id` to check if the asset is ready** by polling the [Asset API](https://docs.mux.com/api-reference#video/tag/assets?utm_source=remix-examples&utm_medium=mux-video&utm_campaign=remix-examples) +1. **Play back the video with [Mux Player React](https://docs.mux.com/guides/mux-player-web?utm_source=remix-examples&utm_medium=mux-video&utm_campaign=remix-examples)** (on a page that uses the [Mux Image API](https://docs.mux.com/guides/get-images-from-a-video) to provide og images) + +These steps correspond to the following routes: + +1. [`_index.tsx`](app/routes/_index.tsx) creates the upload in a loader, and exchanges the `upload.id` for an `asset.id` in an action which redirects to... +2. [`status.$assetId.tsx`](app/routes/status.$assetId.tsx) polls the Mux API to see if the asset is ready. When it is, we redirect to... +3. [`playback.$playbackId.tsx`](app/routes/playback.$playbackId.tsx) plays the video. + +## Preparing for Production + +### Set the `cors_origin` + +When creating uploads, this demo sets `cors_origin: "*"` in the [`app/routes/_index.tsx`](app/routes/_index.tsx) file. For extra security, you should update this value to be something like `cors_origin: 'https://your-app.com'`, to restrict uploads to only be allowed from your application. + +### Consider webhooks + +In this example, we poll the Mux API to see if our asset is ready. In production, you'll likely have a database where you can store the `upload.id` and `asset.id`, and you can use [Mux Webhooks](https://docs.mux.com/guides/listen-for-webhooks) to get notified when your upload is complete, and when your asset is ready. + +See [`app/routes/mux.webhook.ts`](app/routes/mux.webhook.ts) for an example of how you might handle a Mux webhook. diff --git a/remixjs-uploader-and-player/app/components/Link.tsx b/remixjs-uploader-and-player/app/components/Link.tsx new file mode 100644 index 0000000..37acb4b --- /dev/null +++ b/remixjs-uploader-and-player/app/components/Link.tsx @@ -0,0 +1,17 @@ +import { Link as LibLink } from "@remix-run/react"; + +/** + * + * @param className this component does not merge className with the default classes -- it only appends -- so beware of duplicates + */ +const Link = ({ + className = "", + ...rest +}: React.ComponentProps) => ( + +); + +export default Link; diff --git a/remixjs-uploader-and-player/app/lib/mux.server.ts b/remixjs-uploader-and-player/app/lib/mux.server.ts new file mode 100644 index 0000000..67344c5 --- /dev/null +++ b/remixjs-uploader-and-player/app/lib/mux.server.ts @@ -0,0 +1,5 @@ +import Mux from "@mux/mux-node"; + +const mux = new Mux(); + +export default mux; diff --git a/remixjs-uploader-and-player/app/root.tsx b/remixjs-uploader-and-player/app/root.tsx new file mode 100644 index 0000000..7069ae3 --- /dev/null +++ b/remixjs-uploader-and-player/app/root.tsx @@ -0,0 +1,36 @@ +import type { MetaFunction, LinksFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +import styles from "./tailwind.css"; + +export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "New Remix App", + viewport: "width=device-width,initial-scale=1", +}); + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/remixjs-uploader-and-player/app/routes/_index.tsx b/remixjs-uploader-and-player/app/routes/_index.tsx new file mode 100644 index 0000000..57ad5c2 --- /dev/null +++ b/remixjs-uploader-and-player/app/routes/_index.tsx @@ -0,0 +1,72 @@ +import MuxUploader from "@mux/mux-uploader-react"; +import { ActionFunctionArgs, json, redirect } from "@remix-run/node"; +import { Form, useActionData, useLoaderData } from "@remix-run/react"; +import { useState } from "react"; +import mux from "~/lib/mux.server"; + +export const loader = async () => { + // Create an endpoint for MuxUploader to upload to + const upload = await mux.video.uploads.create({ + new_asset_settings: { + playback_policy: ["public"], + encoding_tier: "baseline", + }, + // in production, you'll want to change this origin to your-domain.com + cors_origin: "*", + }); + return json({ id: upload.id, url: upload.url }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const formData = await request.formData(); + const uploadId = formData.get("uploadId"); + if (typeof uploadId !== "string") { + throw new Error("No uploadId found"); + } + + // when the upload is complete, + // the upload will have an assetId associated with it + // we'll use that assetId to view the video status + const upload = await mux.video.uploads.retrieve(uploadId); + if (upload.asset_id) { + return redirect(`/status/${upload.asset_id}`); + } + + // while onSuccess is a strong indicator that Mux has received the file + // and created the asset, this isn't a guarantee. + // In production, you might write an api route + // to listen for the`video.upload.asset_created` webhook + // https://docs.mux.com/guides/listen-for-webhooks + // However, to keep things simple here, + // we'll just ask the user to push the button again. + // This should rarely happen. + return json({ message: "Upload has no asset yet. Try again." }); +}; + +export default function UploadPage() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + const [isUploadSuccess, setIsUploadSuccess] = useState(false); + + const { id, url } = loaderData; + const { message } = actionData ?? {}; + + return ( +
+ setIsUploadSuccess(true)} /> + + {/* + you might have other fields here, like name and description, + that you'll save in your CMS alongside the uploadId and assetId + */} + + {message &&

{message}

} + + ); +} diff --git a/remixjs-uploader-and-player/app/routes/mux.webhook.ts b/remixjs-uploader-and-player/app/routes/mux.webhook.ts new file mode 100644 index 0000000..4070f68 --- /dev/null +++ b/remixjs-uploader-and-player/app/routes/mux.webhook.ts @@ -0,0 +1,42 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import mux from "~/lib/mux.server"; + +// while this isn't called anywhere in this example, +// I thought it might be helpful to see what a mux webhook handler looks like. + +// Mux webhooks POST, so let's use an action +export const action = async ({ request }: ActionFunctionArgs) => { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const body = await request.text(); + // mux.webhooks.unwrap will validate that the given payload was sent by Mux and parse the payload. + // It will also provide type-safe access to the payload. + // Generate MUX_WEBHOOK_SIGNING_SECRET in the Mux dashboard + // https://dashboard.mux.com/settings/webhooks + const event = mux.webhooks.unwrap( + body, + request.headers, + process.env.MUX_WEBHOOK_SIGNING_SECRET + ); + + // you can also unwrap the payload yourself: + // const event = await request.json(); + switch (event.type) { + case "video.upload.asset_created": + // we might use this to know that an upload has been completed + // and we can save its assetId to our database + break; + case "video.asset.ready": + // we might use this to know that a video has been encoded + // and we can save its playbackId to our database + break; + // there are many more Mux webhook events + // check them out at https://docs.mux.com/webhook-reference + default: + break; + } + + return json({ message: "ok" }) +}; diff --git a/remixjs-uploader-and-player/app/routes/playback.$playbackId.tsx b/remixjs-uploader-and-player/app/routes/playback.$playbackId.tsx new file mode 100644 index 0000000..ed536f2 --- /dev/null +++ b/remixjs-uploader-and-player/app/routes/playback.$playbackId.tsx @@ -0,0 +1,57 @@ +import MuxPlayer from "@mux/mux-player-react"; +import { MetaFunction } from "@remix-run/node"; +import { useParams } from "@remix-run/react"; +import Link from "~/components/Link"; + +const title = "View this video created with Mux + Remix"; +const description = + "This video was uploaded and processed by Mux in an example Remix application."; +export const meta: MetaFunction = ({ params }) => { + const { playbackId } = params; + return [ + { name: "description", content: description }, + { property: "og:type", content: "video" }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { + property: "og:image", + content: `https://image.mux.com/${playbackId}/thumbnail.png?width=1200&height=630&fit_mode=pad`, + }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "twitter:card", content: "summary_large_image" }, + { property: "twitter:title", content: title }, + { property: "twitter:description", content: description }, + { + property: "twitter:image", + content: `https://image.mux.com/${playbackId}/thumbnail.png?width=1200&height=600&fit_mode=pad`, + }, + { property: "twitter:image:width", content: "1200" }, + { property: "twitter:image:height", content: "600" }, + // These tags should be sufficient for social sharing. + // However, if you're really committed video SEO, I'd suggest adding ld+json, as well. + // https://developers.google.com/search/docs/appearance/structured-data/video + ]; +}; + +export default function Page() { + const { playbackId } = useParams(); + return ( + <> +
+ This video is ready for playback +
+
+ +
+

+ Go back home to upload another video. +

+ + ); +} diff --git a/remixjs-uploader-and-player/app/routes/status.$assetId.tsx b/remixjs-uploader-and-player/app/routes/status.$assetId.tsx new file mode 100644 index 0000000..1fc811a --- /dev/null +++ b/remixjs-uploader-and-player/app/routes/status.$assetId.tsx @@ -0,0 +1,78 @@ +import { LoaderFunctionArgs, json, redirect } from "@remix-run/node"; +import { useFetcher, useLoaderData, useParams } from "@remix-run/react"; +import { useEffect } from "react"; +import Link from "~/components/Link"; +import mux from "~/lib/mux.server"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + // now that we have an assetId, we can see how the video is doing. + // in production, you might have some setup where a Mux webhook + // tells your server the status of your asset. + // https://docs.mux.com/guides/listen-for-webhooks + // for this example, however, we'll just ask the Mux API ourselves + const { assetId } = params; + if (typeof assetId !== "string") { + throw new Error("No assetId found"); + } + const asset = await mux.video.assets.retrieve(assetId); + + // if the asset is ready and it has a public playback ID, + // (which it should, considering the upload settings we used) + // redirect to its playback page + if (asset.status === "ready") { + const playbackIds = asset.playback_ids; + if (Array.isArray(playbackIds)) { + const playbackId = playbackIds.find((id) => id.policy === "public"); + if (playbackId) { + return redirect(`/playback/${playbackId.id}`); + } + } + } + + // if the asset is not ready, we'll keep polling + return json({ + status: asset.status, + errors: asset.errors, + }); +}; + +export default function UploadStatus() { + const { status, errors } = useLoaderData(); + const { assetId } = useParams(); + const fetcher = useFetcher(); + + // we'll poll the API by running the loader every two seconds + useEffect(() => { + if (status !== "preparing") return; + + const interval = setInterval(() => { + fetcher.load(`/status/${assetId}`); + }, 2000); + return () => clearInterval(interval); + }, [fetcher, assetId, status]); + + if (status === "preparing") { + return

Asset is preparing...

; + } + + // if not preparing, then "errored" or "ready" + // if "errored", we'll show the errors + // we don't expect to see "ready" because "ready" should redirect in the action + return ( + <> +

+ Asset is in an unexpected state: {status}. +

+ {Array.isArray(errors) && ( +
    + {errors.map((error, key) => ( +
  • {JSON.stringify(error)}
  • + ))} +
+ )} +

+ This is awkward. Let's refresh and try again. +

+ + ); +} diff --git a/remixjs-uploader-and-player/app/tailwind.css b/remixjs-uploader-and-player/app/tailwind.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/remixjs-uploader-and-player/app/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/remixjs-uploader-and-player/package.json b/remixjs-uploader-and-player/package.json new file mode 100644 index 0000000..1bd3965 --- /dev/null +++ b/remixjs-uploader-and-player/package.json @@ -0,0 +1,33 @@ +{ + "private": true, + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build", + "typecheck": "tsc" + }, + "dependencies": { + "@mux/mux-node": "^8.0.0", + "@mux/mux-player-react": "^2.3.3", + "@mux/mux-uploader-react": "^1.0.0-beta.15", + "@remix-run/node": "^1.19.3", + "@remix-run/react": "^1.19.3", + "@remix-run/serve": "^1.19.3", + "isbot": "^3.6.5", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "^1.19.3", + "@remix-run/eslint-config": "^1.19.3", + "@types/react": "^18.0.25", + "@types/react-dom": "^18.0.8", + "eslint": "^8.27.0", + "tailwindcss": "^3.4.1", + "typescript": "^4.8.4" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/remixjs-uploader-and-player/public/favicon.ico b/remixjs-uploader-and-player/public/favicon.ico new file mode 100644 index 0000000..8830cf6 Binary files /dev/null and b/remixjs-uploader-and-player/public/favicon.ico differ diff --git a/remixjs-uploader-and-player/remix.config.js b/remixjs-uploader-and-player/remix.config.js new file mode 100644 index 0000000..ca00ba9 --- /dev/null +++ b/remixjs-uploader-and-player/remix.config.js @@ -0,0 +1,11 @@ +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + future: { + v2_routeConvention: true, + }, + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // publicPath: "/build/", + // serverBuildPath: "build/index.js", +}; diff --git a/remixjs-uploader-and-player/remix.env.d.ts b/remixjs-uploader-and-player/remix.env.d.ts new file mode 100644 index 0000000..dcf8c45 --- /dev/null +++ b/remixjs-uploader-and-player/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/remixjs-uploader-and-player/sandbox.config.json b/remixjs-uploader-and-player/sandbox.config.json new file mode 100644 index 0000000..f92e025 --- /dev/null +++ b/remixjs-uploader-and-player/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "hardReloadOnChange": true, + "template": "remix", + "container": { + "port": 3000 + } +} diff --git a/remixjs-uploader-and-player/tailwind.config.ts b/remixjs-uploader-and-player/tailwind.config.ts new file mode 100644 index 0000000..64a5243 --- /dev/null +++ b/remixjs-uploader-and-player/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; diff --git a/remixjs-uploader-and-player/tsconfig.json b/remixjs-uploader-and-player/tsconfig.json new file mode 100644 index 0000000..20f8a38 --- /dev/null +++ b/remixjs-uploader-and-player/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +}