Mobile Development 47 min read

Building a Foldable-Aware HarmonyOS Music Player with Dynamic Theming Using @ComponentV2

This article walks through building a HarmonyOS music player that adapts to foldable screens with distinct folded/expanded layouts and generates dynamic immersive themes from album colors using @ComponentV2, covering architecture, state management, and performance optimization.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
Building a Foldable-Aware HarmonyOS Music Player with Dynamic Theming Using @ComponentV2

The article presents a complete HarmonyOS music player demo that tackles two advanced requirements: form-factor adaptation for foldable screens and dynamic immersive theming derived from album cover colors. The author structures the walkthrough around real development decisions, explaining the problem that motivates each technical choice.

Overall Design and File Structure

The author separates pure logic from reactive rendering. Three principles guide the architecture:

Pure logic layer forbids ArkUI imports. PlayerModel.ets and ThemePalette.ets contain only TypeScript-style functions — no @Component, build(), Text, or Column. This enables unit testing under ohosTest without a UI runtime.

Component layer handles only reactive rendering. ImmersionPlayer holds a @Local state machine but delegates all state transitions to pure functions (e.g., next(this.state) returns a new state object).

Demo page only assembles. ErqiMainPage listens for fold state, initializes full-screen immersion, and feeds data to ImmersionPlayer.

File layout:

├── PlayerModel.ets      # Pure logic: playlist mock, playback state machine, progress tick, loop modes
├── ThemePalette.ets     # Pure logic: preset primary color → derives frosted-glass gradient theme
├── ImmersionPlayer.ets  # @ComponentV2 root: state hub + color theming + form-factor dispatch
├── MiniPlayer.ets       # @ComponentV2 folded mini player
├── ExpandedPlayer.ets   # @ComponentV2 expanded dual-column immersive
└── Index.ets            # Module exports

Data Model and Pure Logic Layer

Song and Playback State

Core interfaces:

export interface Song {
  id: number
  title: string
  artist: string
  album: string
  duration: number      // milliseconds, mock data
  accent: string        // preset primary #RRGGBB
  accent2: string       // secondary color for gradient cover
  coverSeed: number     // gradient angle seed for visual variety
  lyrics: string[]      // mock lyrics for expanded dual-column
}

export interface PlaylistState {
  songs: Song[]
  currentIndex: number
  isPlaying: boolean
  positionMs: number
  loopMode: LoopMode    // SINGLE | LIST | SHUFFLE
}

Design notes: accent and accent2 live on Song because they are the source for the immersive theme. Each song carries its own palette.

No real cover images + PixelMap color extraction are used; preset palettes keep the demo stable on simulators. Swapping derivePalette(accent) for a PixelMap-based extractor would require zero component changes. coverSeed varies the gradient angle per song so covers don't look identical. LoopMode is an enum (not strings) for compile-time exhaustiveness checking in switch.

Playback State Machine: Pure Functions + Immutable Updates

All state mutators return a new object; no in-place mutation. This matches @ComponentV2 's @Local semantics: reassignment triggers refresh, mutation does not. ArkTS forbids object spread ( arkts-no-spread), so a copyState helper explicitly copies every field:

function copyState(state: PlaylistState): PlaylistState {
  return {
    songs: state.songs,
    currentIndex: state.currentIndex,
    isPlaying: state.isPlaying,
    positionMs: state.positionMs,
    loopMode: state.loopMode
  }
}

export function togglePlay(state: PlaylistState): PlaylistState {
  const next = copyState(state)
  next.isPlaying = !state.isPlaying
  return next
}

export function tick(state: PlaylistState, deltaMs: number = TICK_MS): PlaylistState {
  if (state.songs.length === 0) return state
  const song = state.songs[state.currentIndex]
  const advanced = state.positionMs + deltaMs
  if (advanced < song.duration) {
    const next = copyState(state)
    next.positionMs = advanced
    return next
  }
  // reached end — handle by loop mode
  const next = copyState(state)
  next.positionMs = POSITION_START
  switch (state.loopMode) {
    case LoopMode.SINGLE: return next
    case LoopMode.SHUFFLE:
      next.currentIndex = randomIndex(state.songs.length, state.currentIndex)
      return next
    case LoopMode.LIST:
    default:
      next.currentIndex = wrapIndex(state.currentIndex + 1, state.songs.length)
      return next
  }
}

Each function copies, mutates the copy, returns it. This satisfies V2's "assign new object to refresh" rule while working around ArkTS's spread restriction. Unit tests cover the three loop-mode branches.

Palette Derivation: Primary Color → Full Theme

ThemePalette.ets

exports derivePalette(accent, accent2): PlayerTheme. Key steps:

Luminance decision drives text color. isLightAccent uses WCAG relative luminance; light accents get dark text ( #1A1A1A), dark accents get white text ( #FFFFFF). Unit tests assert this behavior.

Background gradient uses 5 stops. From mix(accent, black, 0.35) at top down to mix(accent, black, 0.9) at bottom. This preserves hue recognition at the top while fading to deep-space black, embodying "immersive light".

Cover gradient uses accent + accent2 with the song's coverSeed angle.

Form-Factor Awareness: From Screen Width to @Monitor

Why Not foldStatusChange ?

The official display.on('foldStatusChange') API exists at runtime but is missing from the local SDK's .d.ts definitions (only 'add' | 'remove' | 'change' for multi-display events). This "type definitions lag runtime" issue is common in HarmonyOS. Rather than @ts-ignore, the author adopts a more universal approach: screen-width detection.

Width-Based Form-Factor Detection

In ErqiMainPage:

const FOLD_WIDTH_THRESHOLD: number = 600  // vp

private refreshByWidth(): void {
  try {
    const disp = display.getDefaultDisplaySync()
    const density = disp.densityPixels
    const widthVp = density > 0 ? disp.width / density : disp.width
    const form: PlayerFormFactor = widthVp < FOLD_WIDTH_THRESHOLD ? 'folded' : 'expanded'
    if (!this.manualOverride) {
      this.foldSnapshot = { formFactor: form, rawStatus: form === 'folded' ? 2 : 1 }
    }
  } catch (err) {
    hilog.error(DOMAIN, 'erqi', 'getDefaultDisplaySync failed: %{public}s', JSON.stringify(err))
  }
}
width

is physical pixels; densityPixels is physical density. Division yields vp (virtual pixels), ArkUI's density-independent unit. A 600 vp threshold classifies typical folded outer screens as folded, inner screens as expanded. Using vp normalizes across densities.

Capturing Fold/Unfold with onAreaChange

Initial width check in aboutToAppear isn't enough; users fold/unfold. The root container uses onAreaChange:

build() {
  Stack({ alignContent: Alignment.Top }) {
    ImmersionPlayer({ ... foldSnapshot: this.foldSnapshot ... })
    this.DebugBar()
  }
  .width('100%')
  .height('100%')
  .onAreaChange((_oldValue: Area, _newValue: Area) => {
    this.refreshByWidth()
  })
}

When the window resizes during fold/unfold, onAreaChange fires and re-evaluates width. No fold-specific API required.

Manual Debug Toggle

On regular phone simulators (fixed width) you can't demo fold transitions. A manual override flag lets developers force folded/expanded on any device:

private onManualToggle(on: boolean): void {
  this.manualOverride = true
  this.manualFolded = on
  this.foldSnapshot = { formFactor: on ? 'folded' : 'expanded', rawStatus: on ? 2 : 1 }
}

Once manual override is set, automatic width detection stops overriding it. Practical for demos, recordings, and colleague reviews.

@Monitor: Component-Layer Form-Factor Reaction

ImmersionPlayer

receives foldSnapshot via @Param and uses @Monitor to react:

@ComponentV2
export struct ImmersionPlayer {
  @Param foldSnapshot: FoldSnapshot = { formFactor: 'expanded', rawStatus: 1 }
  @Event onFormChanged: (form: PlayerFormFactor) => void = () => {}

  @Monitor('foldSnapshot.formFactor')
  onFoldChanged(): void {
    const snapshot = this.foldSnapshot
    console.info(`[ImmersionPlayer] form changed -> ${snapshot.formFactor}`)
    this.onFormChanged(snapshot.formFactor)
  }
}
@Monitor('foldSnapshot.formFactor')

watches a specific field path, not the whole object. Only actual form-factor changes trigger the callback; rawStatus changes alone do not. @Monitor is for side-effects (logging, callbacks, animation triggers); @Computed is for derived data.

Translating System Concepts to Domain Concepts

The demo page converts screen width/density (system concepts) into PlayerFormFactor = 'folded' | 'expanded' (domain concept) before passing to components. Components never import display or know about vp/density. This anti-corruption layer isolates internal code from external API changes — e.g., switching to real fold events later only touches the demo page.

Immersive Light: Full-Screen Immersion

Full-Screen Layout + Hidden System Bars

Physical immersion uses window APIs:

private async enterImmersion(): Promise<void> {
  try {
    const ctx = this.getUIContext().getHostContext() as common.UIAbilityContext
    const win = await window.getLastWindow(ctx)
    await win.setWindowLayoutFullScreen(true)   // layout extends under status bar
    await win.setWindowSystemBarEnable([])      // hide status & navigation bars
  } catch (err) {
    hilog.error(DOMAIN, 'erqi', 'enterImmersion failed: %{public}s', JSON.stringify(err))
  }
}
setWindowLayoutFullScreen(true)

extends layout into status-bar area; setWindowSystemBarEnable([]) hides both bars. The gradient background then covers the entire screen without system-bar fragmentation. The call is async and may fail (window not ready), so it's wrapped in try/catch and logged only.

Why Not expandSafeArea ?

Many tutorials add .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.ALL]), but the local SDK lacks SafeAreaEdge enum (same version-lag issue). The author achieves immersion without it: hiding the status bar via setWindowSystemBarEnable([]) removes the obstruction, and a top padding (e.g., top: 44 on the debug bar) keeps content clear of the screen edge. Pragmatic trade-off: avoid version-controversial APIs while delivering the same visual result.

Debug Bar Immersion Treatment

The top debug bar uses frosted glass:

@Builder
DebugBar() {
  Row({ space: 12 }) { /* ... */ }
  .width('100%')
  .padding({ left: 20, right: 20, top: 44, bottom: 10 })
  .backgroundColor('#00000044')
  .backdropBlur(12)
}
.backdropBlur(12)

blurs the player background behind the bar; semi-transparent black background + blur is the standard HarmonyOS floating-layer pattern. The top: 44 padding avoids the status-bar zone (though hidden, it reserves space). Production code should use getWindowAvoidArea dynamically.

Cover-Color Theming: Preset Palette → Frosted-Glass Gradient

@Computed Theme Derivation

ImmersionPlayer

derives current song and theme via @Computed:

@ComponentV2
export struct ImmersionPlayer {
  @Local state: PlaylistState = createPlaylist([], 0)

  @Computed
  get song(): Song {
    return currentSong(this.state) ?? emptySong()
  }

  @Computed
  get theme(): PlayerTheme {
    const s = this.song
    if (s.id === 0) return derivePalette('#3A4A6B', '#1A2340')
    return derivePalette(s.accent, s.accent2)
  }
}
@Computed

means: recompute only when dependencies ( @Local / @Param) change. theme depends on song, which depends on state. On track change, state.currentIndex changes → song recomputes → theme recomputes → background gradient updates automatically. No manual "update theme" calls needed. This is cleaner than V1's @Observed + manual refresh or explicit this.updateTheme().

Applying the Background Gradient

Two-layer stack:

build() {
  Stack({ alignContent: Alignment.TopStart }) {
    // Layer 1: full-screen immersive gradient
    Column()
      .width('100%')
      .height('100%')
      .linearGradient({ angle: this.theme.angle, colors: this.bgColors() })
      .animation({ duration: 700, curve: Curve.EaseInOut })

    // Layer 2: frosted-glass light spot
    Column()
      .width('100%')
      .height('46%')
      .linearGradient({ angle: 180, colors: this.glowColors() })
      .hitTestBehavior(HitTestMode.None)
      .opacity(0.35)
      .backgroundBlurStyle(BlurStyle.BACKGROUND_THICK)
    // ...
  }
}

Layer 1 is the 5-stop vertical gradient (hue → deep black). Layer 2 is a top light spot using BACKGROUND_THICK frosted glass, simulating a beam of light from above. Both layers get .animation({ duration: 700, curve: Curve.EaseInOut }) for smooth cross-track transitions.

Colors Format Conversion

linearGradient

expects [ResourceColor, number][] (color + position tuples), but theme.backgroundStops is GradientStop[] objects. A safe conversion uses explicit forEach + typed array push (ArkTS map with tuple as assertions can be unstable):

private bgColors(): Array<[ResourceColor, number]> {
  const result: Array<[ResourceColor, number]> = []
  this.theme.backgroundStops.forEach((stop: GradientStop) => {
    const pair: [ResourceColor, number] = [stop.color, stop.position]
    result.push(pair)
  })
  return result
}

Internal logic uses object arrays (testable, readable); rendering layer converts to API tuples. This mirrors the pure-logic / component separation.

Eliminating Color-Flicker on Track Change

Initial version flickered: @Computed theme recalculated instantly on state.currentIndex change, and linearGradient.colors hard-cut to new values. Fix: attach .animation() to the gradient-bearing Column. When colors changes, ArkUI animates the transition over 700 ms instead of snapping. Both gradient layers must have matching .animation() or one layer will hard-cut while the other animates, causing visual tear.

General rule: any visual property derived via @Computed that changes (color, size, position) should be wrapped with .animation() or animateTo. @Computed is instantaneous; transition is a rendering-layer concern.

Folded/Expanded Layouts: Mini Player & Dual-Column Immersive

Form-Factor Dispatch

ImmersionPlayer.build()

switches via if/else on foldSnapshot.formFactor:

if (this.foldSnapshot.formFactor === 'folded') {
  if (this.hasSong) {
    MiniPlayer({ song: this.song, state: this.state, theme: this.theme, ratio: this.ratio, ... })
      .transition(TransitionEffect.OPACITY
        .combine(TransitionEffect.scale({ x: 0.88, y: 0.88 }))
        .animation({ duration: 300, curve: Curve.EaseOut }))
  }
} else {
  if (this.hasSong) {
    ExpandedPlayer({ song: this.song, state: this.state, theme: this.theme, ratio: this.ratio, ... })
      .transition(TransitionEffect.OPACITY
        .combine(TransitionEffect.scale({ x: 1.06, y: 1.06 }))
        .animation({ duration: 320, curve: Curve.EaseOut }))
  }
}
TransitionEffect

uses chained API ( OPACITY.combine(scale(...))), not object literals. scale takes {x, y}, not a single number. When formFactor flips, the old branch triggers Delete transition, new branch triggers Insert transition — cross-fade with scale. Mini scales down (0.88) for "small-screen pop"; Expanded scales up (1.06) for "large-screen expand". Directional moves ( TransitionEffect.move) were attempted but the SDK lacked TransitionEdge.BOTTOM/RIGHT constants, so the author stuck with verified opacity+scale combos.

MiniPlayer: Folded Compact Layout

Vertical stack: status label ("折叠态 · 外屏播放"), square gradient cover block showing first letter of title, title/artist, thin Progress (linear, not Slider — saves space), three large circle buttons (prev/play/next). No loop-mode or playlist to avoid accidental taps.

Component signature:

@ComponentV2
export struct MiniPlayer {
  @Param song: Song = emptySong()
  @Param state: PlaylistState = emptyState()
  @Param theme: PlayerTheme = emptyTheme()
  @Param ratio: number = 0
  @Event onTogglePlay: () => void = () => {}
  @Event onNext: () => void = () => {}
  @Event onPrev: () => void = () => {}
  @Event onSeek: (ratio: number) => void = () => {}
}

Pure controlled component: receives everything via @Param, emits intents via @Event. No internal business state. Default values use factory functions ( emptySong()) because ArkTS strict mode rejects {} as Song.

ExpandedPlayer: Expanded Dual-Column Immersive

Row

with two Column s:

Left (weight 1.2): large 240×240 cover + title, lyrics block, Slider progress (draggable), five-key controls (prev/play/next + loop-mode + favorite).

Right (weight 1): playlist with per-track color swatches (using item.accent / accent2), frosted-glass background ( .backdropBlur(20)) for visual separation without hard division. Slider handles drag via onChange(value, mode): set isDragging true on Moving / Click, call onSeek(value/100) and clear flag on End / Click.

Core value: not "scale up small screen" but "reorganize information density". Folded drops secondary features (playlist, loop mode, scrubbing); expanded restores them and adds spatial dual-column layout.

Transition Timing & Sync

Physical fold/unfold takes ~300–500 ms; transition durations kept in that range (300/320 ms) to avoid "screen already folded, animation still playing".

Track-change theme transition uses 700 ms (balanced: 200 ms feels abrupt, 1200 ms feels laggy).

Cover gradient and background gradient must animate together; otherwise cover snaps to new color while background is still transitioning. Both layers receive identical .animation().

@ComponentV2 Decorator Reference

@Local — Used in ImmersionPlayer. Role: internal component state; assign new object to refresh. Code location: main state machine.

@Param — Used in MiniPlayer / ExpandedPlayer. Role: read-only input from parent; parent change → child refresh. Code location: child component data entry.

@Event — Used in child components. Role: child→parent callbacks ( onTogglePlay, onNext, onSeek …). Code location: child interaction.

@Once — Used in ImmersionPlayer ( initialSongs). Role: accept only first assignment; ignore subsequent parent updates. Code location: initial data.

@Monitor — Used in ImmersionPlayer. Role: side-effect on dependency change (log, callback, animation trigger). Code location: fold-state response.

@Computed — Used in ImmersionPlayer. Role: derived data; cached until dependencies change. Code location: theme derivation.

Decorator Deep-Dives

@Local : assign to refresh. this.state = togglePlay(this.state) ✅; this.state.isPlaying = true ❌ (V2 won't detect). This is the key mental shift from V1 ( @Observed + mutation worked).

@Param + @Event : controlled components. One-way data flow down ( @Param), events up ( @Event). Child reusable, state centralized. Cost: parent writes forwarding arrow functions ( () => this.onTogglePlay()).

@Monitor vs @Computed . @Computed = pure derivation (returns value, cached). @Monitor = side-effect (no return, runs every change). Putting console.log or callbacks in @Computed breaks caching semantics.

@Once : avoid parent refresh overwriting child state. @Param @Once initialSongs used only in aboutToAppear to seed local state; later parent changes ignored.

Decorator Decision Cheat-Sheet

Internal state, self read/write → @Local Receive read-only from parent → @Param (add @Once for one-time init)

Emit event to parent → @Event Compute from other data → @Computed Run action on dependency change →

@Monitor

Performance & Smoothness: Keeping Immersion Jitter-Free

Timer Precision & Refresh Rate

Progress tick uses setInterval at 1000 ms (not 100 ms). One-second jumps are perceptually smooth for a progress bar; 10 Hz would trigger @Local assignment → @Computed ratio recompute → Progress refresh every 100 ms, stressing low-end devices. Real-world "silky" progress bars decouple: data layer updates once/sec, UI layer interpolates via its own animation.

Critical: if (this.state.isPlaying) guard inside the tick — otherwise paused tracks would silently advance.

Frosted-Glass Cost

backgroundBlurStyle

/ backdropBlur are GPU-heavy. Used only on two static surfaces: background light spot and debug bar. Never on scrolling lists or large frequently-repainting areas. Principle: frosted glass on static/low-frequency surfaces only.

Restrained Transition Effects

Form-factor transitions use only opacity+scale (no rotate/translate/color). During cross-fade both old and new components exist simultaneously; complex multi-property animations would overload mid-tier foldable hardware. Restrained effects preserve perceived quality without overwhelming GPU.

@Computed Caching Value

theme

depends on song. While state.currentIndex is stable (no track change), theme returns cached value — derivePalette (hex parsing, luminance calc, 5-stop mix) never re-runs. Without @Computed, a plain getter would recompute on every render (e.g., each progress tick). Rule: expensive derivations belong in @Computed, not plain getters.

Key Takeaways

Pure-logic + component layering is the most comfortable V2 structure. Logic testable without UI; components stay thin and reactive.

Translate system concepts to domain concepts at the boundary. Demo page converts vp/density → PlayerFormFactor; components never see display. Anti-corruption layer isolates internals from external API churn.

Immersion = physical + visual. Physical: setWindowLayoutFullScreen(true) + setWindowSystemBarEnable([]). Visual: dynamic theming + frosted-glass light spot. Both required — physical alone looks "full-screen but ugly"; visual alone gets blocked by status bar.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Performance OptimizationState ManagementHarmonyOSArkUIfoldable screendynamic themingmusic player@ComponentV2
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.