Template
feat: 完善课程模板通用能力
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1e02c7835e
commit
a641d0e2ea
70 files changed
+2868
-205
No files matched your search
+16
-10
@@ -1,10 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ToastProvider, ToastViewport } from "@/components/ui/toast";
|
||||
import { CourseCanvas, useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { OrientationGuard } from "@/course-runtime/orientation";
|
||||
import {
|
||||
CourseCanvas,
|
||||
registerAppPortalRoot,
|
||||
useCourseCanvas,
|
||||
} from "@/course/canvas";
|
||||
import { OrientationGuard } from "@/course/orientation";
|
||||
import { GameAudioProvider } from "@/utils/audio";
|
||||
import { Navigation } from "./components/business";
|
||||
import { registerAppPortalRoot } from "./components/common/canvasPortal";
|
||||
|
||||
function CourseShell() {
|
||||
const { portalContainer } = useCourseCanvas();
|
||||
@@ -12,13 +16,15 @@ function CourseShell() {
|
||||
useEffect(() => registerAppPortalRoot(portalContainer), [portalContainer]);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Navigation />
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<ToastViewport container={portalContainer} />
|
||||
</ToastProvider>
|
||||
<GameAudioProvider>
|
||||
<ToastProvider>
|
||||
<Navigation />
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<ToastViewport container={portalContainer} />
|
||||
</ToastProvider>
|
||||
</GameAudioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
.container {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply relative h-full z-10;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getStaticImage } from "@/utils/assets";
|
||||
import styles from "./BackgroundContainer.module.css";
|
||||
import type { BackgroundContainerProps } from "./BackgroundContainer.types";
|
||||
|
||||
const DIRECT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/|\.\.?\/)/i;
|
||||
|
||||
function resolveBackgroundImage(src: string): string {
|
||||
return DIRECT_URL_PATTERN.test(src) ? src : getStaticImage(src) || src;
|
||||
}
|
||||
|
||||
export function BackgroundContainer({
|
||||
src,
|
||||
fit = "cover",
|
||||
position = "center",
|
||||
repeat = "no-repeat",
|
||||
as: Component = "div",
|
||||
className,
|
||||
contentClassName,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: BackgroundContainerProps) {
|
||||
const imageSrc = resolveBackgroundImage(src);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(styles.container, className)}
|
||||
style={{
|
||||
backgroundImage: `url(${JSON.stringify(imageSrc)})`,
|
||||
backgroundSize: fit,
|
||||
backgroundPosition: position,
|
||||
backgroundRepeat: repeat,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn(styles.content, contentClassName)}>{children}</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type BackgroundContainerElement = "div" | "section" | "main" | "article";
|
||||
export type BackgroundContainerFit = "cover" | "contain";
|
||||
export type BackgroundContainerRepeat =
|
||||
| "no-repeat"
|
||||
| "repeat"
|
||||
| "repeat-x"
|
||||
| "repeat-y";
|
||||
|
||||
export interface BackgroundContainerProps
|
||||
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
|
||||
/** 静态资源 key(相对 src/assets/images/)或可直接使用的图片 URL */
|
||||
src: string;
|
||||
fit?: BackgroundContainerFit;
|
||||
position?: string;
|
||||
repeat?: BackgroundContainerRepeat;
|
||||
as?: BackgroundContainerElement;
|
||||
contentClassName?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { BackgroundContainer } from "./BackgroundContainer";
|
||||
export type {
|
||||
BackgroundContainerElement,
|
||||
BackgroundContainerFit,
|
||||
BackgroundContainerProps,
|
||||
BackgroundContainerRepeat,
|
||||
} from "./BackgroundContainer.types";
|
||||
@@ -1,21 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type GuideDialogProps,
|
||||
GuideDialog as RegistryGuideDialog,
|
||||
} from "@/components/course-interactions/guide-dialog";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { GuideDialog as RegistryGuideDialog } from "@/course/interactions/guide-dialog";
|
||||
import { useDialogStore } from "@/stores";
|
||||
import { getAppPortalRoot } from "../canvasPortal";
|
||||
import type { GuideDialogProps } from "./GuideDialog.types";
|
||||
|
||||
export function GuideDialog({
|
||||
children,
|
||||
onFinish,
|
||||
onSkip,
|
||||
...props
|
||||
}: Omit<GuideDialogProps, "actions" | "state" | "viewport"> & {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}) {
|
||||
}: GuideDialogProps) {
|
||||
const {
|
||||
open,
|
||||
steps,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { GuideDialogProps as RegistryGuideDialogProps } from "@/course/interactions/guide-dialog";
|
||||
|
||||
export interface GuideDialogProps
|
||||
extends Omit<RegistryGuideDialogProps, "actions" | "state" | "viewport"> {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
GuideDialogProps,
|
||||
GuideDialogRenderState,
|
||||
GuideDialogTheme,
|
||||
} from "@/components/course-interactions/guide-dialog";
|
||||
} from "@/course/interactions/guide-dialog";
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type GuideSpotlightProps,
|
||||
GuideSpotlight as RegistryGuideSpotlight,
|
||||
} from "@/components/course-interactions/guide-spotlight";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { GuideSpotlight as RegistryGuideSpotlight } from "@/course/interactions/guide-spotlight";
|
||||
import { useSpotlightStore } from "@/stores";
|
||||
import { getAppPortalRoot } from "../canvasPortal";
|
||||
import type { GuideSpotlightProps } from "./GuideSpotlight.types";
|
||||
|
||||
export function GuideSpotlight({
|
||||
children,
|
||||
@@ -14,10 +11,7 @@ export function GuideSpotlight({
|
||||
container = getAppPortalRoot,
|
||||
viewport,
|
||||
...props
|
||||
}: Omit<GuideSpotlightProps, "actions" | "state"> & {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}) {
|
||||
}: GuideSpotlightProps) {
|
||||
const {
|
||||
open,
|
||||
steps,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { GuideSpotlightProps as RegistryGuideSpotlightProps } from "@/course/interactions/guide-spotlight";
|
||||
|
||||
export interface GuideSpotlightProps
|
||||
extends Omit<RegistryGuideSpotlightProps, "actions" | "state"> {
|
||||
onFinish?: () => void;
|
||||
onSkip?: () => void;
|
||||
}
|
||||
|
||||
export type {
|
||||
GuideSpotlightProps,
|
||||
GuideSpotlightRenderState,
|
||||
GuideSpotlightTheme,
|
||||
Rect,
|
||||
SpotlightLayout,
|
||||
} from "@/components/course-interactions/guide-spotlight";
|
||||
} from "@/course/interactions/guide-spotlight";
|
||||
@@ -0,0 +1,34 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* 不设 position:组件无绝对定位子元素,留空以便调用方用 className 自行定位 */
|
||||
.button {
|
||||
/* hover/按下缩放倍率由组件通过内联 --hover-scale / --press-scale 变量传入 */
|
||||
--hover-scale: 1.05;
|
||||
--press-scale: 0.95;
|
||||
transition: transform 0.18s ease-out;
|
||||
|
||||
@apply m-0 box-border cursor-pointer select-none appearance-none overflow-hidden rounded-md border-0 bg-transparent p-0 align-middle hover:brightness-110;
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500;
|
||||
@apply disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: scale(var(--hover-scale));
|
||||
}
|
||||
|
||||
.button:active {
|
||||
transform: scale(var(--press-scale));
|
||||
}
|
||||
|
||||
.image {
|
||||
@apply block h-full w-full object-cover;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button,
|
||||
.button:hover,
|
||||
.button:active {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type CSSProperties, forwardRef } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getStaticImage } from "@/utils/assets";
|
||||
import styles from "./ImageButton.module.css";
|
||||
import type { ImageButtonProps } from "./ImageButton.types";
|
||||
|
||||
// 静态资源 key vs 外部 URL 的解析;未命中时回退原样交给 <img>
|
||||
function resolveImage(src: string): string {
|
||||
return /^(https?:|data:|blob:)/.test(src) ? src : getStaticImage(src) || src;
|
||||
}
|
||||
|
||||
export const ImageButton = forwardRef<HTMLButtonElement, ImageButtonProps>(
|
||||
(
|
||||
{
|
||||
src,
|
||||
alt = "",
|
||||
width,
|
||||
height,
|
||||
hoverScale = 1.05,
|
||||
pressScale = 0.95,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const imageSrc = resolveImage(src);
|
||||
// hover/按下缩放由 CSS 过渡实现,倍率通过内联变量传入(见 ImageButton.module.css)
|
||||
const scaleVars = {
|
||||
"--hover-scale": hoverScale,
|
||||
"--press-scale": pressScale,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(styles.button, className)}
|
||||
style={{ ...scaleVars, width, height, ...style }}
|
||||
{...rest}
|
||||
>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={styles.image}
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ImageButton.displayName = "ImageButton";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export interface ImageButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** 图片:静态资源 key(相对 src/assets/images/)或外部 http(s)/data/blob URL */
|
||||
src: string;
|
||||
alt?: string;
|
||||
/** 显示尺寸(数字按 px,字符串原样透传)。图片按钮通常应有确定尺寸 */
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
/** hover 放大倍率,默认 1.05 */
|
||||
hoverScale?: number;
|
||||
/** 按下缩小倍率,默认 0.95 */
|
||||
pressScale?: number;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ImageButton } from "./ImageButton";
|
||||
export type { ImageButtonProps } from "./ImageButton.types";
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Overlay } from "@/components/ui/overlay";
|
||||
import { getAppPortalRoot } from "@/course/canvas";
|
||||
import type { VideoOverlayProps } from "./VideoOverlay.types";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
|
||||
/** 视频弹层:支持全屏过场与窗口播放,由调用方决定关闭和流程推进策略 */
|
||||
export function VideoOverlay({
|
||||
open,
|
||||
src,
|
||||
poster,
|
||||
autoPlay = true,
|
||||
mode = "fullscreen",
|
||||
windowWidth = 1200,
|
||||
windowHeight = 675,
|
||||
dismissible = false,
|
||||
lockScroll = true,
|
||||
skipLabel,
|
||||
onClose,
|
||||
onEnded,
|
||||
onSkip,
|
||||
}: VideoOverlayProps) {
|
||||
return (
|
||||
<Overlay
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
container={getAppPortalRoot}
|
||||
variant="dim"
|
||||
closeOnEscape={dismissible}
|
||||
closeOnBackgroundClick={dismissible}
|
||||
lockScroll={lockScroll}
|
||||
className="z-50"
|
||||
contentClassName="flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
style={
|
||||
mode === "window"
|
||||
? { width: windowWidth, height: windowHeight }
|
||||
: undefined
|
||||
}
|
||||
className={
|
||||
mode === "window"
|
||||
? "relative overflow-hidden rounded-lg bg-black shadow-2xl"
|
||||
: "absolute inset-0 h-full w-full bg-black"
|
||||
}
|
||||
>
|
||||
<VideoPlayer
|
||||
src={src}
|
||||
poster={poster}
|
||||
autoPlay={autoPlay}
|
||||
mode={mode}
|
||||
allowFullscreenToggle={false}
|
||||
onEnded={onEnded}
|
||||
/>
|
||||
|
||||
{skipLabel && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-14 top-10 z-10 rounded-full bg-black/55 px-10 py-4 text-[30px] text-white/90 hover:bg-black/75"
|
||||
onClick={onSkip}
|
||||
>
|
||||
{skipLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface VideoOverlayProps {
|
||||
/** 是否显示 */
|
||||
open: boolean;
|
||||
/** 可直接交给 video 元素的视频 URL */
|
||||
src: string;
|
||||
/** 封面图 */
|
||||
poster?: string;
|
||||
/** 自动播放 */
|
||||
autoPlay?: boolean;
|
||||
/** 播放模式 */
|
||||
mode?: "fullscreen" | "window";
|
||||
/** 窗口模式下的宽度(px) */
|
||||
windowWidth?: number;
|
||||
/** 窗口模式下的高度(px) */
|
||||
windowHeight?: number;
|
||||
/** 是否允许点击背景或按 Escape 关闭 */
|
||||
dismissible?: boolean;
|
||||
/** 弹层打开时是否锁定页面滚动 */
|
||||
lockScroll?: boolean;
|
||||
/** 跳过按钮文案,不传则不显示 */
|
||||
skipLabel?: string;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 播放结束回调 */
|
||||
onEnded?: () => void;
|
||||
/** 点击跳过回调 */
|
||||
onSkip?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
/* 播放器容器 */
|
||||
.player {
|
||||
@apply relative flex items-center justify-center overflow-hidden bg-black;
|
||||
}
|
||||
|
||||
.playerFullscreen {
|
||||
@apply absolute inset-0 h-full w-full;
|
||||
}
|
||||
|
||||
.playerWindow {
|
||||
@apply relative mx-auto;
|
||||
}
|
||||
|
||||
/* 视频元素 */
|
||||
.video {
|
||||
@apply h-full w-full object-contain;
|
||||
}
|
||||
|
||||
/* 中央播放按钮(暂停时显示) */
|
||||
.centerPlayButton {
|
||||
@apply absolute flex h-20 w-20 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.centerPlayButton:hover {
|
||||
@apply bg-black/70;
|
||||
}
|
||||
|
||||
/* 控制条容器 */
|
||||
.controls {
|
||||
@apply absolute bottom-0 left-0 right-0 flex flex-col bg-gradient-to-t from-black/80 to-transparent px-4 pb-3 pt-12 transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.controlsHidden {
|
||||
@apply pointer-events-none opacity-0;
|
||||
}
|
||||
|
||||
/* 小进度条(控件隐藏时显示) */
|
||||
.miniProgress {
|
||||
@apply pointer-events-none absolute bottom-0 left-0 right-0 h-1 bg-white/20 opacity-100 transition-opacity duration-300;
|
||||
}
|
||||
|
||||
.miniProgressHidden {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.miniProgressBar {
|
||||
@apply h-full w-full origin-left bg-red-500;
|
||||
transform: scaleX(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progressContainer {
|
||||
@apply relative mb-2 h-1.5 cursor-pointer rounded-full bg-white/30;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.progressBar,
|
||||
.progressBuffered {
|
||||
@apply absolute left-0 top-0 h-full w-full origin-left rounded-full;
|
||||
transform: scaleX(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
|
||||
.progressBuffered {
|
||||
@apply bg-white/30;
|
||||
}
|
||||
|
||||
.progressThumb {
|
||||
@apply absolute left-0 top-1/2 h-3.5 w-3.5 rounded-full bg-red-500 opacity-0 shadow-md transition-opacity duration-200;
|
||||
transform: translate3d(0, -50%, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.progressContainer:hover .progressThumb,
|
||||
.progressDragging .progressThumb {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.progressDragging {
|
||||
@apply cursor-grabbing;
|
||||
}
|
||||
|
||||
/* 时间显示 */
|
||||
.timeDisplay {
|
||||
@apply text-xs text-white/90 tabular-nums;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.controlButton {
|
||||
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-white/90 transition-colors hover:text-white;
|
||||
}
|
||||
|
||||
.controlButton:disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
/* 倍速选择器 */
|
||||
.rateSelector {
|
||||
@apply flex cursor-pointer items-center rounded px-2 py-0.5 text-xs text-white/90 transition-colors hover:bg-white/20 hover:text-white;
|
||||
}
|
||||
|
||||
/* 音量滑块 */
|
||||
.volumeSlider {
|
||||
@apply h-1 w-16 cursor-pointer appearance-none rounded-full bg-white/30;
|
||||
}
|
||||
|
||||
.volumeSlider::-webkit-slider-thumb {
|
||||
@apply h-3 w-3 appearance-none rounded-full bg-white shadow-md;
|
||||
}
|
||||
|
||||
.volumeSlider::-moz-range-thumb {
|
||||
@apply h-3 w-3 rounded-full border-0 bg-white shadow-md;
|
||||
}
|
||||
|
||||
/* 底部控制行 */
|
||||
.controlRow {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-black/30;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply h-10 w-10 animate-spin rounded-full border-4 border-white/30 border-t-white;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
export interface VideoPlayerRef {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
getCurrentTime: () => number;
|
||||
getDuration: () => number;
|
||||
}
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
/** 视频源 URL */
|
||||
src: string;
|
||||
/** 封面图 */
|
||||
poster?: string;
|
||||
/** 自动播放 */
|
||||
autoPlay?: boolean;
|
||||
/** 循环播放 */
|
||||
loop?: boolean;
|
||||
/** 默认静音 */
|
||||
muted?: boolean;
|
||||
/** 预加载策略 */
|
||||
preload?: "none" | "metadata" | "auto";
|
||||
/** 初始播放速度 */
|
||||
playbackRate?: number;
|
||||
/** 显示模式:全屏覆盖或窗口 */
|
||||
mode?: "fullscreen" | "window";
|
||||
/** 是否允许在全屏覆盖与窗口模式之间切换 */
|
||||
allowFullscreenToggle?: boolean;
|
||||
/** 自定义 className */
|
||||
className?: string;
|
||||
|
||||
// 事件回调
|
||||
/** 播放开始时触发 */
|
||||
onPlay?: () => void;
|
||||
/** 暂停时触发 */
|
||||
onPause?: () => void;
|
||||
/** 播放结束时触发 */
|
||||
onEnded?: () => void;
|
||||
/** 时间更新时触发 */
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
/** 进度加载时触发 */
|
||||
onProgress?: (buffered: TimeRanges | null) => void;
|
||||
/** 音量变化时触发 */
|
||||
onVolumeChange?: (volume: number, muted: boolean) => void;
|
||||
/** 播放速度变化时触发 */
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
/** 全屏状态变化时触发 */
|
||||
onFullscreenChange?: (isFullscreen: boolean) => void;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { VideoOverlay } from "./VideoOverlay";
|
||||
export type { VideoOverlayProps } from "./VideoOverlay.types";
|
||||
export { VideoPlayer } from "./VideoPlayer";
|
||||
export type { VideoPlayerProps, VideoPlayerRef } from "./VideoPlayer.types";
|
||||
@@ -15,6 +15,13 @@ export type {
|
||||
ToastViewportProps,
|
||||
} from "@/components/ui/toast";
|
||||
export { ToastProvider, ToastViewport, useToast } from "@/components/ui/toast";
|
||||
export type {
|
||||
BackgroundContainerElement,
|
||||
BackgroundContainerFit,
|
||||
BackgroundContainerProps,
|
||||
BackgroundContainerRepeat,
|
||||
} from "./BackgroundContainer";
|
||||
export { BackgroundContainer } from "./BackgroundContainer";
|
||||
export type {
|
||||
DefineDialogOptions,
|
||||
GuideDialogController,
|
||||
@@ -31,4 +38,12 @@ export type {
|
||||
SpotlightController,
|
||||
} from "./GuideSpotlight";
|
||||
export { defineSpotlight, GuideSpotlight } from "./GuideSpotlight";
|
||||
export type { ImageButtonProps } from "./ImageButton";
|
||||
export { ImageButton } from "./ImageButton";
|
||||
export { RouteError, RouteFallback } from "./RouteFallback";
|
||||
export type {
|
||||
VideoOverlayProps,
|
||||
VideoPlayerProps,
|
||||
VideoPlayerRef,
|
||||
} from "./Video";
|
||||
export { VideoOverlay, VideoPlayer } from "./Video";
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -5,3 +5,4 @@ export type {
|
||||
CourseCanvasProps,
|
||||
CourseCanvasViewport,
|
||||
} from "./CourseCanvas.types";
|
||||
export { getAppPortalRoot, registerAppPortalRoot } from "./canvasPortal";
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+15
-4
@@ -128,10 +128,21 @@ export function GuideDialog({
|
||||
}
|
||||
|
||||
const context = getCoordinateContext({ container, viewport });
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||
height: panelRect ? panelRect.height / context.scale : 220,
|
||||
width: panelElement
|
||||
? Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
panelElement.scrollWidth * context.scale,
|
||||
) / context.scale
|
||||
: panelMaxWidth,
|
||||
height: panelElement
|
||||
? Math.max(
|
||||
panelRect?.height ?? 0,
|
||||
panelElement.scrollHeight * context.scale,
|
||||
) / context.scale
|
||||
: 220,
|
||||
};
|
||||
const position = getPanelPosition(
|
||||
step.placement ?? "bottom",
|
||||
@@ -239,7 +250,7 @@ export function GuideDialog({
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
styles.panel,
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
)}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
@@ -149,6 +149,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mask,
|
||||
.highlight,
|
||||
.panelContainer,
|
||||
.panel,
|
||||
.missingPanel {
|
||||
animation: none;
|
||||
+15
-4
@@ -312,10 +312,21 @@ export function GuideSpotlight({
|
||||
}
|
||||
|
||||
const nextHighlightRect = getHighlightRect(target, padding, context);
|
||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||
const panelElement = panelRef.current;
|
||||
const panelRect = panelElement?.getBoundingClientRect();
|
||||
const panelSize = {
|
||||
width: panelRect ? panelRect.width / context.scale : panelMaxWidth,
|
||||
height: panelRect ? panelRect.height / context.scale : 180,
|
||||
width: panelElement
|
||||
? Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
panelElement.scrollWidth * context.scale,
|
||||
) / context.scale
|
||||
: panelMaxWidth,
|
||||
height: panelElement
|
||||
? Math.max(
|
||||
panelRect?.height ?? 0,
|
||||
panelElement.scrollHeight * context.scale,
|
||||
) / context.scale
|
||||
: 180,
|
||||
};
|
||||
const position = getPanelPosition(
|
||||
nextHighlightRect,
|
||||
@@ -503,7 +514,7 @@ export function GuideSpotlight({
|
||||
ref={panelRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
styles.panel,
|
||||
panelClassName,
|
||||
styles.measurePanel,
|
||||
theme?.panelClassName,
|
||||
step.panelClassName,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCourseCanvas } from "@/course/canvas";
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragDropProviderProps,
|
||||
Draggable,
|
||||
Droppable,
|
||||
} from "@/components/course-interactions/drag-drop";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
} from "@/course/interactions/drag-drop";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type {
|
||||
DragDemoItem,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { GuideSpotlight } from "@/components/common";
|
||||
import { getAppPortalRoot } from "@/components/common/canvasPortal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Overlay, type OverlayProps } from "@/components/ui/overlay";
|
||||
import { Portal } from "@/components/ui/portal";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import { useSpotlightStore } from "@/stores";
|
||||
import { DragDropDemo } from "./DragDropDemo";
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
GuideDialogRenderState,
|
||||
GuideSpotlightRenderState,
|
||||
} from "@/components/common";
|
||||
|
||||
export type CourseResourceCategory = "policy" | "practice" | "support";
|
||||
|
||||
export interface CourseResource {
|
||||
@@ -20,3 +25,11 @@ export interface CourseFlowProgress {
|
||||
completed: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CourseDemoGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
|
||||
export interface CourseDemoGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { GuideDialogRenderState } from "@/components/common";
|
||||
|
||||
interface CourseDemoGuideDialogPanelProps {
|
||||
dialog: GuideDialogRenderState;
|
||||
}
|
||||
import type { CourseDemoGuideDialogPanelProps } from "./CourseDemo.types";
|
||||
|
||||
function DialogProgress({ dialog }: CourseDemoGuideDialogPanelProps) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { GuideSpotlightRenderState } from "@/components/common";
|
||||
|
||||
interface CourseDemoGuideSpotlightPanelProps {
|
||||
spotlight: GuideSpotlightRenderState;
|
||||
}
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { CourseDemoGuideSpotlightPanelProps } from "./CourseDemo.types";
|
||||
|
||||
function GuideActions({
|
||||
spotlight,
|
||||
@@ -31,7 +28,10 @@ function GuideActions({
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.primary}
|
||||
className={`rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition ${className}`}
|
||||
className={cn(
|
||||
"rounded-full px-5 py-2 text-sm font-black text-white shadow-xl transition",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{labels.primary}
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getAppPortalRoot } from "@/components/common/canvasPortal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Overlay } from "@/components/ui/overlay";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getAppPortalRoot, useCourseCanvas } from "@/course/canvas";
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragDropProviderProps,
|
||||
Draggable,
|
||||
Droppable,
|
||||
} from "@/components/course-interactions/drag-drop";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Overlay } from "@/components/ui/overlay";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useCourseCanvas } from "@/course-runtime/canvas";
|
||||
} from "@/course/interactions/drag-drop";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useUserStore } from "@/stores";
|
||||
import { getUserStorage, setUserStorage } from "@/utils/yiban";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { GameAudioManager } from "./audio.types";
|
||||
import { GameAudioContext } from "./audioContext";
|
||||
import { createAudioManager } from "./audioManager";
|
||||
import type { GameAudioProviderProps } from "./GameAudioProvider.types";
|
||||
import { createHowlerAudioEngine } from "./howlerEngine";
|
||||
|
||||
const activeManagers = new Set<GameAudioManager>();
|
||||
|
||||
export function GameAudioProvider({
|
||||
children,
|
||||
manager: injectedManager,
|
||||
}: GameAudioProviderProps) {
|
||||
const managerRef = useRef<GameAudioManager | null>(null);
|
||||
if (!managerRef.current) {
|
||||
managerRef.current =
|
||||
injectedManager ??
|
||||
createAudioManager({ engine: createHowlerAudioEngine() });
|
||||
}
|
||||
const manager = managerRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
activeManagers.add(manager);
|
||||
const handleVisibilityChange = () => {
|
||||
manager.handleVisibilityChange(document.hidden);
|
||||
};
|
||||
const handlePageHide = () => manager.stopAll();
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
handleVisibilityChange();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
activeManagers.delete(manager);
|
||||
manager.stopAll();
|
||||
};
|
||||
}, [manager]);
|
||||
|
||||
return (
|
||||
<GameAudioContext.Provider value={manager}>
|
||||
{children}
|
||||
</GameAudioContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
for (const manager of activeManagers) manager.stopAll();
|
||||
activeManagers.clear();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { GameAudioManager } from "./audio.types";
|
||||
|
||||
export interface GameAudioProviderProps {
|
||||
children: ReactNode;
|
||||
/** 测试或特殊运行环境可注入自定义 manager。 */
|
||||
manager?: GameAudioManager;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export type AudioChannel = "voice" | "bgm" | "sfx";
|
||||
|
||||
export type AudioSource = string | readonly string[];
|
||||
|
||||
export interface AudioPlayOptions {
|
||||
loop?: boolean;
|
||||
volume?: number;
|
||||
rate?: number;
|
||||
/** 长音频使用 HTML5 Audio 流式加载,短音频默认使用 Web Audio。 */
|
||||
stream?: boolean;
|
||||
onEnd?: () => void;
|
||||
onError?: (result: AudioPlayFailureResult) => void;
|
||||
}
|
||||
|
||||
export type AudioPlayFailureResult =
|
||||
| { status: "blocked"; error: unknown }
|
||||
| { status: "load-error"; error: unknown }
|
||||
| { status: "play-error"; error: unknown };
|
||||
|
||||
export type AudioPlayResult =
|
||||
| { status: "playing" }
|
||||
| { status: "cancelled" }
|
||||
| AudioPlayFailureResult;
|
||||
|
||||
export type AudioPreloadResult =
|
||||
| { status: "loaded" }
|
||||
| { status: "cancelled" }
|
||||
| { status: "load-error"; error: unknown };
|
||||
|
||||
export interface AudioPlaybackHandle {
|
||||
started: Promise<AudioPlayResult>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export interface AudioPreloadHandle {
|
||||
ready: Promise<AudioPreloadResult>;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export interface AudioPageScope {
|
||||
readonly id: number;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export interface AudioChannelController {
|
||||
play: (
|
||||
source: AudioSource,
|
||||
options?: AudioPlayOptions,
|
||||
) => AudioPlaybackHandle;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export interface GameAudioManager {
|
||||
readonly voice: AudioChannelController;
|
||||
readonly bgm: AudioChannelController;
|
||||
readonly sfx: AudioChannelController;
|
||||
enterPage: (key?: string) => AudioPageScope;
|
||||
preload: (
|
||||
source: AudioSource,
|
||||
options?: Pick<AudioPlayOptions, "stream">,
|
||||
) => AudioPreloadHandle;
|
||||
stopPage: () => void;
|
||||
stopAll: () => void;
|
||||
setMuted: (muted: boolean) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
handleVisibilityChange: (hidden: boolean) => void;
|
||||
}
|
||||
|
||||
export interface AudioEngineClipOptions {
|
||||
source: readonly string[];
|
||||
loop: boolean;
|
||||
volume: number;
|
||||
rate: number;
|
||||
stream: boolean;
|
||||
}
|
||||
|
||||
export interface AudioEngineClip {
|
||||
load: () => Promise<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => void;
|
||||
resume: () => Promise<void>;
|
||||
stop: () => void;
|
||||
unload: () => void;
|
||||
configure: (
|
||||
options: Omit<AudioEngineClipOptions, "source" | "stream">,
|
||||
) => void;
|
||||
onEnd: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface AudioEngineFactory {
|
||||
createClip: (options: AudioEngineClipOptions) => AudioEngineClip;
|
||||
setMuted: (muted: boolean) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
}
|
||||
|
||||
export interface CreateAudioManagerOptions {
|
||||
engine: AudioEngineFactory;
|
||||
channelDefaults?: Partial<
|
||||
Record<
|
||||
AudioChannel,
|
||||
Pick<AudioPlayOptions, "loop" | "volume" | "rate" | "stream">
|
||||
>
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "react";
|
||||
import type { GameAudioManager } from "./audio.types";
|
||||
|
||||
export const GameAudioContext = createContext<GameAudioManager | null>(null);
|
||||
@@ -0,0 +1,483 @@
|
||||
import type {
|
||||
AudioChannel,
|
||||
AudioChannelController,
|
||||
AudioEngineClip,
|
||||
AudioPageScope,
|
||||
AudioPlaybackHandle,
|
||||
AudioPlayFailureResult,
|
||||
AudioPlayOptions,
|
||||
AudioPlayResult,
|
||||
AudioPreloadHandle,
|
||||
AudioPreloadResult,
|
||||
AudioSource,
|
||||
CreateAudioManagerOptions,
|
||||
GameAudioManager,
|
||||
} from "./audio.types";
|
||||
|
||||
const CHANNELS: readonly AudioChannel[] = ["voice", "bgm", "sfx"];
|
||||
|
||||
const DEFAULT_OPTIONS: Record<
|
||||
AudioChannel,
|
||||
Required<Pick<AudioPlayOptions, "loop" | "volume" | "rate" | "stream">>
|
||||
> = {
|
||||
voice: { loop: false, volume: 1, rate: 1, stream: false },
|
||||
bgm: { loop: true, volume: 0.45, rate: 1, stream: true },
|
||||
sfx: { loop: false, volume: 0.8, rate: 1, stream: false },
|
||||
};
|
||||
|
||||
interface PageScopeState {
|
||||
id: number;
|
||||
key?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface ChannelSlot {
|
||||
generation: number;
|
||||
current?: PlaybackRecord;
|
||||
}
|
||||
|
||||
interface PlaybackRecord {
|
||||
id: number;
|
||||
channel: AudioChannel;
|
||||
generation: number;
|
||||
scopeId: number;
|
||||
clip: AudioEngineClip;
|
||||
state: "loading" | "playing" | "visibility-paused";
|
||||
cleaned: boolean;
|
||||
detachEnd: () => void;
|
||||
resolveStarted: (result: AudioPlayResult) => void;
|
||||
startedSettled: boolean;
|
||||
onEnd?: () => void;
|
||||
onError?: (result: AudioPlayFailureResult) => void;
|
||||
}
|
||||
|
||||
interface PreloadRecord {
|
||||
id: number;
|
||||
key: string;
|
||||
scopeId: number;
|
||||
clip: AudioEngineClip;
|
||||
ready: Promise<AudioPreloadResult>;
|
||||
released: boolean;
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
function normalizeSource(source: AudioSource): readonly string[] {
|
||||
const values = typeof source === "string" ? [source] : source;
|
||||
return values.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function clampVolume(volume: number) {
|
||||
return Math.min(1, Math.max(0, volume));
|
||||
}
|
||||
|
||||
function createPreloadKey(source: readonly string[], stream: boolean) {
|
||||
return `${stream ? "stream" : "buffer"}:${JSON.stringify(source)}`;
|
||||
}
|
||||
|
||||
function isPlaybackBlocked(error: unknown) {
|
||||
if (typeof error === "object" && error !== null && "name" in error) {
|
||||
const name = String(error.name);
|
||||
if (name === "NotAllowedError") return true;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /notallowed|autoplay|user (?:gesture|interaction)/i.test(message);
|
||||
}
|
||||
|
||||
export class AudioManager implements GameAudioManager {
|
||||
readonly voice: AudioChannelController;
|
||||
readonly bgm: AudioChannelController;
|
||||
readonly sfx: AudioChannelController;
|
||||
|
||||
private readonly slots: Record<AudioChannel, ChannelSlot> = {
|
||||
voice: { generation: 0 },
|
||||
bgm: { generation: 0 },
|
||||
sfx: { generation: 0 },
|
||||
};
|
||||
private readonly scopes = new Map<number, PageScopeState>();
|
||||
private readonly preloads = new Set<PreloadRecord>();
|
||||
private activeScope?: PageScopeState;
|
||||
private nextScopeId = 1;
|
||||
private nextRequestId = 1;
|
||||
private hidden = false;
|
||||
|
||||
constructor(private readonly options: CreateAudioManagerOptions) {
|
||||
this.voice = this.createChannelController("voice");
|
||||
this.bgm = this.createChannelController("bgm");
|
||||
this.sfx = this.createChannelController("sfx");
|
||||
}
|
||||
|
||||
enterPage = (key?: string): AudioPageScope => {
|
||||
this.activeScope?.active && this.disposeScope(this.activeScope.id);
|
||||
|
||||
const scope: PageScopeState = {
|
||||
id: this.nextScopeId++,
|
||||
key,
|
||||
active: true,
|
||||
};
|
||||
this.scopes.set(scope.id, scope);
|
||||
this.activeScope = scope;
|
||||
|
||||
return {
|
||||
id: scope.id,
|
||||
dispose: () => this.disposeScope(scope.id),
|
||||
};
|
||||
};
|
||||
|
||||
preload = (
|
||||
source: AudioSource,
|
||||
options: Pick<AudioPlayOptions, "stream"> = {},
|
||||
): AudioPreloadHandle => {
|
||||
const normalizedSource = normalizeSource(source);
|
||||
const scope = this.activeScope;
|
||||
if (!scope?.active || normalizedSource.length === 0) {
|
||||
return {
|
||||
ready: Promise.resolve({ status: "cancelled" }),
|
||||
release: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const stream = options.stream ?? false;
|
||||
const clip = this.options.engine.createClip({
|
||||
source: normalizedSource,
|
||||
loop: false,
|
||||
volume: 1,
|
||||
rate: 1,
|
||||
stream,
|
||||
});
|
||||
const record: PreloadRecord = {
|
||||
id: this.nextRequestId++,
|
||||
key: createPreloadKey(normalizedSource, stream),
|
||||
scopeId: scope.id,
|
||||
clip,
|
||||
ready: Promise.resolve({ status: "cancelled" }),
|
||||
released: false,
|
||||
claimed: false,
|
||||
};
|
||||
|
||||
record.ready = clip.load().then(
|
||||
(): AudioPreloadResult =>
|
||||
record.released || !this.isScopeActive(record.scopeId)
|
||||
? { status: "cancelled" }
|
||||
: { status: "loaded" },
|
||||
(error: unknown): AudioPreloadResult => {
|
||||
if (record.released || !this.isScopeActive(record.scopeId)) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
this.releasePreload(record);
|
||||
return { status: "load-error", error };
|
||||
},
|
||||
);
|
||||
this.preloads.add(record);
|
||||
|
||||
return {
|
||||
ready: record.ready,
|
||||
release: () => this.releasePreload(record),
|
||||
};
|
||||
};
|
||||
|
||||
stopPage = () => {
|
||||
const scope = this.activeScope;
|
||||
if (scope?.active) this.stopOwnedByScope(scope.id);
|
||||
};
|
||||
|
||||
stopAll = () => {
|
||||
for (const channel of CHANNELS) this.stopChannel(channel);
|
||||
for (const preload of [...this.preloads]) this.releasePreload(preload);
|
||||
};
|
||||
|
||||
setMuted = (muted: boolean) => {
|
||||
this.options.engine.setMuted(muted);
|
||||
};
|
||||
|
||||
setVolume = (volume: number) => {
|
||||
this.options.engine.setVolume(clampVolume(volume));
|
||||
};
|
||||
|
||||
handleVisibilityChange = (hidden: boolean) => {
|
||||
if (this.hidden === hidden) return;
|
||||
this.hidden = hidden;
|
||||
|
||||
if (hidden) {
|
||||
this.stopChannel("voice");
|
||||
this.stopChannel("sfx");
|
||||
const bgm = this.slots.bgm.current;
|
||||
if (!bgm) return;
|
||||
if (bgm.state === "playing") {
|
||||
bgm.clip.pause();
|
||||
bgm.state = "visibility-paused";
|
||||
} else if (bgm.state === "loading") {
|
||||
this.stopChannel("bgm");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bgm = this.slots.bgm.current;
|
||||
if (!bgm || bgm.state !== "visibility-paused" || !this.isCurrent(bgm)) {
|
||||
return;
|
||||
}
|
||||
void this.resumeAfterVisibility(bgm);
|
||||
};
|
||||
|
||||
private createChannelController(
|
||||
channel: AudioChannel,
|
||||
): AudioChannelController {
|
||||
return {
|
||||
play: (source, options) => this.play(channel, source, options),
|
||||
stop: () => this.stopChannel(channel),
|
||||
};
|
||||
}
|
||||
|
||||
private play(
|
||||
channel: AudioChannel,
|
||||
source: AudioSource,
|
||||
options: AudioPlayOptions = {},
|
||||
): AudioPlaybackHandle {
|
||||
const normalizedSource = normalizeSource(source);
|
||||
const scope = this.activeScope;
|
||||
if (!scope?.active || this.hidden || normalizedSource.length === 0) {
|
||||
return {
|
||||
started: Promise.resolve({ status: "cancelled" }),
|
||||
stop: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
...DEFAULT_OPTIONS[channel],
|
||||
...this.options.channelDefaults?.[channel],
|
||||
};
|
||||
const resolved = {
|
||||
loop: options.loop ?? defaults.loop ?? false,
|
||||
volume: clampVolume(options.volume ?? defaults.volume ?? 1),
|
||||
rate: options.rate ?? defaults.rate ?? 1,
|
||||
stream: options.stream ?? defaults.stream ?? false,
|
||||
};
|
||||
const slot = this.slots[channel];
|
||||
slot.generation += 1;
|
||||
if (slot.current)
|
||||
this.cleanupPlayback(slot.current, true, { status: "cancelled" });
|
||||
|
||||
const preload = this.claimPreload(
|
||||
normalizedSource,
|
||||
resolved.stream,
|
||||
scope.id,
|
||||
);
|
||||
const clip =
|
||||
preload?.clip ??
|
||||
this.options.engine.createClip({
|
||||
source: normalizedSource,
|
||||
...resolved,
|
||||
});
|
||||
if (preload) {
|
||||
clip.configure({
|
||||
loop: resolved.loop,
|
||||
volume: resolved.volume,
|
||||
rate: resolved.rate,
|
||||
});
|
||||
}
|
||||
|
||||
let resolveStarted: (result: AudioPlayResult) => void = () => undefined;
|
||||
const started = new Promise<AudioPlayResult>((resolve) => {
|
||||
resolveStarted = resolve;
|
||||
});
|
||||
const record: PlaybackRecord = {
|
||||
id: this.nextRequestId++,
|
||||
channel,
|
||||
generation: slot.generation,
|
||||
scopeId: scope.id,
|
||||
clip,
|
||||
state: "loading",
|
||||
cleaned: false,
|
||||
detachEnd: () => undefined,
|
||||
resolveStarted,
|
||||
startedSettled: false,
|
||||
onEnd: options.onEnd,
|
||||
onError: options.onError,
|
||||
};
|
||||
record.detachEnd = clip.onEnd(() => this.handleEnd(record));
|
||||
slot.current = record;
|
||||
void this.startPlayback(record, preload?.ready);
|
||||
|
||||
return {
|
||||
started,
|
||||
stop: () => {
|
||||
if (!this.isCurrent(record)) return;
|
||||
this.slots[channel].generation += 1;
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async startPlayback(
|
||||
record: PlaybackRecord,
|
||||
preloaded?: Promise<AudioPreloadResult>,
|
||||
) {
|
||||
try {
|
||||
if (preloaded) {
|
||||
const preloadResult = await preloaded;
|
||||
if (preloadResult.status === "load-error") throw preloadResult.error;
|
||||
if (preloadResult.status === "cancelled") {
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await record.clip.load();
|
||||
}
|
||||
} catch (error) {
|
||||
this.failPlayback(record, { status: "load-error", error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isCurrent(record) || this.hidden) {
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await record.clip.play();
|
||||
} catch (error) {
|
||||
this.failPlayback(record, {
|
||||
status: isPlaybackBlocked(error) ? "blocked" : "play-error",
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isCurrent(record) || this.hidden) {
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
return;
|
||||
}
|
||||
record.state = "playing";
|
||||
this.settleStarted(record, { status: "playing" });
|
||||
}
|
||||
|
||||
private async resumeAfterVisibility(record: PlaybackRecord) {
|
||||
try {
|
||||
await record.clip.resume();
|
||||
} catch (error) {
|
||||
this.failPlayback(record, {
|
||||
status: isPlaybackBlocked(error) ? "blocked" : "play-error",
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hidden && this.isCurrent(record)) {
|
||||
record.state = "playing";
|
||||
} else {
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
}
|
||||
}
|
||||
|
||||
private handleEnd(record: PlaybackRecord) {
|
||||
if (!this.isCurrent(record)) return;
|
||||
const onEnd = record.onEnd;
|
||||
this.cleanupPlayback(record, false);
|
||||
onEnd?.();
|
||||
}
|
||||
|
||||
private failPlayback(record: PlaybackRecord, result: AudioPlayFailureResult) {
|
||||
if (!this.isCurrent(record)) {
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
return;
|
||||
}
|
||||
const onError = record.onError;
|
||||
this.cleanupPlayback(record, true, result);
|
||||
onError?.(result);
|
||||
}
|
||||
|
||||
private settleStarted(record: PlaybackRecord, result: AudioPlayResult) {
|
||||
if (record.startedSettled) return;
|
||||
record.startedSettled = true;
|
||||
record.resolveStarted(result);
|
||||
}
|
||||
|
||||
private cleanupPlayback(
|
||||
record: PlaybackRecord,
|
||||
stop: boolean,
|
||||
result?: AudioPlayResult,
|
||||
) {
|
||||
if (record.cleaned) return;
|
||||
record.cleaned = true;
|
||||
record.detachEnd();
|
||||
if (stop) record.clip.stop();
|
||||
record.clip.unload();
|
||||
const slot = this.slots[record.channel];
|
||||
if (slot.current === record) slot.current = undefined;
|
||||
if (result) this.settleStarted(record, result);
|
||||
}
|
||||
|
||||
private stopChannel(channel: AudioChannel) {
|
||||
const slot = this.slots[channel];
|
||||
slot.generation += 1;
|
||||
if (slot.current) {
|
||||
this.cleanupPlayback(slot.current, true, { status: "cancelled" });
|
||||
}
|
||||
}
|
||||
|
||||
private disposeScope(scopeId: number) {
|
||||
const scope = this.scopes.get(scopeId);
|
||||
if (!scope?.active) return;
|
||||
scope.active = false;
|
||||
this.stopOwnedByScope(scopeId);
|
||||
this.scopes.delete(scopeId);
|
||||
if (this.activeScope?.id === scopeId) this.activeScope = undefined;
|
||||
}
|
||||
|
||||
private stopOwnedByScope(scopeId: number) {
|
||||
for (const channel of CHANNELS) {
|
||||
const record = this.slots[channel].current;
|
||||
if (record?.scopeId !== scopeId) continue;
|
||||
this.slots[channel].generation += 1;
|
||||
this.cleanupPlayback(record, true, { status: "cancelled" });
|
||||
}
|
||||
for (const preload of [...this.preloads]) {
|
||||
if (preload.scopeId === scopeId) this.releasePreload(preload);
|
||||
}
|
||||
}
|
||||
|
||||
private isScopeActive(scopeId: number) {
|
||||
return this.scopes.get(scopeId)?.active === true;
|
||||
}
|
||||
|
||||
private isCurrent(record: PlaybackRecord) {
|
||||
const slot = this.slots[record.channel];
|
||||
return (
|
||||
!record.cleaned &&
|
||||
this.isScopeActive(record.scopeId) &&
|
||||
slot.generation === record.generation &&
|
||||
slot.current === record
|
||||
);
|
||||
}
|
||||
|
||||
private claimPreload(
|
||||
source: readonly string[],
|
||||
stream: boolean,
|
||||
scopeId: number,
|
||||
) {
|
||||
const key = createPreloadKey(source, stream);
|
||||
const preload = [...this.preloads].find(
|
||||
(candidate) =>
|
||||
candidate.key === key &&
|
||||
candidate.scopeId === scopeId &&
|
||||
!candidate.released &&
|
||||
!candidate.claimed,
|
||||
);
|
||||
if (!preload) return undefined;
|
||||
preload.claimed = true;
|
||||
this.preloads.delete(preload);
|
||||
return preload;
|
||||
}
|
||||
|
||||
private releasePreload(record: PreloadRecord) {
|
||||
if (record.released || record.claimed) return;
|
||||
record.released = true;
|
||||
this.preloads.delete(record);
|
||||
record.clip.stop();
|
||||
record.clip.unload();
|
||||
}
|
||||
}
|
||||
|
||||
export function createAudioManager(options: CreateAudioManagerOptions) {
|
||||
return new AudioManager(options);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Howl, Howler } from "howler";
|
||||
import type {
|
||||
AudioEngineClip,
|
||||
AudioEngineClipOptions,
|
||||
AudioEngineFactory,
|
||||
} from "./audio.types";
|
||||
|
||||
function toError(value: unknown, fallback: string) {
|
||||
return value instanceof Error ? value : new Error(String(value || fallback));
|
||||
}
|
||||
|
||||
class HowlerAudioClip implements AudioEngineClip {
|
||||
private readonly howl: Howl;
|
||||
private readonly endListeners = new Set<() => void>();
|
||||
private readonly pendingRejectors = new Set<(error: Error) => void>();
|
||||
private loadPromise?: Promise<void>;
|
||||
private soundId?: number;
|
||||
private loop: boolean;
|
||||
private disposed = false;
|
||||
|
||||
constructor(options: AudioEngineClipOptions) {
|
||||
this.loop = options.loop;
|
||||
this.howl = new Howl({
|
||||
src: [...options.source],
|
||||
autoplay: false,
|
||||
html5: options.stream,
|
||||
loop: options.loop,
|
||||
preload: false,
|
||||
rate: options.rate,
|
||||
volume: options.volume,
|
||||
});
|
||||
this.howl.on("end", this.handleEnd);
|
||||
}
|
||||
|
||||
load = () => {
|
||||
if (this.disposed) return Promise.reject(new Error("Audio clip unloaded"));
|
||||
if (this.howl.state() === "loaded") return Promise.resolve();
|
||||
if (this.loadPromise) return this.loadPromise;
|
||||
|
||||
this.loadPromise = new Promise<void>((resolve, reject) => {
|
||||
const rejectPending = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const handleLoad = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleLoadError = (_id: number, error: unknown) => {
|
||||
rejectPending(toError(error, "Audio failed to load"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
this.pendingRejectors.delete(rejectPending);
|
||||
this.howl.off("load", handleLoad);
|
||||
this.howl.off("loaderror", handleLoadError);
|
||||
};
|
||||
|
||||
this.pendingRejectors.add(rejectPending);
|
||||
this.howl.once("load", handleLoad);
|
||||
this.howl.once("loaderror", handleLoadError);
|
||||
this.howl.load();
|
||||
});
|
||||
return this.loadPromise;
|
||||
};
|
||||
|
||||
play = () => this.startSound();
|
||||
|
||||
pause = () => {
|
||||
if (this.soundId !== undefined) this.howl.pause(this.soundId);
|
||||
};
|
||||
|
||||
resume = () => {
|
||||
if (this.soundId === undefined) {
|
||||
return Promise.reject(new Error("Audio has not started"));
|
||||
}
|
||||
return this.startSound(this.soundId);
|
||||
};
|
||||
|
||||
stop = () => {
|
||||
if (this.soundId !== undefined) this.howl.stop(this.soundId);
|
||||
};
|
||||
|
||||
unload = () => {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
const error = new Error("Audio clip unloaded");
|
||||
for (const reject of [...this.pendingRejectors]) reject(error);
|
||||
this.pendingRejectors.clear();
|
||||
this.endListeners.clear();
|
||||
this.howl.off();
|
||||
this.howl.unload();
|
||||
};
|
||||
|
||||
configure = (options: Omit<AudioEngineClipOptions, "source" | "stream">) => {
|
||||
if (this.disposed) return;
|
||||
this.loop = options.loop;
|
||||
this.howl.loop(options.loop);
|
||||
this.howl.volume(options.volume);
|
||||
this.howl.rate(options.rate);
|
||||
};
|
||||
|
||||
onEnd = (listener: () => void) => {
|
||||
this.endListeners.add(listener);
|
||||
return () => this.endListeners.delete(listener);
|
||||
};
|
||||
|
||||
private startSound(soundId?: number) {
|
||||
if (this.disposed) return Promise.reject(new Error("Audio clip unloaded"));
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let expectedId = soundId;
|
||||
const rejectPending = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const handlePlay = (playedId: number) => {
|
||||
if (expectedId !== undefined && playedId !== expectedId) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handlePlayError = (playedId: number, error: unknown) => {
|
||||
if (expectedId !== undefined && playedId !== expectedId) return;
|
||||
rejectPending(toError(error, "Audio failed to play"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
this.pendingRejectors.delete(rejectPending);
|
||||
this.howl.off("play", handlePlay);
|
||||
this.howl.off("playerror", handlePlayError);
|
||||
};
|
||||
|
||||
this.pendingRejectors.add(rejectPending);
|
||||
this.howl.on("play", handlePlay);
|
||||
this.howl.on("playerror", handlePlayError);
|
||||
expectedId = this.howl.play(soundId);
|
||||
this.soundId = expectedId;
|
||||
});
|
||||
}
|
||||
|
||||
private handleEnd = (soundId: number) => {
|
||||
if (this.soundId !== undefined && soundId !== this.soundId) return;
|
||||
// 循环音频每次回到起点都会触发 end,不应视作播放结束
|
||||
if (this.loop) return;
|
||||
for (const listener of this.endListeners) listener();
|
||||
};
|
||||
}
|
||||
|
||||
export const createHowlerAudioEngine = (): AudioEngineFactory => ({
|
||||
createClip: (options) => new HowlerAudioClip(options),
|
||||
setMuted: (muted) => Howler.mute(muted),
|
||||
setVolume: (volume) => Howler.volume(volume),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export type {
|
||||
AudioChannel,
|
||||
AudioChannelController,
|
||||
AudioEngineClip,
|
||||
AudioEngineClipOptions,
|
||||
AudioEngineFactory,
|
||||
AudioPageScope,
|
||||
AudioPlaybackHandle,
|
||||
AudioPlayFailureResult,
|
||||
AudioPlayOptions,
|
||||
AudioPlayResult,
|
||||
AudioPreloadHandle,
|
||||
AudioPreloadResult,
|
||||
AudioSource,
|
||||
CreateAudioManagerOptions,
|
||||
GameAudioManager,
|
||||
} from "./audio.types";
|
||||
export { AudioManager, createAudioManager } from "./audioManager";
|
||||
export { GameAudioProvider } from "./GameAudioProvider";
|
||||
export type { GameAudioProviderProps } from "./GameAudioProvider.types";
|
||||
export { useGameAudio } from "./useGameAudio";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { GameAudioContext } from "./audioContext";
|
||||
|
||||
export function useGameAudio() {
|
||||
const manager = useContext(GameAudioContext);
|
||||
if (!manager) {
|
||||
throw new Error("useGameAudio must be used within GameAudioProvider");
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./assets";
|
||||
export * from "./audio";
|
||||
export * from "./lazyRoute";
|
||||
export * from "./motion";
|
||||
export * from "./tailwind";
|
||||
export * from "./yiban";
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useGSAP } from "@gsap/react";
|
||||
import { gsap } from "gsap";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// GSAP 仅用于时间轴编排、播放控制和 CSS 难以清晰表达的课件动画。
|
||||
// 普通状态过渡、hover 和简单入场动画优先使用 CSS/Tailwind。
|
||||
gsap.registerPlugin(useGSAP);
|
||||
|
||||
// 项目统一缓动,对齐现有 CSS cubic-bezier(0.16, 1, 0.3, 1)
|
||||
export const EASE_OUT = "power3.out";
|
||||
export const EASE_IN_OUT = "power2.inOut";
|
||||
|
||||
export { gsap, useGSAP };
|
||||
|
||||
// 响应 prefers-reduced-motion,供动画降级
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const onChange = () => setReduced(mql.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
Reference in new issue
Block a user