Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,118 changes: 998 additions & 120 deletions package-lock.json

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"size": "size-limit",
"size:check": "size-limit --limit"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand All @@ -56,6 +58,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.5",
"@size-limit/preset-small-lib": "^12.0.1",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-interactions": "^8.5.0",
"@storybook/addon-links": "^8.5.0",
Expand All @@ -66,14 +69,17 @@
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.1.4",
"autoprefixer": "^10.4.20",
"jsdom": "^26.0.0",
"postcss": "^8.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"size-limit": "^12.0.1",
"storybook": "^8.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
Expand All @@ -94,5 +100,17 @@
"repository": {
"type": "git",
"url": "https://github.com/sushidev-team/fairu-player.git"
}
},
"size-limit": [
{
"path": "./dist/index.js",
"import": "*",
"limit": "80 kB",
"ignore": ["react", "react-dom"]
},
{
"path": "./dist/player.css",
"limit": "15 kB"
}
]
}
100 changes: 100 additions & 0 deletions src/components/ErrorBoundary/PlayerErrorBoundary.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PlayerErrorBoundary } from './PlayerErrorBoundary';

function ErrorThrower(): never {
throw new Error('Something went wrong in the player');
}

function ConditionalErrorThrower({ shouldError }: { shouldError: boolean }) {
if (shouldError) {
throw new Error('Something went wrong in the player');
}
return <div className="text-[var(--fp-color-text)] p-4">Player content is working fine</div>;
}

const meta: Meta<typeof PlayerErrorBoundary> = {
title: 'Components/PlayerErrorBoundary',
component: PlayerErrorBoundary,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#121212' }],
},
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div
className="w-[500px]"
style={{
// Provide CSS variables for the glass/theme system
['--fp-color-background' as string]: '#121212',
['--fp-color-text' as string]: '#ffffff',
['--fp-color-text-secondary' as string]: '#a1a1aa',
['--fp-glass-bg' as string]: 'rgba(255,255,255,0.08)',
['--fp-glass-border' as string]: 'rgba(255,255,255,0.12)',
}}
>
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof PlayerErrorBoundary>;

export const DefaultFallback: Story = {
render: () => (
<PlayerErrorBoundary>
<ErrorThrower />
</PlayerErrorBoundary>
),
};

export const InlineFallback: Story = {
render: () => (
<PlayerErrorBoundary inline>
<ErrorThrower />
</PlayerErrorBoundary>
),
};

export const CustomFallback: Story = {
render: () => (
<PlayerErrorBoundary
fallback={(error, reset) => (
<div className="flex flex-col items-center gap-3 p-8 rounded-xl bg-red-950/30 border border-red-500/30 text-center">
<span className="text-red-400 text-2xl">!</span>
<p className="text-white font-medium">Custom Error UI</p>
<p className="text-red-300/70 text-sm">{error.message}</p>
<button
onClick={reset}
className="mt-2 px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-500 transition-colors"
>
Reset Player
</button>
</div>
)}
>
<ErrorThrower />
</PlayerErrorBoundary>
),
};

export const NoError: Story = {
render: () => (
<PlayerErrorBoundary>
<ConditionalErrorThrower shouldError={false} />
</PlayerErrorBoundary>
),
};

export const WithClassName: Story = {
render: () => (
<PlayerErrorBoundary className="aspect-video">
<ErrorThrower />
</PlayerErrorBoundary>
),
};
165 changes: 165 additions & 0 deletions src/components/ErrorBoundary/PlayerErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { Component, type ReactNode, type ErrorInfo } from 'react';
import { cn } from '@/utils/cn';

export interface PlayerErrorBoundaryProps {
children: ReactNode;
/** Custom fallback UI. If not provided, uses default fallback */
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
/** Called when an error is caught */
onError?: (error: Error, errorInfo: ErrorInfo) => void;
/** Additional CSS classes for the fallback container */
className?: string;
/** If true, shows a minimal inline fallback instead of a full block */
inline?: boolean;
}

interface State {
hasError: boolean;
error: Error | null;
}

/**
* Error boundary component for catching render errors in the player.
* Provides a graceful fallback UI when an error occurs.
*/
export class PlayerErrorBoundary extends Component<PlayerErrorBoundaryProps, State> {
constructor(props: PlayerErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.props.onError?.(error, errorInfo);
}

reset = (): void => {
this.setState({ hasError: false, error: null });
};

render(): ReactNode {
if (!this.state.hasError || !this.state.error) {
return this.props.children;
}

const { fallback, className, inline } = this.props;
const error = this.state.error;

// Custom fallback (ReactNode or render function)
if (fallback !== undefined) {
if (typeof fallback === 'function') {
return fallback(error, this.reset);
}
return fallback;
}

// Inline variant: compact single-line fallback
if (inline) {
return (
<div
className={cn(
'inline-flex items-center gap-2 px-3 py-1.5 rounded-lg',
'bg-[var(--fp-glass-bg)] border border-[var(--fp-glass-border)]',
'text-[var(--fp-color-text)] text-sm',
className
)}
>
<svg
className="w-4 h-4 text-red-400 flex-shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>Error</span>
<button
onClick={this.reset}
className={cn(
'ml-1 p-1 rounded-md transition-colors',
'hover:bg-white/10 text-[var(--fp-color-text-secondary)]',
'hover:text-[var(--fp-color-text)]'
)}
aria-label="Retry"
>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
</div>
);
}

// Default block fallback
const truncatedMessage =
error.message.length > 100
? `${error.message.slice(0, 100)}...`
: error.message;

return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 p-6 rounded-xl',
'bg-[var(--fp-glass-bg)] backdrop-blur-[20px]',
'border border-[var(--fp-glass-border)]',
'shadow-[0_8px_32px_rgba(0,0,0,0.4)]',
className
)}
>
{/* Warning triangle icon */}
<svg
className="w-10 h-10 text-red-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>

<p className="text-[var(--fp-color-text)] text-sm font-medium">
Something went wrong
</p>

{truncatedMessage && (
<p className="text-[var(--fp-color-text-secondary)] text-xs text-center max-w-xs">
{truncatedMessage}
</p>
)}

<button
onClick={this.reset}
className={cn(
'mt-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'bg-[var(--fp-glass-bg)] border border-[var(--fp-glass-border)]',
'text-[var(--fp-color-text)]',
'hover:bg-white/10'
)}
>
Try Again
</button>
</div>
);
}
}
1 change: 1 addition & 0 deletions src/components/ErrorBoundary/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PlayerErrorBoundary, type PlayerErrorBoundaryProps } from './PlayerErrorBoundary';
2 changes: 1 addition & 1 deletion src/components/Player/EpisodeView/EpisodeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const WithAds: Story = {
ads: [
{
id: 'ad-1',
src: 'https://example.com/ad.mp3',
src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
duration: 10,
skipAfterSeconds: 5,
title: 'Sponsor: Fairu Premium',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PlayerProvider } from '@/context/PlayerContext';

const sampleTrack = {
id: '1',
src: 'https://example.com/audio.mp3',
src: 'https://files.fairu.app/a182ad73-8ecd-46d2-80f0-126cdf933b27/Sushi-jpop-04.mp3',
title: 'Awesome Track Title',
artist: 'Amazing Artist',
album: 'Greatest Hits',
Expand Down
Loading
Loading