feat(Favourites): implement favourites store for managing user favourites
This commit is contained in:
		
							parent
							
								
									3d962c647f
								
							
						
					
					
						commit
						6a5d6369fa
					
				
							
								
								
									
										13
									
								
								src/assets/icons/starfilled.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/assets/icons/starfilled.vue
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
defineProps<{
 | 
			
		||||
	size: number
 | 
			
		||||
}>()
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
	<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" :class="`w-${size} h-${size}`">
 | 
			
		||||
		<path
 | 
			
		||||
			d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z">
 | 
			
		||||
		</path>
 | 
			
		||||
	</svg>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import { onMounted, onUnmounted, nextTick } from 'vue'
 | 
			
		|||
import { useTemplateRef } from 'vue'
 | 
			
		||||
import { ref, watch } from 'vue'
 | 
			
		||||
import { usePreferences } from '../stores/usePreferences'
 | 
			
		||||
import { useFavourites } from '../stores/useFavourites'
 | 
			
		||||
 | 
			
		||||
import ScrollingLyrics from '../components/ScrollingLyrics.vue'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -18,6 +19,7 @@ import LoadingIndicator from '../assets/icons/loadingindicator.vue'
 | 
			
		|||
import ChatBubbleQuoteIcon from '../assets/icons/chatbubblequote.vue'
 | 
			
		||||
import ChatBubbleQuoteFullIcon from '../assets/icons/chatbubblequotefull.vue'
 | 
			
		||||
import StarEmptyIcon from '../assets/icons/starempty.vue'
 | 
			
		||||
import StarFilledIcon from '../assets/icons/starfilled.vue'
 | 
			
		||||
import MusicListIcon from '../assets/icons/musiclist.vue'
 | 
			
		||||
import EllipsisHorizontalIcon from '../assets/icons/ellipsishorizontal.vue'
 | 
			
		||||
import XIcon from '../assets/icons/x.vue'
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +30,8 @@ import SpeakerIcon from '../assets/icons/speaker.vue'
 | 
			
		|||
 | 
			
		||||
const playQueueStore = usePlayQueueStore()
 | 
			
		||||
const preferences = usePreferences()
 | 
			
		||||
const favourites = useFavourites()
 | 
			
		||||
 | 
			
		||||
gsap.registerPlugin(Draggable)
 | 
			
		||||
 | 
			
		||||
const progressBarThumb = useTemplateRef('progressBarThumb')
 | 
			
		||||
| 
						 | 
				
			
			@ -353,10 +357,12 @@ watch(() => playQueueStore.currentIndex, () => {
 | 
			
		|||
					</div>
 | 
			
		||||
 | 
			
		||||
					<button
 | 
			
		||||
						class="h-10 w-10 flex justify-center items-center rounded-full bg-black/10 backdrop-blur-3xl transition-all duration-200 hover:bg-black/20 hover:scale-110"
 | 
			
		||||
						ref="favoriteButton">
 | 
			
		||||
						<span class="text-white">
 | 
			
		||||
							<StarEmptyIcon :size="6" />
 | 
			
		||||
						class="h-10 w-10 flex justify-center items-center rounded-full backdrop-blur-3xl transition-all duration-200 hover:scale-110"
 | 
			
		||||
						ref="favoriteButton" @click="favourites.toggleFavourite(getCurrentTrack())"
 | 
			
		||||
						:class="favourites.isFavourite(getCurrentTrack().song.cid) ? 'bg-neutral-200/90' : 'bg-black/10 hover:bg-black/20'">
 | 
			
		||||
						<span :class="favourites.isFavourite(getCurrentTrack().song.cid) ? 'text-neutral-700' : 'text-white'">
 | 
			
		||||
							<StarFilledIcon v-if="favourites.isFavourite(getCurrentTrack().song.cid)" :size="6" />
 | 
			
		||||
							<StarEmptyIcon v-else :size="6" />
 | 
			
		||||
						</span>
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										260
									
								
								src/stores/useFavourites.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								src/stores/useFavourites.ts
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,260 @@
 | 
			
		|||
import { defineStore } from "pinia"
 | 
			
		||||
import { ref, watch, computed } from "vue"
 | 
			
		||||
 | 
			
		||||
// 声明全局类型
 | 
			
		||||
declare global {
 | 
			
		||||
	interface Window {
 | 
			
		||||
		browser?: any
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const useFavourites = defineStore('favourites', () => {
 | 
			
		||||
	const favourites = ref<QueueItem[]>([])
 | 
			
		||||
 | 
			
		||||
	const isLoaded = ref(false)
 | 
			
		||||
	const storageType = ref<'chrome' | 'localStorage' | 'memory'>('chrome')
 | 
			
		||||
 | 
			
		||||
	// 默认收藏列表
 | 
			
		||||
	const defaultFavourites: QueueItem[] = []
 | 
			
		||||
 | 
			
		||||
	// 检测可用的 API
 | 
			
		||||
	const detectAvailableAPIs = () => {
 | 
			
		||||
		// 检查原生 chrome API
 | 
			
		||||
		try {
 | 
			
		||||
			if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
 | 
			
		||||
				storageType.value = 'chrome'
 | 
			
		||||
				return 'chrome'
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			// Silent fail
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 检查 window.chrome
 | 
			
		||||
		try {
 | 
			
		||||
			if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
 | 
			
		||||
				storageType.value = 'chrome'
 | 
			
		||||
				return 'chrome'
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			// Silent fail
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 检查 localStorage
 | 
			
		||||
		try {
 | 
			
		||||
			if (typeof localStorage !== 'undefined') {
 | 
			
		||||
				localStorage.setItem('msr_test', 'test')
 | 
			
		||||
				localStorage.removeItem('msr_test')
 | 
			
		||||
				storageType.value = 'localStorage'
 | 
			
		||||
				return 'localStorage'
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			// Silent fail
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// 都不可用,使用内存存储
 | 
			
		||||
		storageType.value = 'memory'
 | 
			
		||||
		return 'memory'
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 通用的获取存储值函数
 | 
			
		||||
	const getStoredValue = async (key: string, defaultValue: any) => {
 | 
			
		||||
		const type = detectAvailableAPIs()
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			switch (type) {
 | 
			
		||||
				case 'chrome':
 | 
			
		||||
					return await new Promise((resolve) => {
 | 
			
		||||
						const api = chrome?.storage?.sync || window.chrome?.storage?.sync
 | 
			
		||||
						if (api) {
 | 
			
		||||
							api.get({ [key]: defaultValue }, (result) => {
 | 
			
		||||
								if (chrome.runtime.lastError) {
 | 
			
		||||
									resolve(defaultValue)
 | 
			
		||||
								} else {
 | 
			
		||||
									resolve(result[key])
 | 
			
		||||
								}
 | 
			
		||||
							})
 | 
			
		||||
						} else {
 | 
			
		||||
							resolve(defaultValue)
 | 
			
		||||
						}
 | 
			
		||||
					})
 | 
			
		||||
 | 
			
		||||
				case 'localStorage':
 | 
			
		||||
					const stored = localStorage.getItem(`msr_${key}`)
 | 
			
		||||
					const value = stored ? JSON.parse(stored) : defaultValue
 | 
			
		||||
					return value
 | 
			
		||||
 | 
			
		||||
				case 'memory':
 | 
			
		||||
				default:
 | 
			
		||||
					return defaultValue
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			return defaultValue
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 通用的设置存储值函数
 | 
			
		||||
	const setStoredValue = async (key: string, value: any) => {
 | 
			
		||||
		const type = storageType.value
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			switch (type) {
 | 
			
		||||
				case 'chrome':
 | 
			
		||||
					return await new Promise<void>((resolve, reject) => {
 | 
			
		||||
						const api = chrome?.storage?.sync || window.chrome?.storage?.sync
 | 
			
		||||
						if (api) {
 | 
			
		||||
							api.set({ [key]: value }, () => {
 | 
			
		||||
								if (chrome.runtime.lastError) {
 | 
			
		||||
									reject(new Error(chrome.runtime.lastError.message))
 | 
			
		||||
								} else {
 | 
			
		||||
									resolve()
 | 
			
		||||
								}
 | 
			
		||||
							})
 | 
			
		||||
						} else {
 | 
			
		||||
							reject(new Error('Chrome storage API 不可用'))
 | 
			
		||||
						}
 | 
			
		||||
					})
 | 
			
		||||
 | 
			
		||||
				case 'localStorage':
 | 
			
		||||
					localStorage.setItem(`msr_${key}`, JSON.stringify(value))
 | 
			
		||||
					break
 | 
			
		||||
 | 
			
		||||
				case 'memory':
 | 
			
		||||
					// 内存存储(不持久化)
 | 
			
		||||
					break
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			throw error
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 获取收藏列表
 | 
			
		||||
	const getFavourites = async () => {
 | 
			
		||||
		const result = await getStoredValue('favourites', defaultFavourites)
 | 
			
		||||
		// 确保返回的是数组
 | 
			
		||||
		return Array.isArray(result) ? result : defaultFavourites
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 保存收藏列表
 | 
			
		||||
	const saveFavourites = async () => {
 | 
			
		||||
		// 确保保存的是数组
 | 
			
		||||
		await setStoredValue('favourites', [...favourites.value])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 检查歌曲是否已收藏
 | 
			
		||||
	const isFavourite = (songCid: string): boolean => {
 | 
			
		||||
		return favourites.value.some(item => item.song.cid === songCid)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 添加到收藏
 | 
			
		||||
	const addToFavourites = async (queueItem: QueueItem) => {
 | 
			
		||||
		if (!isFavourite(queueItem.song.cid)) {
 | 
			
		||||
			favourites.value.push(queueItem)
 | 
			
		||||
			if (isLoaded.value) {
 | 
			
		||||
				try {
 | 
			
		||||
					await saveFavourites()
 | 
			
		||||
				} catch (error) {
 | 
			
		||||
					// 保存失败时回滚
 | 
			
		||||
					favourites.value.pop()
 | 
			
		||||
					throw error
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 从收藏中移除
 | 
			
		||||
	const removeFromFavourites = async (songCid: string) => {
 | 
			
		||||
		const index = favourites.value.findIndex(item => item.song.cid === songCid)
 | 
			
		||||
		if (index !== -1) {
 | 
			
		||||
			const removedItem = favourites.value.splice(index, 1)[0]
 | 
			
		||||
			if (isLoaded.value) {
 | 
			
		||||
				try {
 | 
			
		||||
					await saveFavourites()
 | 
			
		||||
				} catch (error) {
 | 
			
		||||
					// 保存失败时回滚
 | 
			
		||||
					favourites.value.splice(index, 0, removedItem)
 | 
			
		||||
					throw error
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 切换收藏状态
 | 
			
		||||
	const toggleFavourite = async (queueItem: QueueItem) => {
 | 
			
		||||
		if (isFavourite(queueItem.song.cid)) {
 | 
			
		||||
			await removeFromFavourites(queueItem.song.cid)
 | 
			
		||||
		} else {
 | 
			
		||||
			await addToFavourites(queueItem)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 清空收藏列表
 | 
			
		||||
	const clearFavourites = async () => {
 | 
			
		||||
		const backup = [...favourites.value]
 | 
			
		||||
		favourites.value = []
 | 
			
		||||
		if (isLoaded.value) {
 | 
			
		||||
			try {
 | 
			
		||||
				await saveFavourites()
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				// 保存失败时回滚
 | 
			
		||||
				favourites.value = backup
 | 
			
		||||
				throw error
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 获取收藏数量
 | 
			
		||||
	const favouritesCount = computed(() => favourites.value.length)
 | 
			
		||||
 | 
			
		||||
	// 异步初始化函数
 | 
			
		||||
	const initializeFavourites = async () => {
 | 
			
		||||
		try {
 | 
			
		||||
			const savedFavourites = await getFavourites()
 | 
			
		||||
			// 确保设置的是有效数组
 | 
			
		||||
			favourites.value = Array.isArray(savedFavourites) ? savedFavourites : []
 | 
			
		||||
			isLoaded.value = true
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			favourites.value = []
 | 
			
		||||
			isLoaded.value = true
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// 监听变化并保存(防抖处理)
 | 
			
		||||
	let saveTimeout: NodeJS.Timeout | null = null
 | 
			
		||||
	watch(favourites, async () => {
 | 
			
		||||
		if (isLoaded.value) {
 | 
			
		||||
			// 清除之前的定时器
 | 
			
		||||
			if (saveTimeout) {
 | 
			
		||||
				clearTimeout(saveTimeout)
 | 
			
		||||
			}
 | 
			
		||||
			// 设置新的定时器,防抖保存
 | 
			
		||||
			saveTimeout = setTimeout(async () => {
 | 
			
		||||
				try {
 | 
			
		||||
					await saveFavourites()
 | 
			
		||||
				} catch (error) {
 | 
			
		||||
					// Silent fail
 | 
			
		||||
				}
 | 
			
		||||
			}, 300)
 | 
			
		||||
		}
 | 
			
		||||
	}, { deep: true })
 | 
			
		||||
 | 
			
		||||
	// 立即初始化
 | 
			
		||||
	initializeFavourites()
 | 
			
		||||
 | 
			
		||||
	return {
 | 
			
		||||
		favourites,
 | 
			
		||||
		isLoaded,
 | 
			
		||||
		storageType,
 | 
			
		||||
		favouritesCount,
 | 
			
		||||
		initializeFavourites,
 | 
			
		||||
		getFavourites,
 | 
			
		||||
		saveFavourites,
 | 
			
		||||
		isFavourite,
 | 
			
		||||
		addToFavourites,
 | 
			
		||||
		removeFromFavourites,
 | 
			
		||||
		toggleFavourite,
 | 
			
		||||
		clearFavourites,
 | 
			
		||||
		getStoredValue,
 | 
			
		||||
		setStoredValue
 | 
			
		||||
	}
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user