ahutchen commited on
Commit
0823b09
·
1 Parent(s): 71ebd26
Files changed (2) hide show
  1. src/App.vue +49 -20
  2. src/utils/mediaSession.js +335 -0
src/App.vue CHANGED
@@ -59,7 +59,7 @@
59
  </template>
60
 
61
  <script setup>
62
- import { ref, computed, onMounted, watch } from 'vue'
63
  import { useRoute } from 'vue-router'
64
  import { usePlayerStore } from '@/stores/player'
65
  import { usePlayQueueStore } from '@/stores/playqueue'
@@ -68,6 +68,7 @@ import { useFavoritesStore } from '@/stores/favorites'
68
  import { useHistoryStore } from '@/stores/history'
69
  import { useSettingsStore } from '@/stores/settings'
70
  import { musicApi, utils } from '@/services/musicApi'
 
71
  import DesktopSidebar from '@/components/layout/DesktopSidebar.vue'
72
  import AppTabBar from '@/components/layout/AppTabBar.vue'
73
  import MiniPlayer from '@/components/layout/MiniPlayer.vue'
@@ -265,6 +266,14 @@ const handleLoadedMetadata = () => {
265
  const handleTimeUpdate = () => {
266
  if (audioRef.value && isFinite(audioRef.value.currentTime)) {
267
  playerStore.setCurrentTime(audioRef.value.currentTime)
 
 
 
 
 
 
 
 
268
  }
269
  }
270
 
@@ -276,34 +285,44 @@ const handleEnded = () => {
276
  }
277
  }
278
 
279
- const handlePlay = () => {
280
  playerStore.setPlayingState(true)
281
 
282
- // 设置 MediaSession
283
- if ('mediaSession' in navigator && currentSong.value) {
284
- navigator.mediaSession.metadata = new MediaMetadata({
285
- title: currentSong.value.name,
286
- artist: utils.formatArtist(currentSong.value.artist),
287
- album: currentSong.value.album,
288
- artwork: [
289
- {
290
- src: `${import.meta.env.BASE_URL}icons/icon-512x512.svg`,
291
- sizes: '512x512',
292
- type: 'image/svg+xml'
293
  }
294
- ]
295
  })
296
-
297
- // 设置播放控制
298
- navigator.mediaSession.setActionHandler('play', togglePlay)
299
- navigator.mediaSession.setActionHandler('pause', togglePlay)
300
- navigator.mediaSession.setActionHandler('nexttrack', playNext)
301
- navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
 
 
 
 
 
302
  }
303
  }
304
 
305
  const handlePause = () => {
306
  playerStore.setPlayingState(false)
 
 
 
 
 
307
  }
308
 
309
  const handleError = (e) => {
@@ -386,6 +405,16 @@ onMounted(() => {
386
  currentTheme: theme
387
  })
388
  })
 
 
 
 
 
 
 
 
 
 
389
  </script>
390
 
391
  <style>
 
59
  </template>
60
 
61
  <script setup>
62
+ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
63
  import { useRoute } from 'vue-router'
64
  import { usePlayerStore } from '@/stores/player'
65
  import { usePlayQueueStore } from '@/stores/playqueue'
 
68
  import { useHistoryStore } from '@/stores/history'
69
  import { useSettingsStore } from '@/stores/settings'
70
  import { musicApi, utils } from '@/services/musicApi'
71
+ import mediaSessionManager from '@/utils/mediaSession'
72
  import DesktopSidebar from '@/components/layout/DesktopSidebar.vue'
73
  import AppTabBar from '@/components/layout/AppTabBar.vue'
74
  import MiniPlayer from '@/components/layout/MiniPlayer.vue'
 
266
  const handleTimeUpdate = () => {
267
  if (audioRef.value && isFinite(audioRef.value.currentTime)) {
268
  playerStore.setCurrentTime(audioRef.value.currentTime)
269
+
270
+ // 更新Media Session位置信息
271
+ if (settingsStore.getSetting('mediaSession') && audioRef.value.duration) {
272
+ mediaSessionManager.setPositionState(
273
+ audioRef.value.duration,
274
+ audioRef.value.currentTime
275
+ )
276
+ }
277
  }
278
  }
279
 
 
285
  }
286
  }
287
 
288
+ const handlePlay = async () => {
289
  playerStore.setPlayingState(true)
290
 
291
+ // 使用新的Media Session管理器
292
+ if (currentSong.value && settingsStore.getSetting('mediaSession')) {
293
+ await mediaSessionManager.setMetadata(currentSong.value, {
294
+ onPlay: togglePlay,
295
+ onPause: togglePlay,
296
+ onNext: playNext,
297
+ onPrev: playPrevious,
298
+ onSeekTo: (time) => {
299
+ if (audioRef.value) {
300
+ audioRef.value.currentTime = time
301
+ playerStore.setCurrentTime(time)
302
  }
303
+ }
304
  })
305
+
306
+ // 设置播放状态
307
+ mediaSessionManager.setPlaybackState('playing')
308
+
309
+ // 设置位置信息
310
+ if (audioRef.value) {
311
+ mediaSessionManager.setPositionState(
312
+ audioRef.value.duration || 0,
313
+ audioRef.value.currentTime || 0
314
+ )
315
+ }
316
  }
317
  }
318
 
319
  const handlePause = () => {
320
  playerStore.setPlayingState(false)
321
+
322
+ // 更新Media Session播放状态
323
+ if (settingsStore.getSetting('mediaSession')) {
324
+ mediaSessionManager.setPlaybackState('paused')
325
+ }
326
  }
327
 
328
  const handleError = (e) => {
 
405
  currentTheme: theme
406
  })
407
  })
408
+
409
+ // 组件卸载时清理资源
410
+ onBeforeUnmount(() => {
411
+ // 清理Media Session资源
412
+ mediaSessionManager.cleanup()
413
+
414
+ // 移除事件监听器
415
+ window.removeEventListener('loadAndPlaySong', () => {})
416
+ window.removeEventListener('sidebarTogglePlay', () => {})
417
+ })
418
  </script>
419
 
420
  <style>
src/utils/mediaSession.js ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Media Session API 管理工具
3
+ * 专门处理iOS PWA的锁屏和灵动岛专辑图片显示
4
+ *
5
+ * 基于2024年最佳实践:
6
+ * - iOS 18修复了低分辨率图片问题,现在使用512x512而不是像素化放大
7
+ * - 提供多尺寸图片以兼容不同设备和显示模式
8
+ * - 使用Canvas优化图片以确保iOS兼容性
9
+ */
10
+
11
+ class MediaSessionManager {
12
+ constructor() {
13
+ this.isSupported = 'mediaSession' in navigator
14
+ this.currentBlobUrl = null
15
+ this.artworkCache = new Map() // 缓存处理后的图片
16
+
17
+ // 默认图标路径
18
+ this.defaultArtwork = {
19
+ src: `${import.meta.env.BASE_URL}icons/icon-512x512.svg`,
20
+ sizes: '512x512',
21
+ type: 'image/svg+xml'
22
+ }
23
+
24
+ if (this.isSupported) {
25
+ console.log('Media Session API 支持已启用')
26
+ this.setupActionHandlers()
27
+ } else {
28
+ console.log('Media Session API 不支持')
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 设置媒体会话信息
34
+ * @param {Object} song - 歌曲信息
35
+ * @param {Object} options - 选项
36
+ * @param {Function} options.onPlay - 播放回调
37
+ * @param {Function} options.onPause - 暂停回调
38
+ * @param {Function} options.onNext - 下一首回调
39
+ * @param {Function} options.onPrev - 上一首回调
40
+ */
41
+ async setMetadata(song, options = {}) {
42
+ if (!this.isSupported || !song) {
43
+ return
44
+ }
45
+
46
+ try {
47
+ // 获取优化后的专辑封面
48
+ const artwork = await this.getOptimizedArtwork(song)
49
+
50
+ // 设置媒体元数据
51
+ navigator.mediaSession.metadata = new MediaMetadata({
52
+ title: song.name || '未知歌曲',
53
+ artist: this.formatArtist(song.artist) || '未知艺术家',
54
+ album: song.album || '未知专辑',
55
+ artwork: artwork
56
+ })
57
+
58
+ // 更新动作处理器
59
+ if (options.onPlay) this.onPlayHandler = options.onPlay
60
+ if (options.onPause) this.onPauseHandler = options.onPause
61
+ if (options.onNext) this.onNextHandler = options.onNext
62
+ if (options.onPrev) this.onPrevHandler = options.onPrev
63
+
64
+ console.log('Media Session 元数据已更新:', song.name)
65
+ } catch (error) {
66
+ console.error('设置 Media Session 元数据失败:', error)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 获取优化后的专辑封面
72
+ * 使用Canvas处理以确保iOS兼容性
73
+ */
74
+ async getOptimizedArtwork(song) {
75
+ // 尝试获取真实的专辑封面
76
+ let coverUrl = null
77
+
78
+ if (song.cover) {
79
+ coverUrl = song.cover
80
+ } else {
81
+ // 尝试从playerStore获取封面
82
+ try {
83
+ const { usePlayerStore } = await import('@/stores/player')
84
+ const playerStore = usePlayerStore()
85
+ coverUrl = await playerStore.getAlbumCover(song, 512)
86
+ } catch (error) {
87
+ console.warn('获取专辑封面失败:', error)
88
+ }
89
+ }
90
+
91
+ // 如果有真实封面,创建多尺寸版本
92
+ if (coverUrl && coverUrl !== this.defaultArtwork.src) {
93
+ return await this.createMultiSizeArtwork(coverUrl, song)
94
+ }
95
+
96
+ // 返回默认图标
97
+ return [this.defaultArtwork]
98
+ }
99
+
100
+ /**
101
+ * 创建多尺寸专辑封面
102
+ * 基于iOS 18的最佳实践
103
+ */
104
+ async createMultiSizeArtwork(imageUrl, song) {
105
+ const cacheKey = `${song.source}-${song.id || song.pic_id}`
106
+
107
+ // 检查缓存
108
+ if (this.artworkCache.has(cacheKey)) {
109
+ return this.artworkCache.get(cacheKey)
110
+ }
111
+
112
+ try {
113
+ const artwork = await this.processImageForMediaSession(imageUrl)
114
+ this.artworkCache.set(cacheKey, artwork)
115
+ return artwork
116
+ } catch (error) {
117
+ console.error('处理专辑封面失败:', error)
118
+ return [this.defaultArtwork]
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 使用Canvas处理图片,确保iOS兼容性
124
+ */
125
+ async processImageForMediaSession(imageUrl) {
126
+ return new Promise((resolve, reject) => {
127
+ const image = new Image()
128
+ image.crossOrigin = 'anonymous'
129
+
130
+ image.onload = async () => {
131
+ try {
132
+ // 创建多个尺寸的Canvas版本
133
+ const sizes = [
134
+ { width: 96, height: 96 }, // 小播放器
135
+ { width: 128, height: 128 }, // 通知
136
+ { width: 192, height: 192 }, // 中等尺寸
137
+ { width: 256, height: 256 }, // 低端设备
138
+ { width: 512, height: 512 } // 高分辨率,iOS 18推荐
139
+ ]
140
+
141
+ const artwork = []
142
+
143
+ // 为每个尺寸创建优化的版本
144
+ for (const size of sizes) {
145
+ const canvas = document.createElement('canvas')
146
+ canvas.width = size.width
147
+ canvas.height = size.height
148
+ const ctx = canvas.getContext('2d')
149
+
150
+ // 使用高质量缩放
151
+ ctx.imageSmoothingEnabled = true
152
+ ctx.imageSmoothingQuality = 'high'
153
+
154
+ // 绘制图片
155
+ ctx.drawImage(image, 0, 0, size.width, size.height)
156
+
157
+ // 转换为blob URL
158
+ const blob = await new Promise(resolve => {
159
+ canvas.toBlob(resolve, 'image/png', 0.9)
160
+ })
161
+
162
+ if (blob) {
163
+ const blobUrl = URL.createObjectURL(blob)
164
+ artwork.push({
165
+ src: blobUrl,
166
+ sizes: `${size.width}x${size.height}`,
167
+ type: 'image/png'
168
+ })
169
+ }
170
+ }
171
+
172
+ resolve(artwork)
173
+ } catch (error) {
174
+ reject(error)
175
+ }
176
+ }
177
+
178
+ image.onerror = () => {
179
+ reject(new Error('图片加载失败'))
180
+ }
181
+
182
+ image.src = imageUrl
183
+ })
184
+ }
185
+
186
+ /**
187
+ * 设置播放状态
188
+ */
189
+ setPlaybackState(state) {
190
+ if (!this.isSupported) return
191
+
192
+ try {
193
+ navigator.mediaSession.playbackState = state // 'playing', 'paused', 'none'
194
+ } catch (error) {
195
+ console.error('设置播放状态失败:', error)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 设置播放位置信息
201
+ */
202
+ setPositionState(duration, position, playbackRate = 1.0) {
203
+ if (!this.isSupported) return
204
+
205
+ try {
206
+ if ('setPositionState' in navigator.mediaSession) {
207
+ navigator.mediaSession.setPositionState({
208
+ duration: duration || 0,
209
+ position: position || 0,
210
+ playbackRate: playbackRate
211
+ })
212
+ }
213
+ } catch (error) {
214
+ console.error('设置播放位置失败:', error)
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 设置动作处理器
220
+ */
221
+ setupActionHandlers() {
222
+ if (!this.isSupported) return
223
+
224
+ const actions = [
225
+ 'play',
226
+ 'pause',
227
+ 'previoustrack',
228
+ 'nexttrack',
229
+ 'seekbackward',
230
+ 'seekforward',
231
+ 'seekto'
232
+ ]
233
+
234
+ actions.forEach(action => {
235
+ try {
236
+ navigator.mediaSession.setActionHandler(action, (details) => {
237
+ this.handleAction(action, details)
238
+ })
239
+ } catch (error) {
240
+ console.warn(`不支持的媒体动作: ${action}`)
241
+ }
242
+ })
243
+ }
244
+
245
+ /**
246
+ * 处理媒体控制动作
247
+ */
248
+ handleAction(action, details) {
249
+ console.log('媒体控制动作:', action, details)
250
+
251
+ switch (action) {
252
+ case 'play':
253
+ if (this.onPlayHandler) this.onPlayHandler()
254
+ break
255
+ case 'pause':
256
+ if (this.onPauseHandler) this.onPauseHandler()
257
+ break
258
+ case 'previoustrack':
259
+ if (this.onPrevHandler) this.onPrevHandler()
260
+ break
261
+ case 'nexttrack':
262
+ if (this.onNextHandler) this.onNextHandler()
263
+ break
264
+ case 'seekto':
265
+ if (this.onSeekToHandler && details.seekTime !== undefined) {
266
+ this.onSeekToHandler(details.seekTime)
267
+ }
268
+ break
269
+ case 'seekbackward':
270
+ if (this.onSeekBackwardHandler) {
271
+ this.onSeekBackwardHandler(details.seekOffset || 10)
272
+ }
273
+ break
274
+ case 'seekforward':
275
+ if (this.onSeekForwardHandler) {
276
+ this.onSeekForwardHandler(details.seekOffset || 10)
277
+ }
278
+ break
279
+ }
280
+ }
281
+
282
+ /**
283
+ * 格式化艺术家名称
284
+ */
285
+ formatArtist(artist) {
286
+ if (!artist) return '未知艺术家'
287
+ if (Array.isArray(artist)) {
288
+ return artist.join(', ')
289
+ }
290
+ if (typeof artist === 'string') {
291
+ return artist.replace(/;|;/g, ', ')
292
+ }
293
+ return String(artist)
294
+ }
295
+
296
+ /**
297
+ * 清理资源
298
+ */
299
+ cleanup() {
300
+ // 清理blob URLs
301
+ this.artworkCache.forEach(artwork => {
302
+ artwork.forEach(art => {
303
+ if (art.src && art.src.startsWith('blob:')) {
304
+ URL.revokeObjectURL(art.src)
305
+ }
306
+ })
307
+ })
308
+ this.artworkCache.clear()
309
+
310
+ if (this.currentBlobUrl) {
311
+ URL.revokeObjectURL(this.currentBlobUrl)
312
+ this.currentBlobUrl = null
313
+ }
314
+ }
315
+
316
+ /**
317
+ * 重置媒体会话
318
+ */
319
+ reset() {
320
+ if (!this.isSupported) return
321
+
322
+ try {
323
+ navigator.mediaSession.metadata = null
324
+ navigator.mediaSession.playbackState = 'none'
325
+ } catch (error) {
326
+ console.error('重置媒体会话失败:', error)
327
+ }
328
+ }
329
+ }
330
+
331
+ // 创建单例实例
332
+ const mediaSessionManager = new MediaSessionManager()
333
+
334
+ export default mediaSessionManager
335
+ export { MediaSessionManager }