@@ -601,95 +601,105 @@ function FloatBall({ themeName, bpm, conf, viz, onExit, isLockedHighlight }: { t
601601 const [ hover , setHover ] = React . useState ( false )
602602 const lastClickRef = React . useRef < number > ( 0 )
603603 const dragStartRef = React . useRef < { x :number , y :number } | null > ( null )
604- const mode : 'bars' = 'bars '
605- // 悬浮球固定尺寸,需在依赖它的 effect 之前声明
606- const ballSize = 56
607- // 中等夸张参数(与绘制一致),据此给出透明边距,避免裁剪
608- const baseStroke = 2.2
609- const widthGain = 2.2
610- const radiusGain = 1.5
611- const shadowBlur = 6
612- const innerRadiusGain = 0.9
613- const marginPx = Math . ceil ( shadowBlur + ( baseStroke + widthGain ) / 2 + radiusGain + 1 )
604+ const accent = theme . ring || '#eb1a50 '
605+ const ballSize = 58
606+ const baseStroke = 1.68
607+ const widthGain = 2.24
608+ const radiusGain = 2.56
609+ const innerRadiusGain = 0.96
610+ const shadowBlur = 9.6
611+ const segments = 64
612+ const segmentGap = 0.12
613+ const marginPx = Math . ceil ( shadowBlur + ( baseStroke + widthGain ) / 2 + radiusGain + 2 )
614614 const canvasSize = ballSize + marginPx * 2
615615
616- const canvasRef = React . useRef < HTMLCanvasElement | null > ( null )
617- // rAF 驱动:以最新 viz 作为源,在屏幕刷新节奏下重绘,保证前端帧率不受事件抖动影响
616+ const toRgba = React . useCallback (
617+ ( alpha : number ) => {
618+ const hex = accent . startsWith ( '#' ) ? accent . slice ( 1 ) : null
619+ if ( ! hex || ( hex . length !== 3 && hex . length !== 6 ) ) {
620+ return `rgba(235,26,80,${ alpha } )`
621+ }
622+ const full = hex . length === 3 ? hex . split ( '' ) . map ( ( c ) => c + c ) . join ( '' ) : hex
623+ const num = parseInt ( full , 16 )
624+ const r = ( num >> 16 ) & 255
625+ const g = ( num >> 8 ) & 255
626+ const b = num & 255
627+ return `rgba(${ r } ,${ g } ,${ b } ,${ alpha } )`
628+ } ,
629+ [ accent ]
630+ )
631+
632+ const canvasRef = React . useRef < HTMLCanvasElement | null > ( null )
633+ // rAF ?????? viz ?????????????????????????????
618634 const lastVizRef = React . useRef < AudioViz | null > ( null )
619635 React . useEffect ( ( ) => { lastVizRef . current = viz } , [ viz ] )
620636 React . useEffect ( ( ) => {
621637 let rafId = 0
622- let phase = 0 // 中等夸张:轻微相位流动
623- function tick ( ) {
638+ const tick = ( ) => {
624639 const cvs = canvasRef . current
625640 if ( cvs ) {
626641 const ctx = cvs . getContext ( '2d' )
627642 if ( ctx ) {
628643 const dpr = Math . max ( 1 , Math . floor ( window . devicePixelRatio || 1 ) )
629- const cssW = canvasSize , cssH = canvasSize
644+ const cssW = canvasSize
645+ const cssH = canvasSize
630646 if ( cvs . width !== cssW * dpr || cvs . height !== cssH * dpr ) {
631647 cvs . width = cssW * dpr ; cvs . height = cssH * dpr
632648 }
633649 cvs . style . width = cssW + 'px' ; cvs . style . height = cssH + 'px'
634650 ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 )
635- const W = cssW , H = cssH
636- ctx . clearRect ( 0 , 0 , W , H )
637- const cx = W / 2 , cy = H / 2
638- // 以视觉圆直径为基准,确保可视化与圆边对齐(静止外缘≈圆半径,稍作+0.25px外扩防露底)
639- const rBase = Math . max ( 1 , ballSize / 2 - baseStroke / 2 + 0.25 )
640- ctx . lineCap = 'round' as CanvasLineCap
641- ctx . lineJoin = 'round' as CanvasLineJoin
642- const N = 64
643- const gap = 0.08
651+ ctx . clearRect ( 0 , 0 , cssW , cssH )
652+ const cx = cssW / 2
653+ const cy = cssH / 2
644654 const samples = lastVizRef . current ?. samples ?? [ ]
645655 const rms = lastVizRef . current ?. rms ?? 0
646- const energyBoost = Math . min ( 2.0 , 0.9 + rms * 2.0 )
647- ctx . shadowBlur = shadowBlur
648- ctx . shadowColor = 'rgba(235,26,80,0.80)'
649- const silent = ( rms <= 0.001 )
650- // 基础环:静音时更亮但不过分厚
651- {
652- ctx . save ( )
653- ctx . shadowBlur = silent ? ( shadowBlur + 1 ) : shadowBlur
654- // 让静止时外缘与 rBase 一致:取与动态线宽同一“视觉厚度”级别
655- ctx . lineWidth = silent ? Math . max ( 1.6 , baseStroke * 0.9 ) : Math . max ( 1.0 , baseStroke * 0.7 )
656- ctx . strokeStyle = `rgba(235,26,80,${ silent ? 0.55 : 0.20 } )`
657- ctx . beginPath ( ) ; ctx . arc ( cx , cy , rBase , 0 , Math . PI * 2 , false ) ; ctx . stroke ( )
658- ctx . restore ( )
659- }
660- for ( let i = 0 ; i < N ; i ++ ) {
661- let v = 0 , cnt = 0
662- if ( samples . length ) {
663- const i0 = Math . floor ( i * samples . length / N ) ; const i1 = Math . floor ( ( i + 1 ) * samples . length / N )
664- for ( let j = i0 ; j < i1 ; j ++ ) { v += Math . abs ( samples [ j ] || 0 ) ; cnt ++ }
665- v = cnt ? v / cnt : 0
656+ const silent = rms <= 0.002
657+ const energyBoost = Math . min ( 2.2 , 0.85 + rms * 2.4 )
658+ const rBase = Math . max ( 8 , ballSize / 2 - baseStroke / 2 + radiusGain )
659+ ctx . lineCap = 'round' as CanvasLineCap
660+ ctx . lineJoin = 'round' as CanvasLineJoin
661+
662+ ctx . save ( )
663+ ctx . shadowBlur = 0
664+ ctx . lineWidth = Math . max ( 1.1 , baseStroke * ( silent ? 0.85 : 0.7 ) )
665+ ctx . strokeStyle = toRgba ( silent ? 0.5 : 0.25 )
666+ ctx . beginPath ( ) ; ctx . arc ( cx , cy , rBase , 0 , Math . PI * 2 , false ) ; ctx . stroke ( )
667+ ctx . restore ( )
668+
669+ const seg = ( Math . PI * 2 ) / segments
670+ for ( let i = 0 ; i < segments ; i ++ ) {
671+ let acc = 0
672+ let cnt = 0
673+ if ( samples . length ) {
674+ const i0 = Math . floor ( ( i / segments ) * samples . length )
675+ const i1 = Math . floor ( ( ( i + 1 ) / segments ) * samples . length )
676+ for ( let j = i0 ; j < i1 ; j ++ ) { acc += Math . abs ( samples [ j ] || 0 ) ; cnt ++ }
666677 }
667- const v2 = Math . pow ( Math . min ( 1 , v * energyBoost * 2.0 ) , 0.58 )
668- const baseA = silent ? 0.40 : 0.26
669- const alpha = Math . min ( 1 , baseA + v2 * ( silent ? 0.45 : 0.60 ) )
670- const seg = ( 2 * Math . PI ) / N
671- const a0 = - Math . PI / 2 + i * seg + seg * gap * 0.5 + phase
672- const a1 = a0 + seg * ( 1 - gap )
673- const r2 = rBase + v2 * radiusGain
674- ctx . lineWidth = baseStroke + v2 * widthGain
675- ctx . strokeStyle = `rgba(235,26,80,${ alpha } )`
676- ctx . beginPath ( ) ; ctx . arc ( cx , cy , r2 , a0 , a1 , false ) ; ctx . stroke ( )
677- // 轻微向内扩一圈,增强“环厚度”质感(更细、更淡)
678- const r3 = Math . max ( 2 , rBase - v2 * innerRadiusGain )
679- const alpha2 = Math . min ( 1 , ( silent ? 0.36 : 0.22 ) + v2 * ( silent ? 0.32 : 0.38 ) )
680- ctx . lineWidth = Math . max ( 1 , 1.0 + v2 * 1.0 )
681- ctx . strokeStyle = `rgba(235,26,80,${ alpha2 } )`
682- ctx . beginPath ( ) ; ctx . arc ( cx , cy , r3 , a0 , a1 , false ) ; ctx . stroke ( )
678+ const avg = cnt ? acc / cnt : 0
679+ const amp = Math . pow ( Math . min ( 1 , avg * energyBoost * 1.8 ) , 0.6 )
680+ const alpha = Math . min ( 1 , ( silent ? 0.32 : 0.2 ) + amp * ( silent ? 0.55 : 0.75 ) )
681+ const a0 = - Math . PI / 2 + i * seg + seg * segmentGap * 0.5
682+ const a1 = a0 + seg * ( 1 - segmentGap )
683+ const outerR = rBase + amp * radiusGain
684+ ctx . lineWidth = baseStroke + amp * widthGain
685+ ctx . strokeStyle = toRgba ( alpha )
686+ ctx . beginPath ( ) ; ctx . arc ( cx , cy , outerR , a0 , a1 , false ) ; ctx . stroke ( )
687+
688+ const innerR = Math . max ( 4 , rBase - amp * innerRadiusGain )
689+ const innerAlpha = Math . min ( 1 , ( silent ? 0.25 : 0.16 ) + amp * 0.55 )
690+ ctx . lineWidth = 1 + amp * 1.1
691+ ctx . strokeStyle = toRgba ( innerAlpha )
692+ ctx . beginPath ( ) ; ctx . arc ( cx , cy , innerR , a0 , a1 , false ) ; ctx . stroke ( )
683693 }
684694 ctx . shadowBlur = 0
685- phase += 0.025
686695 }
687696 }
688697 rafId = requestAnimationFrame ( tick )
689698 }
690699 rafId = requestAnimationFrame ( tick )
691700 return ( ) => { if ( rafId ) cancelAnimationFrame ( rafId ) }
692- } , [ themeName , ballSize , canvasSize ] )
701+ } , [ themeName , ballSize , canvasSize , toRgba , segments , segmentGap , baseStroke , radiusGain , widthGain , innerRadiusGain , shadowBlur ] )
702+
693703
694704 async function handlePointerDown ( e : React . PointerEvent ) {
695705 dragStartRef . current = { x : e . clientX , y : e . clientY }
@@ -741,7 +751,9 @@ function FloatBall({ themeName, bpm, conf, viz, onExit, isLockedHighlight }: { t
741751 const color = isLockedHighlight ? theme . textPrimary : ( conf == null ? confGray : ( conf >= 0.5 ? theme . textPrimary : confGray ) )
742752 const fontPx = 22
743753 const rootStyle : React . CSSProperties = { height :'100vh' , display :'flex' , alignItems :'center' , justifyContent :'center' , background :'transparent' , cursor :'default' }
754+ const bpmLabelColor = hover ? accent : ( themeName === 'dark' ? '#c8d2df' : '#5c606d' )
744755 const textStyle : React . CSSProperties = { fontSize :fontPx , fontWeight :700 , color, letterSpacing :1 , lineHeight :fontPx + 'px' }
756+ const bpmLabelStyle : React . CSSProperties = { fontSize :8 , letterSpacing :2 , color : bpmLabelColor , transition :'color 0.2s ease' }
745757 return (
746758 < main style = { rootStyle } >
747759 < div
@@ -750,37 +762,45 @@ function FloatBall({ themeName, bpm, conf, viz, onExit, isLockedHighlight }: { t
750762 onMouseEnter = { ( ) => setHover ( true ) }
751763 onMouseLeave = { ( ) => setHover ( false ) }
752764 style = { {
753- // 包裹层:更大的真实矩形用于容纳发光/外扩,保持透明
754- width :canvasSize ,
755- height :canvasSize ,
756- position :'relative' ,
757- display :'flex' ,
758- alignItems :'center' ,
759- justifyContent :'center' ,
760- cursor :'default' ,
761- background :'transparent'
765+ width : canvasSize ,
766+ height : canvasSize + 32 ,
767+ position : 'relative' ,
768+ display : 'flex' ,
769+ flexDirection : 'column' ,
770+ alignItems : 'center' ,
771+ justifyContent : 'center' ,
772+ gap : 10 ,
773+ cursor : 'default' ,
774+ background : 'transparent'
762775 } }
763- // 去掉“单击刷新”文案,保留双击行为
764776 >
765- { /* 可视化画布:更大尺寸,避免裁剪;置于最上层以显示“向内”弧线 */ }
766- < canvas ref = { canvasRef } width = { canvasSize } height = { canvasSize } style = { { position :'absolute' , inset :0 , pointerEvents :'none' , zIndex :2 } } />
767- { /* 视觉圆:保持 56px,不变 */ }
768- < div
769- style = { {
770- width :ballSize ,
771- height :ballSize ,
772- borderRadius :ballSize / 2 ,
773- background : theme . background ,
774- display :'flex' ,
775- alignItems :'center' ,
776- justifyContent :'center' ,
777- position :'relative' ,
778- zIndex :1
779- } }
780- >
781- < div style = { textStyle } > { Math . round ( bpm || 0 ) } </ div >
777+ < div style = { { position : 'relative' , width : canvasSize , height : canvasSize , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } } >
778+ < canvas
779+ ref = { canvasRef }
780+ width = { canvasSize }
781+ height = { canvasSize }
782+ style = { { position : 'absolute' , inset : 0 , pointerEvents : 'none' , zIndex : 2 , filter : 'none' } }
783+ />
784+ < div
785+ style = { {
786+ width : ballSize ,
787+ height : ballSize ,
788+ borderRadius : ballSize / 2 ,
789+ background : theme . background ,
790+ display : 'flex' ,
791+ flexDirection : 'column' ,
792+ alignItems : 'center' ,
793+ justifyContent : 'center' ,
794+ gap : 2 ,
795+ position : 'relative' ,
796+ zIndex : 3 ,
797+ boxShadow : 'none'
798+ } }
799+ >
800+ < div style = { textStyle } > { Math . round ( bpm || 0 ) } </ div >
801+ < div style = { bpmLabelStyle } > BPM</ div >
802+ </ div >
782803 </ div >
783- { /* 悬浮球刷新动画已移除 */ }
784804 </ div >
785805 < style > { `@keyframes spin360{from{transform:rotate(0)}to{transform:rotate(360deg)}}` } </ style >
786806 </ main >
0 commit comments