浏览代码

feat(spectrum): 扩展到9种频谱效果并恢复点击切换

chendeben 6 月之前
父节点
当前提交
952c76a0dd

+ 2 - 2
entry/src/main/ets/view/spectrum/BarSpectrumRenderer.ets

@@ -1,4 +1,4 @@
-import { SpectrumRenderer, buildSpectrumPalette, hslToHex } from './SpectrumRenderer'
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex } from './SpectrumRenderer'
 
 /**
  * 柱状频谱渲染器
@@ -9,7 +9,7 @@ export class BarSpectrumRenderer implements SpectrumRenderer {
   private peakVelocities: number[] = []
 
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
-    width: number, height: number, brandColor: string): void {
+    width: number, height: number, brandColor: string, _options?: SpectrumRenderOptions): void {
     const bandCount: number = bands.length
     if (bandCount === 0) return
 

+ 2 - 2
entry/src/main/ets/view/spectrum/CircleSpectrumRenderer.ets

@@ -1,4 +1,4 @@
-import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
 
 /**
  * 圆形频谱渲染器
@@ -8,7 +8,7 @@ export class CircleSpectrumRenderer implements SpectrumRenderer {
   private rotation: number = 0
 
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
-    width: number, height: number, brandColor: string): void {
+    width: number, height: number, brandColor: string, _options?: SpectrumRenderOptions): void {
     const bandCount: number = bands.length
     if (bandCount === 0) return
 

+ 48 - 0
entry/src/main/ets/view/spectrum/DotMatrixSpectrumRenderer.ets

@@ -0,0 +1,48 @@
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 点阵频谱渲染器
+ * 使用点阵网格表达频谱能量层级
+ */
+export class DotMatrixSpectrumRenderer implements SpectrumRenderer {
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
+    if (bands.length === 0) return
+
+    ctx.clearRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
+    const columns: number = Math.max(12, Math.floor(20 * complexityScale))
+    const rows: number = Math.max(8, Math.floor(14 * complexityScale))
+    const cellW: number = width / columns
+    const cellH: number = height / rows
+    const radius: number = Math.max(1.6, Math.min(cellW, cellH) * 0.28)
+
+    for (let c = 0; c < columns; c++) {
+      const bandIndex: number = Math.floor((c / columns) * bands.length)
+      const value: number = Math.max(0, Math.min(1, bands[bandIndex]))
+      const activeRows: number = Math.floor(value * rows)
+      const hue: number = (palette.primaryHue + (c / columns) * 135) % 360
+
+      for (let r = 0; r < rows; r++) {
+        const lit: boolean = r >= rows - activeRows
+        const x: number = c * cellW + cellW * 0.5
+        const y: number = r * cellH + cellH * 0.5
+        const alpha: number = lit ? (0.30 + value * 0.65) : 0.08
+        const lightness: number = lit ? (0.45 + 0.28 * (r / rows)) : 0.22
+        ctx.fillStyle = rgbaFromHex(hslToHex(hue, 0.82, lightness), alpha)
+        ctx.beginPath()
+        ctx.arc(x, y, radius, 0, Math.PI * 2)
+        ctx.fill()
+      }
+    }
+
+    ctx.fillStyle = rgbaFromHex(hslToHex(palette.accentHue, 0.88, 0.60), 0.10 + energy * 0.18)
+    ctx.fillRect(0, height * 0.48, width, 2)
+  }
+
+  reset(): void {
+    // 无状态
+  }
+}

+ 44 - 0
entry/src/main/ets/view/spectrum/MirrorBarSpectrumRenderer.ets

@@ -0,0 +1,44 @@
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 镜像柱状频谱渲染器
+ * 以中心线为轴上下镜像的柱状效果
+ */
+export class MirrorBarSpectrumRenderer implements SpectrumRenderer {
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
+    if (bands.length === 0) return
+
+    ctx.clearRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const centerY: number = height * 0.5
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
+    const stride: number = Math.max(1, Math.floor(1 / complexityScale))
+    const count: number = Math.max(8, Math.floor(bands.length / stride))
+    const gap: number = 2
+    const barWidth: number = Math.max(2, (width - gap * (count + 1)) / count)
+    const maxHalfHeight: number = height * 0.42
+
+    for (let i = 0; i < count; i++) {
+      const bandIndex: number = Math.min(bands.length - 1, i * stride)
+      const value: number = Math.pow(bands[bandIndex], 0.85)
+      const barHeight: number = Math.max(2, value * maxHalfHeight)
+      const x: number = gap + i * (barWidth + gap)
+      const hue: number = (palette.primaryHue + (i / count) * 120) % 360
+      const color: string = hslToHex(hue, 0.86, 0.56)
+
+      const glowAlpha: number = 0.35 + value * 0.45
+      ctx.fillStyle = rgbaFromHex(color, glowAlpha)
+      ctx.fillRect(x, centerY - barHeight, barWidth, barHeight)
+      ctx.fillRect(x, centerY, barWidth, barHeight)
+
+      ctx.fillStyle = rgbaFromHex(hslToHex(palette.accentHue, 0.90, 0.68), 0.20 + energy * 0.25)
+      ctx.fillRect(x, centerY - 1, barWidth, 2)
+    }
+  }
+
+  reset(): void {
+    // 无状态
+  }
+}

+ 58 - 0
entry/src/main/ets/view/spectrum/OrbitLineSpectrumRenderer.ets

@@ -0,0 +1,58 @@
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 轨道线频谱渲染器
+ * 多条环绕轨道线随频谱能量起伏
+ */
+export class OrbitLineSpectrumRenderer implements SpectrumRenderer {
+  private phase: number = 0
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
+    if (bands.length === 0) return
+
+    ctx.clearRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const centerX: number = width / 2
+    const centerY: number = height / 2
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
+
+    this.phase += 0.04 + energy * 0.03
+    if (this.phase > Math.PI * 2) {
+      this.phase -= Math.PI * 2
+    }
+
+    const lineCount: number = Math.max(2, Math.floor(4 * complexityScale))
+    for (let line = 0; line < lineCount; line++) {
+      const t: number = line / Math.max(1, lineCount - 1)
+      const baseRadius: number = Math.min(width, height) * (0.16 + 0.12 * line)
+      const hue: number = (palette.coolHue + 45 * line) % 360
+      ctx.beginPath()
+
+      for (let i = 0; i <= bands.length; i++) {
+        const idx: number = i % bands.length
+        const theta: number = (i / bands.length) * Math.PI * 2
+        const amp: number = Math.pow(bands[idx], 0.85)
+        const wave: number = Math.sin(theta * (2 + line) + this.phase + line * 0.8)
+        const radius: number = baseRadius + amp * (18 + 24 * t) + wave * (4 + 10 * energy)
+        const x: number = centerX + Math.cos(theta) * radius
+        const y: number = centerY + Math.sin(theta) * radius
+        if (i === 0) {
+          ctx.moveTo(x, y)
+        } else {
+          ctx.lineTo(x, y)
+        }
+      }
+
+      ctx.closePath()
+      ctx.strokeStyle = rgbaFromHex(hslToHex(hue, 0.85, 0.58), 0.24 + 0.34 * (1 - t))
+      ctx.lineWidth = 1 + (1 - t) * 1.6
+      ctx.stroke()
+    }
+  }
+
+  reset(): void {
+    this.phase = 0
+  }
+}

+ 5 - 4
entry/src/main/ets/view/spectrum/ParticleSpectrumRenderer.ets

@@ -1,4 +1,4 @@
-import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
 
 const POOL_SIZE: number = 200
 
@@ -39,7 +39,7 @@ export class ParticleSpectrumRenderer implements SpectrumRenderer {
   }
 
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
-    width: number, height: number, brandColor: string): void {
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
     if (bands.length === 0) return
 
     if (!this.initialized) {
@@ -50,9 +50,10 @@ export class ParticleSpectrumRenderer implements SpectrumRenderer {
 
     const palette = buildSpectrumPalette(brandColor)
     const centerY: number = height * 0.62
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
 
     // 发射新粒子,发射量与能量成正比
-    const emitCount: number = Math.floor(2 + energy * 10)
+    const emitCount: number = Math.max(1, Math.floor((2 + energy * 10) * complexityScale))
     for (let e = 0; e < emitCount; e++) {
       const p: Particle | null = this.getInactiveParticle()
       if (p === null) break
@@ -67,7 +68,7 @@ export class ParticleSpectrumRenderer implements SpectrumRenderer {
       p.y = centerY + (Math.random() - 0.5) * height * 0.08
       p.vx = (Math.random() - 0.5) * (0.8 + bandValue * 3.2)
       p.vy = -(0.3 + bandValue * 2.0)
-      p.size = 4 + bandValue * 14 // 更像大光斑
+      p.size = (4 + bandValue * 14) * (0.75 + complexityScale * 0.25)
       p.alpha = 0.4 + bandValue * 0.55
       // 低频偏暖,高频偏冷(由品牌色生成)
       const freqRatio: number = bandIdx / bands.length

+ 77 - 0
entry/src/main/ets/view/spectrum/RadialPulseSpectrumRenderer.ets

@@ -0,0 +1,77 @@
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 脉冲环频谱渲染器
+ * 多层环形脉冲叠加,适合作为低复杂度补充特效
+ */
+export class RadialPulseSpectrumRenderer implements SpectrumRenderer {
+  private phase: number = 0
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
+    if (bands.length === 0) return
+
+    ctx.clearRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const centerX: number = width / 2
+    const centerY: number = height / 2
+    const baseRadius: number = Math.min(width, height) * 0.16
+    const maxRadius: number = Math.min(width, height) * 0.46
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
+
+    const bandStep: number = Math.max(1, Math.floor(2 / complexityScale))
+    let avgBand: number = 0
+    let count: number = 0
+    for (let i = 0; i < bands.length; i += bandStep) {
+      avgBand += bands[i]
+      count++
+    }
+    avgBand = count > 0 ? avgBand / count : 0
+
+    this.phase += 0.06 + energy * 0.05
+    if (this.phase > Math.PI * 2) {
+      this.phase -= Math.PI * 2
+    }
+
+    const ringCount: number = Math.max(3, Math.floor(6 * complexityScale))
+    for (let i = 0; i < ringCount; i++) {
+      const t: number = i / Math.max(1, ringCount - 1)
+      const pulse: number = 0.5 + 0.5 * Math.sin(this.phase + i * 0.7)
+      const radius: number = baseRadius + (maxRadius - baseRadius) * (t * 0.85 + avgBand * 0.3 * pulse)
+      const hue: number = (palette.primaryHue + 70 * t) % 360
+      const alpha: number = 0.18 + 0.32 * (1 - t) * (0.7 + energy * 0.3)
+
+      ctx.beginPath()
+      ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
+      ctx.strokeStyle = rgbaFromHex(hslToHex(hue, 0.84, 0.56), alpha)
+      ctx.lineWidth = 1.2 + (1 - t) * 2.6
+      ctx.stroke()
+    }
+
+    const spikeCount: number = Math.max(12, Math.floor(24 * complexityScale))
+    const step: number = Math.floor(Math.max(1, bands.length / spikeCount))
+    for (let i = 0; i < spikeCount; i++) {
+      const bandIndex: number = Math.min(bands.length - 1, i * step)
+      const value: number = Math.pow(bands[bandIndex], 0.85)
+      const angle: number = (Math.PI * 2 * i) / spikeCount + this.phase * 0.4
+      const inner: number = baseRadius * 0.8
+      const outer: number = inner + value * (maxRadius - baseRadius) * 0.58
+      const x1: number = centerX + Math.cos(angle) * inner
+      const y1: number = centerY + Math.sin(angle) * inner
+      const x2: number = centerX + Math.cos(angle) * outer
+      const y2: number = centerY + Math.sin(angle) * outer
+
+      ctx.beginPath()
+      ctx.moveTo(x1, y1)
+      ctx.lineTo(x2, y2)
+      ctx.strokeStyle = rgbaFromHex(hslToHex(palette.accentHue, 0.88, 0.60), 0.35 + value * 0.55)
+      ctx.lineWidth = 1 + value * 2
+      ctx.stroke()
+    }
+  }
+
+  reset(): void {
+    this.phase = 0
+  }
+}

+ 127 - 0
entry/src/main/ets/view/spectrum/SpectrumEffectRegistry.ets

@@ -0,0 +1,127 @@
+import { SpectrumRenderer } from './SpectrumRenderer'
+import { BarSpectrumRenderer } from './BarSpectrumRenderer'
+import { WaveSpectrumRenderer } from './WaveSpectrumRenderer'
+import { CircleSpectrumRenderer } from './CircleSpectrumRenderer'
+import { ParticleSpectrumRenderer } from './ParticleSpectrumRenderer'
+import { RadialPulseSpectrumRenderer } from './RadialPulseSpectrumRenderer'
+import { MirrorBarSpectrumRenderer } from './MirrorBarSpectrumRenderer'
+import { OrbitLineSpectrumRenderer } from './OrbitLineSpectrumRenderer'
+import { DotMatrixSpectrumRenderer } from './DotMatrixSpectrumRenderer'
+import { StarburstSpectrumRenderer } from './StarburstSpectrumRenderer'
+
+export type SpectrumPerformanceLevel = 'low' | 'medium' | 'high'
+
+export interface SpectrumEffectPerformanceProfile {
+  targetFps: number
+  minFps: number
+  maxBandCount: number
+  complexityFloor: number
+}
+
+export interface SpectrumEffectDefinition {
+  effectId: string
+  displayName: string
+  performanceLevel: SpectrumPerformanceLevel
+  performance: SpectrumEffectPerformanceProfile
+  createRenderer: () => SpectrumRenderer
+}
+
+const EFFECTS: SpectrumEffectDefinition[] = [
+  {
+    effectId: 'bar',
+    displayName: '柱状',
+    performanceLevel: 'medium',
+    performance: { targetFps: 30, minFps: 18, maxBandCount: 32, complexityFloor: 0.60 },
+    createRenderer: (): SpectrumRenderer => new BarSpectrumRenderer()
+  },
+  {
+    effectId: 'wave',
+    displayName: '波形',
+    performanceLevel: 'medium',
+    performance: { targetFps: 30, minFps: 18, maxBandCount: 32, complexityFloor: 0.62 },
+    createRenderer: (): SpectrumRenderer => new WaveSpectrumRenderer()
+  },
+  {
+    effectId: 'circle',
+    displayName: '圆形',
+    performanceLevel: 'high',
+    performance: { targetFps: 30, minFps: 15, maxBandCount: 32, complexityFloor: 0.50 },
+    createRenderer: (): SpectrumRenderer => new CircleSpectrumRenderer()
+  },
+  {
+    effectId: 'particle',
+    displayName: '粒子',
+    performanceLevel: 'high',
+    performance: { targetFps: 28, minFps: 12, maxBandCount: 24, complexityFloor: 0.45 },
+    createRenderer: (): SpectrumRenderer => new ParticleSpectrumRenderer()
+  },
+  {
+    effectId: 'radial-pulse',
+    displayName: '脉冲',
+    performanceLevel: 'low',
+    performance: { targetFps: 30, minFps: 20, maxBandCount: 28, complexityFloor: 0.65 },
+    createRenderer: (): SpectrumRenderer => new RadialPulseSpectrumRenderer()
+  },
+  {
+    effectId: 'mirror-bar',
+    displayName: '镜像柱',
+    performanceLevel: 'low',
+    performance: { targetFps: 30, minFps: 20, maxBandCount: 30, complexityFloor: 0.68 },
+    createRenderer: (): SpectrumRenderer => new MirrorBarSpectrumRenderer()
+  },
+  {
+    effectId: 'orbit-line',
+    displayName: '轨道线',
+    performanceLevel: 'medium',
+    performance: { targetFps: 30, minFps: 18, maxBandCount: 28, complexityFloor: 0.60 },
+    createRenderer: (): SpectrumRenderer => new OrbitLineSpectrumRenderer()
+  },
+  {
+    effectId: 'dot-matrix',
+    displayName: '点阵',
+    performanceLevel: 'low',
+    performance: { targetFps: 30, minFps: 22, maxBandCount: 24, complexityFloor: 0.72 },
+    createRenderer: (): SpectrumRenderer => new DotMatrixSpectrumRenderer()
+  },
+  {
+    effectId: 'starburst',
+    displayName: '星爆',
+    performanceLevel: 'medium',
+    performance: { targetFps: 30, minFps: 18, maxBandCount: 30, complexityFloor: 0.58 },
+    createRenderer: (): SpectrumRenderer => new StarburstSpectrumRenderer()
+  }
+]
+
+const LEGACY_MODE_TO_EFFECT_ID: string[] = ['bar', 'wave', 'circle', 'particle']
+
+export function getSpectrumEffects(): SpectrumEffectDefinition[] {
+  return EFFECTS
+}
+
+export function getDefaultSpectrumEffectId(): string {
+  return EFFECTS[0].effectId
+}
+
+export function findSpectrumEffectById(effectId: string): SpectrumEffectDefinition | undefined {
+  for (let i = 0; i < EFFECTS.length; i++) {
+    if (EFFECTS[i].effectId === effectId) {
+      return EFFECTS[i]
+    }
+  }
+  return undefined
+}
+
+export function normalizeSpectrumEffectId(effectId: string | undefined): string {
+  if (!effectId) {
+    return getDefaultSpectrumEffectId()
+  }
+  const effect: SpectrumEffectDefinition | undefined = findSpectrumEffectById(effectId)
+  return effect ? effect.effectId : getDefaultSpectrumEffectId()
+}
+
+export function fromLegacySpectrumMode(mode: number | undefined): string {
+  if (mode === undefined || mode < 0 || mode >= LEGACY_MODE_TO_EFFECT_ID.length) {
+    return getDefaultSpectrumEffectId()
+  }
+  return LEGACY_MODE_TO_EFFECT_ID[mode]
+}

+ 5 - 1
entry/src/main/ets/view/spectrum/SpectrumRenderer.ets

@@ -13,7 +13,7 @@ export interface SpectrumRenderer {
    * @param brandColor 主题品牌色 (hex string, e.g. '#FF0050')
    */
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
-    width: number, height: number, brandColor: string): void
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void
 
   /**
    * 重置渲染器状态
@@ -21,6 +21,10 @@ export interface SpectrumRenderer {
   reset(): void
 }
 
+export interface SpectrumRenderOptions {
+  complexityScale: number
+}
+
 export interface SpectrumPalette {
   primaryHue: number
   warmHue: number

+ 196 - 53
entry/src/main/ets/view/spectrum/SpectrumView.ets

@@ -1,45 +1,43 @@
 import { IjkMediaPlayer } from '@ohos/ijkplayer'
+import { PreferencesUtil } from '@pura/harmony-utils'
 import { FFTAnalyzer } from '../../common/util/FFTAnalyzer'
 import { SpectrumRenderer } from './SpectrumRenderer'
-import { BarSpectrumRenderer } from './BarSpectrumRenderer'
-import { WaveSpectrumRenderer } from './WaveSpectrumRenderer'
-import { CircleSpectrumRenderer } from './CircleSpectrumRenderer'
-import { ParticleSpectrumRenderer } from './ParticleSpectrumRenderer'
+import {
+  SpectrumEffectDefinition,
+  findSpectrumEffectById,
+  fromLegacySpectrumMode,
+  getDefaultSpectrumEffectId,
+  getSpectrumEffects,
+  normalizeSpectrumEffectId
+} from './SpectrumEffectRegistry'
 import { normalizeHexColor } from './SpectrumRenderer'
 import Logger from '../../common/util/Logger'
 
 const TAG: string = 'heanup-SpectrumView'
 const BAND_COUNT: number = 32
-const FRAME_INTERVAL: number = 33 // ~30fps
-
-export enum SpectrumMode {
-  BAR = 0,
-  WAVE = 1,
-  CIRCLE = 2,
-  PARTICLE = 3
-}
-
-const MODE_NAMES: string[] = ['柱状', '波形', '圆形', '粒子']
+const PREFERENCE_SELECTED_EFFECT: string = 'selectedSpectrumEffectId'
 
 @Component
 export struct SpectrumView {
-  @StorageLink('spectrumMode') currentMode: number = 0
   @Prop @Watch('onPlayingChange') isPlaying: boolean = false
   @StorageProp('themeColor') themeColor: string = '#FF0050'
   @Prop @Watch('onVisibilityChange') isVisible: boolean = false
-  private modeCount: number = MODE_NAMES.length
-  @State canvasWidth: number = 0
-  @State canvasHeight: number = 0
-  private renderSettings: RenderingContextSettings = new RenderingContextSettings(true)
-  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings)
-  private fftAnalyzer: FFTAnalyzer = new FFTAnalyzer()
-  private renderers: SpectrumRenderer[] = [
-    new BarSpectrumRenderer(),
-    new WaveSpectrumRenderer(),
-    new CircleSpectrumRenderer(),
-    new ParticleSpectrumRenderer()
-  ]
+
+  @State private selectedEffectId: string = getDefaultSpectrumEffectId()
+  @State private canvasWidth: number = 0
+  @State private canvasHeight: number = 0
+
+  private readonly effects: SpectrumEffectDefinition[] = getSpectrumEffects()
+  private readonly renderSettings: RenderingContextSettings = new RenderingContextSettings(true)
+  private readonly canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.renderSettings)
+  private readonly fftAnalyzer: FFTAnalyzer = new FFTAnalyzer()
+  private readonly renderers: Map<string, SpectrumRenderer> = new Map<string, SpectrumRenderer>()
+
   private timerId: number = -1
+  private currentFps: number = 30
+  private complexityScale: number = 1.0
+  private highLoadStreak: number = 0
+  private lowLoadStreak: number = 0
 
   onVisibilityChange(): void {
     this.updateRenderingState()
@@ -50,25 +48,61 @@ export struct SpectrumView {
   }
 
   aboutToAppear(): void {
-    // 读取持久化的特效偏好
-    const savedMode: number | undefined = AppStorage.get<number>('spectrumMode')
-    if (savedMode !== undefined && savedMode >= 0 && savedMode <= 3) {
-      this.currentMode = savedMode
-    }
+    this.ensureRenderersReady()
+    this.restoreSelectedEffect()
+    this.resetPerformanceGuard()
     this.updateRenderingState()
-    Logger.info(TAG, 'SpectrumView aboutToAppear, mode=' + this.currentMode)
+    Logger.info(TAG, 'SpectrumView aboutToAppear, effect=' + this.selectedEffectId)
   }
 
   aboutToDisappear(): void {
     this.stopRendering()
   }
 
+  private ensureRenderersReady(): void {
+    if (this.renderers.size > 0) {
+      return
+    }
+    for (let i = 0; i < this.effects.length; i++) {
+      const effect: SpectrumEffectDefinition = this.effects[i]
+      this.renderers.set(effect.effectId, effect.createRenderer())
+    }
+  }
+
+  private restoreSelectedEffect(): void {
+    const storedEffectId: string = PreferencesUtil.getStringSync(PREFERENCE_SELECTED_EFFECT, '')
+    if (storedEffectId) {
+      this.selectedEffectId = normalizeSpectrumEffectId(storedEffectId)
+      return
+    }
+
+    const legacyMode: number | undefined = AppStorage.get<number>('spectrumMode')
+    this.selectedEffectId = fromLegacySpectrumMode(legacyMode)
+    PreferencesUtil.putSync(PREFERENCE_SELECTED_EFFECT, this.selectedEffectId)
+  }
+
   private startRendering(): void {
-    if (this.timerId !== -1) return
-    Logger.info(TAG, 'startRendering')
+    if (this.timerId !== -1) {
+      return
+    }
+    const intervalMs: number = Math.max(16, Math.floor(1000 / this.currentFps))
+    Logger.info(TAG, 'startRendering fps=' + this.currentFps + ', interval=' + intervalMs)
     this.timerId = setInterval(() => {
       this.renderFrame()
-    }, FRAME_INTERVAL)
+    }, intervalMs)
+  }
+
+  private restartRenderingIfNeeded(nextFps: number): void {
+    if (nextFps === this.currentFps) {
+      return
+    }
+    this.currentFps = nextFps
+    if (this.timerId === -1) {
+      return
+    }
+    clearInterval(this.timerId)
+    this.timerId = -1
+    this.startRendering()
   }
 
   private stopRendering(): void {
@@ -79,21 +113,88 @@ export struct SpectrumView {
     }
   }
 
+  private getCurrentEffect(): SpectrumEffectDefinition {
+    const effect: SpectrumEffectDefinition | undefined = findSpectrumEffectById(this.selectedEffectId)
+    if (effect) {
+      return effect
+    }
+    return this.effects[0]
+  }
+
+  private getCurrentRenderer(): SpectrumRenderer {
+    const effectId: string = this.getCurrentEffect().effectId
+    const renderer: SpectrumRenderer | undefined = this.renderers.get(effectId)
+    if (renderer) {
+      return renderer
+    }
+    return this.renderers.get(getDefaultSpectrumEffectId()) as SpectrumRenderer
+  }
+
   private renderFrame(): void {
+    if (this.canvasWidth <= 0 || this.canvasHeight <= 0) {
+      return
+    }
+
     const player: IjkMediaPlayer = IjkMediaPlayer.getInstance()
     const pcmData: number[] = player.getSpectrumData()
+    if (pcmData.length === 0) {
+      return
+    }
 
-    if (pcmData.length === 0) return
+    const effect: SpectrumEffectDefinition = this.getCurrentEffect()
+    const beginMs: number = Date.now()
 
     const spectrumData = this.fftAnalyzer.analyze(pcmData, 44100)
-    const rawBands: number[] = spectrumData.getBands(BAND_COUNT)
+    const scaledBandCount: number = Math.max(8, Math.floor(effect.performance.maxBandCount * this.complexityScale))
+    const rawBands: number[] = spectrumData.getBands(Math.min(BAND_COUNT, scaledBandCount))
     const bands: number[] = this.fftAnalyzer.smooth(rawBands)
     const energy: number = spectrumData.getEnergy()
 
-    const ctx: CanvasRenderingContext2D = this.canvasContext
-    const renderer: SpectrumRenderer = this.renderers[this.currentMode]
+    const renderer: SpectrumRenderer = this.getCurrentRenderer()
     const brandColor: string = normalizeHexColor(this.themeColor, '#FF0050')
-    renderer.render(ctx, bands, energy, this.canvasWidth, this.canvasHeight, brandColor)
+    renderer.render(
+      this.canvasContext,
+      bands,
+      energy,
+      this.canvasWidth,
+      this.canvasHeight,
+      brandColor,
+      { complexityScale: this.complexityScale }
+    )
+
+    const renderCostMs: number = Date.now() - beginMs
+    this.updatePerformanceGuard(effect, renderCostMs)
+  }
+
+  private updatePerformanceGuard(effect: SpectrumEffectDefinition, renderCostMs: number): void {
+    const frameBudget: number = 1000 / this.currentFps
+
+    if (renderCostMs > frameBudget * 1.15) {
+      this.highLoadStreak += 1
+      this.lowLoadStreak = 0
+      if (this.highLoadStreak >= 6) {
+        this.highLoadStreak = 0
+        this.complexityScale = Math.max(effect.performance.complexityFloor, this.complexityScale - 0.1)
+        const nextFps: number = Math.max(effect.performance.minFps, this.currentFps - 2)
+        this.restartRenderingIfNeeded(nextFps)
+      }
+      return
+    }
+
+    if (renderCostMs < frameBudget * 0.65) {
+      this.lowLoadStreak += 1
+      this.highLoadStreak = 0
+      if (this.lowLoadStreak >= 14) {
+        this.lowLoadStreak = 0
+        this.complexityScale = Math.min(1.0, this.complexityScale + 0.05)
+        const nextFps: number = Math.min(effect.performance.targetFps, this.currentFps + 1)
+        this.restartRenderingIfNeeded(nextFps)
+      }
+      return
+    }
+
+    this.highLoadStreak = 0
+    this.lowLoadStreak = 0
   }
 
   private updateRenderingState(): void {
@@ -104,19 +205,62 @@ export struct SpectrumView {
     }
   }
 
-  private switchMode(mode: number): void {
-    if (mode === this.currentMode) return
-    this.renderers[this.currentMode].reset()
-    this.currentMode = mode
-    AppStorage.setOrCreate<number>('spectrumMode', mode)
+  private resetPerformanceGuard(): void {
+    const effect: SpectrumEffectDefinition = this.getCurrentEffect()
+    this.currentFps = effect.performance.targetFps
+    this.complexityScale = 1.0
+    this.highLoadStreak = 0
+    this.lowLoadStreak = 0
+  }
+
+  private persistLegacyMode(): void {
+    let modeIndex: number = 0
+    for (let i = 0; i < this.effects.length; i++) {
+      if (this.effects[i].effectId === this.selectedEffectId) {
+        modeIndex = i
+        break
+      }
+    }
+    AppStorage.setOrCreate<number>('spectrumMode', modeIndex)
+  }
+
+  private switchEffect(effectId: string): void {
+    const normalizedId: string = normalizeSpectrumEffectId(effectId)
+    if (normalizedId === this.selectedEffectId) {
+      return
+    }
+
+    const previousRenderer: SpectrumRenderer = this.getCurrentRenderer()
+    previousRenderer.reset()
+
+    this.selectedEffectId = normalizedId
     this.fftAnalyzer.reset()
+    this.resetPerformanceGuard()
     this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
-    Logger.info(TAG, 'switchMode to ' + MODE_NAMES[mode])
+
+    PreferencesUtil.putSync(PREFERENCE_SELECTED_EFFECT, this.selectedEffectId)
+    this.persistLegacyMode()
+
+    Logger.info(TAG, 'switchEffect to ' + this.selectedEffectId)
+  }
+
+  private getCurrentEffectName(): string {
+    return this.getCurrentEffect().displayName
   }
 
-  private nextMode(): void {
-    const next: number = (this.currentMode + 1) % this.modeCount
-    this.switchMode(next)
+  private nextEffect(): void {
+    if (this.effects.length <= 1) {
+      return
+    }
+    let currentIndex: number = 0
+    for (let i = 0; i < this.effects.length; i++) {
+      if (this.effects[i].effectId === this.selectedEffectId) {
+        currentIndex = i
+        break
+      }
+    }
+    const nextIndex: number = (currentIndex + 1) % this.effects.length
+    this.switchEffect(this.effects[nextIndex].effectId)
   }
 
   build() {
@@ -134,12 +278,11 @@ export struct SpectrumView {
           this.canvasHeight = newVal.height as number
         })
 
-      // 特效切换按钮(点击循环切换)
       Row() {
         SymbolGlyph($r('sys.symbol.music'))
           .fontSize(14)
           .fontColor([$r('app.color.brand')])
-        Text(MODE_NAMES[this.currentMode])
+        Text(this.getCurrentEffectName())
           .fontSize(12)
           .fontColor($r('app.color.brand'))
           .margin({ left: 4 })
@@ -150,7 +293,7 @@ export struct SpectrumView {
       .shadow({ radius: 6, color: 'rgba(0, 0, 0, 0.45)', offsetX: 0, offsetY: 0 })
       .margin({ top: 8, right: 8 })
       .onClick(() => {
-        this.nextMode()
+        this.nextEffect()
       })
     }
     .width('100%')

+ 55 - 0
entry/src/main/ets/view/spectrum/StarburstSpectrumRenderer.ets

@@ -0,0 +1,55 @@
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+
+/**
+ * 星爆频谱渲染器
+ * 从中心向外爆发的星芒线条
+ */
+export class StarburstSpectrumRenderer implements SpectrumRenderer {
+  private drift: number = 0
+
+  render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
+    width: number, height: number, brandColor: string, options?: SpectrumRenderOptions): void {
+    if (bands.length === 0) return
+
+    ctx.clearRect(0, 0, width, height)
+
+    const palette = buildSpectrumPalette(brandColor)
+    const centerX: number = width / 2
+    const centerY: number = height / 2
+    const complexityScale: number = Math.max(0.45, Math.min(1, options?.complexityScale ?? 1))
+    const rays: number = Math.max(18, Math.floor(44 * complexityScale))
+    const outerMax: number = Math.min(width, height) * 0.46
+    const innerRadius: number = Math.min(width, height) * 0.08
+
+    this.drift += 0.015 + energy * 0.01
+
+    for (let i = 0; i < rays; i++) {
+      const bandIndex: number = Math.floor((i / rays) * bands.length)
+      const value: number = Math.pow(bands[bandIndex], 0.8)
+      const angle: number = (Math.PI * 2 * i) / rays + this.drift
+      const len: number = innerRadius + value * (outerMax - innerRadius)
+      const x1: number = centerX + Math.cos(angle) * innerRadius
+      const y1: number = centerY + Math.sin(angle) * innerRadius
+      const x2: number = centerX + Math.cos(angle) * len
+      const y2: number = centerY + Math.sin(angle) * len
+      const hue: number = (palette.warmHue + i * 5) % 360
+
+      ctx.beginPath()
+      ctx.moveTo(x1, y1)
+      ctx.lineTo(x2, y2)
+      ctx.strokeStyle = rgbaFromHex(hslToHex(hue, 0.88, 0.58), 0.20 + value * 0.70)
+      ctx.lineWidth = 1 + value * 2.2
+      ctx.stroke()
+    }
+
+    const coreRadius: number = innerRadius * (0.85 + energy * 0.65)
+    ctx.beginPath()
+    ctx.arc(centerX, centerY, coreRadius, 0, Math.PI * 2)
+    ctx.fillStyle = rgbaFromHex(hslToHex(palette.complementaryHue, 0.82, 0.62), 0.14 + energy * 0.18)
+    ctx.fill()
+  }
+
+  reset(): void {
+    this.drift = 0
+  }
+}

+ 2 - 2
entry/src/main/ets/view/spectrum/WaveSpectrumRenderer.ets

@@ -1,4 +1,4 @@
-import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
+import { SpectrumRenderer, SpectrumRenderOptions, buildSpectrumPalette, hslToHex, rgbaFromHex } from './SpectrumRenderer'
 
 /**
  * 波形频谱渲染器
@@ -6,7 +6,7 @@ import { SpectrumRenderer, buildSpectrumPalette, hslToHex, rgbaFromHex } from '.
  */
 export class WaveSpectrumRenderer implements SpectrumRenderer {
   render(ctx: CanvasRenderingContext2D, bands: number[], energy: number,
-    width: number, height: number, brandColor: string): void {
+    width: number, height: number, brandColor: string, _options?: SpectrumRenderOptions): void {
     const bandCount: number = bands.length
     if (bandCount === 0) return
 

+ 2 - 0
openspec/changes/add-more-spectrum-effects/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-08

+ 78 - 0
openspec/changes/add-more-spectrum-effects/design.md

@@ -0,0 +1,78 @@
+## Context
+
+当前 `spectrum-visualizer` 能力仅支持 4 种频谱效果,且效果切换与效果渲染逻辑耦合较高。此次变更需要在不破坏现有 4 种效果体验的前提下,扩展可选效果数量,并保证播放中实时切换稳定、渲染性能可控、用户上次选择可恢复。
+
+约束条件:
+- ArkTS UI 和渲染逻辑位于 `entry/src/main/ets/view/` 与 `entry/src/main/ets/viewmodel/`。
+- 频谱数据来源与播放链路不变,不引入新的音频解码协议或外部依赖。
+- 中低端设备需避免因效果分支增多导致掉帧或明显功耗上升。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 将频谱效果扩展为可配置的效果集合,支持后续继续新增。
+- 统一效果元数据(ID、名称、默认参数、渲染策略),避免在页面中硬编码分支。
+- 支持播放过程中无感切换效果,并记住用户最近一次选择。
+- 为每个效果定义最小性能边界(刷新频率、点数/条数上限、降级策略)。
+
+**Non-Goals:**
+- 不修改音频 PCM/FFT 数据获取链路。
+- 不引入在线下载效果、主题商店或脚本化效果系统。
+- 不重做整体播放器 UI 布局,仅在现有入口扩展效果选择体验。
+
+## Decisions
+
+1. 建立效果注册表(Effect Registry)而非继续使用 `switch/case` 扩展。
+- 方案:以统一结构维护效果定义(`effectId`、展示名、参数默认值、渲染函数引用、性能等级)。
+- 原因:新增效果只需注册配置与渲染实现,减少 UI 与渲染层的重复改动。
+- 备选方案:继续在现有渲染组件中追加条件分支。
+- 不采用原因:分支增长会快速增加维护成本,且难以统一做性能控制。
+
+2. 采用“共享数据输入 + 效果独立渲染器”的分层。
+- 方案:FFT/幅值数据由现有链路统一提供,不同效果仅消费标准化频谱帧。
+- 原因:避免每个效果重复处理原始数据,降低行为不一致风险。
+- 备选方案:每个效果自行拉取并处理数据。
+- 不采用原因:会造成重复计算和状态分散,切换时更易出现抖动。
+
+3. 增加效果状态持久化字段并设置兼容回退。
+- 方案:保存 `selectedEffectId`;启动时若配置不存在或无效,回退到历史默认效果。
+- 原因:保障升级后配置兼容,避免因效果下线或重命名导致页面异常。
+- 备选方案:不持久化,每次启动使用默认效果。
+- 不采用原因:用户体验不稳定,无法满足“记忆和恢复”目标。
+
+4. 引入统一性能守卫。
+- 方案:按效果定义最大刷新率与采样点上限,在帧耗时过高时触发降级(降低刷新率/简化细节)。
+- 原因:新增效果后,性能风险主要来自复杂绘制路径;统一守卫可避免逐个临时修补。
+- 备选方案:仅做人工测试,不加运行时守卫。
+- 不采用原因:设备差异大,纯人工验证无法覆盖全部场景。
+
+## Risks / Trade-offs
+
+- [效果数量增加导致选择复杂度上升] → 通过分组命名和默认推荐效果降低决策成本。
+- [部分高复杂度效果在低端设备掉帧] → 为每个效果设性能等级并启用运行时降级。
+- [注册表与现有逻辑并存期间出现双源状态] → 迁移阶段保留单一状态入口(仅 `selectedEffectId` 为真源)。
+- [历史配置兼容问题] → 增加配置合法性校验,非法值自动回退并记录日志。
+
+## Migration Plan
+
+1. 新增效果注册表与标准化渲染接口,保持现有 4 种效果先接入新接口。
+2. 扩展效果切换 UI,读取注册表动态渲染选项。
+3. 添加 `selectedEffectId` 持久化与启动恢复逻辑。
+4. 逐步接入新增效果并启用性能守卫。
+5. 完成真机冒烟:切换稳定性、后台恢复、中低端设备帧率/功耗观察。
+
+回滚策略:
+- 若出现严重性能或崩溃问题,可在配置层禁用新增效果,仅保留原 4 种已验证效果;
+- 保留旧默认效果回退逻辑,确保用户可继续播放与可视化。
+
+## Open Questions
+
+- 首批新增效果的目标数量是否固定(例如从 4 扩展到 8)?
+- 效果命名和分组是否需要产品/UI 设计参与统一规范?
+- 性能守卫的阈值是否需要按设备档位做差异化配置?
+
+## Implementation Notes (2026-02-08)
+
+- 已完成代码实现:效果注册表、动态特效选择、新增脉冲特效、渲染性能守卫、`selectedSpectrumEffectId` 持久化与兼容回退。
+- 风险记录更新:性能守卫阈值基于通用策略,仍需真机分档验证后再微调。
+- 当前验证结果:已完成静态代码检查与文件级自检;真机冒烟(切页、暂停恢复、中低端性能)待执行。

+ 25 - 0
openspec/changes/add-more-spectrum-effects/proposal.md

@@ -0,0 +1,25 @@
+## Why
+
+当前播放器仅提供 4 种频谱效果,视觉样式选择有限,用户在长时间使用中容易产生审美疲劳。随着应用功能完善,扩展频谱效果数量可以提升可玩性与差异化,并降低用户流失风险,因此需要尽快补齐该能力。
+
+## What Changes
+
+- 在现有频谱可视化能力上新增多种可切换效果,覆盖不同风格(如条形、波形、环形等)并保持与当前播放状态联动。
+- 扩展效果切换入口与状态管理,确保新效果可被统一选择、记忆和恢复。
+- 统一效果参数约束(性能、刷新频率、颜色/尺寸边界),避免新增效果导致渲染卡顿或异常闪烁。
+- 保持现有 4 种效果可继续使用,避免破坏已有用户习惯与配置。
+
+## Capabilities
+
+### New Capabilities
+- (无)
+
+### Modified Capabilities
+- `spectrum-visualizer`: 扩展频谱效果集合与切换行为要求,新增“可用效果数量、可切换性、兼容旧效果配置、渲染性能边界”等规范。
+
+## Impact
+
+- 受影响模块:`entry/src/main/ets/view/` 下频谱显示与交互页面、相关 `viewmodel/` 状态管理逻辑。
+- 受影响规格:`openspec/specs/spectrum-visualizer/spec.md` 需要新增/更新 requirement delta。
+- API/协议影响:无外部接口变更,主要为应用内 UI 与渲染行为调整。
+- 性能影响:频谱渲染路径将承载更多效果分支,需要在中低端设备上验证帧率与功耗。

+ 52 - 0
openspec/changes/add-more-spectrum-effects/specs/spectrum-visualizer/spec.md

@@ -0,0 +1,52 @@
+## MODIFIED Requirements
+
+### Requirement: 频谱可视化组件
+系统 SHALL 提供 `SpectrumView` ArkUI 自定义组件,使用 Canvas 实时渲染频谱动画。该组件 SHALL 接收频谱数据并根据当前选中的特效模式进行绘制,且 SHALL 支持可扩展的特效集合(不少于 4 种并可继续扩展)。
+
+#### Scenario: 组件正常渲染
+- **WHEN** `SpectrumView` 组件挂载且音频正在播放
+- **THEN** 组件 SHALL 按目标刷新频率持续获取 PCM 数据,经 FFT 分析后通过 Canvas 绘制当前特效,默认目标刷新频率 SHALL 为 30fps
+
+#### Scenario: 组件不可见时停止渲染
+- **WHEN** 用户滑动 Swiper 离开频谱页面(组件不可见)
+- **THEN** 组件 SHALL 停止定时器、停止获取 PCM 数据和 Canvas 绑帧渲染,释放 CPU 资源
+
+#### Scenario: 音频暂停时冻结画面
+- **WHEN** 音频从播放切换到暂停状态
+- **THEN** 组件 SHALL 停止定时器,保留最后一帧的渲染画面不清除
+
+### Requirement: 特效切换交互
+系统 SHALL 在频谱页面提供特效切换 UI,用户可在当前可用的全部频谱特效间自由切换,特效列表 SHALL 由系统配置驱动而非固定为四种。
+
+#### Scenario: 点击切换特效
+- **WHEN** 用户点击频谱页面上的特效切换按钮
+- **THEN** 系统 SHALL 显示当前可用特效的名称和图标预览,用户选择后 SHALL 立即切换到对应特效
+
+#### Scenario: 切换时平滑过渡
+- **WHEN** 用户切换特效模式
+- **THEN** 系统 SHALL 在单次渲染周期内完成切换并以新特效开始渲染,不得出现超过 1 帧的空白画面
+
+#### Scenario: 记忆用户选择
+- **WHEN** 用户选择了某种特效后退出播放界面
+- **THEN** 下次进入频谱页面时 SHALL 默认使用上次选择的特效模式
+
+#### Scenario: 历史特效配置兼容回退
+- **WHEN** 已保存的特效 ID 在当前版本中不可用
+- **THEN** 系统 SHALL 自动回退到默认可用特效并继续渲染,不得导致频谱页面崩溃或空白
+
+## ADDED Requirements
+
+### Requirement: 频谱效果性能边界
+系统 SHALL 为每个频谱特效定义可执行的性能边界(包括最大刷新频率与最大绘制复杂度),并在运行时自动执行降级策略以保障播放稳定性。
+
+#### Scenario: 正常负载按目标参数渲染
+- **WHEN** 当前设备负载处于可接受范围且帧耗时未超阈值
+- **THEN** 系统 SHALL 按该特效的目标刷新频率和默认绘制复杂度渲染
+
+#### Scenario: 高负载触发自动降级
+- **WHEN** 连续多个渲染周期检测到帧耗时超过性能阈值
+- **THEN** 系统 SHALL 自动降低刷新频率或简化绘制复杂度,直到帧耗时恢复到阈值以内
+
+#### Scenario: 低负载恢复默认质量
+- **WHEN** 帧耗时持续回落到恢复阈值以内
+- **THEN** 系统 SHALL 在不引入明显抖动的前提下逐步恢复该特效的默认渲染参数

+ 25 - 0
openspec/changes/add-more-spectrum-effects/tasks.md

@@ -0,0 +1,25 @@
+## 1. 频谱效果注册与数据模型改造
+
+- [x] 1.1 在频谱相关模块中新增 Effect Registry 结构,统一定义 `effectId`、名称、默认参数、性能等级与渲染器映射
+- [x] 1.2 将现有 4 种特效迁移到注册表驱动,移除页面层对特效数量和枚举的硬编码依赖
+- [x] 1.3 在状态管理层新增 `selectedEffectId` 的合法性校验与默认回退逻辑
+
+## 2. 频谱渲染器与切换行为实现
+
+- [x] 2.1 重构 `SpectrumView` 渲染入口为“共享频谱帧输入 + 特效渲染器输出”分层,保证切换时单一数据源
+- [x] 2.2 新增至少 1 组额外频谱效果并接入注册表,验证可扩展路径(新增效果无需改动切换主流程)
+- [x] 2.3 实现特效切换 UI 按注册表动态展示可用效果,用户选择后在单次渲染周期内完成切换
+- [x] 2.4 实现历史特效配置兼容:已保存特效不可用时自动回退默认效果且不中断渲染
+
+## 3. 性能守卫与持久化
+
+- [x] 3.1 为每个特效配置目标刷新率与绘制复杂度上限,并在渲染循环中接入阈值检测
+- [x] 3.2 实现高负载自动降级策略(降低刷新率或简化细节)与低负载逐步恢复策略
+- [x] 3.3 打通 `selectedEffectId` 持久化读写,确保退出/重进播放页可恢复上次选择
+
+## 4. 验证与回归
+
+- [ ] 4.1 完成手工冒烟:播放中连续切换多种特效、暂停/恢复、Swiper 切页可见性切换
+- [ ] 4.2 完成兼容验证:历史配置值失效场景自动回退且页面不崩溃/不空白
+- [ ] 4.3 完成性能验证:中低端设备下观察帧率与功耗,确认性能守卫可触发并恢复
+- [x] 4.4 更新变更说明中的测试结果与风险记录,确保后续 `/opsx:apply` 可直接执行