Selaa lähdekoodia

继续搬迁 播放页功能

onecold 4 kuukautta sitten
vanhempi
sitoutus
c53d819468
24 muutettua tiedostoa jossa 3244 lisäystä ja 403 poistoa
  1. 41 0
      entry/src/main/ets/common/util/PlayerDismissHelper.ets
  2. 2 2
      entry/src/main/ets/lyric/LyricController.ets
  3. 0 321
      entry/src/main/ets/lyric/view/CcLyricView.ets
  4. 43 22
      entry/src/main/ets/lyric/view/LyricView2.ets
  5. 4 0
      entry/src/main/ets/lyric/view/LyricViewRenderHelper.ets
  6. 95 8
      entry/src/main/ets/pages/NewIndex.ets
  7. 136 4
      entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets
  8. 2 0
      entry/src/main/ets/view/EqualizerView.ets
  9. 26 33
      entry/src/main/ets/view/FindView.ets
  10. 65 0
      entry/src/main/ets/view/player/MusiPlayerViewStateHelper.ets
  11. 1066 13
      entry/src/main/ets/view/player/MusicPlayerView.ets
  12. 115 0
      entry/src/main/ets/view/player/PlayerAdvancedControlHelper.ets
  13. 114 0
      entry/src/main/ets/view/player/PlayerFavoriteService.ets
  14. 103 0
      entry/src/main/ets/view/player/PlayerJumpTopEndSheet.ets
  15. 60 0
      entry/src/main/ets/view/player/PlayerLyricCopyHelper.ets
  16. 607 0
      entry/src/main/ets/view/player/PlayerLyricSettingSheet.ets
  17. 108 0
      entry/src/main/ets/view/player/PlayerLyricSettingsHelper.ets
  18. 305 0
      entry/src/main/ets/view/player/PlayerMetadataService.ets
  19. 28 0
      entry/src/main/ets/view/player/PlayerMoreActionHelper.ets
  20. 54 0
      entry/src/main/ets/view/player/PlayerMoreSheet.ets
  21. 77 0
      entry/src/main/ets/view/player/PlayerTimeCloseSheet.ets
  22. 127 0
      entry/src/main/ets/view/player/PlayerTimedCloseService.ets
  23. 27 0
      entry/src/ohosTest/ets/test/PlayerDismissHelper.test.ets
  24. 39 0
      entry/src/ohosTest/ets/test/PlayerFavoriteService.test.ets

+ 41 - 0
entry/src/main/ets/common/util/PlayerDismissHelper.ets

@@ -40,6 +40,16 @@ export interface MiniPlayerRevealAnimationPlan {
   shakeDelayMs: number
 }
 
+export interface PlayerPageTransitionState {
+  scaleX: number
+  scaleY: number
+  opacity: number
+  translateX: number
+  translateY: number
+  centerX: string
+  centerY: string
+}
+
 export function resolveMiniPlayerCoverCenterOffset(barWidthVp: number, coverSizeVp: number = 48,
   horizontalPaddingVp: number = 16, coverLeftMarginVp: number = 5): number {
   const safeBarWidth = barWidthVp > 0 ? barWidthVp : 324
@@ -131,3 +141,34 @@ export function resolveMiniPlayerRevealAnimationPlan(target: MiniPlayerMorphTarg
     shakeDelayMs: safeMorphDuration + safeFrameDelay,
   }
 }
+
+export function resolvePlayerPageCollapsedTransitionState(viewportWidthVp: number, viewportHeightVp: number,
+  bottomSafeHeightVp: number, useFixedBottomMargin: boolean, miniPlayerHeightVp: number = 70,
+  fixedBottomMarginVp: number = 30, sourceDiameterVp: number = 10, collapsedOpacity: number = 0.18): PlayerPageTransitionState {
+  const safeWidth = viewportWidthVp > 0 ? viewportWidthVp : 360
+  const safeHeight = viewportHeightVp > 0 ? viewportHeightVp : 800
+  const target = resolvePlayerDismissMorphTarget(viewportWidthVp, viewportHeightVp, bottomSafeHeightVp, useFixedBottomMargin,
+    miniPlayerHeightVp, fixedBottomMarginVp, sourceDiameterVp + 8, sourceDiameterVp)
+
+  return {
+    scaleX: target.dotScaleX,
+    scaleY: target.dotScaleY,
+    opacity: Math.max(0, Math.min(1, collapsedOpacity)),
+    translateX: 0,
+    translateY: 0,
+    centerX: `${target.targetCenterX / safeWidth * 100}%`,
+    centerY: `${target.targetCenterY / safeHeight * 100}%`
+  }
+}
+
+export function resolvePlayerPageExpandedTransitionState(centerX: string = '50%', centerY: string = '50%'): PlayerPageTransitionState {
+  return {
+    scaleX: 1,
+    scaleY: 1,
+    opacity: 1,
+    translateX: 0,
+    translateY: 0,
+    centerX,
+    centerY
+  }
+}

+ 2 - 2
entry/src/main/ets/lyric/LyricController.ets

@@ -4,8 +4,8 @@ import { Lyric } from './bean/Lyric'
 const DEFAULT_LINE_SPACE = 16
 const DEFAULT_TEXT_SIZE = 18
 const DEFAULT_HIGHLIGHT_SCALE = 1.2
-const DEFAULT_TEXT_COLOR = "#80000000"
-const DEFAULT_HIGHLIGHT_COLOR = "#000000"
+const DEFAULT_TEXT_COLOR = "#ffffff"
+const DEFAULT_HIGHLIGHT_COLOR = "#ffffff"
 const DEFAULT_EDGE_COLOR = "#ffffff"
 const DEFAULT_ANIM_DURATION = 300
 const DEFAULT_CACHE_SIZE = 4

+ 0 - 321
entry/src/main/ets/lyric/view/CcLyricView.ets

@@ -1,321 +0,0 @@
-import { DrawContext, FrameNode, NodeController, RenderNode, UIContext } from '@kit.ArkUI';
-import { drawing } from '@kit.ArkGraphics2D';
-import { Lyric } from '../bean/Lyric';
-import { LyricLineMeasureResult } from '../bean/LyricLineMeasureResult';
-
-/**
- * A component to display the lyric for music player.
- *
- * Author: Seagazer
- * Date: 2024/12/17
- */
-
-export class CcLyricView extends NodeController {
-    private lyricView = new LyricNode()
-
-    makeNode(uiContext: UIContext): FrameNode | null {
-        let rootView = new FrameNode(uiContext)
-        let renderRoot = rootView.getRenderNode()
-        if (renderRoot) {
-            renderRoot.appendChild(this.lyricView)
-        }
-        return rootView
-    }
-
-    aboutToAppear(): void {
-        this.lyricView.onCreate()
-    }
-
-    aboutToDisappear(): void {
-        this.lyricView.onDestroy()
-    }
-
-    aboutToResize(size: Size): void {
-        this.lyricView.onSizeChanged(size)
-        console.warn('abc', '-------' + JSON.stringify(this.lyricView.frame))
-    }
-
-    onTouchEvent(event: TouchEvent): void {
-        this.lyricView.onTouch(event)
-    }
-
-    setLyric(lyric?: Lyric): void {
-        this.lyricView.setLyric(lyric)
-    }
-}
-
-
-class LyricNode extends RenderNode {
-    private lyric?: Lyric = undefined
-    private lyricMeasureResult: Map<number, LyricLineMeasureResult> = new Map()
-    private w: number = 0
-    private h: number = 0
-    private highLightPaint: drawing.Brush = new drawing.Brush()
-    private normalPaint: drawing.Brush = new drawing.Brush()
-    private textSize: number = vp2px(24)
-    private lineSpace: number = vp2px(24)
-    private multiLineSpace: number = vp2px(16)
-    private startY: number = 0
-    private scrollY: number = 0
-    private centerY: number = 0
-    private currentIndex: number = 4
-    private emptyHint: string = ""
-    private highlightScale: number = 1.2
-    private normalFont: drawing.Font = new drawing.Font()
-    private highlightFont: drawing.Font = new drawing.Font()
-    private padding: number = vp2px(16)
-    private downY: number = 0
-
-    onCreate(): void {
-        this.highLightPaint.setColor({
-            red: 0,
-            green: 0,
-            blue: 0,
-            alpha: 255
-        })
-        this.normalPaint.setColor({
-            red: 125,
-            green: 125,
-            blue: 125,
-            alpha: 200
-        })
-    }
-
-    onDestroy(): void {
-    }
-
-    onSizeChanged(size: Size): void {
-        this.frame = {
-            x: 0,
-            y: 0,
-            width: size.width,
-            height: size.height
-        }
-        this.w = vp2px(size.width)
-        this.h = vp2px(size.height)
-        this.reSize()
-    }
-
-    setHighlightScale(scale: number) {
-        this.highlightScale = scale
-        this.reSize()
-    }
-
-    setFontSize(size: number): void {
-        this.textSize = vp2px(size)
-        this.normalFont.setSize(this.textSize)
-        this.reSize()
-    }
-
-    setEmptyHint(hint: string): void {
-        this.emptyHint = hint
-    }
-
-    setLyric(lyric?: Lyric): void {
-        if (this.lyric == lyric) {
-            return
-        }
-        this.lyric = lyric
-        if (lyric) {
-            this.lyricMeasureResult.clear()
-            this.lyricMeasureResult = this.measureLines(lyric)
-            this.lyricMeasureResult.forEach((v) => {
-                console.debug('abc', '---' + JSON.stringify(v))
-            })
-        } else {
-            this.setEmpty()
-        }
-        this.invalidate()
-    }
-
-    private reSize(): void {
-        this.centerY = this.h / 2 - this.textSize / 2
-        this.normalFont.setSize(this.textSize)
-        this.highlightFont.setSize(this.textSize * this.highlightScale)
-        if (this.lyric) {
-            this.lyricMeasureResult.clear()
-            this.lyricMeasureResult = this.measureLines(this.lyric)
-            this.invalidate()
-        }
-    }
-
-    private setEmpty(): void {
-
-    }
-
-    private measureLines(lyric: Lyric): Map<number, LyricLineMeasureResult> {
-        const lineHeight = this.textSize
-        const measureResult = new Map<number, LyricLineMeasureResult>()
-        const lyricList = lyric.lyricList
-        for (let i = 0; i < lyricList.length; i++) {
-            const lyricText = lyricList[i].text
-            const measureItem = new LyricLineMeasureResult()
-            let contentW = this.w - this.padding * 2
-            // normal style
-            this.measureByFont(measureItem, false, lyricText, contentW, lineHeight)
-            // highlight style
-            this.measureByFont(measureItem, true, lyricText, contentW, lineHeight * this.highlightScale)
-            measureResult.set(i, measureItem)
-        }
-        return measureResult
-    }
-
-    private measureByFont(measureItem: LyricLineMeasureResult, isHighlight: boolean, lyricText: string, contentW: number, lineHeight: number): void {
-        const font = isHighlight ? this.highlightFont : this.normalFont
-        const lineWidth = font.measureText(lyricText, drawing.TextEncoding.TEXT_ENCODING_UTF8)
-        if (lineWidth > contentW) { // multi lines
-            let tempWidth = 0
-            let tempText = ''
-            let tempHeight = lineHeight
-            let tempCount = 1
-            for (let j = 0; j < lyricText.length; j++) {
-                let char = lyricText[j]
-                let charWidth = font.measureText(char, drawing.TextEncoding.TEXT_ENCODING_UTF8)
-                if (tempWidth + charWidth > contentW) {
-                    if (isHighlight) {
-                        measureItem.preLineWidthHL.push(tempWidth)
-                        measureItem.preLineTextHL.push(tempText)
-                    } else {
-                        measureItem.preLineWidth.push(tempWidth)
-                        measureItem.preLineText.push(tempText)
-                    }
-                    tempWidth = 0
-                    tempText = ''
-                    tempCount++
-                    tempHeight = tempHeight + this.multiLineSpace + lineHeight
-                }
-                tempWidth += charWidth
-                tempText += char
-            }
-            if (isHighlight) {
-                measureItem.preLineWidthHL.push(tempWidth)
-                measureItem.preLineTextHL.push(tempText)
-                measureItem.lineCountHL = tempCount
-                measureItem.lineHeightHL = tempHeight
-            } else {
-                measureItem.preLineWidth.push(tempWidth)
-                measureItem.preLineText.push(tempText)
-                measureItem.lineCount = tempCount
-                measureItem.lineHeight = tempHeight
-            }
-        } else { // single line
-            if (isHighlight) {
-                measureItem.lineCountHL = 1
-                measureItem.preLineWidthHL = [lineWidth]
-                measureItem.lineHeightHL = lineHeight
-                measureItem.preLineTextHL = [lyricText]
-            } else {
-                measureItem.lineCount = 1
-                measureItem.preLineWidth = [lineWidth]
-                measureItem.lineHeight = lineHeight
-                measureItem.preLineText = [lyricText]
-            }
-        }
-    }
-
-    onTouch(event: TouchEvent): void {
-        const ev = event.touches[0]
-        switch (ev.type) {
-            case TouchType.Down:
-                this.downY = ev.y
-                break
-            case TouchType.Move:
-                let curY = ev.y
-                let dy = curY - this.downY
-                this.scrollY += vp2px(dy)
-                this.downY = curY
-                break
-            case TouchType.Up:
-                this.downY = 0
-                break
-        }
-        this.invalidate()
-    }
-
-    draw(context: DrawContext): void {
-        const canvas = context.canvas
-        this.startY = this.centerY + this.scrollY
-        if (this.lyric) {
-            const lyricLines = this.lyric.lyricList
-            for (let i = 0; i < lyricLines.length; i++) {
-                const measureResult = this.lyricMeasureResult.get(i)!
-                if (this.currentIndex == i) {
-                    this.drawHighlight(canvas, measureResult)
-                } else {
-                    this.drawNormal(canvas, measureResult)
-                }
-            }
-            canvas.detachBrush()
-        }
-    }
-
-    private isDrawOnScreen(): boolean {
-        return this.startY > -this.textSize && this.startY < this.h + this.textSize
-    }
-
-    private drawHighlight(canvas: drawing.Canvas, measureResult: LyricLineMeasureResult): void {
-        canvas.attachBrush(this.highLightPaint)
-        const count = measureResult.lineCountHL
-        const subLines = measureResult.preLineTextHL
-        if (count > 1) { //draw multi lines
-            for (let i = 0; i < subLines.length; i++) {
-                if (this.isDrawOnScreen()) {
-                    const subLineText = subLines[i]
-                    if (subLineText) {
-                        const textBlob = drawing.TextBlob.makeFromString(subLineText, this.highlightFont, drawing.TextEncoding.TEXT_ENCODING_UTF8)
-                        let drawX = (this.w - measureResult.preLineWidthHL[i]) / 2
-                        canvas.drawTextBlob(textBlob, drawX, this.startY)
-                    }
-                }
-                if (i < subLines.length - 1) {
-                    this.startY += this.textSize + this.multiLineSpace
-                } else {
-                    this.startY += this.textSize + this.lineSpace
-                }
-            }
-        } else { // draw single line
-            if (this.isDrawOnScreen()) {
-                const lineText = subLines[0]
-                if (lineText) {
-                    let textBlob = drawing.TextBlob.makeFromString(lineText, this.highlightFont, drawing.TextEncoding.TEXT_ENCODING_UTF8)
-                    let drawX = (this.w - measureResult.preLineWidthHL[0]) / 2
-                    canvas.drawTextBlob(textBlob, drawX, this.startY)
-                }
-            }
-            this.startY += this.textSize + this.lineSpace
-        }
-    }
-
-    private drawNormal(canvas: drawing.Canvas, measureResult: LyricLineMeasureResult): void {
-        canvas.attachBrush(this.normalPaint)
-        const count = measureResult.lineCount
-        const subLines = measureResult.preLineText
-        if (count > 1) { //draw multi lines
-            for (let j = 0; j < subLines.length; j++) {
-                if (this.isDrawOnScreen()) {
-                    const subLineText = subLines[j]
-                    if (subLineText) {
-                        const textBlob = drawing.TextBlob.makeFromString(subLineText, this.normalFont, drawing.TextEncoding.TEXT_ENCODING_UTF8)
-                        let drawX = (this.w - measureResult.preLineWidth[j]) / 2
-                        canvas.drawTextBlob(textBlob, drawX, this.startY)
-                    }
-                }
-                if (j < subLines.length - 1) {
-                    this.startY += this.textSize + this.multiLineSpace
-                } else {
-                    this.startY += this.textSize + this.lineSpace
-                }
-            }
-        } else { // draw single line
-            if (this.isDrawOnScreen()) {
-                const lineText = subLines[0]
-                if (lineText) {
-                    let textBlob = drawing.TextBlob.makeFromString(lineText, this.normalFont, drawing.TextEncoding.TEXT_ENCODING_UTF8)
-                    let drawX = (this.w - measureResult.preLineWidth[0]) / 2
-                    canvas.drawTextBlob(textBlob, drawX, this.startY)
-                }
-            }
-            this.startY += this.textSize + this.lineSpace
-        }
-    }
-}

+ 43 - 22
entry/src/main/ets/lyric/view/LyricView2.ets

@@ -1,3 +1,4 @@
+import { duration2text } from '../extensions/Extension';
 import { LyricController } from '../LyricController';
 import { Lyric } from '../bean/Lyric';
 import { ListAdapter } from '../extensions/ListAdapter';
@@ -5,7 +6,7 @@ import { LyricLine } from '../bean/LyricLine';
 import { LyricWord } from '../bean/LyricWord';
 import { transverter, TransverterType, TransverterLanguage } from "@nutpi/chinese_transverter"
 import { LengthMetrics } from '@kit.ArkUI';
-import { duration2text } from '../extensions/Extension';
+import { shouldApplyNormalLyricKaraokeEffect } from './LyricViewRenderHelper';
 
 /**
  * A component to display the lyric with scroll animation.
@@ -258,7 +259,7 @@ export struct LyricView2 {
                         if (item.hasWords() && item.words.length > 0) {
                             this.WordByWordLyric(item, index)
                         } else {
-                            // 普通歌词渲染(原有逻辑)
+                            // 普通歌词渲染(原有逻辑,这个里面也有逐字歌词的功能,如果用户开启逐字的话
                             this.NormalLyricLine(item, index)
                         }
 
@@ -353,13 +354,13 @@ export struct LyricView2 {
             Text(item.text)
                 .fontSize(this.getAnimatedLyricFontSize(index))
                 .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
-                .fontColor(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor)
-                .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
+                .fontColor(this.getNormalLyricTextColor(index))
+                .fontWeight(this.getNormalLyricFontWeight(index))
                 .padding(this.isSingleLine?0:{ top:5,bottom:5 })
                 .visibility(this.isSingleLine?
                     (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
                     : Visibility.Visible)
-                .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '80%')
+                .width(this.getNormalLyricLineWidth(index, '80%'))
                 .textShadow(this.isLightText ? {
                     radius: 20,
                     color: Color.White,
@@ -367,10 +368,8 @@ export struct LyricView2 {
                     offsetY: 0
                 } : undefined)
                 .blendMode(
-                    index == this.currentIndex && this.enableWordByWordLyric&&
-                        !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
-                    index == this.currentIndex && this.enableWordByWordLyric
-                        && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
+                    this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.DST_IN : undefined,
+                    this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
                 )
 
             // 中文翻译(整行显示)
@@ -379,8 +378,8 @@ export struct LyricView2 {
                 Text(item.translation)
                     .fontSize(this.getAnimatedLyricFontSize(index))
                     .fontColor(this.currentMediaPosition >= item.beginTime ?
-                        index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
-                    .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
+                        this.getNormalLyricTextColor(index) : this.textColor)
+                    .fontWeight(this.getNormalLyricFontWeight(index))
                     .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
                     .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
                     .visibility(this.isSingleLine?
@@ -393,28 +392,50 @@ export struct LyricView2 {
                         offsetY: 0
                     } : undefined)
                     .blendMode(
-                        index == this.currentIndex && this.enableWordByWordLyric&&
-                            !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
-                        index == this.currentIndex && this.enableWordByWordLyric&&
-                            !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
+                        this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.DST_IN : undefined,
+                        this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
                     )
-                    .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '80%')
+                    .width(this.getNormalLyricLineWidth(index, '80%'))
 
             }
         }
         // 在 Row 上应用渐变
-        .linearGradient(index == this.currentIndex && this.enableWordByWordLyric&&
-            !(this.currentLyric && this.currentLyric.isPlainText) ? {
+        .linearGradient(this.shouldApplyNormalLyricKaraoke(index) ? {
             direction: GradientDirection.Right,
             colors: this.getLyricItemLinearGradient(item, index)
         } : undefined)
         .blendMode(
-            index == this.currentIndex && this.enableWordByWordLyric&&
-                !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.SRC_OVER : undefined,
-            index == this.currentIndex && this.enableWordByWordLyric&&
-                !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
+            this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.SRC_OVER : undefined,
+            this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
+        )
+
+    }
+
+    private isCurrentNormalHighlightLine(index: number): boolean {
+        return index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)
+    }
+
+    private shouldApplyNormalLyricKaraoke(index: number): boolean {
+        return shouldApplyNormalLyricKaraokeEffect(
+            this.enableWordByWordLyric,
+            index == this.currentIndex,
+            this.currentLyric ? this.currentLyric.isPlainText : false
         )
+    }
+
+    private getNormalLyricTextColor(index: number): ResourceColor {
+        return this.isCurrentNormalHighlightLine(index) ? this.textHighlightColor : this.textColor
+    }
 
+    private getNormalLyricFontWeight(index: number): number | FontWeight | string {
+        return this.isCurrentNormalHighlightLine(index) && this.isHighlightBold ? FontWeight.Bold : this.textWeight
+    }
+
+    private getNormalLyricLineWidth(index: number, normalWidth: string): string {
+        if (this.alignMode == 'center') {
+            return '100%'
+        }
+        return this.isCurrentNormalHighlightLine(index) ? '95%' : normalWidth
     }
 
 

+ 4 - 0
entry/src/main/ets/lyric/view/LyricViewRenderHelper.ets

@@ -0,0 +1,4 @@
+export function shouldApplyNormalLyricKaraokeEffect(enableWordByWordLyric: boolean, isCurrentLine: boolean,
+  isPlainTextLyric: boolean): boolean {
+  return enableWordByWordLyric && isCurrentLine && !isPlainTextLyric
+}

+ 95 - 8
entry/src/main/ets/pages/NewIndex.ets

@@ -65,6 +65,9 @@ import { hdsEffect } from '@kit.UIDesignKit';
 import { MiniPlayerBar } from '../view/MiniPlayerBar';
 import { MusicPlayerView } from '../view/player/MusicPlayerView';
 import {
+  PlayerPageTransitionState,
+  resolvePlayerPageCollapsedTransitionState,
+  resolvePlayerPageExpandedTransitionState,
   resolveMiniPlayerMorphTarget,
 } from '../common/util/PlayerDismissHelper';
 import { shouldShowMiniPlayerBar } from '../common/util/MiniPlayerHostViewHelper';
@@ -102,6 +105,13 @@ const FIND_DEFAULT_HOME_PROMPT_TARGET_COUNT = 5
 @Component
 struct NewIndex {
   @State isShowMusicPlayView:boolean = false
+  @State playerPageScaleX: number = 1
+  @State playerPageScaleY: number = 1
+  @State playerPageOpacity: number = 1
+  @State playerPageTranslateX: number = 0
+  @State playerPageTranslateY: number = 0
+  @State playerPageScaleCenterX: string = '50%'
+  @State playerPageScaleCenterY: string = '50%'
   //背景流光控制器
   @State bgController: hdsEffect.ShaderEffectController|undefined = deviceInfo.sdkApiVersion>=20
     &&canIUse("SystemCapability.UIDesign.HDSComponent.Core")?
@@ -243,6 +253,10 @@ struct NewIndex {
   private readonly miniPlayerModeDuration: number = 360;
   private readonly miniPlayerModeBounceDuration: number = 220;
   private readonly miniPlayerOrbDoubleTapWindowMs: number = 240;
+  private readonly playerPageOpenDuration: number = 320;
+  private readonly playerPageCloseDuration: number = 260;
+  private playerPageTransitionTimer: number = -1;
+  private playerPageUnmountTimer: number = -1;
   private isMiniPlayerModeTransitioning: boolean = false;
   private pendingPlayerPageOpenTimer: number = -1;
   private readonly playbackHostActions: MusicPlaybackHostActions = {
@@ -303,6 +317,75 @@ struct NewIndex {
     }
     this.playbackCoordinator.clearRuntime()
   }
+
+  private resolvePlayerPageCollapsedState(): PlayerPageTransitionState {
+    const viewportWidth: number = this.windowWidth > 0 ? this.windowWidth : DisplayUtil.getWidth()
+    const viewportHeight: number = this.windowHeight > 0 ? this.windowHeight : DisplayUtil.getHeight()
+    return resolvePlayerPageCollapsedTransitionState(viewportWidth, viewportHeight, this.bottomSafeHeight,
+      this.curDisplayIsHiCar || this.isBigScreen())
+  }
+
+  private applyPlayerPageTransitionState(state: PlayerPageTransitionState): void {
+    this.playerPageScaleX = state.scaleX
+    this.playerPageScaleY = state.scaleY
+    this.playerPageOpacity = state.opacity
+    this.playerPageTranslateX = state.translateX
+    this.playerPageTranslateY = state.translateY
+    this.playerPageScaleCenterX = state.centerX
+    this.playerPageScaleCenterY = state.centerY
+  }
+
+  private clearPlayerPageTransitionTimers(): void {
+    if (this.playerPageTransitionTimer >= 0) {
+      clearTimeout(this.playerPageTransitionTimer)
+      this.playerPageTransitionTimer = -1
+    }
+    if (this.playerPageUnmountTimer >= 0) {
+      clearTimeout(this.playerPageUnmountTimer)
+      this.playerPageUnmountTimer = -1
+    }
+  }
+
+  private openMusicPlayerView(): void {
+    this.clearPlayerPageTransitionTimers()
+    const collapsedState: PlayerPageTransitionState = this.resolvePlayerPageCollapsedState()
+    this.isShowMusicPlayView = true
+    this.applyPlayerPageTransitionState(collapsedState)
+    this.playerPageTransitionTimer = setTimeout((): void => {
+      this.getUIContext()?.animateTo({
+        duration: this.playerPageOpenDuration,
+        curve: curves.springMotion(0.82, 0.88)
+      }, (): void => {
+        this.applyPlayerPageTransitionState(resolvePlayerPageExpandedTransitionState(
+          collapsedState.centerX,
+          collapsedState.centerY
+        ))
+      })
+      this.playerPageTransitionTimer = -1
+    }, 16)
+  }
+
+  private closeMusicPlayerView(): void {
+    if (!this.isShowMusicPlayView) {
+      return
+    }
+    this.clearPlayerPageTransitionTimers()
+    const collapsedState: PlayerPageTransitionState = this.resolvePlayerPageCollapsedState()
+    this.getUIContext()?.animateTo({
+      duration: this.playerPageCloseDuration,
+      curve: Curve.EaseInOut
+    }, (): void => {
+      this.applyPlayerPageTransitionState(collapsedState)
+    })
+    this.playerPageUnmountTimer = setTimeout((): void => {
+      this.isShowMusicPlayView = false
+      this.applyPlayerPageTransitionState(resolvePlayerPageExpandedTransitionState())
+      if (this.shouldShowMiniPlayer() && this.isMiniPlayerMounted) {
+        this.startMiniPlayerRevealShake()
+      }
+      this.playerPageUnmountTimer = -1
+    }, this.playerPageCloseDuration)
+  }
   //当胶囊按钮的选择发生变化时调用此函数
   tabSelectedIndexesChanged() {
     if(this.tabSelectedIndexes[0]==1){
@@ -339,7 +422,7 @@ struct NewIndex {
     console.info('onecold onBackPress isDetailView = '+ this.isDetailView);
     console.info('onecold onBackPress mType = '+  this.mType);
     if (this.isShowMusicPlayView) {
-      this.isShowMusicPlayView = false
+      this.closeMusicPlayerView()
       return true
     }
     if (this.isShowPlay) {
@@ -719,6 +802,7 @@ struct NewIndex {
   }
 
   private clearMiniPlayerAnimationTimer(): void {
+    this.clearPlayerPageTransitionTimers()
     if (this.pendingPlayerPageOpenTimer >= 0) {
       clearTimeout(this.pendingPlayerPageOpenTimer)
       this.pendingPlayerPageOpenTimer = -1
@@ -1227,8 +1311,7 @@ struct NewIndex {
       miniPlayerOrbScale: this.miniPlayerOrbScale,
       miniPlayerOrbTranslateX: this.miniPlayerOrbTranslateX,
       onOpenPlayer: (): void => {
-        this.isShowMusicPlayView = true
-
+        this.openMusicPlayerView()
       },
       onCollapseToOrb: (): void => {
         this.collapseMiniPlayerToOrb()
@@ -1272,10 +1355,14 @@ struct NewIndex {
             .width('100%')
             .height('100%')
             .zIndex(99)
-            .transition(TransitionEffect.asymmetric(
-              TransitionEffect.opacity(1),
-              TransitionEffect.OPACITY
-            ))
+            .opacity(this.playerPageOpacity)
+            .scale({
+              x: this.playerPageScaleX,
+              y: this.playerPageScaleY,
+              centerX: this.playerPageScaleCenterX,
+              centerY: this.playerPageScaleCenterY
+            })
+            .translate({ x: this.playerPageTranslateX, y: this.playerPageTranslateY })
           }
         }
         .alignContent(Alignment.Bottom)
@@ -1311,7 +1398,7 @@ struct NewIndex {
   MusicPlayBuilder() {
     MusicPlayerView({
       onClose: (): void => {
-        this.isShowMusicPlayView = false
+        this.closeMusicPlayerView()
       }
     })
   }

+ 136 - 4
entry/src/main/ets/playback/BackgroundAudioPlaybackHost.ets

@@ -22,7 +22,8 @@ import { MusicCardManager, MusicCardPlaybackStateOptions } from '../common/playe
 import { BackgroundTaskManager } from '../common/util/BackgroundTaskManager'
 import Logger from '../common/util/Logger'
 import { imagePathToPixelMap } from '../common/util/CommUtils'
-import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil'
+import { isRemoteCloudType, setVideoUrlForSong } from '../common/util/RemotePlayerUtil'
+import { resolveSeekGuardDecision } from '../common/util/PlaybackSeekGuard'
 import { MusicPlaybackController } from '../controller/MusicPlaybackController'
 import { PlaybackRuntime } from '../controller/PlaybackCoordinator'
 import { VideoItem } from '../viewmodel/VideoItem'
@@ -36,6 +37,12 @@ import {
   BackgroundAudioRecoveredQueue
 } from './BackgroundAudioPlaybackHostHelper'
 import { PlaybackSnapshot, PlaybackSnapshotSong, resolvePlaybackSnapshotProgressValue } from './model/PlaybackSnapshot'
+import {
+  buildPlayerJumpPreferenceKey,
+  resolvePlayerABLoopSeekTarget,
+  resolvePlayerSkipIntroSeekTarget,
+  shouldPlayerSkipOutro
+} from '../view/player/PlayerAdvancedControlHelper'
 
 const TAG = 'BackgroundAudioHost'
 const KILLCARD_TRACE = '[KILLCARD_TRACE]'
@@ -43,6 +50,7 @@ const PLAYER_ID = 'audioIjkId'
 const PROGRESS_INTERVAL_MS = 1000
 const PLAYBACK_VERIFY_DELAY_MS = 200
 const PLAYBACK_VERIFY_RETRY_DELAY_MS = 350
+const SEEK_REPLAY_TOLERANCE_MS = 1500
 
 interface PlaybackSnapshotWriter {
   write(snapshot: PlaybackSnapshot): void
@@ -68,6 +76,9 @@ export class BackgroundAudioPlaybackHost {
   private playType: number = 0
   private isPlaying: boolean = false
   private isPrepared: boolean = false
+  private isSeekInFlight: boolean = false
+  private pendingSeekValue: string = ''
+  private activeSeekTargetMs: number = -1
   private currentUrl: string = ''
   private progressTimerId: number = -1
   private playbackStartVerifyToken: number = 0
@@ -314,6 +325,9 @@ export class BackgroundAudioPlaybackHost {
       this.playbackStartVerifyToken++
       this.isPrepared = false
       this.isPlaying = false
+      this.isSeekInFlight = false
+      this.pendingSeekValue = ''
+      this.activeSeekTargetMs = -1
       this.currentUrl = await setVideoUrlForSong(this.currentSong, {
         context: this.context as Context,
         extractAudioInfo: false,
@@ -346,6 +360,9 @@ export class BackgroundAudioPlaybackHost {
       Logger.error(TAG, `[MusicCast] prepare failed: ${(error as Error).message}`)
       this.isPrepared = false
       this.isPlaying = false
+      this.isSeekInFlight = false
+      this.pendingSeekValue = ''
+      this.activeSeekTargetMs = -1
       this.syncHostStorage(PlayStatus.PAUSE)
       this.publishSnapshot(false)
       return false
@@ -368,6 +385,8 @@ export class BackgroundAudioPlaybackHost {
         `duration=${player.getDuration()}, isPlaying=${player.isPlaying()}, position=${player.getCurrentPosition()}, ` +
         `audioSessionId=${player.getAudioSessionId()}`)
       this.startProgressTimer()
+      this.drainPendingSeekIfNeeded(player.getCurrentPosition())
+      this.applySkipIntroOnPrepared()
       this.verifyPlaybackStarted(this.playbackStartVerifyToken, 'onPrepared')
       this.syncHostStorage(PlayStatus.PLAY)
       this.syncPlaybackSessionState(PlayStatus.PLAY)
@@ -382,6 +401,9 @@ export class BackgroundAudioPlaybackHost {
       Logger.error(TAG, `[MusicCast] player error what=${what}, extra=${extra}, url=${this.currentUrl}`)
       this.isPrepared = false
       this.isPlaying = false
+      this.isSeekInFlight = false
+      this.pendingSeekValue = ''
+      this.activeSeekTargetMs = -1
       this.stopProgressTimer()
       this.syncHostStorage(PlayStatus.PAUSE)
       this.syncPlaybackSessionState(PlayStatus.PAUSE)
@@ -389,6 +411,9 @@ export class BackgroundAudioPlaybackHost {
     }))
     player.setOnSeekCompleteListener(new ImplOnSeekCompleteListener(() => {
       Logger.info(TAG, `[MusicCast] onSeekComplete position=${player.getCurrentPosition()}`)
+      this.isSeekInFlight = false
+      this.activeSeekTargetMs = -1
+      this.drainPendingSeekIfNeeded(player.getCurrentPosition())
       this.publishProgress()
     }))
     player.on('audioInterrupt', (event: InterruptEvent) => {
@@ -494,6 +519,9 @@ export class BackgroundAudioPlaybackHost {
     }
     this.isPlaying = false
     this.isPrepared = false
+    this.isSeekInFlight = false
+    this.pendingSeekValue = ''
+    this.activeSeekTargetMs = -1
     this.stopProgressTimer()
     this.syncHostStorage(PlayStatus.INIT)
     this.syncPlaybackSessionState(PlayStatus.INIT)
@@ -501,6 +529,35 @@ export class BackgroundAudioPlaybackHost {
     BackgroundTaskManager.stopContinuousTask(abilityContext)
   }
 
+  private dispatchResolvedSeek(targetMs: number): boolean {
+    const player = this.player
+    if (!player || !this.currentSong) {
+      return false
+    }
+    this.isSeekInFlight = true
+    this.activeSeekTargetMs = targetMs
+    Logger.info(TAG, `[MusicCast] seek dispatch target=${targetMs}, song=${this.currentSong.name}`)
+    player.seekTo(`${targetMs}`)
+    return true
+  }
+
+  private drainPendingSeekIfNeeded(currentPositionMs?: number): void {
+    if (this.pendingSeekValue === '' || this.isSeekInFlight) {
+      return
+    }
+    const pendingValue = this.pendingSeekValue
+    this.pendingSeekValue = ''
+    const pendingTarget = Number.parseInt(pendingValue)
+    if (Number.isNaN(pendingTarget)) {
+      return
+    }
+    const resolvedCurrentPosition = currentPositionMs ?? this.player?.getCurrentPosition() ?? 0
+    if (Math.abs(resolvedCurrentPosition - pendingTarget) <= SEEK_REPLAY_TOLERANCE_MS) {
+      return
+    }
+    void this.seekToInternal(pendingValue)
+  }
+
   private seekToInternal(value: string): boolean {
     const player = this.player
     if (!player || !this.currentSong || value === '') {
@@ -508,9 +565,33 @@ export class BackgroundAudioPlaybackHost {
         `[MusicCast] seek ignored playerReady=${player !== undefined}, songReady=${this.currentSong !== undefined}, value=${value}`)
       return false
     }
-    Logger.info(TAG, `[MusicCast] seekTo value=${value}, song=${this.currentSong.name}`)
-    player.seekTo(value)
-    return true
+    const resolvedSeek = MusicPlaybackController.resolveSeekPosition({
+      requestedValue: value,
+      activeDuration: this.resolveDurationMs(),
+      isRemoteSong: isRemoteCloudType(this.currentSong.type)
+    })
+    if (!resolvedSeek.canSeek) {
+      Logger.warn(TAG, `[MusicCast] seek blocked by unresolved duration target=${resolvedSeek.seekPos}, value=${value}`)
+      return false
+    }
+    const targetMs = resolvedSeek.seekPos
+    const seekGuardDecision = resolveSeekGuardDecision(this.currentUrl, targetMs)
+    if (!seekGuardDecision.allow) {
+      Logger.warn(TAG,
+        `[MusicCast] seek blocked reason=${seekGuardDecision.reason}, target=${targetMs}, url=${this.currentUrl}`)
+      return false
+    }
+    if (!this.isPrepared) {
+      this.pendingSeekValue = `${targetMs}`
+      Logger.info(TAG, `[MusicCast] seek deferred until prepared target=${targetMs}, song=${this.currentSong.name}`)
+      return true
+    }
+    if (this.isSeekInFlight) {
+      this.pendingSeekValue = `${targetMs}`
+      Logger.info(TAG, `[MusicCast] seek queued target=${targetMs}, activeTarget=${this.activeSeekTargetMs}`)
+      return true
+    }
+    return this.dispatchResolvedSeek(targetMs)
   }
 
   private startProgressTimer(): void {
@@ -533,6 +614,9 @@ export class BackgroundAudioPlaybackHost {
     }
     const durationMs = this.resolveDurationMs()
     const positionMs = Math.max(0, this.player.getCurrentPosition())
+    if (this.applyRuntimePositionRules(positionMs, durationMs)) {
+      return
+    }
     const progressValue = resolvePlaybackSnapshotProgressValue(positionMs, durationMs, 100)
     Logger.info(TAG,
       `[MusicCast] progress position=${positionMs}, duration=${durationMs}, isPlaying=${this.player.isPlaying()}, ` +
@@ -551,6 +635,54 @@ export class BackgroundAudioPlaybackHost {
     this.persistPlaybackSnapshot(this.isPlaying)
   }
 
+  private applySkipIntroOnPrepared(): void {
+    const jumpEnabledKey = buildPlayerJumpPreferenceKey(this.currentSong?.filePath, 'isOpenJump')
+    const jumpTopKey = buildPlayerJumpPreferenceKey(this.currentSong?.filePath, 'jumpTopTime')
+    const jumpEnabled = jumpEnabledKey.length > 0 && PreferencesUtil.getBooleanSync(jumpEnabledKey, false)
+    const jumpTopSeconds = jumpTopKey.length > 0 ? PreferencesUtil.getNumberSync(jumpTopKey, 0) : 0
+    const targetMs = resolvePlayerSkipIntroSeekTarget(jumpEnabled, jumpTopSeconds, this.resolveDurationMs(), 0)
+    if (targetMs <= 0) {
+      return
+    }
+    void this.seekTo(`${targetMs}`, 'player-skip-intro')
+  }
+
+  private applyRuntimePositionRules(positionMs: number, durationMs: number): boolean {
+    const abSeekTarget = resolvePlayerABLoopSeekTarget(
+      AppStorage.get<boolean>('playerRuntimeAbEnabled') ?? false,
+      this.currentSong?.filePath,
+      AppStorage.get<string>('playerRuntimeAbSongPath') ?? '',
+      AppStorage.get<number>('playerRuntimeAbStartMs') ?? 0,
+      AppStorage.get<number>('playerRuntimeAbEndMs') ?? 0,
+      positionMs
+    )
+    if (abSeekTarget >= 0) {
+      void this.seekTo(`${abSeekTarget}`, 'player-ab-loop')
+      return true
+    }
+
+    const jumpEnabledKey = buildPlayerJumpPreferenceKey(this.currentSong?.filePath, 'isOpenJump')
+    const jumpEndKey = buildPlayerJumpPreferenceKey(this.currentSong?.filePath, 'jumpEndTime')
+    const jumpEnabled = jumpEnabledKey.length > 0 && PreferencesUtil.getBooleanSync(jumpEnabledKey, false)
+    const jumpEndSeconds = jumpEndKey.length > 0 ? PreferencesUtil.getNumberSync(jumpEndKey, 0) : 0
+    if (!shouldPlayerSkipOutro(jumpEnabled, jumpEndSeconds, positionMs, durationMs)) {
+      return false
+    }
+
+    const completionAction = MusicPlaybackController.resolveCompletionAction(this.playType, this.currentIndex, this.queue.length)
+    if (completionAction.action === 'replay_current') {
+      void this.playIndex(this.currentIndex)
+      return true
+    }
+    if (completionAction.action === 'stop_current') {
+      this.stop()
+      return true
+    }
+    const nextIndex = MusicPlaybackController.resolveNextQueueIndex(this.currentIndex, this.queue.length).nextIndex
+    void this.playIndex(nextIndex)
+    return true
+  }
+
   private async ensurePlaybackSession(): Promise<void> {
     const abilityContext = this.context as common.UIAbilityContext | undefined
     if (!abilityContext) {

+ 2 - 0
entry/src/main/ets/view/EqualizerView.ets

@@ -1194,6 +1194,8 @@ export struct EqualizerViewWithCallback {
       }
     }
     .width('100%')
+    .height('100%')
+    .layoutWeight(1)
     .backgroundColor($r('app.color.settings_background_main'))
     .borderRadius(16)
   }

+ 26 - 33
entry/src/main/ets/view/FindView.ets

@@ -2958,11 +2958,11 @@ export struct FindView {
         }
         .width('100%')
         .padding({ left: 8, right: 8, bottom: 8 })
-        .justifyContent(FlexAlign.Start)
+        .justifyContent(FlexAlign.End)
       }
 
       Text(this.getSongTitle(item))
-        .fontSize(14)
+        .fontSize(12)
         .fontWeight(FontWeight.Bold)
         .lineHeight(18)
         .fontColor(this.getPrimaryTextColor())
@@ -3178,31 +3178,31 @@ export struct FindView {
     Column({ space: 5 }) {
       Stack({ alignContent: Alignment.Bottom }) {
         Image(this.getSongCover(item))
-          .aspectRatio(3 / 4)
+          .aspectRatio(1)
           .borderRadius(16)
-          .width(160)
+          .width(112)
           .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
-        Row() {
-          Text(this.getSongSubtitle(item))
-            .fontColor($r('app.color.white'))
-            .fontSize(9)
-            .lineHeight(11)
-            .maxLines(1)
-            .textOverflow({ overflow: TextOverflow.Ellipsis })
-        }
-        .justifyContent(FlexAlign.End)
-        .padding({ left: 8, right: 8, bottom: 9 })
-        .width(160)
+        // Row() {
+        //   Text(this.getSongSubtitle(item))
+        //     .fontColor($r('app.color.white'))
+        //     .fontSize(9)
+        //     .lineHeight(11)
+        //     .maxLines(1)
+        //     .textOverflow({ overflow: TextOverflow.Ellipsis })
+        // }
+        // .justifyContent(FlexAlign.End)
+        // .padding({ left: 8, right: 8, bottom: 9 })
+        // .width(112)
       }
 
       Text(this.getSongTitle(item))
         .lineHeight(16)
         .maxLines(1)
-        .width(160)
+        .width(112)
         .fontColor(this.getPrimaryTextColor())
-        .fontSize(14)
+        .fontSize(12)
         .fontWeight(FontWeight.Bold)
         .textOverflow({ overflow: TextOverflow.Ellipsis })
 
@@ -3212,7 +3212,7 @@ export struct FindView {
         .maxLines(1)
         .textOverflow({ overflow: TextOverflow.Ellipsis })
         .lineHeight(11)
-        .width(160)
+        .width(112)
         .textAlign(TextAlign.Start)
     }
   }
@@ -3259,20 +3259,13 @@ export struct FindView {
     Column({ space: 6 }) {
       Stack({ alignContent: Alignment.Bottom }) {
         Image(this.getPlaylistCover(playlist))
-          .aspectRatio(3 / 4)
+          .aspectRatio(1)
           .borderRadius(16)
-          .width(160)
+          .width(112)
           .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {
-          Text('心动歌单')
-            .fontColor($r('app.color.white'))
-            .fontSize(9)
-            .padding({ left: 6, right: 6, top: 3, bottom: 3 })
-            .backgroundColor('#7A000000')
-            .borderRadius(999)
-
           Text(`${playlist.songCount} 首`)
             .fontColor($r('app.color.white'))
             .fontSize(9)
@@ -3282,15 +3275,15 @@ export struct FindView {
         }
         .justifyContent(FlexAlign.SpaceBetween)
         .padding({ left: 8, right: 8, bottom: 8 })
-        .width(160)
+        .width(112)
       }
 
       Text(playlist.name)
         .lineHeight(16)
         .maxLines(1)
-        .width(160)
+        .width(112)
         .fontColor(this.getPrimaryTextColor())
-        .fontSize(14)
+        .fontSize(12)
         .fontWeight(FontWeight.Bold)
         .textOverflow({ overflow: TextOverflow.Ellipsis })
 
@@ -3300,7 +3293,7 @@ export struct FindView {
         .maxLines(1)
         .textOverflow({ overflow: TextOverflow.Ellipsis })
         .lineHeight(11)
-        .width(160)
+        .width(112)
         .textAlign(TextAlign.Start)
     }
   }
@@ -3564,11 +3557,11 @@ export struct FindView {
         }
         .width('100%')
         .padding({ left: 8, right: 8, bottom: 8 })
-        .justifyContent(FlexAlign.SpaceBetween)
+        .justifyContent(FlexAlign.End)
       }
 
       Text(this.getSongTitle(item))
-        .fontSize(13)
+        .fontSize(12)
         .fontWeight(FontWeight.Bold)
         .lineHeight(17)
         .fontColor(this.getPrimaryTextColor())

+ 65 - 0
entry/src/main/ets/view/player/MusiPlayerViewStateHelper.ets

@@ -6,6 +6,23 @@ export interface PlayerTimeTexts {
   totalTime: string
 }
 
+export interface PlayerSeekSyncDecision {
+  shouldUsePlaybackState: boolean
+  shouldCompletePendingSeek: boolean
+}
+
+export function resolvePlayerAnimatedPlaybackPosition(anchorPositionMs: number, durationMs: number, elapsedMs: number,
+  isPlaying: boolean, isSeekPending: boolean): number {
+  const safeAnchorPosition = normalizeMilliseconds(anchorPositionMs)
+  const safeDuration = normalizeMilliseconds(durationMs)
+  if (!isPlaying || isSeekPending) {
+    return safeDuration > 0 ? Math.min(safeAnchorPosition, safeDuration) : safeAnchorPosition
+  }
+  const safeElapsedMs = normalizeMilliseconds(elapsedMs)
+  const advancedPosition = safeAnchorPosition + safeElapsedMs
+  return safeDuration > 0 ? Math.min(advancedPosition, safeDuration) : advancedPosition
+}
+
 function normalizeMilliseconds(value: number): number {
   if (!Number.isFinite(value) || value < 0) {
     return 0
@@ -21,6 +38,54 @@ export function resolvePlayerTimeTexts(positionMs: number, durationMs: number):
   }
 }
 
+export function resolvePlayerProgressPercent(positionMs: number, durationMs: number): number {
+  const safeDuration = normalizeMilliseconds(durationMs)
+  if (safeDuration <= 0) {
+    return 0
+  }
+  const safePosition = Math.max(0, Math.min(normalizeMilliseconds(positionMs), safeDuration))
+  return safePosition * 100 / safeDuration
+}
+
+export function resolvePlayerSeekSyncDecision(isSeekPending: boolean, pendingSeekPositionMs: number,
+  seekAnchorPositionMs: number, incomingPositionMs: number, songChanged: boolean,
+  settleThresholdMs: number = 1500): PlayerSeekSyncDecision {
+  if (!isSeekPending) {
+    return {
+      shouldUsePlaybackState: true,
+      shouldCompletePendingSeek: false
+    }
+  }
+
+  if (songChanged) {
+    return {
+      shouldUsePlaybackState: true,
+      shouldCompletePendingSeek: true
+    }
+  }
+
+  if (pendingSeekPositionMs < 0) {
+    return {
+      shouldUsePlaybackState: false,
+      shouldCompletePendingSeek: false
+    }
+  }
+
+  const safeThreshold = Math.max(0, settleThresholdMs)
+  const safePendingPosition = normalizeMilliseconds(pendingSeekPositionMs)
+  const safeAnchorPosition = normalizeMilliseconds(seekAnchorPositionMs)
+  const safeIncomingPosition = normalizeMilliseconds(incomingPositionMs)
+  const isForwardSeek = safePendingPosition >= safeAnchorPosition
+  const seekSettled = isForwardSeek
+    ? safeIncomingPosition + safeThreshold >= safePendingPosition
+    : safeIncomingPosition - safeThreshold <= safePendingPosition
+
+  return {
+    shouldUsePlaybackState: seekSettled,
+    shouldCompletePendingSeek: seekSettled
+  }
+}
+
 export function resolvePlayerLyricContent(currentSong?: VideoItem): string {
   const lyricContent = currentSong?.lyricContent ?? ''
   return lyricContent.trim().length > 0 ? lyricContent : ''

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1066 - 13
entry/src/main/ets/view/player/MusicPlayerView.ets


+ 115 - 0
entry/src/main/ets/view/player/PlayerAdvancedControlHelper.ets

@@ -0,0 +1,115 @@
+const PLAYER_SKIP_SECONDS_MAX = 120
+
+export class PlayerTimedCloseSelectionResolution {
+  selectedMinutes: number = 0
+  remainingSeconds: number = 0
+  shouldStart: boolean = false
+  shouldStop: boolean = false
+}
+
+export class PlayerTimedCloseTickResolution {
+  remainingSeconds: number = 0
+  shouldShowConfirm: boolean = false
+  shouldTimeout: boolean = false
+}
+
+function padPlayerCountdown(value: number): string {
+  const safeValue = Math.max(0, Math.floor(value))
+  return safeValue < 10 ? `0${safeValue}` : `${safeValue}`
+}
+
+export function formatPlayerTimedCloseCountdown(seconds: number): string {
+  const safeSeconds = Math.max(0, Math.floor(seconds))
+  const minutes = Math.floor(safeSeconds / 60)
+  const remainSeconds = safeSeconds % 60
+  return `${padPlayerCountdown(minutes)}:${padPlayerCountdown(remainSeconds)}`
+}
+
+export function resolvePlayerTimedCloseSelection(currentSelectedMinutes: number,
+  nextMinutes: number): PlayerTimedCloseSelectionResolution {
+  const result = new PlayerTimedCloseSelectionResolution()
+  const safeNextMinutes = Math.max(0, Math.floor(nextMinutes))
+  if (safeNextMinutes > 0 && safeNextMinutes !== currentSelectedMinutes) {
+    result.selectedMinutes = safeNextMinutes
+    result.remainingSeconds = safeNextMinutes * 60
+    result.shouldStart = true
+    return result
+  }
+  result.shouldStop = true
+  return result
+}
+
+export function resolvePlayerTimedCloseTick(remainingSeconds: number): PlayerTimedCloseTickResolution {
+  const result = new PlayerTimedCloseTickResolution()
+  const safeRemainingSeconds = Math.max(0, Math.floor(remainingSeconds))
+  if (safeRemainingSeconds <= 0) {
+    result.shouldTimeout = true
+    return result
+  }
+  result.remainingSeconds = safeRemainingSeconds - 1
+  result.shouldShowConfirm = safeRemainingSeconds === 15
+  return result
+}
+
+export function clampPlayerSkipSeconds(value: number): number {
+  if (!Number.isFinite(value)) {
+    return 0
+  }
+  return Math.max(0, Math.min(Math.round(value), PLAYER_SKIP_SECONDS_MAX))
+}
+
+export function buildPlayerJumpPreferenceKey(filePath: string | undefined, suffix: string): string {
+  const safeFilePath = filePath ?? ''
+  if (safeFilePath.length <= 0 || suffix.length <= 0) {
+    return ''
+  }
+  return `${safeFilePath}#player#${suffix}`
+}
+
+export function resolvePlayerSkipIntroSeekTarget(enabled: boolean, jumpTopSeconds: number,
+  durationMs: number, memoryPositionMs: number = 0): number {
+  const safeMemoryPositionMs = Math.max(0, Math.floor(memoryPositionMs))
+  if (!enabled) {
+    return safeMemoryPositionMs
+  }
+  const safeJumpMs = clampPlayerSkipSeconds(jumpTopSeconds) * 1000
+  if (safeJumpMs <= 0) {
+    return safeMemoryPositionMs
+  }
+  if (durationMs > 0 && safeJumpMs >= durationMs) {
+    return safeMemoryPositionMs
+  }
+  return Math.max(safeMemoryPositionMs, safeJumpMs)
+}
+
+export function shouldPlayerSkipOutro(enabled: boolean, jumpEndSeconds: number,
+  positionMs: number, durationMs: number): boolean {
+  if (!enabled) {
+    return false
+  }
+  const safeJumpEndMs = clampPlayerSkipSeconds(jumpEndSeconds) * 1000
+  if (safeJumpEndMs <= 0 || durationMs <= safeJumpEndMs) {
+    return false
+  }
+  return Math.max(0, Math.floor(positionMs)) >= durationMs - safeJumpEndMs
+}
+
+export function isPlayerABLoopRangeValid(jumpATimeMs: number, jumpBTimeMs: number): boolean {
+  const safeATimeMs = Math.max(0, Math.floor(jumpATimeMs))
+  const safeBTimeMs = Math.max(0, Math.floor(jumpBTimeMs))
+  return safeBTimeMs > safeATimeMs
+}
+
+export function resolvePlayerABLoopSeekTarget(enabled: boolean, activeSongPath: string | undefined,
+  configuredSongPath: string | undefined, jumpATimeMs: number, jumpBTimeMs: number, positionMs: number): number {
+  if (!enabled || !isPlayerABLoopRangeValid(jumpATimeMs, jumpBTimeMs)) {
+    return -1
+  }
+  const safeActiveSongPath = activeSongPath ?? ''
+  const safeConfiguredSongPath = configuredSongPath ?? ''
+  if (safeActiveSongPath.length <= 0 || safeConfiguredSongPath.length <= 0 ||
+    safeActiveSongPath !== safeConfiguredSongPath) {
+    return -1
+  }
+  return Math.max(0, Math.floor(positionMs)) >= Math.floor(jumpBTimeMs) ? Math.max(0, Math.floor(jumpATimeMs)) : -1
+}

+ 114 - 0
entry/src/main/ets/view/player/PlayerFavoriteService.ets

@@ -0,0 +1,114 @@
+import MediaTable from '../../common/util/MediaTable'
+import { cloneVideoItem, isRemoteCloudType, isWebDavType } from '../../common/util/RemotePlayerUtil'
+import { VideoItem } from '../../viewmodel/VideoItem'
+import { StrUtil } from '@pura/harmony-utils'
+import { Context } from '@kit.AbilityKit'
+
+export interface PlayerFavoriteToggleResult {
+  success: boolean
+  message: string
+  updatedItem?: VideoItem
+  isFavorite: boolean
+}
+
+function openMediaTable(context: Context): Promise<MediaTable> {
+  const table = new MediaTable(context)
+  return new Promise<MediaTable>((resolve, reject) => {
+    table.getRdbStore(context, (error?: Error) => {
+      if (error) {
+        reject(error)
+        return
+      }
+      resolve(table)
+    })
+  })
+}
+
+function updateFavoriteAsync(table: MediaTable, filePath: string, isFav: number): Promise<boolean> {
+  return new Promise<boolean>((resolve) => {
+    table.updateIsFavByFilePath(filePath, isFav, (success: boolean) => {
+      resolve(success)
+    })
+  })
+}
+
+function resolveRemoteStoragePath(item: VideoItem): string {
+  if (!item || !isRemoteCloudType(item.type)) {
+    return item?.filePath ?? ''
+  }
+  if (isWebDavType(item.type)) {
+    const storagePath = item.remote_rel_path || item.filePath
+    if (StrUtil.isNotEmpty(storagePath)) {
+      item.remote_rel_path = storagePath
+      return storagePath
+    }
+    return item.filePath
+  }
+  if (StrUtil.isNotEmpty(item.remote_rel_path)) {
+    return item.remote_rel_path as string
+  }
+  return item.filePath
+}
+
+async function persistRemoteFavoriteTarget(context: Context, item: VideoItem): Promise<void> {
+  const storagePath = resolveRemoteStoragePath(item)
+  if (StrUtil.isEmpty(storagePath)) {
+    return
+  }
+  const table = await openMediaTable(context)
+  const cloneItem = cloneVideoItem(item)
+  cloneItem.filePath = storagePath
+  cloneItem.remote_rel_path = storagePath
+  await table.saveOrUpdateWebDavItem(cloneItem)
+}
+
+export function resolveNextFavoriteValue(isFavorite: boolean): number {
+  return isFavorite ? 0 : 1
+}
+
+export function resolvePlayerFavoriteTargetFilePath(item: VideoItem): string {
+  if (!item) {
+    return ''
+  }
+  if (!isRemoteCloudType(item.type)) {
+    return item.filePath
+  }
+  return resolveRemoteStoragePath(item)
+}
+
+export async function toggleSongFavorite(context: Context, item: VideoItem,
+  currentIsFavorite: boolean): Promise<PlayerFavoriteToggleResult> {
+  if (!item || StrUtil.isEmpty(item.filePath)) {
+    return {
+      success: false,
+      message: '当前歌曲不存在',
+      isFavorite: currentIsFavorite
+    }
+  }
+
+  const nextIsFav = resolveNextFavoriteValue(currentIsFavorite)
+  const targetFilePath = resolvePlayerFavoriteTargetFilePath(item)
+  const table = await openMediaTable(context)
+  let updated = await updateFavoriteAsync(table, targetFilePath, nextIsFav)
+  if (!updated && isRemoteCloudType(item.type)) {
+    await persistRemoteFavoriteTarget(context, item)
+    updated = await updateFavoriteAsync(table, targetFilePath, nextIsFav)
+  }
+  if (!updated) {
+    return {
+      success: false,
+      message: nextIsFav === 1 ? '收藏失败' : '取消收藏失败',
+      isFavorite: currentIsFavorite
+    }
+  }
+
+  item.isFav = nextIsFav
+  const updatedItem = cloneVideoItem(item)
+  updatedItem.isFav = nextIsFav
+  return {
+    success: true,
+    message: nextIsFav === 1 ? '收藏成功' : '取消收藏成功',
+    updatedItem,
+    isFavorite: nextIsFav === 1
+  }
+}

+ 103 - 0
entry/src/main/ets/view/player/PlayerJumpTopEndSheet.ets

@@ -0,0 +1,103 @@
+import { CommonConstants } from '../../common/constants/CommonConstants'
+
+@Component
+export struct PlayerJumpTopEndSheet {
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Prop jumpTopTime: number = 0
+  @Prop jumpEndTime: number = 0
+  onJumpTopTimeChanged: (value: number) => void = (_value: number): void => {}
+  onJumpEndTimeChanged: (value: number) => void = (_value: number): void => {}
+  onSave: () => void = (): void => {}
+
+  build() {
+    Scroll() {
+      Column({ space: 18 }) {
+        Text('仅对当前歌曲有效')
+          .fontSize(15)
+          .fontColor('#CCFFFFFF')
+          .width('100%')
+          .textAlign(TextAlign.Start)
+
+        Column({ space: 10 }) {
+          Row() {
+            Text('跳过片头')
+              .fontSize(16)
+              .fontColor(Color.White)
+            Blank()
+            Text(`${this.jumpTopTime}s`)
+              .fontSize(16)
+              .fontColor(Color.White)
+          }
+          .width('100%')
+
+          Slider({
+            value: this.jumpTopTime,
+            min: 0,
+            max: 120,
+            step: 1,
+            style: SliderStyle.OutSet
+          })
+            .blockColor(this.themeColor)
+            .trackColor('rgba(255,255,255,0.25)')
+            .selectedColor(Color.White)
+            .trackThickness(8)
+            .onChange((value: number) => {
+              this.onJumpTopTimeChanged(value)
+            })
+            .width('100%')
+        }
+        .width('100%')
+        .padding(16)
+        .backgroundColor('rgba(255,255,255,0.08)')
+        .borderRadius(16)
+
+        Column({ space: 10 }) {
+          Row() {
+            Text('跳过片尾')
+              .fontSize(16)
+              .fontColor(Color.White)
+            Blank()
+            Text(`${this.jumpEndTime}s`)
+              .fontSize(16)
+              .fontColor(Color.White)
+          }
+          .width('100%')
+
+          Slider({
+            value: this.jumpEndTime,
+            min: 0,
+            max: 120,
+            step: 1,
+            style: SliderStyle.OutSet
+          })
+            .blockColor(this.themeColor)
+            .trackColor('rgba(255,255,255,0.25)')
+            .selectedColor(Color.White)
+            .trackThickness(8)
+            .onChange((value: number) => {
+              this.onJumpEndTimeChanged(value)
+            })
+            .width('100%')
+        }
+        .width('100%')
+        .padding(16)
+        .backgroundColor('rgba(255,255,255,0.08)')
+        .borderRadius(16)
+
+        Button('保存设置')
+          .width('100%')
+          .height(46)
+          .backgroundColor(this.themeColor)
+          .fontColor(Color.White)
+          .borderRadius(23)
+          .onClick(() => {
+            this.onSave()
+          })
+      }
+      .width('100%')
+      .padding({ left: 16, right: 16, top: 14, bottom: 28 })
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 60 - 0
entry/src/main/ets/view/player/PlayerLyricCopyHelper.ets

@@ -0,0 +1,60 @@
+import { StrUtil } from '@pura/harmony-utils'
+import LyricUtil from '../../common/util/LyricUtil'
+
+export function extractPlayerLyricCopyLines(lyricText: string): string[] {
+  if (StrUtil.isEmpty(lyricText)) {
+    return []
+  }
+  const normalizedLyric = LyricUtil.convertLyricToSimpleLrc(lyricText)
+  const source = StrUtil.isNotEmpty(normalizedLyric) ? normalizedLyric : lyricText
+  const lines = source.split('\n')
+  const result: string[] = []
+  const metaTagPattern = /^\[(ti|ar|al|by|offset|hash|sign|qq|total|tool|re|ve|length|au):?.*]$/i
+
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim()
+    if (!line || metaTagPattern.test(line)) {
+      continue
+    }
+    const pureLine = line
+      .replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?]/g, '')
+      .replace(/<\d{1,2}:\d{2}(?:\.\d{1,3})?>/g, '')
+      .trim()
+    if (pureLine.length > 0) {
+      result.push(pureLine)
+    }
+  }
+  return result
+}
+
+export function togglePlayerLyricLineSelection(selectedIndexes: number[], index: number): number[] {
+  const selectedIndex = selectedIndexes.indexOf(index)
+  if (selectedIndex >= 0) {
+    const nextSelectedIndexes = [...selectedIndexes]
+    nextSelectedIndexes.splice(selectedIndex, 1)
+    return nextSelectedIndexes
+  }
+  return [...selectedIndexes, index]
+}
+
+export function togglePlayerLyricSelectAll(lines: string[], selectedIndexes: number[]): number[] {
+  if (selectedIndexes.length === lines.length) {
+    return []
+  }
+  return lines.map((_: string, index: number) => index)
+}
+
+export function resolvePlayerSelectedLyricLines(lines: string[], selectedIndexes: number[]): string[] {
+  if (selectedIndexes.length <= 0) {
+    return []
+  }
+  const sortedIndexes = [...selectedIndexes].sort((a: number, b: number) => a - b)
+  const selectedLines: string[] = []
+  for (let i = 0; i < sortedIndexes.length; i++) {
+    const index = sortedIndexes[i]
+    if (index >= 0 && index < lines.length) {
+      selectedLines.push(lines[index])
+    }
+  }
+  return selectedLines
+}

+ 607 - 0
entry/src/main/ets/view/player/PlayerLyricSettingSheet.ets

@@ -0,0 +1,607 @@
+import { PreferencesUtil } from '@pura/harmony-utils'
+import { HSBColorPicker, HSBColorPickerLayout } from '@keke/color-picker'
+import { CommonConstants } from '../../common/constants/CommonConstants'
+import { LyricController } from '../../lyric/LyricController'
+import { SettingPage } from '../../pages/SettingPage'
+import {
+  applyPlayerLyricHighlightColor,
+  applyPlayerLyricHighlightScale,
+  applyPlayerLyricLineSpace,
+  applyPlayerLyricTextColor,
+  applyPlayerLyricTextSize,
+  applyPlayerLyricTextWeight,
+  clampPlayerLyricOffset,
+  formatPlayerLyricOffsetText,
+  resolvePlayerLyricAlignMode
+} from './PlayerLyricSettingsHelper'
+
+@Component
+export struct PlayerLyricSettingSheet {
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Link timeOffset: number
+  @Link isShowSingleLineLyric: boolean
+  @Link isEnableWordByWordLyric: boolean
+  @Link isLightText: boolean
+  @Link isHightLightCenter: boolean
+  @Link currentLyricAlignMode: number
+  @Link lyricController: LyricController
+  @Link lyricControllerSingle: LyricController
+  onTimeOffsetChanged: () => void = (): void => {}
+  onSelectLocalLyric: () => void = (): void => {}
+  onFetchLyric: () => void = (): void => {}
+  onShareLyric: () => void = (): void => {}
+
+  @State private currentLyricColor: string = '#FFFFFF'
+  @State private currentHighLightLyricColor: string = '#FFFFFF'
+  @State private currentLyricSize: number = 18
+  @State private currentHightLyricSize: number = 1.23
+  @State private currentLyricLineSpace: number = 10
+  @State private lyricTextWeight: number = 400
+  @State private isShowSelectColor: boolean = false
+  @State private isShowHLSelectColor: boolean = false
+
+  aboutToAppear(): void {
+    this.currentLyricColor = PreferencesUtil.getStringSync('LyricColor', '#FFFFFF')
+    this.currentHighLightLyricColor = PreferencesUtil.getStringSync('LyricHighLightColor', '#FFFFFF')
+    this.currentLyricSize = PreferencesUtil.getNumberSync('LyricTextSize', 18)
+    this.currentHightLyricSize = PreferencesUtil.getNumberSync('HighLyricTextSize', 1.23)
+    this.currentLyricLineSpace = PreferencesUtil.getNumberSync('LyricLineSpace', 10)
+    this.lyricTextWeight = PreferencesUtil.getNumberSync('lyricTextWeight', 400)
+  }
+
+  private syncWordByWordLyricMode(): void {
+    this.lyricController.setEnableWordByWordLyric(this.isEnableWordByWordLyric)
+  }
+
+  private syncLyricLightTextMode(): void {
+    this.lyricController.setLightText(this.isLightText)
+  }
+
+  private setLyricAlignMode(index: number): void {
+    this.currentLyricAlignMode = index === 1 ? 1 : 0
+    this.lyricController.setAlignMode(resolvePlayerLyricAlignMode(this.currentLyricAlignMode))
+    PreferencesUtil.putSync('LyricAlignMode', this.currentLyricAlignMode)
+  }
+
+  private setLyricTextSize(value: number): void {
+    this.currentLyricSize = value
+    applyPlayerLyricTextSize(this.lyricController, this.lyricControllerSingle, this.currentLyricSize)
+    PreferencesUtil.putSync('LyricTextSize', this.currentLyricSize)
+  }
+
+  private setHighLyricTextSize(scale: number): void {
+    this.currentHightLyricSize = scale
+    applyPlayerLyricHighlightScale(this.lyricController, this.lyricControllerSingle, this.currentHightLyricSize)
+    PreferencesUtil.putSync('HighLyricTextSize', scale)
+  }
+
+  private setLyricLineSpace(value: number): void {
+    this.currentLyricLineSpace = value
+    applyPlayerLyricLineSpace(this.lyricController, this.lyricControllerSingle, this.currentLyricLineSpace)
+    PreferencesUtil.putSync('LyricLineSpace', this.currentLyricLineSpace)
+  }
+
+  private setLyricTextWeight(value: number): void {
+    this.lyricTextWeight = value
+    applyPlayerLyricTextWeight(this.lyricController, this.lyricControllerSingle, this.lyricTextWeight)
+    PreferencesUtil.putSync('lyricTextWeight', this.lyricTextWeight)
+  }
+
+  private changeLyricColor(color: string): void {
+    this.currentLyricColor = color
+    applyPlayerLyricTextColor(this.lyricController, this.lyricControllerSingle, this.currentLyricColor)
+    PreferencesUtil.putSync('LyricColor', this.currentLyricColor)
+  }
+
+  private changeLyricHighlightColor(color: string): void {
+    this.currentHighLightLyricColor = color
+    applyPlayerLyricHighlightColor(this.lyricController, this.lyricControllerSingle, this.currentHighLightLyricColor)
+    PreferencesUtil.putSync('LyricHighLightColor', this.currentHighLightLyricColor)
+  }
+
+  private handlePrecisionAdjust(step: number): void {
+    if (step === 0) {
+      this.timeOffset = 0
+      this.onTimeOffsetChanged()
+      return
+    }
+    const nextOffset = clampPlayerLyricOffset(this.timeOffset + step)
+    if (nextOffset === this.timeOffset) {
+      return
+    }
+    this.timeOffset = nextOffset
+    this.onTimeOffsetChanged()
+  }
+
+  private isButtonDisabled(step: number): boolean {
+    if (step === 0) {
+      return this.timeOffset === 0
+    }
+    return clampPlayerLyricOffset(this.timeOffset + step) === this.timeOffset
+  }
+
+  private getLyricSizeDisplayValue(): string {
+    return this.currentLyricSize.toFixed(1)
+  }
+
+  private getHighLyricSizeDisplayValue(): string {
+    return this.currentHightLyricSize.toFixed(1)
+  }
+
+  private getLyricLineSpaceDisplayValue(): string {
+    return Math.round(this.currentLyricLineSpace).toString()
+  }
+
+  private getLyricTextWeightDisplayValue(): string {
+    return Math.round(this.lyricTextWeight).toString()
+  }
+
+  @Builder
+  private SelectColor(isHighColor: boolean) {
+    Row({ space: 10 }) {
+      HSBColorPicker({
+        color: '#FFFFFF',
+        radius: 8,
+        layout: HSBColorPickerLayout.COLUMN,
+        predefine: ['#8b27f4', '#73f9fc', '#fffe55', '#f5cee3', '#eb4827', '#e93bf4', '#3e68f4', '#c5e6d3', '#e4e4e4',
+          '#fa7105'],
+        onChange: (value: string) => {
+          if (isHighColor) {
+            this.changeLyricHighlightColor(value)
+            return
+          }
+          this.changeLyricColor(value)
+        }
+      })
+        .height(250)
+        .layoutWeight(1)
+        .padding(25)
+    }
+    .width('66%')
+  }
+
+  @Builder
+  private BuildTimeControls() {
+    Column() {
+      Text(formatPlayerLyricOffsetText(this.timeOffset))
+        .fontSize(18)
+        .fontColor(Color.White)
+        .margin({ bottom: 10 })
+
+      Row() {
+        this.PrecisionTimeButton($r('app.media.ic_previous'), -0.5, ' -0.5s ')
+        this.PrecisionTimeButton($r('app.media.loop'), 0, '重置')
+        this.PrecisionTimeButton($r('app.media.ic_next'), 0.5, ' +0.5s ')
+      }
+      .justifyContent(FlexAlign.Center)
+    }
+    .margin({ top: 12, bottom: 8 })
+  }
+
+  @Builder
+  private PrecisionTimeButton(icon: Resource, step: number, label: string) {
+    Button({ type: ButtonType.Capsule, stateEffect: true }) {
+      Column() {
+        Image(icon)
+          .width(28)
+          .margin({ bottom: 5 })
+          .opacity(this.isButtonDisabled(step) ? 0.5 : 1)
+
+        Text(label)
+          .fontSize(12)
+          .fontColor('#FFFFFF')
+      }
+    }
+    .backgroundColor(Color.Transparent)
+    .enabled(!this.isButtonDisabled(step))
+    .onClick(() => {
+      this.handlePrecisionAdjust(step)
+    })
+    .padding(5)
+    .width(66)
+    .height(66)
+    .margin({ left: 18, right: 18 })
+    .border({
+      color: '#FFFFFF',
+      width: 1.8
+    })
+  }
+
+  @Builder
+  private pushLyricButton(icon: Resource, step: number, label: string) {
+    Button({ type: ButtonType.Capsule, stateEffect: true }) {
+      Row() {
+        Image(icon)
+          .width(14)
+          .margin({ left: 13 })
+
+        Text(label)
+          .fontSize(12)
+          .margin({ left: 6 })
+          .fontColor('#FFFFFF')
+      }
+    }
+    .onClick(() => {
+      if (step === 0) {
+        this.onSelectLocalLyric()
+        return
+      }
+      if (step === 1) {
+        this.onFetchLyric()
+        return
+      }
+      this.onShareLyric()
+    })
+    .padding({ left: 6, right: 6 })
+    .margin({ left: 10, right: 10 })
+    .height(42)
+    .backgroundColor(Color.Transparent)
+    .border({
+      color: '#FFFFFF',
+      width: 1.8
+    })
+  }
+
+  build() {
+    Scroll() {
+      Column() {
+        this.BuildTimeControls()
+
+        Row() {
+          Row() {
+            Text('迷你歌词:')
+              .fontSize(14)
+              .fontColor(Color.White)
+              .width(88)
+            Toggle({ type: ToggleType.Switch, isOn: this.isShowSingleLineLyric })
+              .selectedColor(this.themeColor)
+              .switchPointColor(Color.White)
+              .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 })
+              .onChange((checked: boolean) => {
+                this.isShowSingleLineLyric = checked
+                PreferencesUtil.putSync(SettingPage.IS_SHOW_SLLYRIC, this.isShowSingleLineLyric)
+              })
+              .width(50)
+              .height(30)
+              .alignSelf(ItemAlign.Start)
+              .margin({ left: 12 })
+            Blank()
+          }
+          .layoutWeight(1)
+
+          Row() {
+            Text('逐字歌词:')
+              .fontSize(14)
+              .fontColor(Color.White)
+              .width(88)
+            Toggle({ type: ToggleType.Switch, isOn: this.isEnableWordByWordLyric })
+              .selectedColor(this.themeColor)
+              .switchPointColor(Color.White)
+              .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 })
+              .onChange((checked: boolean) => {
+                this.isEnableWordByWordLyric = checked
+                PreferencesUtil.putSync(SettingPage.IS_ENABLE_WORD_BY_WORD_LYRIC, this.isEnableWordByWordLyric)
+                this.syncWordByWordLyricMode()
+              })
+              .width(50)
+              .height(30)
+              .alignSelf(ItemAlign.Start)
+              .margin({ left: 12 })
+            Blank()
+          }
+          .layoutWeight(1)
+        }
+        .width('100%')
+        .margin({ left: 20, right: 15, top: 10, bottom: 10 })
+
+        Row() {
+          Row() {
+            Text('歌词发光:')
+              .fontSize(14)
+              .fontColor(Color.White)
+              .width(88)
+            Toggle({ type: ToggleType.Switch, isOn: this.isLightText })
+              .selectedColor(this.themeColor)
+              .switchPointColor(Color.White)
+              .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 })
+              .onChange((checked: boolean) => {
+                this.isLightText = checked
+                PreferencesUtil.putSync(SettingPage.IS_LIGHT_TEXT, this.isLightText)
+                this.syncLyricLightTextMode()
+              })
+              .width(50)
+              .height(30)
+              .alignSelf(ItemAlign.Start)
+              .margin({ left: 12 })
+            Blank()
+          }
+          .layoutWeight(1)
+
+          Row() {
+            Text('高亮居中:')
+              .fontSize(14)
+              .fontColor(Color.White)
+              .width(88)
+            Toggle({ type: ToggleType.Switch, isOn: this.isHightLightCenter })
+              .selectedColor(this.themeColor)
+              .switchPointColor(Color.White)
+              .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 })
+              .onChange((checked: boolean) => {
+                this.isHightLightCenter = checked
+                PreferencesUtil.putSync(SettingPage.IS_SHOW_SIMI, this.isHightLightCenter)
+                this.lyricController.setHightLightCenter(this.isHightLightCenter)
+              })
+              .width(50)
+              .height(30)
+              .alignSelf(ItemAlign.Start)
+              .margin({ left: 12 })
+            Blank()
+          }
+          .layoutWeight(1)
+        }
+        .width('100%')
+        .margin({ left: 20, right: 15, top: 10, bottom: 10 })
+
+        Row() {
+          Text('歌词居中:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Row() {
+            ForEach(['居中', '居左'], (size: string, index: number) => {
+              Button(size)
+                .width(60)
+                .height(28)
+                .fontSize(12)
+                .fontColor('#FFFFFF')
+                .backgroundColor(this.currentLyricAlignMode === index ? this.themeColor : Color.Transparent)
+                .border({
+                  color: this.currentLyricAlignMode === index ? '#007DFF' : '#DDDDDD',
+                  width: 1.8
+                })
+                .onClick(() => {
+                  this.setLyricAlignMode(index)
+                })
+                .margin({ left: 10, right: 8 })
+            })
+          }
+          .layoutWeight(1)
+
+          Blank()
+        }
+        .width('100%')
+        .margin({ top: 10, bottom: 10, right: 15, left: 20 })
+
+        Row() {
+          Text('歌词颜色:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Text(this.currentLyricColor)
+            .fontSize(15)
+            .fontColor(Color.White)
+            .margin({ left: 6, right: 6 })
+          Column() {
+          }
+          .backgroundColor(this.currentLyricColor)
+          .margin({ right: 12 })
+          .borderRadius(20)
+          .height(28)
+          .width(28)
+
+          Button({ type: ButtonType.Capsule, stateEffect: true }) {
+            Row() {
+              SymbolGlyph($r('sys.symbol.paintbrush'))
+                .fontColor([Color.White])
+                .effectStrategy(1)
+              Text('选择颜色')
+                .fontSize(13)
+                .margin({ left: 10 })
+                .fontColor(Color.White)
+            }
+          }
+          .backgroundColor(Color.Transparent)
+          .border({
+            color: '#FFFFFF',
+            radius: 20,
+            width: 1.8
+          })
+          .height(36)
+          .width(110)
+          .onClick(() => {
+            this.isShowSelectColor = !this.isShowSelectColor
+          })
+          .bindPopup(this.isShowSelectColor, {
+            builder: this.SelectColor(false),
+            placement: Placement.Top,
+            mask: { color: '#33000000' },
+            enableArrow: false,
+            showInSubWindow: false,
+            onStateChange: (e) => {
+              if (!e.isVisible) {
+                this.isShowSelectColor = false
+              }
+            }
+          })
+        }
+        .width('100%')
+        .margin({ top: 10, bottom: 10, right: 15, left: 20 })
+
+        Row() {
+          Text('高亮颜色:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Text(this.currentHighLightLyricColor)
+            .fontSize(15)
+            .fontColor(Color.White)
+            .margin({ left: 6, right: 6 })
+          Column() {
+          }
+          .backgroundColor(this.currentHighLightLyricColor)
+          .margin({ right: 12 })
+          .borderRadius(20)
+          .height(28)
+          .width(28)
+
+          Button({ type: ButtonType.Capsule, stateEffect: true }) {
+            Row() {
+              SymbolGlyph($r('sys.symbol.paintbrush'))
+                .fontColor([Color.White])
+                .effectStrategy(1)
+              Text('选择颜色')
+                .fontSize(13)
+                .margin({ left: 10 })
+                .fontColor(Color.White)
+            }
+          }
+          .backgroundColor(Color.Transparent)
+          .border({
+            color: '#FFFFFF',
+            radius: 20,
+            width: 1.8
+          })
+          .height(36)
+          .width(110)
+          .onClick(() => {
+            this.isShowHLSelectColor = !this.isShowHLSelectColor
+          })
+          .bindPopup(this.isShowHLSelectColor, {
+            builder: this.SelectColor(true),
+            placement: Placement.Top,
+            mask: { color: '#33000000' },
+            enableArrow: false,
+            showInSubWindow: false,
+            onStateChange: (e) => {
+              if (!e.isVisible) {
+                this.isShowHLSelectColor = false
+              }
+            }
+          })
+        }
+        .width('100%')
+        .margin({ top: 10, bottom: 10, right: 15, left: 20 })
+
+        Row() {
+          Text('歌词字号:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Slider({
+            value: this.currentLyricSize,
+            min: 12,
+            max: 30,
+            step: 0.1,
+            style: SliderStyle.OutSet
+          })
+            .blockColor(this.themeColor)
+            .trackColor($r('app.color.speed_text_color'))
+            .selectedColor(Color.White)
+            .trackThickness(6)
+            .onChange((value: number) => {
+              this.setLyricTextSize(value)
+            })
+            .layoutWeight(1)
+            .margin({ right: 3 })
+          Text(this.getLyricSizeDisplayValue())
+            .fontSize(13)
+            .fontColor(Color.White)
+            .textAlign(TextAlign.Start)
+        }
+        .width('100%')
+        .margin({ left: 20, right: 15, top: 5, bottom: 5 })
+
+        Row() {
+          Text('高亮倍数:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Slider({
+            value: this.currentHightLyricSize,
+            min: 1,
+            max: 2,
+            step: 0.1,
+            style: SliderStyle.OutSet
+          })
+            .blockColor(this.themeColor)
+            .trackColor($r('app.color.speed_text_color'))
+            .selectedColor(Color.White)
+            .trackThickness(6)
+            .onChange((value: number) => {
+              this.setHighLyricTextSize(value)
+            })
+            .layoutWeight(1)
+            .margin({ right: 3 })
+          Text(this.getHighLyricSizeDisplayValue())
+            .fontSize(13)
+            .fontColor(Color.White)
+            .textAlign(TextAlign.Start)
+        }
+        .width('100%')
+        .margin({ left: 20, right: 15, top: 5, bottom: 5 })
+
+        Row() {
+          Text('歌词间隙:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Slider({
+            value: this.currentLyricLineSpace,
+            min: 0,
+            max: 100,
+            step: 1,
+            style: SliderStyle.OutSet
+          })
+            .blockColor(this.themeColor)
+            .trackColor($r('app.color.speed_text_color'))
+            .selectedColor(Color.White)
+            .trackThickness(6)
+            .onChange((value: number) => {
+              this.setLyricLineSpace(value)
+            })
+            .layoutWeight(1)
+            .margin({ right: 3 })
+          Text(this.getLyricLineSpaceDisplayValue())
+            .fontSize(13)
+            .fontColor(Color.White)
+            .textAlign(TextAlign.Start)
+        }
+        .width('100%')
+        .margin({ left: 20, right: 15, top: 5, bottom: 5 })
+
+        Row() {
+          Text('歌词字重:')
+            .fontSize(14)
+            .fontColor(Color.White)
+          Slider({
+            value: this.lyricTextWeight,
+            min: 100,
+            max: 900,
+            step: 100,
+            style: SliderStyle.OutSet
+          })
+            .blockColor(this.themeColor)
+            .trackColor($r('app.color.speed_text_color'))
+            .selectedColor(Color.White)
+            .trackThickness(6)
+            .onChange((value: number) => {
+              this.setLyricTextWeight(value)
+            })
+            .layoutWeight(1)
+            .margin({ right: 3 })
+          Text(this.getLyricTextWeightDisplayValue())
+            .fontSize(13)
+            .fontColor(Color.White)
+            .textAlign(TextAlign.Start)
+        }
+        .width('100%')
+        .margin({ left: 20, right: 15, top: 5, bottom: 5 })
+
+        Row() {
+          this.pushLyricButton($r('app.media.cut_current'), 0, '本地歌词')
+          this.pushLyricButton($r('app.media.share2'), 2, '分享歌词')
+          this.pushLyricButton($r('app.media.white_search'), 1, '获取歌词')
+        }
+        .justifyContent(FlexAlign.Center)
+        .margin({ top: 3, bottom: 10 })
+      }
+      .width('88%')
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 108 - 0
entry/src/main/ets/view/player/PlayerLyricSettingsHelper.ets

@@ -0,0 +1,108 @@
+import { MD5, StrUtil } from '@pura/harmony-utils'
+import { LyricController } from '../../lyric/LyricController'
+
+export const PLAYER_SPECTRUM_MODE_NAMES: string[] = [
+  '柱状',
+  '波形',
+  '水波纹',
+  '圆形',
+  '粒子',
+  '脉冲',
+  '镜像柱',
+  '影子柱',
+  '轨道线',
+  '点阵',
+  '星爆',
+  '心电图',
+  '特效'
+]
+
+export function clampPlayerLyricOffset(offsetSeconds: number): number {
+  if (!Number.isFinite(offsetSeconds)) {
+    return 0
+  }
+  const roundedOffset = Number(offsetSeconds.toFixed(1))
+  if (roundedOffset < -30) {
+    return -30
+  }
+  if (roundedOffset > 30) {
+    return 30
+  }
+  return roundedOffset
+}
+
+export function formatPlayerLyricOffsetText(offsetSeconds: number): string {
+  const normalizedOffset = clampPlayerLyricOffset(offsetSeconds)
+  if (normalizedOffset === 0) {
+    return '歌词时间已重置'
+  }
+  const offsetText = Math.abs(normalizedOffset).toFixed(1)
+  return `歌词已${normalizedOffset < 0 ? '延后' : '提前'} ${offsetText} 秒`
+}
+
+export function buildPlayerLyricOffsetPreferenceKey(filePath?: string): string {
+  if (!filePath || StrUtil.isEmpty(filePath)) {
+    return ''
+  }
+  return `lyric_offset_${MD5.digestSync(filePath)}`
+}
+
+export function resolvePlayerSpectrumModeName(index: number): string {
+  if (index < 0 || index >= PLAYER_SPECTRUM_MODE_NAMES.length) {
+    return PLAYER_SPECTRUM_MODE_NAMES[0]
+  }
+  return PLAYER_SPECTRUM_MODE_NAMES[index]
+}
+
+export function resolveNextPlayerSpectrumModeIndex(currentIndex: number): number {
+  if (PLAYER_SPECTRUM_MODE_NAMES.length <= 0) {
+    return 0
+  }
+  if (currentIndex < 0 || currentIndex >= PLAYER_SPECTRUM_MODE_NAMES.length) {
+    return 0
+  }
+  return (currentIndex + 1) % PLAYER_SPECTRUM_MODE_NAMES.length
+}
+
+export function resolvePlayerLyricAlignMode(modeIndex: number): 'center' | 'left' {
+  if (modeIndex === 1) {
+    return 'left'
+  }
+  return 'center'
+}
+
+export function applyPlayerLyricTextSize(lyricController: LyricController,
+  lyricControllerSingle: LyricController, textSize: number): void {
+  lyricController.setTextSize(textSize)
+  lyricControllerSingle.setTextSize(textSize)
+}
+
+export function applyPlayerLyricHighlightScale(lyricController: LyricController,
+  lyricControllerSingle: LyricController, scale: number): void {
+  lyricController.setHighlightScale(scale)
+  lyricControllerSingle.setHighlightScale(scale)
+}
+
+export function applyPlayerLyricLineSpace(lyricController: LyricController,
+  lyricControllerSingle: LyricController, lineSpace: number): void {
+  lyricController.setLineSpace(lineSpace)
+  lyricControllerSingle.setLineSpace(lineSpace)
+}
+
+export function applyPlayerLyricTextColor(lyricController: LyricController,
+  lyricControllerSingle: LyricController, color: string): void {
+  lyricController.setTextColor(color)
+  lyricControllerSingle.setTextColor(color)
+}
+
+export function applyPlayerLyricHighlightColor(lyricController: LyricController,
+  lyricControllerSingle: LyricController, color: string): void {
+  lyricController.setHighlightColor(color)
+  lyricControllerSingle.setHighlightColor(color)
+}
+
+export function applyPlayerLyricTextWeight(lyricController: LyricController,
+  lyricControllerSingle: LyricController, textWeight: number): void {
+  lyricController.setTextWeight(textWeight)
+  lyricControllerSingle.setTextWeight(textWeight)
+}

+ 305 - 0
entry/src/main/ets/view/player/PlayerMetadataService.ets

@@ -0,0 +1,305 @@
+import { fileUri } from '@kit.CoreFileKit'
+import { emitter } from '@kit.BasicServicesKit'
+import { FileUtil, PreferencesUtil, StrUtil } from '@pura/harmony-utils'
+import { EventConstants } from '../../common/constants/EventConstants'
+import { CommonConstants } from '../../common/constants/CommonConstants'
+import { getApiLyric, repairAudioMetadata, changeMusicCover } from '../../common/util/MusicTagUtils'
+import MediaTable from '../../common/util/MediaTable'
+import NetAxiosUtil from '../../common/util/NetAxiosUtil'
+import PermissionUtil from '../../common/util/PermissionUtil'
+import { extractHwMediaMetadata, FFMpegTags, Utility } from '../../common/util/Utility'
+import {
+  cloneVideoItem,
+  isRemoteCloudType,
+  isWebDavType,
+  WebDavMetadataUpdatePayload
+} from '../../common/util/RemotePlayerUtil'
+import { VideoItem } from '../../viewmodel/VideoItem'
+import { SongEditFormState, applySongEditFormStateToItem, createSongEditFormState } from './SongEditFormState'
+
+export interface PlayerMetadataSaveOptions {
+  context: Context
+  item: VideoItem
+  form: SongEditFormState
+  selectedCoverPath?: string
+  currentPath?: string
+  packName?: string
+  autoParseMusicName?: boolean
+}
+
+export interface PlayerMetadataSaveResult {
+  success: boolean
+  message: string
+  updatedItem?: VideoItem
+}
+
+export interface SongEditCurrentFields {
+  titleStr: string
+  artistStr: string
+  ablumStr: string
+  lyricConStr: string
+  yearStr: string
+  genreStr: string
+  trackStr: string
+  albumArtistStr: string
+  composerStr: string
+  lyricistStr: string
+  commentStr: string
+  discStr: string
+}
+
+function openMediaTable(context: Context): Promise<MediaTable> {
+  const table = new MediaTable(context)
+  return new Promise<MediaTable>((resolve, reject) => {
+    table.getRdbStore(context, (error?: Error) => {
+      if (error) {
+        reject(error)
+        return
+      }
+      resolve(table)
+    })
+  })
+}
+
+function updateMediaInfoAsync(table: MediaTable, filePath: string, form: SongEditFormState): Promise<boolean> {
+  return new Promise<boolean>((resolve) => {
+    table.updateMediaInfo(filePath, form.title, form.artist, form.album, form.lyricContent, form.year, form.genre,
+      form.track, form.albumArtist, form.composer, form.lyricist, form.comment, form.disc,
+      (success: boolean) => {
+        resolve(success)
+      })
+  })
+}
+
+function updateCustomCoverPathAsync(table: MediaTable, filePath: string, coverPath: string): Promise<boolean> {
+  return new Promise<boolean>((resolve) => {
+    table.updateCustomCoverPath(filePath, coverPath, (success: boolean) => {
+      resolve(success)
+    })
+  })
+}
+
+function normalizeCoverPath(path: string): string {
+  if (StrUtil.isEmpty(path)) {
+    return ''
+  }
+  const lowerPath = path.toLowerCase()
+  if (lowerPath.startsWith('http://') || lowerPath.startsWith('https://')
+    || lowerPath.startsWith('file://') || lowerPath.startsWith('data:')) {
+    return path
+  }
+  return fileUri.getUriFromPath(path)
+}
+
+function buildMetadata(form: SongEditFormState): FFMpegTags {
+  return {
+    title: form.title,
+    artist: form.artist,
+    album: form.album,
+    TYER: form.year,
+    genre: form.genre,
+    track: form.track,
+    album_artist: form.albumArtist,
+    TPE2: form.albumArtist,
+    COMPOSER: form.composer,
+    lyricist: form.lyricist,
+    TEXT: form.lyricist,
+    comment: form.comment,
+    disc: form.disc,
+  }
+}
+
+function isHighSampleFormat(filePath: string): boolean {
+  const lowerPath = filePath.toLowerCase()
+  return lowerPath.endsWith('.wav') || lowerPath.endsWith('.ape') || lowerPath.endsWith('.dsf') ||
+    lowerPath.endsWith('.dff')
+}
+
+function resolveRemoteStoragePath(item: VideoItem): string | null {
+  if (!item || !isRemoteCloudType(item.type)) {
+    return null
+  }
+  if (isWebDavType(item.type)) {
+    const storagePath = item.remote_rel_path || item.filePath
+    if (StrUtil.isNotEmpty(storagePath)) {
+      item.remote_rel_path = storagePath
+      return storagePath
+    }
+    return null
+  }
+  if (StrUtil.isNotEmpty(item.remote_rel_path)) {
+    return item.remote_rel_path as string
+  }
+  return null
+}
+
+function extractParentPathFromStoragePath(path: string): string {
+  if (StrUtil.isEmpty(path)) {
+    return ''
+  }
+  const lastSlash = path.lastIndexOf('/')
+  if (lastSlash <= 0) {
+    return ''
+  }
+  return path.substring(0, lastSlash)
+}
+
+function emitRemoteMetadataUpdated(item: VideoItem): void {
+  if (!item || StrUtil.isEmpty(item.filePath)) {
+    return
+  }
+  const payload: WebDavMetadataUpdatePayload = {
+    filePath: item.filePath,
+    pixelMapPath: item.pixelMapPath,
+    isCustomCover: item.isCustomCover,
+    md5Str: item.md5Str,
+    name: item.name,
+    artist: item.artist
+  }
+  emitter.emit({ eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED }, { data: [payload] })
+}
+
+async function persistRemoteMetadata(context: Context, item: VideoItem): Promise<void> {
+  const storagePath = resolveRemoteStoragePath(item)
+  if (!storagePath) {
+    return
+  }
+  const table = await openMediaTable(context)
+  const cloneItem = cloneVideoItem(item)
+  cloneItem.filePath = storagePath
+  cloneItem.remote_rel_path = storagePath
+  cloneItem.parentPath = extractParentPathFromStoragePath(storagePath)
+  await table.saveOrUpdateWebDavItem(cloneItem)
+}
+
+export function buildSongEditFormStateFromCurrentFields(fields: SongEditCurrentFields): SongEditFormState {
+  const form = createSongEditFormState()
+  form.title = fields.titleStr
+  form.artist = fields.artistStr
+  form.album = fields.ablumStr
+  form.lyricContent = fields.lyricConStr
+  form.year = fields.yearStr
+  form.genre = fields.genreStr
+  form.track = fields.trackStr
+  form.albumArtist = fields.albumArtistStr
+  form.composer = fields.composerStr
+  form.lyricist = fields.lyricistStr
+  form.comment = fields.commentStr
+  form.disc = fields.discStr
+  return form
+}
+
+export async function fetchSongCoverPath(title: string, artist: string): Promise<string> {
+  const api = PreferencesUtil.getStringSync('COVER_API', '')
+  if (StrUtil.isEmpty(api)) {
+    return ''
+  }
+  const result = await NetAxiosUtil.getLyricCover(title, artist, api)
+  if (StrUtil.isEmpty(result) || result === 'unknown' || result === 'Timeout was reached') {
+    return ''
+  }
+  return result
+}
+
+export async function fetchSongLyricText(title: string, artist: string): Promise<string> {
+  const api = PreferencesUtil.getStringSync('LRC_API', '')
+  if (StrUtil.isEmpty(api)) {
+    return ''
+  }
+  const result = await getApiLyric(api, title, artist, false)
+  if (StrUtil.isEmpty(result) || result === 'unknown' || result === 'Timeout was reached') {
+    return ''
+  }
+  return result
+}
+
+export async function repairSongEditFormFromFile(item: VideoItem): Promise<Partial<SongEditFormState>> {
+  const metadata = await extractHwMediaMetadata(item.filePath)
+  return {
+    title: metadata?.title || '',
+    artist: metadata?.artist || '',
+    album: metadata?.album || ''
+  }
+}
+
+export async function saveSongMetadata(options: PlayerMetadataSaveOptions): Promise<PlayerMetadataSaveResult> {
+  const item = options.item
+  const form = options.form
+  if (!item) {
+    return { success: false, message: '歌曲不存在' }
+  }
+  if (StrUtil.isEmpty(form.title)) {
+    return { success: false, message: '标题不能为空' }
+  }
+
+  const coverPath = StrUtil.isNotEmpty(options.selectedCoverPath) ? options.selectedCoverPath as string : ''
+  applySongEditFormStateToItem(item, form)
+
+  if (isRemoteCloudType(item.type)) {
+    if (StrUtil.isNotEmpty(coverPath)) {
+      item.pixelMapPath = normalizeCoverPath(coverPath)
+      item.isCustomCover = 1
+    }
+    await persistRemoteMetadata(options.context, item)
+    emitRemoteMetadataUpdated(item)
+    return {
+      success: true,
+      message: StrUtil.isNotEmpty(item.pixelMapPath) ? '网盘歌曲信息已保存' : '网盘歌曲标签已保存',
+      updatedItem: cloneVideoItem(item)
+    }
+  }
+
+  await PermissionUtil.activatePermission(item.filePath)
+  let tempOutPath = ''
+  if (StrUtil.isNotEmpty(options.currentPath) && StrUtil.isNotEmpty(options.packName)
+    && !item.filePath.toLowerCase().includes((options.packName as string).toLowerCase())) {
+    tempOutPath = `${options.currentPath}/${item.fileName}`
+  }
+
+  if (StrUtil.isNotEmpty(coverPath)) {
+    await changeMusicCover(options.context, item.filePath, coverPath, true, tempOutPath)
+  }
+
+  let inputPath = item.filePath
+  if (StrUtil.isNotEmpty(tempOutPath) && FileUtil.accessSync(tempOutPath)) {
+    inputPath = tempOutPath
+    tempOutPath = ''
+  }
+  const isWavLike = isHighSampleFormat(inputPath)
+  const saved = await repairAudioMetadata(inputPath, form.lyricContent, buildMetadata(form), true, tempOutPath)
+  if (!saved && !isWavLike) {
+    return { success: false, message: '内嵌音乐标签失败' }
+  }
+
+  const table = await openMediaTable(options.context)
+  if (!item.filePath.includes(options.packName ?? '')) {
+    const newItem = await Utility.uriGetMusicAssetsFromFile(options.context, inputPath, CommonConstants.TYPE_LOCAL,
+      options.autoParseMusicName ?? true)
+    await new Promise<void>((resolve) => {
+      table.insert(newItem, () => resolve())
+    })
+  }
+
+  const dbUpdated = await updateMediaInfoAsync(table, item.filePath, form)
+  if (!dbUpdated) {
+    return { success: false, message: '编辑信息数据库失败' }
+  }
+
+  if (StrUtil.isNotEmpty(coverPath)) {
+    const normalizedCoverPath = normalizeCoverPath(coverPath)
+    await updateCustomCoverPathAsync(table, item.filePath, normalizedCoverPath)
+    item.pixelMapPath = normalizedCoverPath
+    item.isCustomCover = 1
+  }
+
+  const updatedItem = await table.queryVideoByFilePath(item.filePath) ?? cloneVideoItem(item)
+  if (StrUtil.isNotEmpty(coverPath)) {
+    updatedItem.pixelMapPath = item.pixelMapPath
+    updatedItem.isCustomCover = item.isCustomCover
+  }
+  return {
+    success: true,
+    message: isWavLike ? 'dsf、wav、dff或者ape格式有可能内嵌失败,但是编辑信息成功' : '内嵌音乐标签成功',
+    updatedItem
+  }
+}

+ 28 - 0
entry/src/main/ets/view/player/PlayerMoreActionHelper.ets

@@ -0,0 +1,28 @@
+export class PlayerMoreActionItem {
+  id: string
+  title: string
+  icon: Resource
+  isSymbol: boolean
+
+  constructor(id: string, title: string, icon: Resource, isSymbol: boolean = true) {
+    this.id = id
+    this.title = title
+    this.icon = icon
+    this.isSymbol = isSymbol
+  }
+}
+
+export function buildPlayerCoreMoreActions(): PlayerMoreActionItem[] {
+  return [
+    new PlayerMoreActionItem('lyric', '歌词设置', $r('sys.symbol.lyrics_square')),
+    new PlayerMoreActionItem('detail', '歌曲详情', $r('sys.symbol.info_circle')),
+    new PlayerMoreActionItem('edit', '编辑标签', $r('sys.symbol.rename')),
+    new PlayerMoreActionItem('equalizer', '均衡器', $r('sys.symbol.slider_vertical_3')),
+    new PlayerMoreActionItem('cover', '获取封面', $r('app.media.cover_online'), false),
+    new PlayerMoreActionItem('timer_close', '定时关闭', $r('app.media.time_close'), false),
+    new PlayerMoreActionItem('skip_intro_outro', '跳过头尾', $r('app.media.time_close'), false),
+    new PlayerMoreActionItem('ab_loop', 'AB循环', $r('app.media.a_b'), false),
+    new PlayerMoreActionItem('playlist', '播放列表', $r('sys.symbol.music_note_list')),
+    new PlayerMoreActionItem('share_lyric', '分享歌词', $r('app.media.share2'), false)
+  ]
+}

+ 54 - 0
entry/src/main/ets/view/player/PlayerMoreSheet.ets

@@ -0,0 +1,54 @@
+import { CommonConstants } from '../../common/constants/CommonConstants'
+import { PlayerMoreActionItem } from './PlayerMoreActionHelper'
+
+@Component
+export struct PlayerMoreSheet {
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Prop actions: PlayerMoreActionItem[] = []
+  onAction: (actionId: string) => void = (_actionId: string): void => {}
+
+  build() {
+    Scroll() {
+      Column({ space: 10 }) {
+        ForEach(this.actions, (action: PlayerMoreActionItem) => {
+          Button() {
+            Row({ space: 12 }) {
+              if (action.isSymbol) {
+                SymbolGlyph(action.icon)
+                  .fontSize(20)
+                  .fontColor([Color.White])
+              } else {
+                Image(action.icon)
+                  .width(20)
+                  .height(20)
+                  .fillColor(Color.White)
+              }
+              Text(action.title)
+                .fontSize(15)
+                .fontColor(Color.White)
+                .layoutWeight(1)
+                .textAlign(TextAlign.Start)
+              SymbolGlyph($r('sys.symbol.chevron_right'))
+                .fontSize(16)
+                .fontColor([Color.White])
+            }
+            .width('100%')
+            .alignItems(VerticalAlign.Center)
+          }
+          .width('100%')
+          .height(52)
+          .backgroundColor('rgba(255,255,255,0.12)')
+          .borderRadius(18)
+          .padding({ left: 16, right: 14 })
+          .onClick(() => {
+            this.onAction(action.id)
+          })
+        }, (action: PlayerMoreActionItem): string => action.id)
+      }
+      .padding({ left: 16, right: 16, top: 12, bottom: 28 })
+      .width('100%')
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 77 - 0
entry/src/main/ets/view/player/PlayerTimeCloseSheet.ets

@@ -0,0 +1,77 @@
+import { CommonConstants } from '../../common/constants/CommonConstants'
+import { formatPlayerTimedCloseCountdown } from './PlayerAdvancedControlHelper'
+
+@Component
+export struct PlayerTimeCloseSheet {
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Prop selectedMinutes: number = 0
+  @Prop remainingSeconds: number = 0
+  @Prop presetTimes: number[] = [0, 5, 15, 30, 45, 60, -1]
+  onSelectMinutes: (minutes: number) => void = (_minutes: number): void => {}
+
+  private getDisplayText(minutes: number): string {
+    if (minutes === -1) {
+      return '自定义'
+    }
+    if (minutes === 0) {
+      return '关闭'
+    }
+    return `${minutes}分钟后`
+  }
+
+  build() {
+    Scroll() {
+      Column() {
+        Text(this.selectedMinutes > 0 ? `剩余时间:${formatPlayerTimedCloseCountdown(this.remainingSeconds)}` : '定时关闭未开启')
+          .fontSize(17)
+          .fontColor(Color.White)
+          .margin({ top: 2, bottom: 10 })
+
+        Column({ space: 12 }) {
+          ForEach(this.presetTimes, (minutes: number) => {
+            Row() {
+              Text(this.getDisplayText(minutes))
+                .fontSize(15)
+                .fontColor(minutes === this.selectedMinutes ? this.themeColor : Color.White)
+                .layoutWeight(1)
+                .textAlign(TextAlign.Start)
+
+              Row() {
+                if (minutes === this.selectedMinutes) {
+                  Image($r('sys.media.ohos_ic_public_ok'))
+                    .width(14)
+                    .height(14)
+                    .fillColor(Color.White)
+                }
+              }
+              .width(24)
+              .height(24)
+              .borderRadius(12)
+              .backgroundColor(minutes === this.selectedMinutes ? this.themeColor : Color.Transparent)
+              .border({
+                width: 2,
+                color: minutes === this.selectedMinutes ? Color.Transparent : '#80FFFFFF',
+                style: BorderStyle.Solid
+              })
+              .justifyContent(FlexAlign.Center)
+              .alignItems(VerticalAlign.Center)
+            }
+            .width('100%')
+            .padding({ top: 18, bottom: 18, left: 18, right: 18 })
+            .backgroundColor(minutes === this.selectedMinutes ? 'rgba(255,255,255,0.16)' : 'rgba(255,255,255,0.08)')
+            .borderRadius(16)
+            .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 })
+            .onClick(() => {
+              this.onSelectMinutes(minutes)
+            })
+          }, (minutes: number): string => `${minutes}`)
+        }
+        .width('100%')
+      }
+      .width('100%')
+      .padding({ left: 16, right: 16, top: 12, bottom: 28 })
+    }
+    .width('100%')
+    .height('100%')
+  }
+}

+ 127 - 0
entry/src/main/ets/view/player/PlayerTimedCloseService.ets

@@ -0,0 +1,127 @@
+import common from '@ohos.app.ability.common'
+import { ToastUtil } from '@pura/harmony-utils'
+import { IjkMediaPlayer } from '@ohos/ijkplayer'
+import {
+  PlayerTimedCloseSelectionResolution,
+  PlayerTimedCloseTickResolution,
+  resolvePlayerTimedCloseSelection,
+  resolvePlayerTimedCloseTick
+} from './PlayerAdvancedControlHelper'
+
+export class PlayerTimedCloseState {
+  selectedMinutes: number = 0
+  remainingSeconds: number = 0
+}
+
+export class PlayerTimedCloseService {
+  private static instance?: PlayerTimedCloseService
+  private state: PlayerTimedCloseState = new PlayerTimedCloseState()
+  private timerId: number = -1
+  private observers: Array<(state: PlayerTimedCloseState) => void> = []
+
+  public static getInstance(): PlayerTimedCloseService {
+    if (!PlayerTimedCloseService.instance) {
+      PlayerTimedCloseService.instance = new PlayerTimedCloseService()
+    }
+    return PlayerTimedCloseService.instance
+  }
+
+  public subscribe(observer: (state: PlayerTimedCloseState) => void): void {
+    this.observers.push(observer)
+    observer(this.cloneState())
+  }
+
+  public unsubscribe(observer: (state: PlayerTimedCloseState) => void): void {
+    this.observers = this.observers.filter((item: (state: PlayerTimedCloseState) => void): boolean => item !== observer)
+  }
+
+  public selectMinutes(minutes: number): void {
+    const result = resolvePlayerTimedCloseSelection(this.state.selectedMinutes, minutes)
+    if (result.shouldStop) {
+      this.cancel()
+      return
+    }
+    this.applySelectionResult(result)
+  }
+
+  public startCustomMinutes(minutes: number): void {
+    const safeMinutes = Math.max(0, Math.floor(minutes))
+    if (safeMinutes <= 0) {
+      return
+    }
+    const result = new PlayerTimedCloseSelectionResolution()
+    result.selectedMinutes = safeMinutes
+    result.remainingSeconds = safeMinutes * 60
+    result.shouldStart = true
+    this.applySelectionResult(result)
+  }
+
+  public cancel(): void {
+    this.clearTimer()
+    this.state.selectedMinutes = 0
+    this.state.remainingSeconds = 0
+    this.notifyObservers()
+  }
+
+  private applySelectionResult(result: PlayerTimedCloseSelectionResolution): void {
+    this.state.selectedMinutes = result.selectedMinutes
+    this.state.remainingSeconds = result.remainingSeconds
+    this.startTimer()
+    this.notifyObservers()
+  }
+
+  private startTimer(): void {
+    this.clearTimer()
+    if (this.state.remainingSeconds <= 0) {
+      return
+    }
+    this.timerId = setInterval(() => {
+      this.handleTick()
+    }, 1000)
+  }
+
+  private handleTick(): void {
+    const result: PlayerTimedCloseTickResolution = resolvePlayerTimedCloseTick(this.state.remainingSeconds)
+    if (result.shouldTimeout) {
+      this.handleTimeout()
+      return
+    }
+    this.state.remainingSeconds = result.remainingSeconds
+    this.notifyObservers()
+  }
+
+  private handleTimeout(): void {
+    this.cancel()
+    try {
+      const player = IjkMediaPlayer.getInstance()
+      player.stop()
+    } catch (_error) {
+    }
+    ToastUtil.showToast('定时关闭已完成')
+    const abilityContext = AppStorage.get('context') as common.UIAbilityContext | undefined
+    if (abilityContext) {
+      abilityContext.terminateSelf()
+    }
+  }
+
+  private clearTimer(): void {
+    if (this.timerId >= 0) {
+      clearInterval(this.timerId)
+      this.timerId = -1
+    }
+  }
+
+  private notifyObservers(): void {
+    const snapshot = this.cloneState()
+    for (let index = 0; index < this.observers.length; index++) {
+      this.observers[index](snapshot)
+    }
+  }
+
+  private cloneState(): PlayerTimedCloseState {
+    const snapshot = new PlayerTimedCloseState()
+    snapshot.selectedMinutes = this.state.selectedMinutes
+    snapshot.remainingSeconds = this.state.remainingSeconds
+    return snapshot
+  }
+}

+ 27 - 0
entry/src/ohosTest/ets/test/PlayerDismissHelper.test.ets

@@ -1,8 +1,11 @@
 import { describe, it, expect } from '@ohos/hypium'
 import {
+  PlayerPageTransitionState,
   resolveMiniPlayerCoverCenterOffset,
   resolveMiniPlayerRevealAnimationPlan,
   resolveMiniPlayerMorphTarget,
+  resolvePlayerPageCollapsedTransitionState,
+  resolvePlayerPageExpandedTransitionState,
   resolvePlayerDismissMorphTarget,
   resolvePlayerDismissTarget
 } from '../../../main/ets/common/util/PlayerDismissHelper'
@@ -88,5 +91,29 @@ export default function playerDismissHelperTest() {
       expect(plan.startProxyOpacity).assertEqual(0)
       expect(plan.shakeDelayMs).assertEqual(236)
     })
+
+    it('resolvePlayerPageCollapsedTransitionStateUsesMiniPlayerCenterDot', 0, () => {
+      const state: PlayerPageTransitionState = resolvePlayerPageCollapsedTransitionState(360, 800, 16, false)
+
+      expect(state.scaleX).assertEqual(0.027777777777777776)
+      expect(state.scaleY).assertEqual(0.0125)
+      expect(state.opacity).assertEqual(0.18)
+      expect(state.translateX).assertEqual(0)
+      expect(state.translateY).assertEqual(0)
+      expect(state.centerX).assertEqual('50%')
+      expect(state.centerY).assertEqual('93.625%')
+    })
+
+    it('resolvePlayerPageExpandedTransitionStateReturnsIdentityTransform', 0, () => {
+      const state: PlayerPageTransitionState = resolvePlayerPageExpandedTransitionState()
+
+      expect(state.scaleX).assertEqual(1)
+      expect(state.scaleY).assertEqual(1)
+      expect(state.opacity).assertEqual(1)
+      expect(state.translateX).assertEqual(0)
+      expect(state.translateY).assertEqual(0)
+      expect(state.centerX).assertEqual('50%')
+      expect(state.centerY).assertEqual('50%')
+    })
   })
 }

+ 39 - 0
entry/src/ohosTest/ets/test/PlayerFavoriteService.test.ets

@@ -0,0 +1,39 @@
+import { describe, expect, it } from '@ohos/hypium'
+import { CommonConstants } from '../../../main/ets/common/constants/CommonConstants'
+import {
+  resolveNextFavoriteValue,
+  resolvePlayerFavoriteTargetFilePath
+} from '../../../main/ets/view/player/PlayerFavoriteService'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
+
+function createLocalSong(): VideoItem {
+  return new VideoItem('Local Song', '1', '/music/local.flac', CommonConstants.TYPE_LOCAL, 0, '2026-04-06')
+}
+
+function createRemoteSong(): VideoItem {
+  const song = new VideoItem('Remote Song', '2', 'https://remote.example/song.flac', CommonConstants.TYPE_WEBDAV, 0,
+    '2026-04-06')
+  song.remote_rel_path = '/library/song.flac'
+  return song
+}
+
+export default function playerFavoriteServiceTest() {
+  describe('PlayerFavoriteService', () => {
+    it('resolveNextFavoriteValueTogglesCurrentFavoriteState', 0, () => {
+      expect(resolveNextFavoriteValue(false)).assertEqual(1)
+      expect(resolveNextFavoriteValue(true)).assertEqual(0)
+    })
+
+    it('resolvePlayerFavoriteTargetFilePathUsesLocalPathForLocalSong', 0, () => {
+      const song = createLocalSong()
+
+      expect(resolvePlayerFavoriteTargetFilePath(song)).assertEqual('/music/local.flac')
+    })
+
+    it('resolvePlayerFavoriteTargetFilePathUsesStoragePathForRemoteSong', 0, () => {
+      const song = createRemoteSong()
+
+      expect(resolvePlayerFavoriteTargetFilePath(song)).assertEqual('/library/song.flac')
+    })
+  })
+}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä