2022-04-24 08:50:45 +00:00
|
|
|
|
import { orderBy, shuffle, throttle } from 'lodash'
|
2022-04-15 14:24:30 +00:00
|
|
|
|
import plyr from 'plyr'
|
|
|
|
|
import { nextTick } from 'vue'
|
|
|
|
|
import isMobile from 'ismobilejs'
|
|
|
|
|
|
2022-04-24 08:50:45 +00:00
|
|
|
|
import { eventBus, isAudioContextSupported, isMediaSessionSupported } from '@/utils'
|
2022-04-24 17:58:12 +00:00
|
|
|
|
|
2022-04-15 14:24:30 +00:00
|
|
|
|
import {
|
2022-04-24 08:50:45 +00:00
|
|
|
|
commonStore,
|
|
|
|
|
preferenceStore as preferences,
|
2022-04-15 14:24:30 +00:00
|
|
|
|
queueStore,
|
|
|
|
|
recentlyPlayedStore,
|
2022-04-24 08:50:45 +00:00
|
|
|
|
songStore,
|
|
|
|
|
userStore
|
2022-04-15 14:24:30 +00:00
|
|
|
|
} from '@/stores'
|
2022-04-24 17:58:12 +00:00
|
|
|
|
|
2022-04-24 08:50:45 +00:00
|
|
|
|
import { audioService, socketService } from '@/services'
|
2022-04-15 14:24:30 +00:00
|
|
|
|
import { app } from '@/config'
|
|
|
|
|
import router from '@/router'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The number of seconds before the current song ends to start preload the next one.
|
|
|
|
|
*/
|
|
|
|
|
const PRELOAD_BUFFER = 30
|
|
|
|
|
const DEFAULT_VOLUME_VALUE = 7
|
|
|
|
|
const VOLUME_INPUT_SELECTOR = '#volumeRange'
|
|
|
|
|
const REPEAT_MODES: RepeatMode[] = ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE']
|
|
|
|
|
|
2022-04-24 08:50:45 +00:00
|
|
|
|
export const playbackService = {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
player: null as Plyr | null,
|
|
|
|
|
volumeInput: null as unknown as HTMLInputElement,
|
|
|
|
|
repeatModes: REPEAT_MODES,
|
|
|
|
|
initialized: false,
|
|
|
|
|
mainWin: null as any,
|
|
|
|
|
|
|
|
|
|
init () {
|
|
|
|
|
// We don't need to init this service twice, or the media events will be duplicated.
|
|
|
|
|
if (this.initialized) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.player = plyr.setup(document.querySelector<HTMLMediaElement>('.plyr')!, {
|
|
|
|
|
controls: []
|
|
|
|
|
})[0]
|
|
|
|
|
|
|
|
|
|
this.volumeInput = document.querySelector<HTMLInputElement>(VOLUME_INPUT_SELECTOR)!
|
|
|
|
|
this.listenToMediaEvents(this.player.media)
|
|
|
|
|
|
|
|
|
|
if (isAudioContextSupported) {
|
|
|
|
|
try {
|
|
|
|
|
this.setVolume(preferences.volume)
|
2022-04-24 08:50:45 +00:00
|
|
|
|
} catch (e) {
|
|
|
|
|
}
|
2022-04-15 14:24:30 +00:00
|
|
|
|
|
|
|
|
|
audioService.init(this.player.media)
|
|
|
|
|
eventBus.emit('INIT_EQUALIZER')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isMediaSessionSupported) {
|
|
|
|
|
this.setMediaSessionActionHandlers()
|
|
|
|
|
}
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
this.listenToSocketEvents()
|
2022-04-15 14:24:30 +00:00
|
|
|
|
this.initialized = true
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
listenToSocketEvents () {
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.listen('SOCKET_TOGGLE_PLAYBACK', () => this.toggle())
|
2022-04-15 14:24:30 +00:00
|
|
|
|
.listen('SOCKET_PLAY_NEXT', () => this.playNext())
|
|
|
|
|
.listen('SOCKET_PLAY_PREV', () => this.playPrev())
|
|
|
|
|
.listen('SOCKET_GET_STATUS', () => {
|
|
|
|
|
const data = queueStore.current ? songStore.generateDataToBroadcast(queueStore.current) : {
|
|
|
|
|
volume: this.volumeInput.value
|
|
|
|
|
}
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.broadcast('SOCKET_STATUS', data)
|
2022-04-15 14:24:30 +00:00
|
|
|
|
})
|
|
|
|
|
.listen('SOCKET_GET_CURRENT_SONG', () => {
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.broadcast(
|
2022-04-15 14:24:30 +00:00
|
|
|
|
'SOCKET_SONG',
|
|
|
|
|
queueStore.current
|
|
|
|
|
? songStore.generateDataToBroadcast(queueStore.current)
|
|
|
|
|
: { song: null }
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.listen('SOCKET_SET_VOLUME', ({ volume }: { volume: number }) => this.setVolume(volume))
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
setMediaSessionActionHandlers () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
if (!isMediaSessionSupported) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
navigator.mediaSession!.setActionHandler('play', () => this.resume())
|
|
|
|
|
navigator.mediaSession!.setActionHandler('pause', () => this.pause())
|
|
|
|
|
navigator.mediaSession!.setActionHandler('previoustrack', () => this.playPrev())
|
|
|
|
|
navigator.mediaSession!.setActionHandler('nexttrack', () => this.playNext())
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
listenToMediaEvents (mediaElement: HTMLMediaElement) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
mediaElement.addEventListener('error', () => this.playNext(), true)
|
|
|
|
|
|
|
|
|
|
mediaElement.addEventListener('ended', () => {
|
2022-04-24 08:50:45 +00:00
|
|
|
|
if (commonStore.state.useLastfm && userStore.current.preferences.lastfm_session_key) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
songStore.scrobble(queueStore.current!)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
preferences.repeatMode === 'REPEAT_ONE' ? this.restart() : this.playNext()
|
|
|
|
|
})
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
mediaElement.addEventListener('timeupdate', throttle(() => {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
const currentSong = queueStore.current!
|
|
|
|
|
|
|
|
|
|
if (!currentSong.playCountRegistered && !this.isTranscoding) {
|
|
|
|
|
// if we've passed 25% of the song, it's safe to say the song has been "played".
|
|
|
|
|
// Refer to https://github.com/koel/koel/issues/1087
|
|
|
|
|
if (!mediaElement.duration || mediaElement.currentTime * 4 >= mediaElement.duration) {
|
|
|
|
|
this.registerPlay(currentSong)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextSong = queueStore.next
|
|
|
|
|
|
|
|
|
|
if (!nextSong || nextSong.preloaded || this.isTranscoding) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mediaElement.duration && mediaElement.currentTime + PRELOAD_BUFFER > mediaElement.duration) {
|
|
|
|
|
this.preload(nextSong)
|
|
|
|
|
}
|
|
|
|
|
}, 3000))
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
get isTranscoding () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
return isMobile.any && preferences.transcodeOnMobile
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
registerPlay (song: Song) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
recentlyPlayedStore.add(song)
|
|
|
|
|
songStore.registerPlay(song)
|
|
|
|
|
recentlyPlayedStore.fetchAll()
|
|
|
|
|
song.playCountRegistered = true
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
preload (song: Song) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
const audioElement = document.createElement('audio')
|
|
|
|
|
audioElement.setAttribute('src', songStore.getSourceUrl(song))
|
|
|
|
|
audioElement.setAttribute('preload', 'auto')
|
|
|
|
|
audioElement.load()
|
|
|
|
|
song.preloaded = true
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Play a song. Because
|
|
|
|
|
*
|
|
|
|
|
* So many adventures couldn't happen today,
|
|
|
|
|
* So many songs we forgot to play
|
|
|
|
|
* So many dreams swinging out of the blue
|
|
|
|
|
* We'll let them come true
|
|
|
|
|
*/
|
|
|
|
|
async play (song: Song | undefined) {
|
|
|
|
|
if (!song) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.title = `${song.title} ♫ ${app.name}`
|
|
|
|
|
this.player!.media.setAttribute('title', `${song.artist.name} - ${song.title}`)
|
|
|
|
|
|
|
|
|
|
if (queueStore.current) {
|
|
|
|
|
queueStore.current.playbackState = 'Stopped'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
song.playbackState = 'Playing'
|
|
|
|
|
queueStore.current = song
|
|
|
|
|
|
|
|
|
|
// Manually set the `src` attribute of the audio to prevent plyr from resetting
|
|
|
|
|
// the audio media object and cause our equalizer to malfunction.
|
|
|
|
|
this.getPlayer().media.src = songStore.getSourceUrl(song)
|
|
|
|
|
|
|
|
|
|
// We'll just "restart" playing the song, which will handle notification, scrobbling etc.
|
|
|
|
|
// Fixes #898
|
|
|
|
|
if (isAudioContextSupported) {
|
|
|
|
|
await audioService.getContext().resume()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.restart()
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
showNotification (song: Song) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
if (!window.Notification || !preferences.notify) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const notif = new window.Notification(`♫ ${song.title}`, {
|
|
|
|
|
icon: song.album.cover,
|
|
|
|
|
body: `${song.album.name} – ${song.artist.name}`
|
|
|
|
|
})
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
notif.onclick = () => window.focus()
|
2022-04-15 14:24:30 +00:00
|
|
|
|
|
|
|
|
|
window.setTimeout(() => notif.close(), 5000)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Notification fails.
|
|
|
|
|
// @link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification
|
|
|
|
|
console.error(e)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isMediaSessionSupported) {
|
|
|
|
|
navigator.mediaSession!.metadata = new MediaMetadata({
|
|
|
|
|
title: song.title,
|
|
|
|
|
artist: song.artist.name,
|
|
|
|
|
album: song.album.name,
|
|
|
|
|
artwork: [
|
|
|
|
|
{ src: song.album.cover, sizes: '256x256', type: 'image/png' }
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async restart () {
|
|
|
|
|
const song = queueStore.current!
|
|
|
|
|
|
|
|
|
|
this.showNotification(song)
|
|
|
|
|
|
|
|
|
|
// Record the UNIX timestamp the song starts playing, for scrobbling purpose
|
|
|
|
|
song.playStartTime = Math.floor(Date.now() / 1000)
|
|
|
|
|
|
|
|
|
|
song.playCountRegistered = false
|
|
|
|
|
|
|
|
|
|
eventBus.emit('SONG_STARTED', song)
|
|
|
|
|
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.broadcast('SOCKET_SONG', songStore.generateDataToBroadcast(song))
|
2022-04-15 14:24:30 +00:00
|
|
|
|
|
|
|
|
|
this.getPlayer().restart()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await this.getPlayer().media.play()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// convert this into a warning, as an error will cause Cypress to fail the tests entirely
|
|
|
|
|
console.warn(error)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The next song in the queue.
|
|
|
|
|
* If we're in REPEAT_ALL mode and there's no next song, just get the first song.
|
|
|
|
|
*/
|
2022-04-24 17:58:12 +00:00
|
|
|
|
get next () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
if (queueStore.next) {
|
|
|
|
|
return queueStore.next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preferences.repeatMode === 'REPEAT_ALL') {
|
|
|
|
|
return queueStore.first
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The previous song in the queue.
|
|
|
|
|
* If we're in REPEAT_ALL mode and there's no prev song, get the last song.
|
|
|
|
|
*/
|
|
|
|
|
get previous (): Song | undefined {
|
|
|
|
|
if (queueStore.previous) {
|
|
|
|
|
return queueStore.previous
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preferences.repeatMode === 'REPEAT_ALL') {
|
|
|
|
|
return queueStore.last
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Circle through the repeat mode.
|
|
|
|
|
* The selected mode will be stored into local storage as well.
|
|
|
|
|
*/
|
2022-04-24 17:58:12 +00:00
|
|
|
|
changeRepeatMode () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
let index = this.repeatModes.indexOf(preferences.repeatMode) + 1
|
|
|
|
|
|
|
|
|
|
if (index >= this.repeatModes.length) {
|
|
|
|
|
index = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
preferences.repeatMode = this.repeatModes[index]
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Play the prev song in the queue, if one is found.
|
|
|
|
|
* If the prev song is not found and the current mode is NO_REPEAT, we stop completely.
|
|
|
|
|
*/
|
|
|
|
|
async playPrev () {
|
|
|
|
|
// If the song's duration is greater than 5 seconds and we've passed 5 seconds into it,
|
|
|
|
|
// restart playing instead.
|
|
|
|
|
if (this.getPlayer().media.currentTime > 5 && queueStore.current!.length > 5) {
|
|
|
|
|
this.getPlayer().restart()
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.previous && preferences.repeatMode === 'NO_REPEAT') {
|
|
|
|
|
this.stop()
|
|
|
|
|
} else {
|
|
|
|
|
await this.play(this.previous)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Play the next song in the queue, if one is found.
|
|
|
|
|
* If the next song is not found and the current mode is NO_REPEAT, we stop completely.
|
|
|
|
|
*/
|
|
|
|
|
async playNext () {
|
|
|
|
|
if (!this.next && preferences.repeatMode === 'NO_REPEAT') {
|
|
|
|
|
this.stop() // Nothing lasts forever, even cold November rain.
|
|
|
|
|
} else {
|
|
|
|
|
await this.play(this.next)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param {Number} volume 0-10
|
|
|
|
|
* @param {Boolean=true} persist Whether the volume should be saved into local storage
|
|
|
|
|
*/
|
2022-04-24 17:58:12 +00:00
|
|
|
|
setVolume (volume: number, persist = true) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
this.getPlayer().setVolume(volume)
|
|
|
|
|
|
|
|
|
|
if (persist) {
|
|
|
|
|
preferences.volume = volume
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.volumeInput.value = String(volume)
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
mute () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
this.setVolume(0, false)
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
unmute () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
// If the saved volume is 0, we unmute to the default level (7).
|
|
|
|
|
if (preferences.volume === 0) {
|
|
|
|
|
preferences.volume = DEFAULT_VOLUME_VALUE
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setVolume(preferences.volume)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
stop () {
|
|
|
|
|
document.title = app.name
|
|
|
|
|
this.getPlayer().pause()
|
|
|
|
|
this.getPlayer().seek(0)
|
|
|
|
|
|
|
|
|
|
if (queueStore.current) {
|
|
|
|
|
queueStore.current.playbackState = 'Stopped'
|
|
|
|
|
}
|
|
|
|
|
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.broadcast('SOCKET_PLAYBACK_STOPPED')
|
2022-04-15 14:24:30 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
pause () {
|
|
|
|
|
this.getPlayer().pause()
|
|
|
|
|
queueStore.current!.playbackState = 'Paused'
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.broadcast('SOCKET_SONG', songStore.generateDataToBroadcast(queueStore.current!))
|
2022-04-15 14:24:30 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async resume () {
|
|
|
|
|
try {
|
|
|
|
|
await this.getPlayer().media.play()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
queueStore.current!.playbackState = 'Playing'
|
|
|
|
|
eventBus.emit('SONG_STARTED', queueStore.current)
|
2022-04-24 08:50:45 +00:00
|
|
|
|
socketService.broadcast('SOCKET_SONG', songStore.generateDataToBroadcast(queueStore.current!))
|
2022-04-15 14:24:30 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async toggle () {
|
|
|
|
|
if (!queueStore.current) {
|
|
|
|
|
await this.playFirstInQueue()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (queueStore.current.playbackState !== 'Playing') {
|
|
|
|
|
await this.resume()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.pause()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Queue up songs (replace them into the queue) and start playing right away.
|
|
|
|
|
*
|
|
|
|
|
* @param {?Song[]} songs An array of song objects. Defaults to all songs if null.
|
|
|
|
|
* @param {Boolean=false} shuffled Whether to shuffle the songs before playing.
|
|
|
|
|
*/
|
2022-04-24 17:58:12 +00:00
|
|
|
|
async queueAndPlay (songs?: Song[], shuffled = false) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
if (!songs) {
|
|
|
|
|
songs = shuffle(songStore.all)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!songs.length) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (shuffled) {
|
|
|
|
|
songs = shuffle(songs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
queueStore.replaceQueueWith(songs)
|
|
|
|
|
|
|
|
|
|
// Wait for the DOM to complete updating and play the first song in the queue.
|
|
|
|
|
await nextTick()
|
|
|
|
|
router.go('queue')
|
|
|
|
|
await this.play(queueStore.first)
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 17:58:12 +00:00
|
|
|
|
getPlayer () {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
return this.player!
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Play the first song in the queue.
|
|
|
|
|
* If the current queue is empty, try creating it by shuffling all songs.
|
|
|
|
|
*/
|
|
|
|
|
async playFirstInQueue () {
|
|
|
|
|
queueStore.all.length ? await this.play(queueStore.first) : await this.queueAndPlay()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async playAllByArtist ({ songs }: { songs: Song[] }, shuffled = true) {
|
|
|
|
|
shuffled
|
|
|
|
|
? await this.queueAndPlay(songs, true /* shuffled */)
|
|
|
|
|
: await this.queueAndPlay(orderBy(songs, ['album_id', 'disc', 'track']))
|
|
|
|
|
},
|
|
|
|
|
|
2022-04-24 08:50:45 +00:00
|
|
|
|
async playAllInAlbum ({ songs }: { songs: Song[] }, shuffled = true) {
|
2022-04-15 14:24:30 +00:00
|
|
|
|
shuffled
|
|
|
|
|
? await this.queueAndPlay(songs, true /* shuffled */)
|
|
|
|
|
: await this.queueAndPlay(orderBy(songs, ['disc', 'track']))
|
|
|
|
|
}
|
|
|
|
|
}
|