with-mux-video: move to app router and update packages (#62297)

## 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>
This commit is contained in:
Darius Cepulis 2024-03-07 16:08:49 -06:00 committed by GitHub
parent 0b679a0fed
commit eace44d129
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 537 additions and 837 deletions

View file

@ -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
@ -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
@ -30,25 +31,17 @@ 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
```bash
bunx create-next-app --example with-mux-video with-mux-video-app
```
**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.
### Step 2. Create an account in Mux
This example uses:
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.
- [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.
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.
## Configuration
### Step 1. 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.
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.
### 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):
@ -56,12 +49,14 @@ Copy the `.env.local.example` file in this directory to `.env.local` (which will
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)).
@ -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.

View file

@ -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()} />;
}

View file

@ -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>
);
}
}

View file

@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <div className="font-mono text-sm">{children}</div>;
}

View file

@ -0,0 +1,3 @@
export default function Loading() {
return <div>Checking asset status...</div>;
}

View file

@ -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);
}}
/>
);
}

View file

@ -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"];
};

View file

@ -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}
</>
);
}

View file

@ -0,0 +1,3 @@
export default function Loading() {
return <div>Preparing upload...</div>;
}

View file

@ -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}
/>
);
}

View file

@ -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 not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -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 not shown.

After

Width:  |  Height:  |  Size: 625 B

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#FA50B5" fill-rule="evenodd" d="M16 0H0v16h16V0Zm-4.56 2.439A1.5 1.5 0 0 1 14 3.499V12.5a1.5 1.5 0 0 1-3 0V7.12L9.06 9.06a1.5 1.5 0 0 1-2.12 0L5 7.12v5.38a1.5 1.5 0 0 1-3 0v-9a1.5 1.5 0 0 1 2.56-1.061L8 5.879l3.44-3.44Zm.316 10.061a.744.744 0 1 0 1.488 0 .744.744 0 0 0-1.488 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 414 B

View file

@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { DM_Sans, JetBrains_Mono } from "next/font/google";
import Image from "next/image";
import { MUX_HOME_PAGE_URL } from "./constants";
import MuxLogo from "./mux.svg";
import "./globals.css";
const dmSans = DM_Sans({ subsets: ["latin"], variable: "--sans" });
const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--mono",
});
const title = "Mux + Next.js";
const description = "Upload and view a video with Mux and Next.js";
export const metadata: Metadata = {
metadataBase: process.env.VERCEL_URL
? new URL(`https://${process.env.VERCEL_URL}`)
: new URL(`http://localhost:${process.env.PORT || 3000}`),
title,
description,
openGraph: {
title,
description,
},
twitter: {
title,
description,
card: "summary_large_image",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`min-h-screen flex flex-col font-sans ${dmSans.variable} ${jetBrainsMono.variable}`}
>
<main className="px-4 py-8 sm:px-8 lg:py-16 w-full max-w-2xl mx-auto">
{children}
</main>
<footer className="mt-auto w-full h-24 border-t border-gray-500 flex items-center justify-center">
Powered by
<a href={MUX_HOME_PAGE_URL} target="_blank" rel="noopener noreferrer">
<Image src={MuxLogo} alt="Mux" className="ml-2 w-16" />
</a>
</footer>
</body>
</html>
);
}

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,3 @@
"use client";
export { default } from "@mux/mux-player-react";

View file

@ -0,0 +1,122 @@
import Link from "@/app/_components/Link";
import MuxPlayer from "./MuxPlayer";
import { MUX_HOME_PAGE_URL } from "@/app/constants";
import Script from "next/script";
type Params = {
playbackId: string;
};
const title = "View this video created with Mux + Next.js";
export const generateMetadata = ({ params }: { params: Params }) => ({
title,
description: undefined,
openGraph: {
title,
images: [
{
url: `https://image.mux.com/${params.playbackId}/thumbnail.png?width=1200&height=630&fit_mode=pad`,
width: 1200,
height: 630,
},
],
},
twitter: {
title,
card: "summary_large_image",
images: [
{
url: `https://image.mux.com/${params.playbackId}/thumbnail.png?width=1200&height=600&fit_mode=pad`,
width: 1200,
height: 600,
},
],
},
});
const Code = ({ children }: { children: React.ReactNode }) => (
<code className="text-mono text-sm bg-red-500/10 px-1 py-0.5 rounded-sm">
{children}
</code>
);
// this function communicates with no external services and relies on no Node APIs
// it's perfect for the edge
export const runtime = "edge";
export default function Page({ params: { playbackId } }: { params: Params }) {
return (
<>
<div className="px-8 py-4 mb-8 text-center bg-green-500/30 rounded-full">
This video is ready for playback
</div>
<div className="bg-black aspect-video mb-8 -mx-4 flex">
<MuxPlayer
className="w-full"
playbackId={playbackId}
metadata={{ player_name: "with-mux-video" }}
accentColor="rgb(220 38 38)"
/>
</div>
<p>
Go <Link href="/">back home</Link> to upload another video.
</p>
<hr className="my-8 bg-gray-500" />
<p className="mb-4">
This video was uploaded and processed by{" "}
<Link
href={MUX_HOME_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
>
Mux
</Link>
. This page was rendered with{" "}
<Link
href="https://nextjs.org/"
target="_blank"
rel="noopener noreferrer"
>
Next.js
</Link>
.
</p>
<p className="mb-4">
Thanks to the Next.js{" "}
<Link href="https://nextjs.org/docs/app/building-your-application/optimizing/metadata">
Metadata API
</Link>
, this page is easily sharable on social and has an{" "}
<Code>og:image</Code> thumbnail generated by Mux. Try clicking the
Twitter button below to share:
</p>
<p className="mb-4 min-h-7">
<Link
className="twitter-share-button"
data-size="large"
target="_blank"
rel="noopener noreferrer"
href="https://twitter.com/intent/tweet?text=Check%20out%20the%20video%20I%20uploaded%20with%20Next.js%2C%20%40Vercel%2C%20and%20%40muxhq%20"
>
Tweet this
</Link>
</p>
<Script
strategy="lazyOnload"
async
src="https://platform.twitter.com/widgets.js"
/>
<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>
</>
);
}

View file

@ -1,34 +0,0 @@
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
children: React.ReactNode;
};
export default function Button({ children, ...otherProps }: ButtonProps) {
return (
<>
<button {...otherProps}>{children}</button>
<style jsx>{`
button {
padding: 16px 16px;
border-radius: 4px;
font-size: 22px;
background-image: linear-gradient(
to right,
rgb(255, 61, 48),
rgb(255, 43, 97)
);
color: white;
line-height: 22px;
border: none;
cursor: pointer;
}
button:hover {
background-image: linear-gradient(
to left,
rgb(253, 95, 85),
rgb(255, 85, 128)
);
}
`}</style>
</>
);
}

View file

@ -1,20 +0,0 @@
interface ErrorMessageProps {
message?: string;
}
export default function ErrorMessage({ message }: ErrorMessageProps) {
return (
<>
<div className="message">{message || "Unknown error"}</div>
<style jsx>{`
.message {
color: #650303;
background-color: #ffb2b2;
padding: 10px 6px;
text-align: center;
border-radius: 4px;
}
`}</style>
</>
);
}

View file

@ -1,195 +0,0 @@
import Head from "next/head";
import { MUX_HOME_PAGE_URL } from "../constants";
interface LayoutProps {
title?: string;
description?: string;
metaTitle?: string;
metaDescription?: string;
image?: string;
children: React.ReactNode;
loadTwitterWidget?: boolean;
}
export default function Layout({
title,
description,
metaTitle = "Mux + Next.js",
metaDescription,
image = "https://with-mux-video.vercel.app/mux-nextjs-og-image.png",
children,
loadTwitterWidget,
}: LayoutProps) {
return (
<div className="container">
<Head>
<title>Mux + Next.js</title>
<link rel="icon" href="/favicon.ico" />
{metaTitle && <meta property="og:title" content={metaTitle} />}
{metaTitle && <meta property="twitter:title" content={metaTitle} />}
{metaDescription && (
<meta property="og:description" content={description} />
)}
{metaDescription && (
<meta property="twitter:description" content={description} />
)}
{image && <meta property="og:image" content={image} />}
{image && (
<meta property="twitter:card" content="summary_large_image" />
)}
{image && <meta property="twitter:image" content={image} />}
{loadTwitterWidget && (
<script
type="text/javascript"
async
src="https://platform.twitter.com/widgets.js"
></script>
)}
</Head>
<main>
<h1 className="title">{title}</h1>
<p className="description">{description}</p>
<div className="grid">{children}</div>
</main>
<footer>
<a href={MUX_HOME_PAGE_URL} target="_blank" rel="noopener noreferrer">
Powered by <img src="/mux.svg" alt="Mux Logo" className="logo" />
</a>
</footer>
<style jsx>{`
.container {
min-height: 100vh;
min-height: -webkit-fill-available;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
main {
padding: 1rem 0 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
footer img {
margin-left: 0.5rem;
width: 71px;
}
footer a {
display: flex;
justify-content: center;
align-items: center;
}
a {
color: inherit;
text-decoration: none;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono,
DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 1rem;
}
.logo {
height: 1em;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
.title {
font-size: 2.5rem;
}
footer {
height: 60px;
}
}
`}</style>
<style jsx global>{`
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
}
a {
color: #ff2b61;
}
p {
line-height: 1.4rem;
}
* {
box-sizing: border-box;
}
`}</style>
</div>
);
}

View file

@ -1,55 +0,0 @@
interface SpinnerProps {
size?: Number;
color?: string;
}
export default function Spinner({ size = 6, color = "#999" }: SpinnerProps) {
return (
<>
<div className="spinner" />
<style jsx>{`
.spinner,
.spinner:after {
border-radius: 50%;
width: 10em;
height: 10em;
}
.spinner {
margin: 60px auto;
font-size: ${size}px;
position: relative;
text-indent: -9999em;
border-top: 1.1em solid ${color};
border-right: 1.1em solid ${color};
border-bottom: 1.1em solid ${color};
border-left: 1.1em solid transparent;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: load8 1.1s infinite linear;
animation: load8 1.1s infinite linear;
}
@-webkit-keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load8 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
`}</style>
</>
);
}

View file

@ -1,115 +0,0 @@
import { useEffect, useRef, useState } from "react";
import Router from "next/router";
import * as UpChunk from "@mux/upchunk";
import useSwr from "swr";
import Button from "./button";
import Spinner from "./spinner";
import ErrorMessage from "./error-message";
const fetcher = (url: string) => {
return fetch(url).then((res) => res.json());
};
const UploadForm = () => {
const [isUploading, setIsUploading] = useState(false);
const [isPreparing, setIsPreparing] = useState(false);
const [uploadId, setUploadId] = useState(null);
const [progress, setProgress] = useState<Number | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { data, error } = useSwr(
() => (isPreparing ? `/api/upload/${uploadId}` : null),
fetcher,
{ refreshInterval: 5000 },
);
const upload = data && data.upload;
useEffect(() => {
if (upload && upload.asset_id) {
Router.push({
pathname: `/asset/${upload.asset_id}`,
});
}
}, [upload]);
if (error) return <ErrorMessage message="Error fetching api" />;
if (data && data.error) return <ErrorMessage message={data.error} />;
const createUpload = async () => {
try {
return fetch("/api/upload", {
method: "POST",
})
.then((res) => res.json())
.then(({ id, url }) => {
setUploadId(id);
return url;
});
} catch (e) {
console.error("Error in createUpload", e);
setErrorMessage("Error creating upload");
}
};
const startUpload = () => {
setIsUploading(true);
const files = inputRef.current?.files;
if (!files) {
setErrorMessage("An unexpected issue occurred");
return;
}
const upload = UpChunk.createUpload({
endpoint: createUpload,
file: files[0],
});
upload.on("error", (err: any) => {
setErrorMessage(err.detail.message);
});
upload.on("progress", (progress: any) => {
setProgress(Math.floor(progress.detail));
});
upload.on("success", () => {
setIsPreparing(true);
});
};
if (errorMessage) return <ErrorMessage message={errorMessage} />;
return (
<>
<div className="container">
{isUploading ? (
<>
{isPreparing ? (
<div>Preparing..</div>
) : (
<div>Uploading...{progress ? `${progress}%` : ""}</div>
)}
<Spinner />
</>
) : (
<label>
<Button type="button" onClick={() => inputRef.current?.click()}>
Select a video file
</Button>
<input type="file" onChange={startUpload} ref={inputRef} />
</label>
)}
</div>
<style jsx>{`
input {
display: none;
}
`}</style>
</>
);
};
export default UploadForm;

View file

@ -1,72 +0,0 @@
import Layout from "./layout";
import { MUX_HOME_PAGE_URL } from "../constants";
interface UploadPageProps {
children: React.ReactNode;
}
export default function UploadPage({ children }: UploadPageProps) {
return (
<Layout
title="Welcome to Mux + Next.js"
description="Get started by uploading a video"
>
<div className="wrapper">
<div className="about-mux">
<p>
<a
href={MUX_HOME_PAGE_URL}
target="_blank"
rel="noopener noreferrer"
>
Mux
</a>{" "}
provides APIs for developers working with video. This example is
useful if you want to build:
</p>
<ul>
<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>
Uploading a video uses the Mux{" "}
<a href="https://docs.mux.com/docs/direct-upload">
direct upload API
</a>
. 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,{" "}
<a
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
</a>
.
</p>
</div>
<div className="children">{children}</div>
</div>
<style jsx>{`
.about-mux {
padding: 0 1rem 1.5rem 1rem;
max-width: 600px;
}
.about-mux {
line-height: 1.4rem;
}
.children {
text-align: center;
min-height: 230px;
}
`}</style>
</Layout>
);
}

View file

@ -1,2 +0,0 @@
export const MUX_HOME_PAGE_URL =
"https://mux.com?utm_source=create-next-app&utm_medium=with-mux-video&utm_campaign=create-next-app";

View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

View file

@ -6,18 +6,21 @@
"start": "next start"
},
"dependencies": {
"@mux/mux-node": "^7.0.0",
"@mux/mux-player-react": "^1",
"@mux/upchunk": "^3",
"@mux/mux-node": "^8",
"@mux/mux-player-react": "^2",
"@mux/mux-uploader-react": "^1.0.0-beta.15",
"next": "latest",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^18",
"react-dom": "^18",
"swr": "^2.0.0"
},
"devDependencies": {
"@types/node": "^18.11.10",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"typescript": "^4.9.3"
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5",
"autoprefixer": "^10",
"postcss": "^8",
"tailwindcss": "^3.4"
}
}

View file

@ -1,32 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Mux from "@mux/mux-node";
const { Video } = new Mux();
export default async function assetHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { method } = req;
switch (method) {
case "GET":
try {
const asset = await Video.Assets.get(req.query.id as string);
res.json({
asset: {
id: asset.id,
status: asset.status,
errors: asset.errors,
playback_id: asset.playback_ids![0].id,
},
});
} catch (e) {
console.error("Request error", e);
res.status(500).json({ error: "Error getting upload/asset" });
}
break;
default:
res.setHeader("Allow", ["GET"]);
res.status(405).end(`Method ${method} Not Allowed`);
}
}

View file

@ -1,31 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Mux from "@mux/mux-node";
const { Video } = new Mux();
export default async function uploadHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { method } = req;
switch (method) {
case "POST":
try {
const upload = await Video.Uploads.create({
new_asset_settings: { playback_policy: "public" },
cors_origin: "*",
});
res.json({
id: upload.id,
url: upload.url,
});
} catch (e) {
console.error("Request error", e);
res.status(500).json({ error: "Error creating upload" });
}
break;
default:
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${method} Not Allowed`);
}
}

View file

@ -1,31 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import Mux from "@mux/mux-node";
const { Video } = new Mux();
export default async function uploadHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
const { method } = req;
switch (method) {
case "GET":
try {
const upload = await Video.Uploads.get(req.query.id as string);
res.json({
upload: {
status: upload.status,
url: upload.url,
asset_id: upload.asset_id,
},
});
} catch (e) {
console.error("Request error", e);
res.status(500).json({ error: "Error getting upload/asset" });
}
break;
default:
res.setHeader("Allow", ["GET"]);
res.status(405).end(`Method ${method} Not Allowed`);
}
}

View file

@ -1,62 +0,0 @@
import { useEffect } from "react";
import Router, { useRouter } from "next/router";
import Link from "next/link";
import useSwr from "swr";
import Spinner from "../../components/spinner";
import ErrorMessage from "../../components/error-message";
import UploadPage from "../../components/upload-page";
const fetcher = (url: string) => {
return fetch(url).then((res) => res.json());
};
export default function Asset() {
const router = useRouter();
const { data, error } = useSwr(
() => (router.query.id ? `/api/asset/${router.query.id}` : null),
fetcher,
{ refreshInterval: 5000 },
);
const asset = data && data.asset;
useEffect(() => {
if (asset && asset.playback_id && asset.status === "ready") {
Router.push(`/v/${asset.playback_id}`);
}
}, [asset]);
let errorMessage: string = "";
if (error) {
errorMessage = "Error fetching api";
}
if (data && data.error) {
errorMessage = data.error;
}
if (asset && asset.status === "errored") {
const message = asset.errors && asset.errors.messages[0];
errorMessage = `Error creating this asset: ${message}`;
}
return (
<UploadPage>
{errorMessage ? (
<>
<ErrorMessage message={errorMessage} />
<p>
Go <Link href="/">back home</Link> to upload another video.
</p>
</>
) : (
<>
<div>Preparing...</div>
<Spinner />
</>
)}
</UploadPage>
);
}

View file

@ -1,10 +0,0 @@
import UploadForm from "../components/upload-form";
import UploadPage from "../components/upload-page";
export default function Home() {
return (
<UploadPage>
<UploadForm />
</UploadPage>
);
}

View file

@ -1,144 +0,0 @@
import type {
InferGetStaticPropsType,
GetStaticProps,
GetStaticPaths,
} from "next";
import MuxPlayer from "@mux/mux-player-react";
import Link from "next/link";
import Layout from "../../components/layout";
import Spinner from "../../components/spinner";
import { MUX_HOME_PAGE_URL } from "../../constants";
import { useRouter } from "next/router";
type Params = {
id?: string;
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const { id: playbackId } = params as Params;
const poster = `https://image.mux.com/${playbackId}/thumbnail.png`;
return { props: { playbackId, poster } };
};
export const getStaticPaths: GetStaticPaths = () => {
return {
paths: [],
fallback: true,
};
};
type CodeProps = {
children: React.ReactNode;
};
const Code = ({ children }: CodeProps) => (
<>
<span className="code">{children}</span>
<style jsx>{`
.code {
font-family: Menlo, Monaco, Lucida Console, Liberation Mono,
DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace,
serif;
color: #ff2b61;
}
`}</style>
</>
);
export default function Playback({
playbackId,
poster,
}: InferGetStaticPropsType<typeof getStaticProps>) {
const router = useRouter();
if (router.isFallback) {
return (
<Layout>
<Spinner />
</Layout>
);
}
return (
<Layout
metaTitle="View this video created with Mux + Next.js"
image={poster}
loadTwitterWidget
>
<div className="flash-message">This video is ready for playback</div>
<MuxPlayer
style={{ width: "100%" }}
playbackId={playbackId}
metadata={{ player_name: "with-mux-video" }}
/>
<p>
Go{" "}
<Link href="/" legacyBehavior>
<a>back home</a>
</Link>{" "}
to upload another video.
</p>
<div className="about-playback">
<p>
This video was uploaded and processed by{" "}
<a href={MUX_HOME_PAGE_URL} target="_blank" rel="noopener noreferrer">
Mux
</a>
. This page was pre-rendered with{" "}
<a
href="https://nextjs.org/"
target="_blank"
rel="noopener noreferrer"
>
Next.js
</a>{" "}
using <Code>`getStaticPaths`</Code> and <Code>`getStaticProps`</Code>.
</p>
<p>
Thanks to pre-rendering this page is easily sharable on social and has
an <Code>`og:image`</Code> thumbnail generated by Mux. Try clicking
the Twitter button below to share:
</p>
<div className="share-button">
<a
className="twitter-share-button"
data-size="large"
target="_blank"
rel="noopener noreferrer"
href={`https://twitter.com/intent/tweet?text=Check%20out%20the%20video%20I%20uploaded%20with%20Next.js%2C%20%40Vercel%2C%20and%20%40muxhq%20`}
>
Tweet this
</a>
</div>
<p>
To learn more,{" "}
<a
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
</a>
.
</p>
</div>
<style jsx>{`
.flash-message {
position: absolute;
top: 0;
background-color: #c1dcc1;
width: 100%;
text-align: center;
padding: 20px 0;
}
.share-button {
display: flex;
align-items: center;
justify-content: center;
margin: 40px 0;
}
`}</style>
</Layout>
);
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

View file

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
fontFamily: {
sans: ["var(--sans)", "sans-serif"],
mono: ["var(--mono)", "monospace"],
},
},
plugins: [],
};

View file

@ -1,20 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}