mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(flash board): add SWF posting support (#1145)
This commit is contained in:
@@ -16,6 +16,7 @@ const testState = vi.hoisted(() => ({
|
||||
hostname: 'example.com',
|
||||
isMobile: false,
|
||||
unmuteExpandedVideoSound: false,
|
||||
ruffleLoadMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -69,6 +70,8 @@ vi.mock('../../embed', () => ({
|
||||
default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url),
|
||||
}));
|
||||
|
||||
vi.mock('@ruffle-rs/ruffle', () => ({}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let setShowThumbnailMock: ReturnType<typeof vi.fn>;
|
||||
@@ -90,6 +93,16 @@ describe('CommentMedia', () => {
|
||||
testState.hostname = 'example.com';
|
||||
testState.isMobile = false;
|
||||
testState.unmuteExpandedVideoSound = false;
|
||||
testState.ruffleLoadMock = vi.fn();
|
||||
const player = document.createElement('ruffle-player') as HTMLElement & {
|
||||
ruffle: () => { load: (source: string | Record<string, unknown>) => void };
|
||||
};
|
||||
player.ruffle = () => ({ load: testState.ruffleLoadMock });
|
||||
window.RufflePlayer = {
|
||||
newest: () => ({
|
||||
createPlayer: () => player,
|
||||
}),
|
||||
};
|
||||
setShowThumbnailMock = vi.fn();
|
||||
|
||||
container = document.createElement('div');
|
||||
@@ -100,6 +113,7 @@ describe('CommentMedia', () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
delete window.RufflePlayer;
|
||||
});
|
||||
|
||||
it('toggles image expansion on mobile and renders the media metadata', async () => {
|
||||
@@ -370,4 +384,52 @@ describe('CommentMedia', () => {
|
||||
expect(video).toBeTruthy();
|
||||
expect(video?.muted).toBe(true);
|
||||
});
|
||||
|
||||
it('renders collapsed SWF media as a compact placeholder and expands through Ruffle', async () => {
|
||||
await renderMedia({
|
||||
commentMediaInfo: {
|
||||
type: 'swf',
|
||||
url: 'https://cdn.example.com/movie.swf',
|
||||
},
|
||||
setShowThumbnail: setShowThumbnailMock,
|
||||
showThumbnail: true,
|
||||
});
|
||||
|
||||
const placeholder = Array.from(container.querySelectorAll('button')).find((node) => node.textContent === '[SWF]');
|
||||
expect(placeholder).toBeTruthy();
|
||||
expect(container.querySelector('object')).toBeNull();
|
||||
expect(container.querySelector('embed')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
placeholder?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(setShowThumbnailMock).toHaveBeenCalledWith(false);
|
||||
|
||||
await renderMedia({
|
||||
commentMediaInfo: {
|
||||
type: 'swf',
|
||||
url: 'https://cdn.example.com/movie.swf',
|
||||
},
|
||||
setShowThumbnail: setShowThumbnailMock,
|
||||
showThumbnail: false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="ruffle-player"]')).toBeTruthy();
|
||||
expect(testState.ruffleLoadMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowNetworking: 'internal',
|
||||
allowScriptAccess: false,
|
||||
autoplay: 'off',
|
||||
openUrlMode: 'confirm',
|
||||
url: 'https://cdn.example.com/movie.swf',
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector('object')).toBeNull();
|
||||
expect(container.querySelector('embed')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,11 +147,77 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swfPlaceholder {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--post-link-text-color);
|
||||
font-family: inherit;
|
||||
font-size: 9pt;
|
||||
line-height: 1.2;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.swfPlaceholder:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
.content iframe {
|
||||
border: none;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.rufflePlayer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(640px, 100%);
|
||||
height: 480px;
|
||||
max-width: 100%;
|
||||
border: var(--reply-desktop-border);
|
||||
background: var(--media-thumbnail-background-color);
|
||||
box-sizing: border-box;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rufflePlayerElement {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rufflePlayerMount {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rufflePlayerMountHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ruffleLoading,
|
||||
.ruffleFallback {
|
||||
padding: 4px;
|
||||
color: var(--post-mobile-file-info-text-color);
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.ruffleFallback a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.ruffleFallback a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
padding-bottom: 5px;
|
||||
text-align: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import styles from './comment-media.module.css';
|
||||
import Embed, { canEmbed } from '../embed';
|
||||
import RufflePlayer from './ruffle-player';
|
||||
|
||||
interface MediaProps {
|
||||
commentMediaInfo?: CommentMediaInfo;
|
||||
@@ -143,6 +144,12 @@ const Thumbnail = ({
|
||||
) : null;
|
||||
} else if (type === 'audio') {
|
||||
thumbnailComponent = <audio src={url} aria-label='Audio preview' controls />;
|
||||
} else if (type === 'swf') {
|
||||
thumbnailComponent = (
|
||||
<button type='button' className={styles.swfPlaceholder} onClick={() => setShowThumbnail(false)}>
|
||||
[SWF]
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const thumbnailSmallPadding = isMobile ? styles.thumbnailMobile : styles.thumbnailReplyDesktop;
|
||||
@@ -219,6 +226,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
)
|
||||
) : type === 'video' ? (
|
||||
<video src={url} aria-label={t('video')} controls autoPlay loop muted={!unmuteExpandedVideoSound} />
|
||||
) : type === 'swf' && url ? (
|
||||
<RufflePlayer url={url} />
|
||||
) : type === 'webpage' ? (
|
||||
disableToggle ? (
|
||||
<img src={thumbnail} alt='' />
|
||||
@@ -237,7 +246,7 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
{mediaDimensions && `, ${mediaDimensions}`})
|
||||
</div>
|
||||
)}
|
||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio') && (
|
||||
{isMobile && (type === 'iframe' || type === 'video' || type === 'audio' || type === 'swf') && (
|
||||
<div className={styles.closeButton}>
|
||||
<button
|
||||
type='button'
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './comment-media.module.css';
|
||||
|
||||
const RUFFLE_CONFIG = {
|
||||
allowFullscreen: false,
|
||||
allowNetworking: 'internal',
|
||||
allowScriptAccess: false,
|
||||
autoplay: 'off',
|
||||
openUrlMode: 'confirm',
|
||||
polyfills: false,
|
||||
showSwfDownload: false,
|
||||
} as const;
|
||||
|
||||
let ruffleLoadPromise: Promise<void> | undefined;
|
||||
|
||||
const getRufflePublicPath = () => new URL('ruffle/', document.baseURI).href;
|
||||
|
||||
const getRuffleConfig = () => ({
|
||||
...window.RufflePlayer?.config,
|
||||
...RUFFLE_CONFIG,
|
||||
publicPath: getRufflePublicPath(),
|
||||
});
|
||||
|
||||
const loadRuffle = async () => {
|
||||
if (!ruffleLoadPromise) {
|
||||
window.RufflePlayer = window.RufflePlayer || {};
|
||||
window.RufflePlayer.config = getRuffleConfig();
|
||||
ruffleLoadPromise = import('@ruffle-rs/ruffle')
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
ruffleLoadPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return ruffleLoadPromise;
|
||||
};
|
||||
|
||||
interface RufflePlayerProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
type RufflePlayerStatus = 'loading' | 'ready' | 'failed';
|
||||
|
||||
const RufflePlayer = ({ url }: RufflePlayerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const [status, setStatus] = useState<RufflePlayerStatus>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let player: RufflePlayerElement | undefined;
|
||||
const mountNode = containerRef.current;
|
||||
|
||||
const mountPlayer = async () => {
|
||||
setStatus('loading');
|
||||
if (mountNode) {
|
||||
mountNode.textContent = '';
|
||||
}
|
||||
|
||||
try {
|
||||
await loadRuffle();
|
||||
|
||||
if (cancelled || !mountNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ruffle = window.RufflePlayer?.newest?.();
|
||||
player = ruffle?.createPlayer?.();
|
||||
if (!player) {
|
||||
throw new Error('Ruffle player API unavailable');
|
||||
}
|
||||
|
||||
player.className = styles.rufflePlayerElement;
|
||||
player.setAttribute('data-testid', 'ruffle-player');
|
||||
mountNode.appendChild(player);
|
||||
|
||||
const playerApi = player.ruffle?.();
|
||||
if (!playerApi) {
|
||||
throw new Error('Ruffle player instance unavailable');
|
||||
}
|
||||
|
||||
await playerApi.load({
|
||||
...getRuffleConfig(),
|
||||
url,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setStatus('ready');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading SWF with Ruffle:', error);
|
||||
if (!cancelled) {
|
||||
setStatus('failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void mountPlayer();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
player?.remove();
|
||||
if (mountNode) {
|
||||
mountNode.textContent = '';
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<span className={styles.rufflePlayer}>
|
||||
<span className={status === 'ready' ? styles.rufflePlayerMount : styles.rufflePlayerMountHidden} ref={containerRef} />
|
||||
{status === 'failed' ? (
|
||||
<span className={styles.ruffleFallback}>
|
||||
{t('media_failed_to_load')}.{' '}
|
||||
<a href={url} target='_blank' rel='noopener noreferrer'>
|
||||
{t('media_failed_to_load_open_source')}
|
||||
</a>
|
||||
</span>
|
||||
) : status === 'loading' ? (
|
||||
<span className={styles.ruffleLoading}>{t('loading')} SWF</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default RufflePlayer;
|
||||
Reference in New Issue
Block a user