@@ -6,6 +6,8 @@ import pause from '@renderer/assets/pause.png?asset'
66import fastForward from ' @renderer/assets/fastForward.png?asset'
77import nextSong from ' @renderer/assets/nextSong.png?asset'
88import more from ' @renderer/assets/more.png?asset'
9+ import volumePng from ' @renderer/assets/volume.png?asset'
10+ import volumeMutePng from ' @renderer/assets/volumeMute.png?asset'
911import { ref , onUnmounted , watch , useTemplateRef , onMounted , computed } from ' vue'
1012import { v4 as uuidV4 } from ' uuid'
1113import { useRuntimeStore } from ' @renderer/stores/runtime'
@@ -33,7 +35,8 @@ const emits = defineEmits([
3335 ' delSong' ,
3436 ' moveToLikeLibrary' ,
3537 ' moveToListLibrary' ,
36- ' exportTrack'
38+ ' exportTrack' ,
39+ ' setVolume'
3740])
3841
3942const setPlayingValue = (value : boolean ) => {
@@ -129,6 +132,96 @@ const showInFileExplorer = () => {
129132 window .electron .ipcRenderer .send (' show-item-in-folder' , runtime .playingData .playingSong ?.filePath )
130133}
131134
135+ // ---------------- 音量控制 ----------------
136+ const VOLUME_KEY = ' frkb_volume'
137+ const volume = ref (1 )
138+ const lastNonZeroVolume = ref (1 )
139+ const sliderHovering = ref (false )
140+ const iconHovering = ref (false )
141+ let draggingVolume = false
142+ const showVolumeSlider = computed (
143+ () => iconHovering .value || sliderHovering .value || draggingVolume
144+ )
145+
146+ const volumeIcon = computed (() => {
147+ return volume .value <= 0 ? volumeMutePng : volumePng
148+ })
149+
150+ const emitSetVolume = (val : number ) => {
151+ const v = Math .min (1 , Math .max (0 , val ))
152+ volume .value = v
153+ if (v > 0 ) lastNonZeroVolume .value = v
154+ emits (' setVolume' , v )
155+ try {
156+ localStorage .setItem (VOLUME_KEY , String (v ))
157+ } catch (_ ) {}
158+ }
159+
160+ let justDragged = false
161+ const toggleMute = () => {
162+ // 仅在拖动中或刚拖完的极短时间内抑制,其他情况立即切换
163+ if (draggingVolume || justDragged ) return
164+ if (volume .value > 0 ) {
165+ lastNonZeroVolume .value = volume .value
166+ emitSetVolume (0 )
167+ } else {
168+ const prev = Number .isFinite (lastNonZeroVolume .value as number )
169+ ? (lastNonZeroVolume .value as number )
170+ : 0
171+ // 若历史非零音量过低(<10%),按 10% 恢复,避免“体感几乎为 0”
172+ const restore = prev > 0 ? Math .max (prev , 0.1 ) : 0.25
173+ emitSetVolume (restore )
174+ }
175+ }
176+
177+ const volumeBarRef = useTemplateRef <HTMLDivElement >(' volumeBarRef' )
178+ const updateVolumeFromClientY = (clientY : number ) => {
179+ const el = volumeBarRef .value
180+ if (! el ) return
181+ const rect = el .getBoundingClientRect ()
182+ if (! rect || rect .height <= 0 ) return
183+ const ratio = 1 - (clientY - rect .top ) / rect .height
184+ emitSetVolume (ratio )
185+ }
186+
187+ const handleBarMousedown = (e : MouseEvent ) => {
188+ draggingVolume = true
189+ updateVolumeFromClientY (e .clientY )
190+ document .addEventListener (' mousemove' , handleBarMousemove )
191+ document .addEventListener (' mouseup' , handleBarMouseup )
192+ }
193+ const handleBarMousemove = (e : MouseEvent ) => {
194+ if (! draggingVolume ) return
195+ updateVolumeFromClientY (e .clientY )
196+ }
197+ const handleBarMouseup = () => {
198+ draggingVolume = false
199+ justDragged = true
200+ setTimeout (() => {
201+ justDragged = false
202+ }, 150 )
203+ document .removeEventListener (' mousemove' , handleBarMousemove )
204+ document .removeEventListener (' mouseup' , handleBarMouseup )
205+ }
206+
207+ onUnmounted (() => {
208+ document .removeEventListener (' mousemove' , handleBarMousemove )
209+ document .removeEventListener (' mouseup' , handleBarMouseup )
210+ })
211+
212+ // 初始化从 localStorage 读取音量(默认 0.8)并应用
213+ onMounted (() => {
214+ try {
215+ const s = localStorage .getItem (VOLUME_KEY )
216+ let v = s !== null ? parseFloat (s ) : NaN
217+ if (! (v >= 0 && v <= 1 )) v = 0.8
218+ // 设置组件内部状态并通知父组件
219+ volume .value = v
220+ lastNonZeroVolume .value = v > 0 ? v : 0
221+ emits (' setVolume' , v )
222+ } catch (_ ) {}
223+ })
224+
132225// ---------------- 系统媒体会话(Media Session API)集成 ----------------
133226// 目标:启用系统的 上一首/下一首/播放/暂停 按钮,并同步元数据和播放状态
134227let artworkUrl: string = ' '
@@ -296,6 +389,27 @@ onUnmounted(() => {
296389 <img :src =" nextSong" draggable =" false" />
297390 </div >
298391 <bubbleBox :dom =" nextSongRef || undefined" :title =" t('player.next')" shortcut =" S" />
392+ <!-- 音量按钮,与“下一首”同风格 -->
393+ <div
394+ class =" buttonIcon volumeIcon"
395+ @mouseenter =" iconHovering = true"
396+ @mouseleave =" iconHovering = false"
397+ style =" position : relative "
398+ >
399+ <img :src =" volumeIcon" draggable =" false" @click.stop =" toggleMute" />
400+ <transition name =" fade" >
401+ <div
402+ v-if =" showVolumeSlider"
403+ class =" volumePopover unselectable"
404+ @mouseenter =" sliderHovering = true"
405+ @mouseleave =" sliderHovering = false"
406+ >
407+ <div class =" volumeBar" ref =" volumeBarRef" @mousedown =" handleBarMousedown" >
408+ <div class =" volumeFill" :style =" { height: Math.round(volume * 100) + '%' }" ></div >
409+ </div >
410+ </div >
411+ </transition >
412+ </div >
299413 <div class =" buttonIcon" @click.stop =" handelMoreClick()" >
300414 <img :src =" more" draggable =" false" />
301415 </div >
@@ -393,10 +507,53 @@ onUnmounted(() => {
393507 filter : contrast (200% ) drop-shadow (0px 0px 10px #fff );
394508 }
395509 }
510+
511+ // 仅移除音量图标的发光
512+ .volumeIcon :hover {
513+ filter : none ;
514+ }
396515}
397516
398517img {
399518 width : 20px ;
400519 height : 20px ;
401520}
521+
522+ .volumePopover {
523+ position : absolute ;
524+ bottom : 38px ; // 更贴近图标,轻微重叠,避免鼠标经过间隙
525+ left : 50% ;
526+ transform : translateX (-50% );
527+ width : 32px ;
528+ height : 120px ;
529+ background-color : #202020 ;
530+ border : 1px solid #424242 ;
531+ border-radius : 6px ;
532+ display : flex ;
533+ align-items : center ;
534+ justify-content : center ;
535+ padding : 8px 0 ;
536+ z-index : 100 ;
537+ // 确保浮层不受任何父级滤镜影响
538+ filter : none ;
539+ }
540+
541+ .volumeBar {
542+ position : relative ;
543+ width : 6px ;
544+ height : 100% ;
545+ background : #2e2e2e ;
546+ border-radius : 4px ;
547+ cursor : pointer ;
548+ filter : none ;
549+ }
550+
551+ .volumeFill {
552+ position : absolute ;
553+ left : 0 ;
554+ bottom : 0 ;
555+ width : 100% ;
556+ background : #0078d4 ;
557+ border-radius : 4px ;
558+ }
402559 </style >
0 commit comments