Skip to content

Commit

Permalink
with-mux-video: move to app router and update packages (#62297)
Browse files Browse the repository at this point in the history
## App Router

Moves the `with-mux-video` example to idiomatic App Router, with goodies
like
* server component data fetching
* server actions
* layouts
* route groups
* loading UI

## Mux Dependencies

* @mux/mux-node 7 -> 8
* @mux/mux-player-react 1 -> 2
* @mux/upchunk + custom UI -> @mux/mux-uploader

## In other news...

* Fleshed out the README
* Updated imagery
* Moved from styled jsx to tailwind and lightly updated styles

## Contributor Checklist

* [x] The "examples guidelines" are followed from our contributing doc
https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md
* [x] Make sure the linting passes by running `pnpm build && pnpm lint`.
See
https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md

---------

Co-authored-by: Sam Ko <sam@vercel.com>
  • Loading branch information
decepulis and samcx committed Mar 7, 2024
1 parent 0b679a0 commit eace44d
Show file tree
Hide file tree
Showing 42 changed files with 537 additions and 837 deletions.
50 changes: 29 additions & 21 deletions examples/with-mux-video/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

This example uses Mux Video, an API-first platform for video. The example features video uploading and playback in a Next.js application.

## Demo
## Try it out

### [https://with-mux-video.vercel.app/](https://with-mux-video.vercel.app/)

### This project was used to create [stream.new](https://stream.new/)
- [https://with-mux-video.vercel.app/](https://with-mux-video.vercel.app/)
- This project was used to create [stream.new](https://stream.new/)

## Deploy your own

Expand All @@ -16,6 +15,8 @@ Deploy the example using [Vercel](https://vercel.com/home):

## How to use

### Step 1. Create a Next app with this example

Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:

```bash
Expand All @@ -30,38 +31,32 @@ yarn create next-app --example with-mux-video with-mux-video-app
pnpm create next-app --example with-mux-video with-mux-video-app
```

## Note

**Important:** When creating uploads, this demo sets `cors_origin: "*"` in the [`pages/api/upload.js`](pages/api/upload.js) 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.

This example uses:

- [SWR](https://swr.vercel.app/) — dynamically changing the `refreshInterval` depending on if the client should be polling for updates or not
- [`/pages/api`](pages/api) routes — a couple endpoints for making authenticated requests to the Mux API.
- Dynamic routes using [`getStaticPaths` and `fallback: true`](https://nextjs.org/docs/basic-features/data-fetching/get-static-paths), as well as dynamic API routes.

## Configuration
```bash
bunx create-next-app --example with-mux-video with-mux-video-app
```

### Step 1. Create an account in Mux
### Step 2. Create an account in Mux

All you need to set this up is a [Mux account](https://mux.com). You can sign up for free and pricing is pay-as-you-go. There are no upfront charges, you get billed monthly only for what you use.
All you need to run this example is a [Mux account](https://www.mux.com?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app). 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 before you are charged.
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
### Step 3. Set up environment variables

Copy the `.env.local.example` file in this directory to `.env.local` (which will be ignored by Git):

```bash
cp .env.local.example .env.local
```

Then, go to the [settings page](https://dashboard.mux.com/settings/access-tokens) in your Mux dashboard set each variable on `.env.local`, get a new **API Access Token** and set each variable in `.env.local`:
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`

### Step 3. Deploy on Vercel
At this point, you're good to `npm run dev` or `yarn dev` or `pnpm dev`. However, if you want to deploy, read on:

### Step 4. Deploy on Vercel

You can deploy this app to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).

Expand All @@ -75,3 +70,16 @@ vercel secrets add next_example_mux_token_secret <MUX_TOKEN_SECRET>
```

Then push the project to GitHub/GitLab/Bitbucket and [import to Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) to deploy.

## Notes

### Preparing for Production

**Important:** When creating uploads, this demo sets `cors_origin: "*"` in the [`app/(upload)/page.tsx`](<app/(upload)/page.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.

### How it works

1. Users land on the home page, `app/(upload)/page.tsx`. The Mux [Direct Uploads API](https://docs.mux.com/api-reference#video/tag/direct-uploads?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app) provides an endpoint to [Mux Uploader React](https://docs.mux.com/guides/mux-uploader?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app).
1. The user uploads a video with Mux Uploader. When their upload is complete, Mux Uploader calls a [server action](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) that redirects to...
1. `app/(upload)/asset/[assetId]/page.tsx`, which polls the [Asset API](https://docs.mux.com/api-reference#video/tag/assets?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app) via server action, waiting for the asset to be ready. Once the asset is ready, it redirects to...
1. `app/v/[assetId]/page.tsx`, where users can watch their video using [Mux Player React](https://docs.mux.com/guides/mux-player-web?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app). This page uses the [Mux Image API](https://docs.mux.com/guides/get-images-from-a-video) and the [Next.js Metadata API](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) to provide an og images tailored to each video.
13 changes: 13 additions & 0 deletions examples/with-mux-video/app/(upload)/MuxUploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { type ComponentPropsWithoutRef } from "react";

import LibMuxUploader from "@mux/mux-uploader-react";

type Props = {
endpoint: ComponentPropsWithoutRef<typeof LibMuxUploader>["endpoint"];
onSuccess: () => void;
};
export default function MuxUploader({ endpoint, onSuccess }: Props) {
return <LibMuxUploader endpoint={endpoint} onSuccess={() => onSuccess()} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { useEffect, useState } from "react";

import { Status } from "./types";
import Link from "@/app/_components/Link";

const Oops = () => (
<p>
This is awkward. Let's <Link href="/">refresh</Link> and try again.
</p>
);

type Props = {
initialStatus: Status;
checkAssetStatus: () => Promise<Status>;
};
export default function AssetStatusPoll({
initialStatus,
checkAssetStatus,
}: Props) {
const [{ status, errors }, setStatus] = useState<Status>(() => initialStatus);

useEffect(() => {
const poll = async () => setStatus(await checkAssetStatus());
const interval = setInterval(poll, 1000);
return () => clearInterval(interval);
}, [checkAssetStatus]);

switch (status) {
case "preparing":
return <p className="animate-pulse">Asset is preparing...</p>;
case "errored":
return (
<div>
<p className="mb-4">Asset encountered an error.</p>
{Array.isArray(errors) && (
<ul className="mb-4">
{errors.map((error, key) => (
<li key={key}>{JSON.stringify(error)}</li>
))}
</ul>
)}
<Oops />
</div>
);
case "ready":
return (
<div>
<p className="mb-4">
Asset is ready. The app really should've redirected you to it by
now.
</p>
<Oops />
</div>
);
default:
return (
<div>
<p className="mb-4">Asset is in an unknown state.</p>
<pre className="mb-4">{JSON.stringify({ status, errors })}</pre>
<Oops />
</div>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="font-mono text-sm">{children}</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Loading() {
return <div>Checking asset status...</div>;
}
51 changes: 51 additions & 0 deletions examples/with-mux-video/app/(upload)/asset/[assetId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Mux from "@mux/mux-node";
import { redirect } from "next/navigation";
import { Status } from "./types";
import AssetStatusPoll from "./AssetStatusPoll";

// reads MUX_TOKEN_ID and MUX_TOKEN_SECRET from your environment
const mux = new Mux();

const checkAssetStatus = async (assetId: string): Promise<Status> => {
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) {
redirect(`/v/${playbackId.id}`);
}
}
}

return {
status: asset.status,
errors: asset.errors,
};
};

// For better performance, we could cache and use a Mux webhook to invalidate the cache.
// https://docs.mux.com/guides/listen-for-webhooks
// For this example, calling the Mux API on each request and then polling is sufficient.
export const dynamic = "force-dynamic";

export default async function Page({
params: { assetId },
}: {
params: { assetId: string };
}) {
const initialStatus = await checkAssetStatus(assetId);
return (
<AssetStatusPoll
initialStatus={initialStatus}
checkAssetStatus={async () => {
"use server";
return await checkAssetStatus(assetId);
}}
/>
);
}
6 changes: 6 additions & 0 deletions examples/with-mux-video/app/(upload)/asset/[assetId]/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type Mux from "@mux/mux-node";

export type Status = {
status: Mux.Video.Assets.Asset["status"];
errors: Mux.Video.Assets.Asset["errors"];
};
57 changes: 57 additions & 0 deletions examples/with-mux-video/app/(upload)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Link from "../_components/Link";
import { MUX_HOME_PAGE_URL } from "../constants";

export default function Layout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<>
<header className="mb-8">
<h1 className="font-bold text-4xl lg:text-5xl mb-2">
Welcome to Mux + Next.js
</h1>
<p className="italic">Get started by uploading a video</p>
</header>
<p className="mb-4">
<Link
href={MUX_HOME_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
>
Mux
</Link>{" "}
provides APIs for developers working with video.
<br />
This example is useful if you want to build:
</p>
<ul className="list-disc pl-8 mb-4">
<li>A video on demand service like Youtube or Netflix</li>
<li>
A platform that supports user uploaded videos like TikTok or Instagram
</li>
<li>Video into your custom CMS</li>
</ul>
<p className="mb-4">
Uploading a video uses the Mux{" "}
<Link href="https://docs.mux.com/docs/direct-upload">
direct upload API
</Link>
. When the upload is complete your video will be processed by Mux and
available for playback on a sharable URL.
</p>
<p>
To learn more,{" "}
<Link
href="https://github.com/vercel/next.js/tree/canary/examples/with-mux-video"
target="_blank"
rel="noopener noreferrer"
>
check out the source code on GitHub
</Link>
.
</p>
<hr className="my-8 bg-gray-500" />
{children}
</>
);
}
3 changes: 3 additions & 0 deletions examples/with-mux-video/app/(upload)/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Loading() {
return <div>Preparing upload...</div>;
}
60 changes: 60 additions & 0 deletions examples/with-mux-video/app/(upload)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Mux from "@mux/mux-node";
import MuxUploader from "./MuxUploader";
import { redirect } from "next/navigation";

// reads MUX_TOKEN_ID and MUX_TOKEN_SECRET from your environment
const mux = new Mux();

const createUpload = async () => {
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 upload;
};

const waitForThreeSeconds = () =>
new Promise((resolve) => setTimeout(resolve, 3000));

const redirectToAsset = async (uploadId: string) => {
let attempts = 0;
while (attempts <= 10) {
const upload = await mux.video.uploads.retrieve(uploadId);
if (upload.asset_id) {
redirect(`/asset/${upload.asset_id}`);
} else {
// 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 listen for the video.upload.asset_created webhook
// https://docs.mux.com/guides/listen-for-webhooks
// To keep things simple here,
// we'll just poll the API at an interval for a few seconds.
await waitForThreeSeconds();
attempts++;
}
}
throw new Error("No asset_id found for upload");
};

// since we want to create a new upload for each visitor,
// we disable caching
export const dynamic = "force-dynamic";

export default async function Page() {
const upload = await createUpload();

return (
<MuxUploader
onSuccess={async () => {
"use server";
await redirectToAsset(upload.id);
}}
endpoint={upload.url}
/>
);
}
14 changes: 14 additions & 0 deletions examples/with-mux-video/app/_components/Link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import LibLink from "next/link";

/**
*
* @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<typeof LibLink>) => (
<LibLink
className={`underline hover:no-underline focus-visible:no-underline text-red-600 ${className}`}
{...rest}
/>
);

export default Link;
Binary file added examples/with-mux-video/app/apple-icon.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions examples/with-mux-video/app/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MUX_HOME_PAGE_URL =
"https://www.mux.com?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app";
Binary file added examples/with-mux-video/app/favicon.ico
Binary file not shown.
3 changes: 3 additions & 0 deletions examples/with-mux-video/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
1 change: 1 addition & 0 deletions examples/with-mux-video/app/icon.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit eace44d

Please sign in to comment.