kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
				
				
				
			Implement embedded player logic
							rodzic
							
								
									86be283c6c
								
							
						
					
					
						commit
						3597527362
					
				| Przed Szerokość: | Wysokość: | Rozmiar: 8.8 KiB Po Szerokość: | Wysokość: | Rozmiar: 8.8 KiB | 
|  | @ -6,6 +6,7 @@ | |||
|   --fw-text: #fff; | ||||
| } | ||||
| 
 | ||||
| audio, | ||||
| [v-cloak] { | ||||
|   display: none; | ||||
| } | ||||
|  | @ -25,6 +26,27 @@ main { | |||
|   height: 100vh; | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|   Error | ||||
| */ | ||||
| 
 | ||||
| .error { | ||||
|   padding-left: 8px; | ||||
|   line-height: 50px; | ||||
|   font-weight: bold; | ||||
|   text-align: center; | ||||
| } | ||||
| 
 | ||||
| .error:first-letter { | ||||
|   text-transform: uppercase; | ||||
| } | ||||
| 
 | ||||
| .error .logo-link { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|   Player | ||||
| */ | ||||
|  | @ -93,9 +115,13 @@ button > svg { | |||
| 
 | ||||
| .logo-link { | ||||
|   display: block; | ||||
|   aspect-ratio: 1; | ||||
|   width: 42px; | ||||
|   height: 42px; | ||||
|   background-color: var(--fw-primary); | ||||
|   padding: 4px; | ||||
| } | ||||
| 
 | ||||
| .player .logo-wrapper { | ||||
|   margin: 8px -8px -8px 8px; | ||||
| } | ||||
| 
 | ||||
|  | @ -164,10 +190,6 @@ input[type=range] { | |||
|   --sx: calc(0.5 * var(--range-size) + var(--ratio) * (100% - var(--range-size))); | ||||
| } | ||||
| 
 | ||||
| input[type=range]:focus { | ||||
|   outline: none; | ||||
| } | ||||
| 
 | ||||
| input[type=range]::-webkit-slider-thumb { | ||||
|   appearance: none; | ||||
|   width: var(--range-size); | ||||
|  |  | |||
|  | @ -14,12 +14,149 @@ | |||
|   <link rel="stylesheet" href="embed.css"> | ||||
| 
 | ||||
|   <script type="module"> | ||||
|     import { createApp, reactive } from 'https://unpkg.com/petite-vue@0.4.1?module' | ||||
|     import { createApp, reactive, nextTick } from 'https://unpkg.com/petite-vue@0.4.1?module' | ||||
| 
 | ||||
|     const SUPPORTED_TYPES = ['track', 'album', 'artist', 'playlist', 'channel'] | ||||
| 
 | ||||
|     // Params | ||||
|     const params = new URL(location.href).searchParams | ||||
|     const type = params.get('type') | ||||
|     const id = params.get('id') | ||||
| 
 | ||||
|     // Error | ||||
|     let error = reactive({ value: false }) | ||||
|     if (!SUPPORTED_TYPES.includes(type)) { | ||||
|       error.value = `Widget improperly configured (bad resource type "${type}").` | ||||
|     } | ||||
| 
 | ||||
|     if (id === null || isNaN(+id)) { | ||||
|       error.value = `Widget improperly configured (bad resource id "${id}").` | ||||
|     } | ||||
| 
 | ||||
|     // Cover | ||||
|     const DEFAULT_COVER = 'embed-default-cover.jpeg' | ||||
|     const cover = reactive({ value: DEFAULT_COVER }) | ||||
| 
 | ||||
|     const fetchArtistCover = async (id) => { | ||||
|       const response = await fetch(`/api/v1/artists/${id}/`) | ||||
|       const data = await response.json() | ||||
|       cover.value = data.cover?.urls.medium_square_crop ?? DEFAULT_COVER | ||||
|     } | ||||
| 
 | ||||
|     if (type === 'artist') { | ||||
|       fetchArtistCover(id) | ||||
|     } | ||||
| 
 | ||||
|     // Tracks | ||||
|     const tracks = reactive([]) | ||||
| 
 | ||||
|     const getTracksUrl = () => type === 'track' | ||||
|       ? `/api/v1/tracks/${id}` | ||||
|       :  type === 'playlist' | ||||
|         ? `/api/v1/playlists/${id}/tracks/` | ||||
|         : `/api/v1/tracks/` | ||||
| 
 | ||||
|     const getAudioSources = (uploads) => { | ||||
|       const sources = uploads | ||||
|         // NOTE: Filter out repeating and unplayable media types | ||||
|         .filter(({ mimetype }, index, array) => array.findIndex((upload) => upload.mimetype === mimetype) === index) | ||||
|         .filter(({ mimetype }) => ['probably', 'maybe'].includes(audio.element?.canPlayType(mimetype))) | ||||
| 
 | ||||
|       // NOTE: Add a transcoded MP3 src at the end for browsers | ||||
|       //       that do not support other codecs to be able to play it :) | ||||
|       if (sources.length > 0 && !sources.some(({ type }) => type === 'audio/mpeg')) { | ||||
|         sources.push({ mimetype: 'audio/mpeg', listen_url: `${sources[0].listen_url}?to=mp3` }) | ||||
|       } | ||||
| 
 | ||||
|       return sources | ||||
|     } | ||||
| 
 | ||||
|     const fetchTracks = async (url = getTracksUrl()) => { | ||||
|       const filters = new URLSearchParams({ | ||||
|         include_channels: true, | ||||
|         playable: true, | ||||
|         [type]: id | ||||
|       }) | ||||
| 
 | ||||
|       switch (type) { | ||||
|         case 'album': | ||||
|           filters.set('ordering', 'disc_number,position') | ||||
|           break | ||||
| 
 | ||||
|         case 'artist': | ||||
|           filters.set('ordering', '-album__release_date,disc_number,position') | ||||
|           break | ||||
| 
 | ||||
|         case 'channel': | ||||
|           filters.set('ordering', '-creation_date') | ||||
|           break | ||||
| 
 | ||||
|         case 'playlist': break | ||||
|         case 'track': break | ||||
| 
 | ||||
|         // NOTE: The type is undefined, let's return before we make any request | ||||
|         default: return | ||||
|       } | ||||
| 
 | ||||
|       const response = await fetch(`${url}?${filters}`) | ||||
|       const data = await response.json() | ||||
| 
 | ||||
|       if (response.status > 299) { | ||||
|         switch (response.status) { | ||||
|           case 404: | ||||
|             error.value = `${type} not found.` | ||||
|             break | ||||
| 
 | ||||
|           case 403: | ||||
|             error.value = `You need to login to access this ${type}.` | ||||
|             break | ||||
| 
 | ||||
|           case 500: | ||||
|             error.value = `An unknown error occurred while loading ${type} data from server.` | ||||
|             break | ||||
| 
 | ||||
|           default: | ||||
|             error.value = `An unknown error occurred while loading ${type} data.` | ||||
|         } | ||||
| 
 | ||||
|         // NOTE: If we already have some tracks, let's fail silently | ||||
|         if (tracks.length > 0) { | ||||
|           console.error(error.value) | ||||
|           error.value = false | ||||
|         } | ||||
| 
 | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       if (type === 'track') { | ||||
|         data.results = [data] | ||||
|       } | ||||
| 
 | ||||
|       if (type === 'playlist') { | ||||
|         data.results = data.results.map(({ track }) => track) | ||||
|       } | ||||
| 
 | ||||
|       tracks.push( | ||||
|         ...data.results.map((track) => ({ | ||||
|           id: track.id, | ||||
|           title: track.title, | ||||
|           artist: track.artist, | ||||
|           album: track.album, | ||||
|           cover: (track.cover ?? track.album.cover)?.urls.medium_square_crop, | ||||
|           sources: getAudioSources(track.uploads) | ||||
|         })).filter(({ sources }) => sources.length > 0) | ||||
|       ) | ||||
| 
 | ||||
|       if (data.next) { | ||||
|         return fetchTracks(data.next) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // NOTE: Fetch tracks only if there is no error | ||||
|     if (error.value === false) { | ||||
|       fetchTracks() | ||||
|     } | ||||
| 
 | ||||
|     // Duration | ||||
|     const ZERO_DATE = +new Date('2022-01-01T00:00:00.000') | ||||
|     const intl = new Intl.DateTimeFormat('en', { | ||||
|  | @ -29,39 +166,55 @@ | |||
|       hourCycle: 'h23' | ||||
|     }) | ||||
| 
 | ||||
|     const tracks = [ | ||||
|       { | ||||
|         id: 8, | ||||
|         title: 'Song name', | ||||
|         artist: { | ||||
|           name: 'Artist name' | ||||
|         }, | ||||
|         sources: [{ duration: 6666 }] | ||||
|       }, | ||||
|       { | ||||
|         id: 9, | ||||
|         title: 'Another song name', | ||||
|         artist: { | ||||
|           name: 'Another artist name' | ||||
|         }, | ||||
|         album: { | ||||
|           title: 'Another album title' | ||||
|         }, | ||||
|         sources: [{ duration: 666 }] | ||||
|       } | ||||
|     ] | ||||
|     const formatDuration = (duration) => { | ||||
|       if (duration === 0) return | ||||
| 
 | ||||
|       const time = intl.format(new Date(ZERO_DATE + duration * 1e3)) | ||||
|       return time.replace(/^00:/, '') | ||||
|     } | ||||
| 
 | ||||
|     // Logo component | ||||
|     const Logo = () => ({ $template: '#logo-template' }) | ||||
| 
 | ||||
|     // Player | ||||
|     const player = reactive({ | ||||
|       playing: false, | ||||
|       current: 0, | ||||
|       seek: 0, | ||||
|       play (index) { | ||||
|       play (unsafeIndex) { | ||||
|         const index = Math.min(tracks.length - 1, Math.max(unsafeIndex, 0)) | ||||
|         if (this.current === index) return | ||||
| 
 | ||||
|         const wasPlaying = this.playing | ||||
|         if (wasPlaying) audio.element.pause() | ||||
| 
 | ||||
|         this.current = index | ||||
|         audio.element.currentTime = 0 | ||||
|         audio.element.load() | ||||
| 
 | ||||
|         if (wasPlaying) audio.element.play() | ||||
|       }, | ||||
| 
 | ||||
|       next () { | ||||
|         this.play(this.current + 1) | ||||
|       }, | ||||
| 
 | ||||
|       prev () { | ||||
|         this.play(this.current - 1) | ||||
|       }, | ||||
| 
 | ||||
|       seekTime (event) { | ||||
|         if (!audio.element) return | ||||
| 
 | ||||
|         const seek = audio.element.duration * event.target.value / 100 | ||||
|         audio.element.currentTime = isNaN(seek) ? 0 : seek | ||||
|       }, | ||||
| 
 | ||||
|       togglePlay () { | ||||
|         this.playing = !this.playing | ||||
| 
 | ||||
|         if (this.playing) audio.element.play() | ||||
|         else audio.element.pause() | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|  | @ -85,21 +238,54 @@ | |||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     // Audio | ||||
|     const audio = reactive({ | ||||
|       element: undefined, | ||||
|       volume: -1 | ||||
|     }) | ||||
| 
 | ||||
|     const watchAudio = (element, volume) => { | ||||
|       if (audio.element !== element) { | ||||
|         audio.element = element | ||||
| 
 | ||||
|         element.addEventListener('timeupdate', (event) => { | ||||
|           const seek = element.currentTime / element.duration * 100 | ||||
|           player.seek = isNaN(seek) ? 0 : seek | ||||
|         }) | ||||
| 
 | ||||
|         element.addEventListener('ended', () => { | ||||
|           // NOTE: Pause playback if it's a last track | ||||
|           if (player.current === tracks.length - 1) { | ||||
|             player.playing = false | ||||
|           } | ||||
| 
 | ||||
|           player.next() | ||||
|         }) | ||||
|       } | ||||
| 
 | ||||
|       if (audio.volume !== volume) { | ||||
|         audio.element.volume = volume / 100 | ||||
|         audio.volume = volume | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Application | ||||
|     const app = createApp({ | ||||
|       coverUrl: '', | ||||
|       type, | ||||
|       id, | ||||
|       // Components | ||||
|       Logo, | ||||
| 
 | ||||
|       // Errors | ||||
|       error, | ||||
| 
 | ||||
|       // Playback | ||||
|       watchAudio, | ||||
|       player, | ||||
|       volume, | ||||
| 
 | ||||
|       // Track info | ||||
|       formatDuration, | ||||
|       tracks, | ||||
| 
 | ||||
|       formatDuration (duration) { | ||||
|         const time = intl.format(new Date(ZERO_DATE + duration * 1e3)) | ||||
|         return time.replace(/^00:/, '') | ||||
|       } | ||||
|       cover | ||||
|     }) | ||||
| 
 | ||||
|     app.directive('range', (ctx) => { | ||||
|  | @ -112,8 +298,17 @@ | |||
|   </script> | ||||
| </head> | ||||
| 
 | ||||
| <template id="track-entry"> | ||||
| 
 | ||||
| <template id="logo-template"> | ||||
|   <a | ||||
|     title="Funkwhale" | ||||
|     href="https://funkwhale.audio" | ||||
|     target="_blank" | ||||
|     rel="noopener noreferrer" | ||||
|     class="logo-link" | ||||
|     tabindex="-1" | ||||
|   > | ||||
|     <img src="logo-white.svg" /> | ||||
|   </a> | ||||
| </template> | ||||
| 
 | ||||
| <body> | ||||
|  | @ -122,99 +317,110 @@ | |||
|   </noscript> | ||||
| 
 | ||||
|   <main v-scope v-cloak> | ||||
|     <div class="player"> | ||||
|       <img :src="coverUrl" class="cover-image" /> | ||||
| 
 | ||||
|       <div class="player-content"> | ||||
|         <h1>{{ tracks[player.current].title }}</h1> | ||||
|         <h2>{{ tracks[player.current].artist.name }}</h2> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="player-controls"> | ||||
|         <button> | ||||
|           <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-start-fill" viewBox="0 0 16 16"> | ||||
|             <path d="M4 4a.5.5 0 0 1 1 0v3.248l6.267-3.636c.54-.313 1.232.066 1.232.696v7.384c0 .63-.692 1.01-1.232.697L5 8.753V12a.5.5 0 0 1-1 0V4z"/> | ||||
|           </svg> | ||||
|         </button> | ||||
|         <button @click="player.togglePlay" class="play"> | ||||
|           <svg v-if="!player.playing" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16"> | ||||
|             <path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/> | ||||
|           </svg> | ||||
|           <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pause-fill" viewBox="0 0 16 16"> | ||||
|             <path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/> | ||||
|           </svg> | ||||
|         </button> | ||||
|         <button> | ||||
|           <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-end-fill" viewBox="0 0 16 16"> | ||||
|             <path d="M12.5 4a.5.5 0 0 0-1 0v3.248L5.233 3.612C4.693 3.3 4 3.678 4 4.308v7.384c0 .63.692 1.01 1.233.697L11.5 8.753V12a.5.5 0 0 0 1 0V4z"/> | ||||
|           </svg> | ||||
|         </button> | ||||
| 
 | ||||
|         <input | ||||
|           v-model.number="player.seek" | ||||
|           v-range="player.seek" | ||||
|           type="range" | ||||
|           step="0.1" | ||||
|         /> | ||||
| 
 | ||||
|         <button @click="volume.mute"> | ||||
|           <svg v-if="volume.level === 0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-volume-mute-fill" viewBox="0 0 16 16"> | ||||
|             <path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zm7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z"/> | ||||
|           </svg> | ||||
|           <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-volume-up-fill" viewBox="0 0 16 16"> | ||||
|             <path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z"/> | ||||
|             <path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z"/> | ||||
|             <path d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z"/> | ||||
|           </svg> | ||||
|         </button> | ||||
| 
 | ||||
|         <input | ||||
|           v-model.number="volume.level" | ||||
|           v-range="volume.level" | ||||
|           type="range" | ||||
|           step="0.1" | ||||
|         /> | ||||
|       </div> | ||||
| 
 | ||||
|       <a | ||||
|         title="Funkwhale" | ||||
|         href="https://funkwhale.audio" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|         class="logo-link" | ||||
|       > | ||||
|         <img src="logo-white.svg" /> | ||||
|       </a> | ||||
|     <div v-if="error.value !== false" class="error"> | ||||
|       {{ error.value }} | ||||
|       <div v-scope="Logo()"></div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="track-list"> | ||||
|       <table> | ||||
|         <tr | ||||
|           v-for="(track, index) in tracks" | ||||
|           :id="'queue-item-' + index" | ||||
|           :key="track.id" | ||||
|           role="button" | ||||
|           :class="{ 'current': player.current === index }" | ||||
|           @click="player.play(index)" | ||||
|     <template v-else> | ||||
|       <div class="player"> | ||||
|         <img :src="tracks[player.current]?.cover ?? cover.value" class="cover-image" /> | ||||
| 
 | ||||
|         <div class="player-content"> | ||||
|           <h1>{{ tracks[player.current]?.title }}</h1> | ||||
|           <h2>{{ tracks[player.current]?.artist.name }}</h2> | ||||
|         </div> | ||||
| 
 | ||||
|         <div class="player-controls"> | ||||
|           <button @click="player.prev"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-start-fill" viewBox="0 0 16 16"> | ||||
|               <path d="M4 4a.5.5 0 0 1 1 0v3.248l6.267-3.636c.54-.313 1.232.066 1.232.696v7.384c0 .63-.692 1.01-1.232.697L5 8.753V12a.5.5 0 0 1-1 0V4z"/> | ||||
|             </svg> | ||||
|           </button> | ||||
|           <button @click="player.togglePlay" class="play"> | ||||
|             <svg v-if="!player.playing" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16"> | ||||
|               <path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/> | ||||
|             </svg> | ||||
|             <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pause-fill" viewBox="0 0 16 16"> | ||||
|               <path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/> | ||||
|             </svg> | ||||
|           </button> | ||||
|           <button @click="player.next"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-skip-end-fill" viewBox="0 0 16 16"> | ||||
|               <path d="M12.5 4a.5.5 0 0 0-1 0v3.248L5.233 3.612C4.693 3.3 4 3.678 4 4.308v7.384c0 .63.692 1.01 1.233.697L11.5 8.753V12a.5.5 0 0 0 1 0V4z"/> | ||||
|             </svg> | ||||
|           </button> | ||||
| 
 | ||||
|           <input | ||||
|             v-model.number="player.seek" | ||||
|             v-range="player.seek" | ||||
|             @input="player.seekTime" | ||||
|             type="range" | ||||
|             step="0.1" | ||||
|           /> | ||||
| 
 | ||||
|           <button @click="volume.mute"> | ||||
|             <svg v-if="volume.level === 0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-volume-mute-fill" viewBox="0 0 16 16"> | ||||
|               <path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06zm7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0z"/> | ||||
|             </svg> | ||||
|             <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-volume-up-fill" viewBox="0 0 16 16"> | ||||
|               <path d="M11.536 14.01A8.473 8.473 0 0 0 14.026 8a8.473 8.473 0 0 0-2.49-6.01l-.708.707A7.476 7.476 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303l.708.707z"/> | ||||
|               <path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.483 5.483 0 0 1 11.025 8a5.483 5.483 0 0 1-1.61 3.89l.706.706z"/> | ||||
|               <path d="M8.707 11.182A4.486 4.486 0 0 0 10.025 8a4.486 4.486 0 0 0-1.318-3.182L8 5.525A3.489 3.489 0 0 1 9.025 8 3.49 3.49 0 0 1 8 10.475l.707.707zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06z"/> | ||||
|             </svg> | ||||
|           </button> | ||||
| 
 | ||||
|           <input | ||||
|             v-model.number="volume.level" | ||||
|             v-range="volume.level" | ||||
|             type="range" | ||||
|             step="0.1" | ||||
|           /> | ||||
|         </div> | ||||
| 
 | ||||
|         <div v-scope="Logo()" class="logo-wrapper"></div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div class="track-list"> | ||||
|         <table> | ||||
|           <tr | ||||
|             v-for="(track, index) in tracks" | ||||
|             :id="'queue-item-' + index" | ||||
|             :key="track.id" | ||||
|             role="button" | ||||
|             :class="{ 'current': player.current === index }" | ||||
|             @click="player.play(index)" | ||||
|             @keyup.enter="player.play(index)" | ||||
|             tabindex="0" | ||||
|           > | ||||
|             <td> | ||||
|               {{ index + 1 }} | ||||
|             </td> | ||||
|             <td :title="track.title"> | ||||
|               {{ track.title }} | ||||
|             </td> | ||||
|             <td :title="track.artist.name"> | ||||
|               {{ track.artist.name }} | ||||
|             </td> | ||||
|             <td :title="track.album?.title"> | ||||
|               {{ track.album?.title }} | ||||
|             </td> | ||||
|             <td> | ||||
|               {{ formatDuration(track.sources?.[0].duration ?? 0) }} | ||||
|             </td> | ||||
|           </tr> | ||||
|         </table> | ||||
|       </div> | ||||
| 
 | ||||
|       <audio v-effect="watchAudio($el, volume.level)"> | ||||
|         <source | ||||
|           v-for="source in tracks[player.current]?.sources ?? []" | ||||
|           :key="source.mimetype + source.listen_url" | ||||
|           :type="source.mimetype" | ||||
|           :src="source.listen_url" | ||||
|         > | ||||
|           <td> | ||||
|             {{ index + 1 }} | ||||
|           </td> | ||||
|           <td :title="track.title"> | ||||
|             {{ track.title }} | ||||
|           </td> | ||||
|           <td :title="track.artist.name"> | ||||
|             {{ track.artist.name }} | ||||
|           </td> | ||||
|           <td :title="track.album?.title"> | ||||
|             {{ track.album?.title }} | ||||
|           </td> | ||||
|           <td> | ||||
|             {{ formatDuration(track.sources[0].duration) }} | ||||
|           </td> | ||||
|         </tr> | ||||
|       </table> | ||||
|     </div> | ||||
|       </audio> | ||||
|     </template> | ||||
|   </main> | ||||
| </body> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,800 +0,0 @@ | |||
| 
 | ||||
| <template> | ||||
|   <main :class="[theme]"> | ||||
|     <!-- SVG from https://cdn.plyr.io/3.4.7/plyr.svg --> | ||||
|     <svg | ||||
|       aria-hidden="true" | ||||
|       style="display: none" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|     > | ||||
|       <symbol id="plyr-download"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zM2 15h14v2H2z" /></symbol> | ||||
|       <symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z" /></symbol> | ||||
|       <symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z" /></symbol> | ||||
|       <symbol id="plyr-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z" /></symbol> | ||||
|       <symbol id="plyr-muted"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z" /></symbol> | ||||
|       <symbol id="plyr-pause"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zM12 1c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z" /></symbol> | ||||
|       <symbol id="plyr-pip"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z" /><path d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2v5z" /></symbol> | ||||
|       <symbol id="plyr-play"><path d="M15.562 8.1L3.87.225C3.052-.337 2 .225 2 1.125v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z" /></symbol> | ||||
|       <symbol id="plyr-restart"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z" /></symbol> | ||||
|       <symbol id="plyr-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z" /></symbol> | ||||
|       <symbol id="plyr-settings"><path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z" /></symbol> | ||||
|       <symbol id="plyr-volume"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z" /><path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z" /></symbol> | ||||
|       <!-- those ones are from fork-awesome --> | ||||
|       <symbol id="plyr-step-backward"><path d="M979 141c25-25 45-16 45 19v1472c0 35-20 44-45 19L269 941c-6-6-10-12-13-19v678c0 35-29 64-64 64H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h128c35 0 64 29 64 64v678c3-7 7-13 13-19z" /></symbol> | ||||
|       <symbol id="plyr-step-forward"><path d="M45 1651c-25 25-45 16-45-19V160c0-35 20-44 45-19l710 710c6 6 10 12 13 19V192c0-35 29-64 64-64h128c35 0 64 29 64 64v1408c0 35-29 64-64 64H832c-35 0-64-29-64-64V922c-3 7-7 13-13 19z" /></symbol> | ||||
|     </svg> | ||||
|     <article> | ||||
|       <aside | ||||
|         v-if="currentTrack" | ||||
|         class="cover main" | ||||
|       > | ||||
|         <img | ||||
|           v-if="currentTrack.cover" | ||||
|           height="120" | ||||
|           :src="currentTrack.cover" | ||||
|           alt="Cover" | ||||
|         > | ||||
|         <img | ||||
|           v-else-if="artistCover" | ||||
|           height="120" | ||||
|           :src="artistCover" | ||||
|           alt="Cover" | ||||
|         > | ||||
|         <img | ||||
|           v-else | ||||
|           height="120" | ||||
|           src="../assets/embed/default-cover.jpeg" | ||||
|           alt="Cover" | ||||
|         > | ||||
|       </aside> | ||||
|       <div | ||||
|         class="content" | ||||
|         aria-label="Track information" | ||||
|       > | ||||
|         <header v-if="currentTrack"> | ||||
|           <h3> | ||||
|             <a | ||||
|               :href="fullUrl('/library/tracks/' + currentTrack.id)" | ||||
|               target="_blank" | ||||
|               rel="noopener noreferrer" | ||||
|             >{{ currentTrack.title }}</a> | ||||
|           </h3> | ||||
|           <a | ||||
|             :href="fullUrl('/library/artists/' + currentTrack.artist.id)" | ||||
|             target="_blank" | ||||
|             rel="noopener noreferrer" | ||||
|           >{{ currentTrack.artist.name }}</a> | ||||
|         </header> | ||||
|         <section | ||||
|           v-if="!isLoading" | ||||
|           class="controls" | ||||
|           aria-label="Audio player" | ||||
|         > | ||||
|           <template v-if="currentTrack && currentTrack.sources.length > 0"> | ||||
|             <div | ||||
|               v-if="tracks.length > 1" | ||||
|               class="queue-controls plyr--audio" | ||||
|             > | ||||
|               <div class="plyr__controls"> | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   class="plyr__control" | ||||
|                   aria-label="Play previous track" | ||||
|                   @focus="setControlFocus($event, true)" | ||||
|                   @blur="setControlFocus($event, false)" | ||||
|                   @click="previous()" | ||||
|                 > | ||||
|                   <svg | ||||
|                     class="icon--not-pressed" | ||||
|                     role="presentation" | ||||
|                     focusable="false" | ||||
|                     viewBox="0 0 1100 1650" | ||||
|                     width="80" | ||||
|                     height="80" | ||||
|                   > | ||||
|                     <use xlink:href="#plyr-step-backward" /> | ||||
|                   </svg> | ||||
|                 </button> | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   class="plyr__control" | ||||
|                   aria-label="Play next track" | ||||
|                   @click="next()" | ||||
|                   @focus="setControlFocus($event, true)" | ||||
|                   @blur="setControlFocus($event, false)" | ||||
|                 > | ||||
|                   <svg | ||||
|                     class="icon--not-pressed" | ||||
|                     role="presentation" | ||||
|                     focusable="false" | ||||
|                     viewBox="0 0 1100 1650" | ||||
|                     width="80" | ||||
|                     height="80" | ||||
|                   > | ||||
|                     <use xlink:href="#plyr-step-forward" /> | ||||
|                   </svg> | ||||
|                 </button> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <vue-plyr | ||||
|               :key="currentIndex" | ||||
|               ref="player" | ||||
|               class="player" | ||||
|               :options="{loadSprite: false, controls: controls, duration: currentTrack.sources[0].duration, autoplay}" | ||||
|             > | ||||
|               <audio preload="none"> | ||||
|                 <source | ||||
|                   v-for="(source, key) in currentTrack.sources" | ||||
|                   :key="key" | ||||
|                   :src="source.src" | ||||
|                   :type="source.type" | ||||
|                 > | ||||
|               </audio> | ||||
|             </vue-plyr> | ||||
|           </template> | ||||
|           <div | ||||
|             v-else | ||||
|             class="player" | ||||
|           > | ||||
|             <span | ||||
|               v-if="error === 'invalid_type'" | ||||
|               class="error" | ||||
|             >Widget improperly configured (bad resource type {{ type }}).</span> | ||||
|             <span | ||||
|               v-else-if="error === 'invalid_id'" | ||||
|               class="error" | ||||
|             >Widget improperly configured (missing resource id).</span> | ||||
|             <span | ||||
|               v-else-if="error === 'server_not_found'" | ||||
|               class="error" | ||||
|             >Track not found.</span> | ||||
|             <span | ||||
|               v-else-if="error === 'server_requires_auth'" | ||||
|               class="error" | ||||
|             >You need to login to access this resource.</span> | ||||
|             <span | ||||
|               v-else-if="error === 'server_error'" | ||||
|               class="error" | ||||
|             >An unknown error occurred while loading track data from server.</span> | ||||
|             <span | ||||
|               v-else-if="currentTrack && currentTrack.sources.length === 0" | ||||
|               class="error" | ||||
|             >This track is unavailable.</span> | ||||
|             <span | ||||
|               v-else | ||||
|               class="error" | ||||
|             >An unknown error occurred while loading track data.</span> | ||||
|           </div> | ||||
|           <a | ||||
|             title="Funkwhale" | ||||
|             href="https://funkwhale.audio" | ||||
|             target="_blank" | ||||
|             rel="noopener noreferrer" | ||||
|             class="logo-wrapper" | ||||
|           > | ||||
|             <logo | ||||
|               :fill="currentTheme.textColor" | ||||
|               class="logo" | ||||
|             /> | ||||
|           </a> | ||||
|         </section> | ||||
|       </div> | ||||
|     </article> | ||||
|     <div | ||||
|       v-if="tracks.length > 1" | ||||
|       id="queue" | ||||
|       class="queue-wrapper" | ||||
|     > | ||||
|       <table class="queue"> | ||||
|         <tbody> | ||||
|           <tr | ||||
|             v-for="(track, index) in filteredTracks" | ||||
|             :id="'queue-item-' + index" | ||||
|             :key="index" | ||||
|             role="button" | ||||
|             :class="[{active: index === currentIndex}]" | ||||
|             @click="play(index)" | ||||
|             @keyup.enter="play(index)" | ||||
|           > | ||||
|             <td | ||||
|               class="position-cell" | ||||
|               width="40" | ||||
|             > | ||||
|               <span class="position"> | ||||
|                 {{ index + 1 }} | ||||
|               </span> | ||||
|             </td> | ||||
|             <td | ||||
|               class="title" | ||||
|               :title="track.title" | ||||
|             > | ||||
|               <div | ||||
|                 colspan="2" | ||||
|                 class="ellipsis" | ||||
|               > | ||||
|                 {{ track.title }} | ||||
|               </div> | ||||
|             </td> | ||||
|             <td | ||||
|               class="artist" | ||||
|               :title="track.artist.name" | ||||
|             > | ||||
|               <div class="ellipsis"> | ||||
|                 {{ track.artist.name }} | ||||
|               </div> | ||||
|             </td> | ||||
|             <td class="album"> | ||||
|               <div | ||||
|                 v-if="track.album" | ||||
|                 class="ellipsis" | ||||
|                 :title="track.album.title" | ||||
|               > | ||||
|                 {{ track.album.title }} | ||||
|               </div> | ||||
|             </td> | ||||
|             <td width="50"> | ||||
|               {{ time.durationFormatted(track.sources[0].duration) }} | ||||
|             </td> | ||||
|           </tr> | ||||
|         </tbody> | ||||
|       </table> | ||||
|     </div> | ||||
|   </main> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import axios from 'axios' | ||||
| import Logo from '~/components/Logo.vue' | ||||
| import updateQueryString from '~/composables/updateQueryString' | ||||
| import time from '~/utils/time' | ||||
| import { reactive, computed } from 'vue' | ||||
| 
 | ||||
| function getURLParams () { | ||||
|   let match | ||||
|   const pl = /\+/g // Regex for replacing addition symbol with a space | ||||
|   const urlParams = {} | ||||
|   const search = /([^&=]+)=?([^&]*)/g | ||||
|   const decode = function (s) { return decodeURIComponent(s.replace(pl, ' ')) } | ||||
|   const query = window.location.search.substring(1) | ||||
| 
 | ||||
|   while ((match = search.exec(query)) !== null) { urlParams[decode(match[1])] = decode(match[2]) } | ||||
| 
 | ||||
|   return urlParams | ||||
| } | ||||
| export default { | ||||
|   name: 'App', | ||||
|   components: { Logo }, | ||||
|   setup () { | ||||
|     const tracks = reactive([]) | ||||
|     const filteredTracks = computed(() => tracks.filter(track => track.sources.length > 0)) | ||||
| 
 | ||||
|     return { tracks, filteredTracks } | ||||
|   }, | ||||
|   data () { | ||||
|     return { | ||||
|       time, | ||||
|       supportedTypes: ['track', 'album', 'artist', 'playlist', 'channel'], | ||||
|       baseUrl: '', | ||||
|       error: null, | ||||
|       type: null, | ||||
|       id: null, | ||||
|       autoplay: false, | ||||
|       url: null, | ||||
|       isLoading: true, | ||||
|       theme: 'dark', | ||||
|       currentIndex: -1, | ||||
|       artistCover: null, | ||||
|       themes: { | ||||
|         dark: { | ||||
|           textColor: 'white' | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     currentTrack () { | ||||
|       if (this.tracks.length === 0) { | ||||
|         return null | ||||
|       } | ||||
|       return this.tracks[this.currentIndex] | ||||
|     }, | ||||
|     currentTheme () { | ||||
|       return this.themes[this.theme] | ||||
|     }, | ||||
|     controls () { | ||||
|       return [ | ||||
|         'play', // Play/pause playback | ||||
|         'progress', // The progress bar and scrubber for playback and buffering | ||||
|         'current-time', // The current time of playback | ||||
|         'mute', // Toggle mute | ||||
|         'volume' // Volume control | ||||
|       ] | ||||
|     }, | ||||
|     hasPrevious () { | ||||
|       return this.currentIndex > 0 | ||||
|     }, | ||||
|     hasNext () { | ||||
|       return this.currentIndex < this.tracks.length - 1 | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     currentIndex (v) { | ||||
|       // we bind player events | ||||
|       const self = this | ||||
|       this.$nextTick(() => { | ||||
|         self.bindEvents() | ||||
|         if (self.tracks.length > 0) { | ||||
|           const el = document.getElementById(`queue-item-${v}`) | ||||
|           if (!el) { | ||||
|             return | ||||
|           } | ||||
|           const topPos = el.offsetTop | ||||
|           document.getElementById('queue').scrollTop = topPos - 10 | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     tracks () { | ||||
|       this.currentIndex = 0 | ||||
|     } | ||||
|   }, | ||||
|   created () { | ||||
|     const params = getURLParams() | ||||
|     this.baseUrl = params.b || '' | ||||
|     this.type = params.type | ||||
|     if (this.supportedTypes.indexOf(this.type) === -1) { | ||||
|       this.error = 'invalid_type' | ||||
|     } | ||||
|     this.id = params.id | ||||
|     if (!this.id) { | ||||
|       this.error = 'invalid_id' | ||||
|     } | ||||
|     if (this.error) { | ||||
|       this.isLoading = false | ||||
|       return | ||||
|     } | ||||
|     if (params.instance) { | ||||
|       this.baseUrl = params.instance | ||||
|     } | ||||
| 
 | ||||
|     this.autoplay = params.autoplay !== undefined || params.auto_play !== undefined | ||||
|     this.fetch(this.type, this.id) | ||||
|   }, | ||||
|   mounted () { | ||||
|     const parser = document.createElement('a') | ||||
|     parser.href = this.baseUrl | ||||
|     this.url = parser | ||||
|   }, | ||||
|   methods: { | ||||
|     next () { | ||||
|       if (this.hasNext) { | ||||
|         this.play(this.currentIndex + 1) | ||||
|       } | ||||
|     }, | ||||
|     previous () { | ||||
|       if (this.hasPrevious) { | ||||
|         this.play(this.currentIndex - 1) | ||||
|       } | ||||
|     }, | ||||
|     setControlFocus (event, enable) { | ||||
|       if (enable) { | ||||
|         event.target.classList.add('plyr__tab-focus') | ||||
|       } else { | ||||
|         event.target.classList.remove('plyr__tab-focus') | ||||
|       } | ||||
|     }, | ||||
|     fetch (type, id) { | ||||
|       if (type === 'track') { | ||||
|         this.fetchTrack(id) | ||||
|       } | ||||
|       if (type === 'album') { | ||||
|         this.fetchTracks({ album: id, playable: true, ordering: 'disc_number,position' }) | ||||
|       } | ||||
|       if (type === 'channel') { | ||||
|         this.fetchTracks({ channel: id, playable: true, include_channels: 'true', ordering: '-creation_date' }) | ||||
|       } | ||||
|       if (type === 'artist') { | ||||
|         this.fetchTracks({ artist: id, playable: true, include_channels: 'true', ordering: '-album__release_date,disc_number,position' }) | ||||
|         this.fetchArtistCover(id) | ||||
|       } | ||||
|       if (type === 'playlist') { | ||||
|         this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`) | ||||
|       } | ||||
|     }, | ||||
|     play (index) { | ||||
|       this.currentIndex = index | ||||
|       const self = this | ||||
|       this.$nextTick(() => { | ||||
|         self.$refs.player.player.play() | ||||
|       }) | ||||
|     }, | ||||
|     fetchTrack (id) { | ||||
|       const url = `${this.baseUrl}/api/v1/tracks/${id}/` | ||||
|       axios.get(url).then(() => { | ||||
|         this.tracks = this.parseTracks([response.data]) | ||||
|         this.isLoading = false | ||||
|       }).catch(error => { | ||||
|         if (error.response) { | ||||
|           if (error.response.status === 404) { | ||||
|             this.error = 'server_not_found' | ||||
|           } else if (error.response.status === 403) { | ||||
|             this.error = 'server_requires_auth' | ||||
|           } else if (error.response.status === 500) { | ||||
|             this.error = 'server_error' | ||||
|           } else { | ||||
|             this.error = 'server_unknown_error' | ||||
|           } | ||||
|         } else { | ||||
|           this.error = 'server_unknown_error' | ||||
|         } | ||||
|         this.isLoading = false | ||||
|       }) | ||||
|     }, | ||||
|     fetchTracks (filters, path) { | ||||
|       path = path || '/api/v1/tracks/' | ||||
|       filters.include_channels = 'true' | ||||
|       const self = this | ||||
|       const url = `${this.baseUrl}${path}` | ||||
|       axios.get(url, { params: filters }).then(response => { | ||||
|         self.tracks = self.parseTracks(response.data.results) | ||||
|         self.isLoading = false | ||||
|       }).catch(error => { | ||||
|         if (error.response) { | ||||
|           if (error.response.status === 404) { | ||||
|             self.error = 'server_not_found' | ||||
|           } else if (error.response.status === 403) { | ||||
|             self.error = 'server_requires_auth' | ||||
|           } else if (error.response.status === 500) { | ||||
|             self.error = 'server_error' | ||||
|           } else { | ||||
|             self.error = 'server_unknown_error' | ||||
|           } | ||||
|         } else { | ||||
|           self.error = 'server_unknown_error' | ||||
|         } | ||||
|         self.isLoading = false | ||||
|       }) | ||||
|     }, | ||||
|     parseTracks (tracks) { | ||||
|       const self = this | ||||
|       if (this.type === 'playlist') { | ||||
|         tracks = tracks.map((t) => { | ||||
|           return t.track | ||||
|         }) | ||||
|       } | ||||
|       return tracks.map(t => { | ||||
|         return { | ||||
|           id: t.id, | ||||
|           title: t.title, | ||||
|           artist: t.artist, | ||||
|           album: t.album, | ||||
|           cover: self.getCover((t || t.album).cover), | ||||
|           sources: self.getSources(t.uploads) | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     fetchArtistCover (id) { | ||||
|       const self = this | ||||
|       self.isLoading = true | ||||
|       const url = `${this.baseUrl}/api/v1/artists/${id}/` | ||||
|       axios.get(url).then(response => { | ||||
|         self.isLoading = false | ||||
|         self.artistCover = response.data.cover.urls.medium_square_crop | ||||
|       }) | ||||
|     }, | ||||
|     bindEvents () { | ||||
|       const self = this | ||||
|       this.$refs.player.player.on('ended', () => { | ||||
|         self.next() | ||||
|       }) | ||||
|     }, | ||||
|     fullUrl (path) { | ||||
|       if (path.startsWith('/')) { | ||||
|         return this.baseUrl + path | ||||
|       } | ||||
|       return path | ||||
|     }, | ||||
|     getCover (albumCover) { | ||||
|       if (albumCover) { | ||||
|         return albumCover.urls.medium_square_crop | ||||
|       } | ||||
|     }, | ||||
|     getSources (uploads) { | ||||
|       const self = this | ||||
|       const a = document.createElement('audio') | ||||
|       const allowed = ['probably', 'maybe'] | ||||
|       const sources = uploads.filter(u => { | ||||
|         const canPlay = a.canPlayType(u.mimetype) | ||||
|         return allowed.indexOf(canPlay) > -1 | ||||
|       }).map(u => { | ||||
|         return { | ||||
|           type: u.mimetype, | ||||
|           src: self.fullUrl(u.listen_url), | ||||
|           duration: u.duration | ||||
|         } | ||||
|       }) | ||||
|       a.remove() | ||||
|       if (sources.length > 0) { | ||||
|         // We always add a transcoded MP3 src at the end | ||||
|         // because transcoding is expensive, but we want browsers that do | ||||
|         // not support other codecs to be able to play it :) | ||||
|         sources.push({ | ||||
|           type: 'audio/mpeg', | ||||
|           src: updateQueryString( | ||||
|             self.fullUrl(sources[0].src), | ||||
|             'to', | ||||
|             'mp3' | ||||
|           ) | ||||
|         }) | ||||
|       } | ||||
|       return sources | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| @import "plyr/src/sass/plyr.scss"; | ||||
| 
 | ||||
| html, | ||||
| body, | ||||
| main { | ||||
|   height: 100%; | ||||
| } | ||||
| body { | ||||
|   margin: 0; | ||||
|   font-family: sans-serif; | ||||
| } | ||||
| main { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
| article { | ||||
|   display: flex; | ||||
|   position: relative; | ||||
|   aside { | ||||
|     padding: 0.5em; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| a { | ||||
|   text-decoration: none; | ||||
| } | ||||
| a:hover { | ||||
|   text-decoration: underline; | ||||
| } | ||||
| section.controls { | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
| } | ||||
| .cover { | ||||
|   max-width: 120px; | ||||
|   max-height: 120px; | ||||
| } | ||||
| 
 | ||||
| .player { | ||||
|   flex: 1; | ||||
|   align-self: flex-end; | ||||
| } | ||||
| .player .plyr { | ||||
|   min-width: inherit; | ||||
| } | ||||
| article .content { | ||||
|   flex: 1; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   h3 { | ||||
|     margin: 0 0 0.5em; | ||||
|   } | ||||
|   header { | ||||
|     flex: 1; | ||||
|     padding: 1em; | ||||
|   } | ||||
| } | ||||
| .player, | ||||
| .queue-controls { | ||||
|   padding: 0.25em 0; | ||||
|   margin-right: 0.25em; | ||||
|   align-self: center; | ||||
| } | ||||
| section .plyr--audio .plyr__controls { | ||||
|   padding: 0; | ||||
| } | ||||
| 
 | ||||
| .error { | ||||
|   font-weight: bold; | ||||
|   display: block; | ||||
|   text-align: center; | ||||
| } | ||||
| .logo-wrapper { | ||||
|   height: 2em; | ||||
|   width: 2em; | ||||
|   padding: 0.25em; | ||||
|   margin-left: 0.5em; | ||||
|   display: block; | ||||
| } | ||||
| [role="button"] { | ||||
|   cursor: pointer; | ||||
| } | ||||
| .ellipsis { | ||||
|   white-space: nowrap; | ||||
|   text-overflow: ellipsis; | ||||
|   overflow: hidden; | ||||
| } | ||||
| .queue-wrapper { | ||||
|   flex: 1; | ||||
|   overflow-y: auto; | ||||
|   padding: 0.5em; | ||||
| } | ||||
| .queue { | ||||
|   width: 100%; | ||||
|   border-collapse: collapse; | ||||
|   table-layout: fixed; | ||||
|   margin-bottom: 0.5em; | ||||
|   td { | ||||
|     padding: 0.5em; | ||||
|     font-size: 90%; | ||||
|     img { | ||||
|       vertical-align: middle; | ||||
|       margin-right: 1em; | ||||
|     } | ||||
|   } | ||||
|   td:last-child { | ||||
|     text-align: right; | ||||
|   } | ||||
|   .position { | ||||
|     padding: 0.1em 0.3em; | ||||
|     display: inline-block; | ||||
|   } | ||||
| } | ||||
| @media screen and (max-width: 640px) { | ||||
|   .queue .album { | ||||
|     display: none; | ||||
|   } | ||||
|   .plyr__controls .plyr__time { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| @media screen and (max-width: 460px) { | ||||
|   article, | ||||
|   article .content { | ||||
|     position: relative; | ||||
|     display: block; | ||||
|   } | ||||
|   .content header { | ||||
|     padding-right: 80px; | ||||
|   } | ||||
|   .cover.main { | ||||
|     position: absolute; | ||||
|     right: 0; | ||||
|     top: 0; | ||||
|     img { | ||||
|       height: 60px; | ||||
|       width: 60px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-width: 320px) { | ||||
|   .content header { | ||||
|     font-size: 14px; | ||||
|   } | ||||
|   .content h3 { | ||||
|     font-size: 15px; | ||||
|   } | ||||
|   .logo-wrapper, | ||||
|   .position-cell { | ||||
|     display: none; | ||||
|   } | ||||
|   .plyr__volume { | ||||
|     min-width: 70px; | ||||
|   } | ||||
|   .queue .artist { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-width: 200px) { | ||||
|   .content header { | ||||
|     padding-right: 1em; | ||||
|     font-size: 13px; | ||||
|   } | ||||
|   .content h3 { | ||||
|     font-size: 14px; | ||||
|   } | ||||
|   .cover.main { | ||||
|     display: none; | ||||
|   } | ||||
|   .plyr__progress { | ||||
|     display: none; | ||||
|   } | ||||
|   .controls .plyr__control, | ||||
|   .player .plyr__control { | ||||
|     padding: 3px; | ||||
|   } | ||||
|   .queue td:last-child { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-width: 170px) { | ||||
|   .plyr__volume { | ||||
|     min-width: inherit; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-height: 180px) { | ||||
|   .queue-wrapper { | ||||
|     display: none; | ||||
|   } | ||||
|   article .content { | ||||
|     display: flex; | ||||
|     align-items: flex-start; | ||||
|     width: 100%; | ||||
|     height: 100vh; | ||||
|   } | ||||
|   article .content header { | ||||
|     flex-grow: 1; | ||||
|   } | ||||
| } | ||||
| // themes | ||||
| 
 | ||||
| .dark { | ||||
|   $primary-color: rgb(242, 113, 28); | ||||
|   $dark: rgb(27, 28, 29); | ||||
|   $lighter: rgb(47, 48, 48); | ||||
|   $clear: rgb(242, 242, 242); | ||||
|   // $primary-color: rgb(255, 88, 78); | ||||
|   .logo-wrapper { | ||||
|     background-color: $primary-color; | ||||
|   } | ||||
|   .plyr--audio .plyr__control.plyr__tab-focus, | ||||
|   .plyr--audio .plyr__control:hover, | ||||
|   .plyr--audio .plyr__control[aria-expanded="true"] { | ||||
|     background-color: $primary-color; | ||||
|   } | ||||
|   .plyr--audio .plyr__control.plyr__tab-focus, | ||||
|   .plyr--audio .plyr__control:hover, | ||||
|   .plyr--audio .plyr__control[aria-expanded="true"] { | ||||
|     background-color: $primary-color; | ||||
|   } | ||||
|   .plyr--full-ui input[type="range"] { | ||||
|     color: $primary-color; | ||||
|   } | ||||
|   article, | ||||
|   .player, | ||||
|   .plyr--audio .plyr__controls { | ||||
|     background-color: $dark; | ||||
|   } | ||||
|   .queue-wrapper { | ||||
|     background-color: $lighter; | ||||
|   } | ||||
|   article, | ||||
|   article a, | ||||
|   .player, | ||||
|   .queue tr, | ||||
|   .plyr--audio .plyr__controls { | ||||
|     color: white; | ||||
|   } | ||||
|   .plyr__control.plyr__tab-focus { | ||||
|     -webkit-box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5); | ||||
|     box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5); | ||||
|     outline: 0; | ||||
|   } | ||||
|   tr:hover, | ||||
|   tr:focus { | ||||
|     background-color: $dark; | ||||
|   } | ||||
|   tr.active { | ||||
|     background-color: $clear; | ||||
|     color: $dark; | ||||
|   } | ||||
| 
 | ||||
|   tr.active { | ||||
|     .position { | ||||
|       background-color: $primary-color; | ||||
|       color: $clear; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  | @ -1,9 +0,0 @@ | |||
| import EmbedFrame from './EmbedFrame.vue' | ||||
| import { createApp } from 'vue' | ||||
| 
 | ||||
| // @ts-expect-error vue-plyr has no types defined
 | ||||
| import VuePlyr from 'vue-plyr' | ||||
| 
 | ||||
| const app = createApp(EmbedFrame) | ||||
| app.use(VuePlyr) | ||||
| app.mount('#app') | ||||
		Ładowanie…
	
		Reference in New Issue
	
	 wvffle
						wvffle