Compare commits

...

2 Commits

Author SHA1 Message Date
993355da12 feat(client): add HLS video player with playback controls
Add support for streaming media using HLS (HTTP Live Streaming) with a custom player component featuring controls for play/pause, seeking, volume, subtitles, and fullscreen. Includes codec detection, playback session management, error and loading states, and integration with title details page for seamless navigation.
2025-11-03 07:36:58 +00:00
4b4e6c3ead chore(config): disable experimental TypeScript TSGO in VS Code settings 2025-11-03 07:35:32 +00:00
12 changed files with 370 additions and 13 deletions

View File

@@ -1,7 +1,7 @@
{
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"typescript.experimental.useTsgo": true,
"typescript.experimental.useTsgo": false,
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "always",
"source.fixAll.biome": "always",

View File

@@ -33,9 +33,11 @@
"hono": "^4.10.3",
"lodash": "^4.17.21",
"lucide-react": "^0.548.0",
"media-chrome": "^4.15.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-player": "^3.3.3",
"react-router": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",

View File

@@ -1,11 +1,11 @@
import {
Bookmark,
Calendar,
Clock,
Film,
Play,
Share2,
Star,
Bookmark,
Calendar,
Clock,
Film,
Play,
Share2,
Star,
} from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
@@ -41,12 +41,16 @@ export default function TitleDetails({ data }: TitleDetailsProps) {
};
const handlePlay = () => {
const searchParams = new URLSearchParams();
if (selectedTracks.audio !== undefined) {
searchParams.append("asi", selectedTracks.audio.toString());
}
if (selectedTracks.subtitle !== undefined) {
searchParams.append("ssi", selectedTracks.subtitle.toString());
}
navigate({
pathname: `/watch/${data.id}`,
search: new URLSearchParams({
asi: selectedTracks.audio?.toString() || "",
ssi: selectedTracks.subtitle?.toString() || "",
}).toString(),
search: searchParams.toString(),
});
};

View File

@@ -0,0 +1,97 @@
import {
MediaCaptionsButton,
MediaControlBar,
MediaController,
MediaFullscreenButton,
MediaMuteButton,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaTimeDisplay,
MediaTimeRange,
MediaVolumeRange,
} from "media-chrome/react";
import { useMemo } from "react";
import ReactPlayer from "react-player";
import { PlayerErrorState } from "@/components/player/states/PlayerErrorState";
import { PlayerLoadingState } from "@/components/player/states/PlayerLoadingState";
import { detectCodecSupport } from "@/utils/codec-detection";
import { usePlaybackSession } from "./hooks/usePlaybackSession";
type SelectedTracks = {
audio?: number;
subtitle?: number;
};
interface HLSPlayerProps {
titleId: string;
episodeId?: string;
selectedTracks?: SelectedTracks;
}
export function HLSPlayer({
titleId,
episodeId,
selectedTracks,
}: HLSPlayerProps) {
const supportedCodecs = useMemo(() => detectCodecSupport(), []);
const { isLoading, isError, data, error } = usePlaybackSession({
titleId,
episodeId,
supportedCodecs,
selectedTracks,
});
if (isLoading) {
return <PlayerLoadingState />;
}
if (isError) {
return <PlayerErrorState error={new Error(error.message)} />;
}
if (!data) {
return <PlayerLoadingState />;
}
return (
<MediaController
style={{
width: "100%",
aspectRatio: "16/9",
}}
>
<ReactPlayer
slot="media"
src={data.playlistUrl}
config={{
hls: {
enableWorker: true,
startPosition: 0,
autoStartLoad: true,
},
}}
playing
playsInline
controls={false}
style={{
width: "100%",
height: "100%",
// "--controls": "none",
}}
/>
<MediaControlBar>
<MediaPlayButton />
<MediaSeekBackwardButton seekOffset={10} />
<MediaSeekForwardButton seekOffset={10} />
<MediaTimeRange />
<MediaTimeDisplay showDuration />
<MediaMuteButton />
<MediaVolumeRange />
<MediaCaptionsButton />
<MediaFullscreenButton />
</MediaControlBar>
</MediaController>
);
}

View File

@@ -0,0 +1,95 @@
import type { Models } from "@nontara/server/models";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useEffectEvent, useRef } from "react";
import { toast } from "sonner";
import { http } from "@/lib/api";
import type { AudioCodec, VideoCodec } from "@/utils/codec-detection";
interface UsePlaybackSessionOptions {
titleId?: string;
episodeId?: string;
supportedCodecs: {
video: VideoCodec[];
audio: AudioCodec[];
};
selectedTracks?: {
audio?: number;
subtitle?: number;
};
maxResolution?: "480p" | "720p" | "1080p" | "2160p";
}
export function usePlaybackSession(options: UsePlaybackSessionOptions) {
const sessionId = useRef<string>(
sessionStorage.getItem("activePlaybackSessionId"),
);
const { mutate, isError, data, error, isPending } = useMutation({
mutationFn: async (variables: Models.API.PlaybackCreateInput) => {
const response = await http.post<Models.API.PlaybackCreateOutput>(
"/playback/create",
variables,
);
return response.data;
},
onError: (error) => {
toast.error(`Failed to create playback session: ${error.message}`);
},
onSuccess: (data) => {
sessionId.current = data.sessionId;
sessionStorage.setItem("activePlaybackSessionId", data.sessionId);
},
});
const { mutate: mutateStopSession } = useMutation({
mutationFn: async (variables: Models.API.PlaybackStopInput) => {
const response = await http.delete<Models.API.PlaybackStopOutput>(
"playback/stop",
{
params: variables,
},
);
return response.data;
},
});
const onCreateSession = useEffectEvent(() => {
mutate({
titleId: options.titleId,
episodeId: options.episodeId,
supportedVideoCodecs: options.supportedCodecs.video,
supportedAudioCodecs: options.supportedCodecs.audio,
subtitleStreamIndex: options.selectedTracks?.subtitle,
maxResolution: options.maxResolution || "1080p",
burnInSubtitle: false,
});
});
const onDisposed = useEffectEvent(() => {
if (!sessionId.current) return;
mutateStopSession({
sessionId: sessionId.current,
});
sessionStorage.removeItem("activePlaybackSessionId");
});
useEffect(() => {
if (sessionId.current) return;
onCreateSession();
return () => {
onDisposed();
};
}, []);
if (isError) {
return {
isLoading: isPending,
isError: isError,
error: error,
};
}
return {
isLoading: isPending,
isError: isError,
error: error,
data: data,
};
}

View File

@@ -0,0 +1,33 @@
import { useNavigate } from "@tanstack/react-router";
import { AlertCircle, ArrowLeft } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
interface PlayerErrorStateProps {
error: Error;
}
export function PlayerErrorState({ error }: PlayerErrorStateProps) {
const navigate = useNavigate();
return (
<div className="flex h-screen w-full items-center justify-center bg-black p-4">
<div className="w-full max-w-md space-y-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Playback Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: "/home" })}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react";
export function PlayerLoadingState() {
return (
<div className="flex h-screen w-full items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">Preparing playback...</p>
</div>
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { OnboardLanguagePage } from "./routes/onboard/language.tsx";
import { OnboardLayout } from "./routes/onboard/layout.tsx";
import { OnboardSetupPage } from "./routes/onboard/setup.tsx";
import { SignupPage } from "./routes/signup.tsx";
import WatchTitlePage from "./routes/watch/id.tsx";
const root = createRoot(document.getElementById("root")!);
@@ -35,6 +36,7 @@ root.render(
<Route index element={<HomeIndexPage />} />
<Route path="details/:id" element={<TitleDetailsPage />} />
</Route>
<Route path="watch/:id" element={<WatchTitlePage />} />
</Route>
</Routes>
</BrowserRouter>,

View File

@@ -0,0 +1,41 @@
import { useMemo } from "react";
import { useLocation, useParams } from "react-router";
import { z } from "zod";
import { HLSPlayer } from "@/components/player/HLSPlayer";
const watchSearchSchema = z.object({
asi: z.coerce.number(),
ssi: z.coerce.number().optional(), // subtitleStreamIndex
});
export default function WatchTitlePage() {
const params = useParams<{ id: string }>();
const { search } = useLocation();
const parsed = useMemo(() => {
const searchParams = new URLSearchParams(search);
return watchSearchSchema.safeParse(Object.fromEntries(searchParams));
}, [search]);
if (!params.id) {
return <div>Title ID is missing.</div>;
}
if (!parsed.success) {
return <div>Invalid playback parameters.</div>;
}
console.log("Rendering watch page with search params:", parsed.data);
return (
<div className="min-h-screen w-full bg-black">
<HLSPlayer
titleId={params.id}
selectedTracks={{
audio: parsed.data.asi,
subtitle: parsed.data.ssi,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,65 @@
export type VideoCodec = "H264" | "H265" | "VP9" | "AV1";
export type AudioCodec = "AAC" | "MP3" | "OPUS" | "VORBIS";
export interface CodecSupport {
video: VideoCodec[];
audio: AudioCodec[];
}
export function detectCodecSupport(): CodecSupport {
const video = document.createElement("video");
const videoCodecs: VideoCodec[] = [];
const audioCodecs: AudioCodec[] = [];
// Test video codecs
if (video.canPlayType('video/mp4; codecs="avc1.42E01E"') !== "") {
videoCodecs.push("H264");
}
if (video.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"') !== "") {
videoCodecs.push("H265");
}
if (video.canPlayType('video/webm; codecs="vp9"') !== "") {
videoCodecs.push("VP9");
}
if (video.canPlayType('video/mp4; codecs="av01.0.04M.08"') !== "") {
videoCodecs.push("AV1");
}
// Fallback: if no codecs detected, add H264 (most compatible)
if (videoCodecs.length === 0) {
videoCodecs.push("H264");
}
// Test audio codecs
if (video.canPlayType('audio/mp4; codecs="mp4a.40.2"') !== "") {
audioCodecs.push("AAC");
}
if (video.canPlayType("audio/mpeg") !== "") {
audioCodecs.push("MP3");
}
if (video.canPlayType('audio/webm; codecs="opus"') !== "") {
audioCodecs.push("OPUS");
}
if (video.canPlayType('audio/ogg; codecs="vorbis"') !== "") {
audioCodecs.push("VORBIS");
}
// Fallback: if no audio codecs detected, add AAC (most compatible)
if (audioCodecs.length === 0) {
audioCodecs.push("AAC");
}
return { video: videoCodecs, audio: audioCodecs };
}
export function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${minutes}:${secs.toString().padStart(2, "0")}`;
}

View File

@@ -10,6 +10,7 @@ import type { HonoEnv } from "./lib/types";
import { LibraryRouter } from "./routes/library";
import { MediaRouter } from "./routes/media";
import { OnboardRouter } from "./routes/onboard";
import { PlaybackRouter } from "./routes/playback";
import { TitleRouter } from "./routes/title";
import { createServiceContainer } from "./services/setup";
import { formatBytes } from "./utils/formatter";
@@ -54,6 +55,7 @@ const app = factory
.route("/title", TitleRouter)
.route("/media", MediaRouter)
.route("/library", LibraryRouter)
.route("/playback", PlaybackRouter)
.get("/", (c) => {
return c.text("OK");

View File

@@ -35,9 +35,11 @@
"hono": "^4.10.3",
"lodash": "^4.17.21",
"lucide-react": "^0.548.0",
"media-chrome": "^4.15.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-player": "^3.3.3",
"react-router": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
@@ -1427,7 +1429,7 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-chrome": ["media-chrome@4.15.0", "", { "dependencies": { "ce-la-react": "^0.3.0" } }, "sha512-OgC6m3Ss4cCUEVhvmRdUbnExqVKf8CDRDmbVTjIuRGAXdmz/vc34fL3onSsOm2uDZbhDB4Lb4KArGA48wG8Puw=="],
"media-chrome": ["media-chrome@4.15.1", "", { "dependencies": { "ce-la-react": "^0.3.0" } }, "sha512-Hxqr0qQ67ewmRaLJBqe5ayu53txFX+DODb9xBSHgTbw7j+gITGZ4llbPPEmqMlDnatw7IsF+AUh9rJYbpnn4ZQ=="],
"media-tracks": ["media-tracks@0.3.3", "", {}, "sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w=="],
@@ -1957,6 +1959,8 @@
"web/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"web/media-chrome": ["media-chrome@4.15.0", "", { "dependencies": { "ce-la-react": "^0.3.0" } }, "sha512-OgC6m3Ss4cCUEVhvmRdUbnExqVKf8CDRDmbVTjIuRGAXdmz/vc34fL3onSsOm2uDZbhDB4Lb4KArGA48wG8Puw=="],
"xmlbuilder2/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"@better-auth/cli/better-auth/@better-auth/core": ["@better-auth/core@1.3.26", "", { "dependencies": { "better-call": "1.0.19", "zod": "^4.1.5" } }, "sha512-S5ooXaOcn9eLV3/JayfbMsAB5PkfoTRaRrtpb5djwvI/UAJOgLyjqhd+rObsBycovQ/nPQvMKjzyM/G1oBKngA=="],