“zhouliujun” 15c784daef fix: 升级vue3
2026-05-23 21:29:44 +08:00

1282 lines
40 KiB
Vue

<template>
<main class="demo-page">
<header class="app-header">
<div class="brand-block">
<span class="version">vue-aliplayer-v2</span>
<h1>Aliplayer Vue 3 Workbench</h1>
</div>
<div class="header-actions">
<button type="button" class="button secondary" @click="resetAll">重置</button>
<button type="button" class="button secondary" @click="show = !show">{{ show ? '销毁' : '挂载' }}</button>
<button type="button" class="button primary" :disabled="isMultiple || !show" @click="playerRef?.retry(currentSource || undefined)">重试</button>
</div>
</header>
<section class="status-strip">
<article>
<span>SDK</span>
<strong>{{ sdkVersion }}</strong>
</article>
<article>
<span>播放模式</span>
<strong>{{ sourceModeLabel }}</strong>
</article>
<article>
<span>推断格式</span>
<strong>{{ inferredFormat || '未识别' }}</strong>
</article>
<article>
<span>播放器状态</span>
<strong>{{ playerStatus }}</strong>
</article>
<article>
<span>事件</span>
<strong>{{ logs.length }}</strong>
</article>
</section>
<section class="workspace">
<div class="stage-column">
<section class="player-panel">
<div class="player-panel-head">
<div>
<h2>{{ isMultiple ? '多实例验证' : '单实例调试' }}</h2>
<p>{{ currentSource || 'VID / STS 初始化模式' }}</p>
</div>
<label class="switch compact-switch">
<span>多实例</span>
<input v-model="isMultiple" type="checkbox">
</label>
</div>
<div class="player-frame" :class="{ 'is-multiple': isMultiple }">
<template v-if="show">
<div v-if="isMultiple" class="player-grid">
<VueAliplayerV2
v-for="item in multipleSources"
:key="item"
class="player"
:source="item"
:options="playerOptions"
:license="resolvedLicense"
:sdk-version="sdkVersion"
:component-scripts="componentScriptList"
:auto-format="autoFormat"
:normalize-source-url="normalizeSourceUrl"
:disable-tracking="disableTracking"
:low-latency="lowLatency"
:forbid-fast-forward="forbidFastForward"
@ready="handleReady"
@error="handleError"
@sdk-error="handleSdkError"
/>
</div>
<VueAliplayerV2
v-else
ref="playerRef"
class="player"
:source="currentSource"
:options="playerOptions"
:license="resolvedLicense"
:sdk-version="sdkVersion"
:component-scripts="componentScriptList"
:auto-format="autoFormat"
:normalize-source-url="normalizeSourceUrl"
:disable-tracking="disableTracking"
:low-latency="lowLatency"
:forbid-fast-forward="forbidFastForward"
@ready="handleReady"
@play="pushLog('play')"
@pause="pushLog('pause')"
@ended="pushLog('ended')"
@waiting="pushLog('waiting')"
@timeupdate="handleTimeUpdate"
@error="handleError"
@sdk-error="handleSdkError"
/>
</template>
<div v-else class="empty-player">
<strong>播放器未挂载</strong>
<span>{{ currentSource || '等待初始化参数' }}</span>
</div>
</div>
<div class="transport">
<button type="button" class="button icon-button" :disabled="isMultiple || !show" @click="playerRef?.play()">播放</button>
<button type="button" class="button icon-button" :disabled="isMultiple || !show" @click="playerRef?.pause()">暂停</button>
<button type="button" class="button icon-button" :disabled="isMultiple || !show" @click="playerRef?.replay()">重播</button>
<button type="button" class="button icon-button" :disabled="isMultiple || !show" @click="seekTo">跳转</button>
<button type="button" class="button icon-button" :disabled="isMultiple || !show" @click="requestFullScreen">全屏</button>
<button type="button" class="button icon-button" :disabled="isMultiple || !show" @click="snapshotStatus">状态</button>
</div>
</section>
<section class="lower-grid">
<article class="event-panel">
<div class="section-head">
<h2>事件流</h2>
<button type="button" class="text-button" @click="logs = []">清空</button>
</div>
<ol class="log-list">
<li v-for="item in logs" :key="item.id">
<time>{{ item.time }}</time>
<span>{{ item.message }}</span>
</li>
<li v-if="!logs.length" class="muted-row">暂无事件</li>
</ol>
</article>
<article class="config-panel">
<div class="section-head">
<h2>配置预览</h2>
<button type="button" class="text-button" @click="copyConfig">复制</button>
</div>
<pre>{{ formattedOptions }}</pre>
</article>
</section>
</div>
<aside class="inspector">
<div class="tabs" role="tablist" aria-label="参数分组">
<button
v-for="tab in tabs"
:key="tab.value"
type="button"
:class="{ active: activeTab === tab.value }"
@click="activeTab = tab.value"
>
{{ tab.label }}
</button>
</div>
<section v-if="activeTab === 'source'" class="panel-section">
<h2>播放源</h2>
<div class="segmented">
<button
v-for="mode in sourceModes"
:key="mode.value"
type="button"
:class="{ active: sourceMode === mode.value }"
@click="sourceMode = mode.value"
>
{{ mode.label }}
</button>
</div>
<label v-if="sourceMode === 'url'" class="field">
<span>预设源</span>
<select v-model="selectedPreset">
<option v-for="item in sourcePresets" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
</label>
<label v-if="sourceMode === 'url'" class="field">
<span>自定义 URL</span>
<input v-model="customSource" type="url" placeholder="https://example.com/video.mp4">
</label>
<div v-if="sourceMode === 'vid'" class="field-grid">
<label class="field">
<span>VID</span>
<input v-model="vidConfig.vid" type="text" placeholder="1e067a2831b641db...">
</label>
<label class="field">
<span>PlayAuth</span>
<input v-model="vidConfig.playauth" type="text" placeholder="Base64 PlayAuth">
</label>
<label class="field">
<span>Auth Timeout</span>
<input v-model.number="vidConfig.authTimeout" type="number" min="60" step="60">
</label>
</div>
<div v-if="sourceMode === 'sts'" class="field-grid">
<label class="field">
<span>VID</span>
<input v-model="stsConfig.vid" type="text">
</label>
<label class="field">
<span>Region</span>
<input v-model="stsConfig.region" type="text" placeholder="cn-shanghai">
</label>
<label class="field">
<span>AccessKey ID</span>
<input v-model="stsConfig.accessKeyId" type="text">
</label>
<label class="field">
<span>AccessKey Secret</span>
<input v-model="stsConfig.accessKeySecret" type="password">
</label>
<label class="field">
<span>Security Token</span>
<textarea v-model="stsConfig.securityToken" rows="3"></textarea>
</label>
</div>
</section>
<section v-if="activeTab === 'basic'" class="panel-section">
<h2>基础</h2>
<div class="switch-grid">
<label class="switch">
<span>自动播放</span>
<input v-model="baseOptions.autoplay" type="checkbox">
</label>
<label class="switch">
<span>直播</span>
<input v-model="baseOptions.isLive" type="checkbox">
</label>
<label class="switch">
<span>H5</span>
<input v-model="baseOptions.useH5Prism" type="checkbox">
</label>
<label class="switch">
<span>Flash</span>
<input v-model="baseOptions.useFlashPrism" type="checkbox">
</label>
<label class="switch">
<span>循环</span>
<input v-model="baseOptions.rePlay" type="checkbox">
</label>
<label class="switch">
<span>预加载</span>
<input v-model="baseOptions.preload" type="checkbox">
</label>
<label class="switch">
<span>内联播放</span>
<input v-model="baseOptions.playsinline" type="checkbox">
</label>
<label class="switch">
<span>禁用 Seek</span>
<input v-model="baseOptions.disableSeek" type="checkbox">
</label>
</div>
<div class="field-grid">
<label class="field">
<span>宽度</span>
<input v-model="baseOptions.width" type="text">
</label>
<label class="field">
<span>高度</span>
<input v-model="baseOptions.height" type="text">
</label>
<label class="field">
<span>语言</span>
<select v-model="baseOptions.language">
<option value="zh-cn">zh-cn</option>
<option value="en-us">en-us</option>
</select>
</label>
<label class="field">
<span>控制栏</span>
<select v-model="baseOptions.controlBarVisibility">
<option value="hover">hover</option>
<option value="click">click</option>
<option value="always">always</option>
</select>
</label>
<label class="field">
<span>隐藏延迟</span>
<input v-model.number="baseOptions.showBarTime" type="number" min="0" step="500">
</label>
<label class="field">
<span>封面</span>
<input v-model="baseOptions.cover" type="url" placeholder="https://...">
</label>
</div>
</section>
<section v-if="activeTab === 'live'" class="panel-section">
<h2>直播与格式</h2>
<div class="switch-grid">
<label class="switch">
<span>自动格式</span>
<input v-model="autoFormat" type="checkbox">
</label>
<label class="switch">
<span>低延迟</span>
<input v-model="lowLatency" type="checkbox">
</label>
<label class="switch">
<span>URL 编码</span>
<input v-model="normalizeSourceUrl" type="checkbox">
</label>
<label class="switch">
<span>禁快进</span>
<input v-model="forbidFastForward" type="checkbox">
</label>
</div>
<div class="field-grid">
<label class="field">
<span>格式</span>
<select v-model="manualFormat">
<option value="">auto</option>
<option value="mp4">mp4</option>
<option value="m3u8">m3u8</option>
<option value="flv">flv</option>
<option value="mp3">mp3</option>
<option value="rtmp">rtmp</option>
</select>
</label>
<label class="field">
<span>FLV stash</span>
<select v-model="flvStash">
<option value="auto">auto</option>
<option value="on">on</option>
<option value="off">off</option>
</select>
</label>
<label class="field">
<span>stash size</span>
<input v-model.number="stashInitialSizeForFlv" type="number" min="0" step="32">
</label>
<label class="field">
<span>RTS 版本</span>
<input v-model="rtsVersion" type="text" placeholder="2.2.1">
</label>
<label class="field">
<span>时移开始</span>
<input v-model="liveStartTime" type="text" placeholder="2026/05/23 10:00:00">
</label>
<label class="field">
<span>时移结束</span>
<input v-model="liveOverTime" type="text" placeholder="2026/05/23 12:00:00">
</label>
</div>
</section>
<section v-if="activeTab === 'sdk'" class="panel-section">
<h2>SDK 与扩展</h2>
<div class="field-grid">
<label class="field">
<span>SDK 版本</span>
<input v-model="sdkVersion" type="text">
</label>
<label class="field">
<span>License 域名</span>
<input v-model="licenseForm.domain" type="text" placeholder="example.com">
</label>
<label class="field">
<span>License Key</span>
<input v-model="licenseForm.key" type="password">
</label>
<label class="field">
<span>组件脚本</span>
<textarea v-model="componentScriptsText" rows="3" placeholder="每行一个脚本地址"></textarea>
</label>
</div>
<div class="switch-grid">
<label class="switch">
<span>拦截 track</span>
<input v-model="disableTracking" type="checkbox">
</label>
<label class="switch">
<span>系统菜单</span>
<input v-model="baseOptions.enableSystemMenu" type="checkbox">
</label>
</div>
</section>
<section v-if="activeTab === 'skin'" class="panel-section">
<h2>皮肤</h2>
<div class="field-grid">
<label class="field">
<span>大播放按钮</span>
<select v-model="skinPreset">
<option value="default">default</option>
<option value="center">center</option>
<option value="minimal">minimal</option>
<option value="hidden">hidden</option>
</select>
</label>
<label class="field">
<span>试看时间</span>
<input v-model.number="previewTime" type="number" min="0">
</label>
<label class="field">
<span>打点秒数</span>
<input v-model="progressMarkersText" type="text" placeholder="10,30,60">
</label>
</div>
<label class="field">
<span>自定义 options JSON</span>
<textarea v-model="customOptionsJson" rows="8" spellcheck="false"></textarea>
</label>
<p v-if="customOptionsError" class="error-line">{{ customOptionsError }}</p>
</section>
</aside>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import VueAliplayerV2, { type AliplayerLicense, type AliplayerOptions, type VueAliplayerV2Expose } from '../packages';
import { inferSourceFormat } from '../packages/AliplayerV2/source';
type SourceMode = 'url' | 'vid' | 'sts';
type TabValue = 'source' | 'basic' | 'live' | 'sdk' | 'skin';
type LogItem = {
id: string;
time: string;
message: string;
};
const playerRef = ref<VueAliplayerV2Expose | null>(null);
const show = ref(true);
const isMultiple = ref(false);
const activeTab = ref<TabValue>('source');
const sourceMode = ref<SourceMode>('url');
const selectedPreset = ref('//player.alicdn.com/video/aliyunmedia.mp4');
const customSource = ref('');
const playerStatus = ref('init');
const logs = ref<LogItem[]>([]);
const sdkVersion = ref('2.37.0');
const autoFormat = ref(true);
const lowLatency = ref(true);
const normalizeSourceUrl = ref(true);
const forbidFastForward = ref(false);
const disableTracking = ref(false);
const componentScriptsText = ref('');
const manualFormat = ref('');
const flvStash = ref<'auto' | 'on' | 'off'>('auto');
const stashInitialSizeForFlv = ref(128);
const rtsVersion = ref('');
const liveStartTime = ref('');
const liveOverTime = ref('');
const skinPreset = ref('center');
const previewTime = ref(0);
const progressMarkersText = ref('10,30,60');
const customOptionsJson = ref('{\n "diagnosisButtonVisible": true\n}');
const customOptionsError = ref('');
let logSeed = 0;
const tabs: Array<{ label: string; value: TabValue }> = [
{ label: '源', value: 'source' },
{ label: '基础', value: 'basic' },
{ label: '直播', value: 'live' },
{ label: 'SDK', value: 'sdk' },
{ label: '皮肤', value: 'skin' }
];
const sourceModes: Array<{ label: string; value: SourceMode }> = [
{ label: 'URL', value: 'url' },
{ label: 'VID', value: 'vid' },
{ label: 'STS', value: 'sts' }
];
const sourcePresets = [
{ label: 'MP4 演示源', value: '//player.alicdn.com/video/aliyunmedia.mp4' },
{ label: 'MP4 备用源', value: '//yunqivedio.alicdn.com/user-upload/nXPDX8AASx.mp4' },
{ label: 'M3U8 直播源', value: '//ivi.bupt.edu.cn/hls/cctv1.m3u8' }
];
const multipleSources = [
'//player.alicdn.com/video/aliyunmedia.mp4',
'//yunqivedio.alicdn.com/user-upload/nXPDX8AASx.mp4',
'//player.alicdn.com/video/aliyunmedia.mp4',
'//yunqivedio.alicdn.com/user-upload/nXPDX8AASx.mp4'
];
const baseOptions = reactive<AliplayerOptions>({
width: '100%',
height: '420px',
autoplay: true,
isLive: false,
useH5Prism: true,
useFlashPrism: false,
playsinline: true,
preload: true,
rePlay: false,
disableSeek: false,
enableSystemMenu: false,
controlBarVisibility: 'hover',
showBarTime: 5000,
language: 'zh-cn',
cover: ''
});
const licenseForm = reactive<AliplayerLicense>({
domain: '',
key: ''
});
const vidConfig = reactive({
vid: '',
playauth: '',
authTimeout: 7200
});
const stsConfig = reactive({
vid: '',
region: 'cn-shanghai',
accessKeyId: '',
accessKeySecret: '',
securityToken: ''
});
const sourceModeLabel = computed(() => sourceModes.find((item) => item.value === sourceMode.value)?.label || 'URL');
const currentSource = computed(() => {
if (sourceMode.value !== 'url') return null;
return customSource.value.trim() || selectedPreset.value;
});
const inferredFormat = computed(() => inferSourceFormat(currentSource.value));
const componentScriptList = computed(() => componentScriptsText.value.split('\n').map((item) => item.trim()).filter(Boolean));
const resolvedLicense = computed(() => {
if (!licenseForm.domain.trim() || !licenseForm.key.trim()) return null;
return {
domain: licenseForm.domain.trim(),
key: licenseForm.key.trim()
};
});
const skinLayout = computed(() => {
if (skinPreset.value === 'default') return undefined;
if (skinPreset.value === 'hidden') return false;
if (skinPreset.value === 'minimal') {
return [
{ name: 'bigPlayButton', align: 'cc' },
{
name: 'controlBar',
align: 'blabs',
x: 0,
y: 0,
children: [
{ name: 'progress', align: 'blabs', x: 0, y: 44 },
{ name: 'playButton', align: 'tl', x: 12, y: 12 },
{ name: 'timeDisplay', align: 'tl', x: 48, y: 10 },
{ name: 'fullScreenButton', align: 'tr', x: 12, y: 12 }
]
}
];
}
return [{ name: 'bigPlayButton', align: 'cc' }];
});
const progressMarkers = computed(() => progressMarkersText.value
.split(',')
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0)
.map((time, index) => ({
offset: time,
text: `Marker ${index + 1}`
})));
const parsedCustomOptions = computed<Record<string, unknown>>(() => {
customOptionsError.value = '';
if (!customOptionsJson.value.trim()) return {};
try {
return JSON.parse(customOptionsJson.value) as Record<string, unknown>;
} catch (error) {
customOptionsError.value = error instanceof Error ? error.message : String(error);
return {};
}
});
const playerOptions = computed<AliplayerOptions>(() => {
const options: AliplayerOptions = {
...baseOptions,
...parsedCustomOptions.value
};
if (!baseOptions.cover) delete options.cover;
if (manualFormat.value) options.format = manualFormat.value;
if (flvStash.value !== 'auto') options.enableStashBufferForFlv = flvStash.value === 'on';
if (stashInitialSizeForFlv.value > 0) options.stashInitialSizeForFlv = stashInitialSizeForFlv.value;
if (rtsVersion.value.trim()) options.rtsVersion = rtsVersion.value.trim();
if (liveStartTime.value.trim()) options.liveStartTime = liveStartTime.value.trim();
if (liveOverTime.value.trim()) options.liveOverTime = liveOverTime.value.trim();
if (skinLayout.value !== undefined) options.skinLayout = skinLayout.value;
if (previewTime.value > 0) options.previewTime = previewTime.value;
if (progressMarkers.value.length) options.progressMarkers = progressMarkers.value;
if (sourceMode.value === 'vid') {
options.vid = vidConfig.vid.trim();
options.playauth = vidConfig.playauth.trim();
options.authTimeout = vidConfig.authTimeout;
}
if (sourceMode.value === 'sts') {
options.vid = stsConfig.vid.trim();
options.region = stsConfig.region.trim();
options.accessKeyId = stsConfig.accessKeyId.trim();
options.accessKeySecret = stsConfig.accessKeySecret.trim();
options.securityToken = stsConfig.securityToken.trim();
}
return options;
});
const formattedOptions = computed(() => JSON.stringify({
source: currentSource.value,
options: playerOptions.value,
license: resolvedLicense.value,
sdkVersion: sdkVersion.value,
autoFormat: autoFormat.value,
lowLatency: lowLatency.value,
normalizeSourceUrl: normalizeSourceUrl.value,
disableTracking: disableTracking.value,
componentScripts: componentScriptList.value
}, null, 2));
function pushLog(message: string): void {
const item = {
id: `${Date.now()}-${logSeed++}`,
time: new Date().toLocaleTimeString(),
message
};
logs.value = [item, ...logs.value].slice(0, 10);
}
function handleReady(): void {
playerStatus.value = 'ready';
pushLog('ready');
}
function handleTimeUpdate(): void {
const current = playerRef.value?.getCurrentTime();
if (typeof current === 'number' && Math.floor(current) % 15 === 0) {
playerStatus.value = `playing ${Math.floor(current)}s`;
}
}
function handleError(event?: unknown): void {
playerStatus.value = 'error';
pushLog(`error ${JSON.stringify(event || {})}`);
}
function handleSdkError(error: Error): void {
playerStatus.value = 'sdk-error';
pushLog(`sdk-error ${error.message}`);
}
function seekTo(): void {
playerRef.value?.seek(15);
pushLog('seek 15s');
}
function requestFullScreen(): void {
playerRef.value?.requestFullScreen();
pushLog('fullscreen');
}
function snapshotStatus(): void {
playerStatus.value = playerRef.value?.getStatus() || 'unknown';
pushLog(`status ${playerStatus.value}`);
}
function copyConfig(): void {
void navigator.clipboard?.writeText(formattedOptions.value);
pushLog('config copied');
}
function resetAll(): void {
sourceMode.value = 'url';
selectedPreset.value = sourcePresets[0].value;
customSource.value = '';
sdkVersion.value = '2.37.0';
autoFormat.value = true;
lowLatency.value = true;
normalizeSourceUrl.value = true;
forbidFastForward.value = false;
disableTracking.value = false;
manualFormat.value = '';
flvStash.value = 'auto';
stashInitialSizeForFlv.value = 128;
rtsVersion.value = '';
liveStartTime.value = '';
liveOverTime.value = '';
skinPreset.value = 'center';
previewTime.value = 0;
progressMarkersText.value = '10,30,60';
customOptionsJson.value = '{\n "diagnosisButtonVisible": true\n}';
componentScriptsText.value = '';
licenseForm.domain = '';
licenseForm.key = '';
Object.assign(baseOptions, {
width: '100%',
height: '420px',
autoplay: true,
isLive: false,
useH5Prism: true,
useFlashPrism: false,
playsinline: true,
preload: true,
rePlay: false,
disableSeek: false,
enableSystemMenu: false,
controlBarVisibility: 'hover',
showBarTime: 5000,
language: 'zh-cn',
cover: ''
});
pushLog('reset');
}
</script>
<style scoped>
:global(*) {
box-sizing: border-box;
}
:global(body) {
margin: 0;
min-width: 320px;
overflow-x: hidden;
color: oklch(24% 0.018 235);
background:
linear-gradient(180deg, oklch(97% 0.012 225), oklch(94% 0.018 210) 58%, oklch(95% 0.011 145));
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
.demo-page {
width: min(1500px, calc(100vw - 32px));
margin: 0 auto;
padding: 22px 0 40px;
}
.app-header,
.status-strip,
.player-panel,
.event-panel,
.config-panel,
.inspector {
border: 1px solid oklch(84% 0.018 225);
background: oklch(99% 0.006 225);
box-shadow: 0 18px 48px oklch(40% 0.05 230 / 10%);
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-radius: 8px;
}
.brand-block {
display: grid;
gap: 4px;
}
.version {
width: fit-content;
padding: 4px 8px;
color: oklch(40% 0.06 170);
background: oklch(94% 0.045 165);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
h1,
h2,
p {
margin: 0;
}
h1 {
color: oklch(21% 0.02 235);
font-size: 26px;
line-height: 1.2;
}
h2 {
color: oklch(25% 0.018 235);
font-size: 16px;
line-height: 1.3;
}
.header-actions,
.transport,
.section-head,
.tabs,
.segmented {
display: flex;
align-items: center;
}
.header-actions,
.transport {
flex-wrap: wrap;
gap: 8px;
}
.button,
.text-button,
.tabs button,
.segmented button {
min-height: 36px;
border: 1px solid oklch(78% 0.024 225);
border-radius: 7px;
color: oklch(27% 0.02 235);
background: oklch(99% 0.004 225);
cursor: pointer;
transition: border-color 180ms ease-out, color 180ms ease-out, background 180ms ease-out, transform 180ms ease-out;
}
.button {
padding: 0 14px;
font-weight: 700;
}
.button:hover,
.text-button:hover,
.tabs button:hover,
.segmented button:hover {
border-color: oklch(59% 0.13 170);
color: oklch(36% 0.1 170);
}
.button:active,
.text-button:active,
.tabs button:active,
.segmented button:active {
transform: translateY(1px);
}
.button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.primary {
border-color: oklch(53% 0.13 170);
color: oklch(98% 0.006 170);
background: oklch(48% 0.12 170);
}
.secondary {
background: oklch(97% 0.01 225);
}
.icon-button {
min-width: 72px;
}
.text-button {
min-height: 30px;
padding: 0 10px;
color: oklch(39% 0.06 170);
background: transparent;
font-size: 12px;
font-weight: 700;
}
.status-strip {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 1px;
margin-top: 14px;
overflow: hidden;
border-radius: 8px;
}
.status-strip article {
display: grid;
gap: 6px;
min-height: 72px;
padding: 14px 16px;
background: oklch(98% 0.007 225);
}
.status-strip span,
.player-panel-head p,
.field span,
.switch span,
.log-list time {
color: oklch(48% 0.025 235);
font-size: 12px;
}
.status-strip strong {
min-width: 0;
overflow: hidden;
color: oklch(24% 0.02 235);
font-size: 16px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 430px;
gap: 16px;
margin-top: 16px;
}
.stage-column {
display: grid;
gap: 16px;
min-width: 0;
}
.player-panel,
.event-panel,
.config-panel,
.inspector {
min-width: 0;
border-radius: 8px;
}
.player-panel {
overflow: hidden;
}
.player-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid oklch(87% 0.016 225);
}
.player-panel-head > div {
display: grid;
min-width: 0;
gap: 4px;
}
.player-panel-head p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-frame {
min-height: 460px;
padding: 14px;
overflow: hidden;
background:
radial-gradient(circle at top left, oklch(83% 0.06 172 / 20%), transparent 30%),
oklch(18% 0.015 235);
}
.player-frame.is-multiple {
min-height: 360px;
}
.player {
width: 100%;
min-height: 430px;
overflow: hidden;
background: oklch(13% 0.014 235);
border: 1px solid oklch(30% 0.025 235);
border-radius: 7px;
}
.player-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.player-grid .player {
min-height: 250px;
}
.empty-player {
display: grid;
min-height: 430px;
place-items: center;
align-content: center;
gap: 8px;
color: oklch(87% 0.01 225);
background: oklch(17% 0.014 235);
border: 1px dashed oklch(43% 0.025 235);
border-radius: 7px;
}
.empty-player span {
max-width: 70ch;
overflow: hidden;
color: oklch(70% 0.018 225);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.transport {
padding: 12px 14px 14px;
border-top: 1px solid oklch(87% 0.016 225);
}
.lower-grid {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
gap: 16px;
}
.event-panel,
.config-panel {
min-width: 0;
padding: 16px;
}
.section-head {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.log-list {
display: grid;
gap: 8px;
max-height: 290px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.log-list li {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
align-items: start;
min-height: 34px;
padding: 8px 10px;
color: oklch(28% 0.02 235);
background: oklch(97% 0.008 225);
border: 1px solid oklch(88% 0.014 225);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.log-list span {
min-width: 0;
overflow-wrap: anywhere;
}
.muted-row {
display: block !important;
color: oklch(52% 0.02 235);
font-family: inherit !important;
}
.config-panel pre {
max-height: 290px;
margin: 0;
padding: 12px;
overflow: auto;
color: oklch(88% 0.02 155);
background: oklch(18% 0.018 235);
border-radius: 7px;
font-size: 12px;
line-height: 1.55;
}
.inspector {
position: sticky;
top: 14px;
max-height: calc(100vh - 28px);
overflow: hidden;
align-self: start;
}
.tabs {
gap: 6px;
padding: 10px;
border-bottom: 1px solid oklch(87% 0.016 225);
background: oklch(96% 0.01 225);
}
.tabs button {
flex: 1;
min-width: 0;
padding: 0 8px;
font-size: 13px;
font-weight: 700;
}
.tabs button.active,
.segmented button.active {
border-color: oklch(54% 0.12 170);
color: oklch(31% 0.09 170);
background: oklch(93% 0.04 165);
}
.panel-section {
display: grid;
gap: 16px;
max-height: calc(100vh - 92px);
padding: 16px;
overflow: auto;
}
.segmented {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 4px;
background: oklch(96% 0.01 225);
border: 1px solid oklch(86% 0.016 225);
border-radius: 8px;
}
.segmented button {
border-color: transparent;
background: transparent;
font-weight: 700;
}
.field-grid,
.switch-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 6px;
min-width: 0;
}
.field input,
.field select,
.field textarea {
width: 100%;
min-height: 38px;
padding: 0 10px;
color: oklch(24% 0.018 235);
background: oklch(99% 0.005 225);
border: 1px solid oklch(78% 0.024 225);
border-radius: 7px;
outline: none;
transition: border-color 160ms ease-out, box-shadow 160ms ease-out;
}
.field textarea {
min-height: 90px;
padding: 10px;
resize: vertical;
line-height: 1.45;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: oklch(55% 0.12 170);
box-shadow: 0 0 0 3px oklch(78% 0.09 170 / 22%);
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 42px;
padding: 10px 12px;
background: oklch(98% 0.007 225);
border: 1px solid oklch(86% 0.016 225);
border-radius: 7px;
}
.compact-switch {
min-width: 112px;
}
.switch input {
width: 18px;
height: 18px;
accent-color: oklch(49% 0.12 170);
}
.error-line {
padding: 10px 12px;
color: oklch(43% 0.13 28);
background: oklch(95% 0.04 35);
border: 1px solid oklch(82% 0.06 35);
border-radius: 7px;
font-size: 12px;
}
@media (max-width: 1180px) {
.workspace,
.lower-grid {
grid-template-columns: 1fr;
}
.inspector {
position: static;
max-height: none;
}
.panel-section {
max-height: none;
}
}
@media (max-width: 780px) {
.demo-page {
width: min(100vw - 20px, 740px);
padding-top: 10px;
}
.app-header {
align-items: stretch;
flex-direction: column;
}
.header-actions,
.transport {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
}
.header-actions .primary {
grid-column: 1 / -1;
}
.button,
.icon-button {
min-width: 0;
padding-inline: 8px;
}
.player-panel-head {
align-items: stretch;
flex-direction: column;
}
.status-strip,
.field-grid,
.switch-grid,
.player-grid {
grid-template-columns: 1fr;
}
.player-frame,
.player,
.empty-player,
.player-grid .player {
min-height: 260px;
}
.tabs {
overflow-x: auto;
}
.tabs button {
min-width: 74px;
}
}
</style>