1 | import '@polymer/iron-flex-layout/iron-flex-layout.js';
|
2 | import { PolymerElement, html } from '@polymer/polymer/polymer-element.js';
|
3 |
|
4 | class KwcMusicPlayer extends PolymerElement {
|
5 | static get template() {
|
6 | return html`
|
7 | <style>
|
8 | :host {
|
9 | background: #58d6fa;
|
10 | @apply --layout-vertical;
|
11 | @apply --layout-center-justified;
|
12 | color: white;
|
13 | padding: 16px;
|
14 | }
|
15 | .player {
|
16 | @apply --layout-horizontal;
|
17 | @apply --layout-center;
|
18 | }
|
19 | .player .spectrum {
|
20 | flex: 1 0 auto;
|
21 | margin-left: 16px;
|
22 | }
|
23 | .playback-time {
|
24 | margin-left: 16px;
|
25 | min-width: 30px;
|
26 | }
|
27 | .playback-button {
|
28 | border-radius: 50%;
|
29 | border: 4px solid white;
|
30 | width: 32px;
|
31 | height: 32px;
|
32 | background: transparent;
|
33 | cursor: pointer;
|
34 | padding: 4px;
|
35 | }
|
36 | .playback-button:focus {
|
37 | outline: none;
|
38 | @apply --shadow-elevation-4dp;
|
39 | }
|
40 | .playback-button svg {
|
41 | width: 100%;
|
42 | height: 100%;
|
43 | }
|
44 | .playback-button svg path {
|
45 | fill : white;
|
46 | transition: all linear 100ms;
|
47 | }
|
48 | </style>
|
49 | <div class="player">
|
50 | <button type="button" class="playback-button" on-click="_playbackButtonTapped" hidden$="[[cannotRenderSample]]">
|
51 | <svg viewBox="0 0 17 19">
|
52 | <path id="button-icon-path" d="M 4,18 10.5,14 10.5,6 4,2 z M 10.5,14 17,10 17,10 10.5,6 z">
|
53 | </path>
|
54 | </svg>
|
55 | </button>
|
56 | <div id="spectrum-container" class="spectrum">
|
57 | <canvas id="canvas"></canvas>
|
58 | </div>
|
59 | <span class="playback-time">[[_formatTime(playbackTime)]]</span>
|
60 | </div>
|
61 | `;
|
62 | }
|
63 | static get properties() {
|
64 | return {
|
65 | share: {
|
66 | type: Object,
|
67 | observer: '_shareChanged',
|
68 | },
|
69 | cannotRenderSample: {
|
70 | type: Boolean,
|
71 | value: false,
|
72 | },
|
73 | playbackTime: {
|
74 | type: Number,
|
75 | },
|
76 | };
|
77 | }
|
78 | static get observers() {
|
79 | return [
|
80 | '_playbackStatusChanged(_playbackStatus)',
|
81 | ];
|
82 | }
|
83 | connectedCallback() {
|
84 | super.connectedCallback();
|
85 | this.stoppedPath = 'M 4,18 10.5,14 10.5,6 4,2 z M 10.5,14 17,10 17,10 10.5,6 z';
|
86 | this.playingPath = 'M 2,18 6,18 6,2 2,2 z M 11,18 15,18 15,2 11,2 z';
|
87 | try {
|
88 | window.AudioContext = window.AudioContext || window.webkitAudioContext;
|
89 | window.OfflineAudioContext = window.OfflineAudioContext
|
90 | || window.webkitOfflineAudioContext;
|
91 | this.context = new AudioContext();
|
92 | } catch (e) {
|
93 | this.cannotRenderSample = true;
|
94 | }
|
95 | this._onPlaybackEnded = this._onPlaybackEnded.bind(this);
|
96 | }
|
97 | _playbackStatusChanged(status) {
|
98 | if (status === 'playing') {
|
99 | this.$['button-icon-path'].setAttribute('d', this.playingPath);
|
100 | } else {
|
101 | this.$['button-icon-path'].setAttribute('d', this.stoppedPath);
|
102 | }
|
103 | }
|
104 | _formatTime(duration) {
|
105 | const min = Math.floor(duration / 60);
|
106 |
|
107 |
|
108 | const sec = Math.floor(duration % 60);
|
109 | return `${min}:${sec < 10 ? `0${sec}` : sec}`;
|
110 | }
|
111 | _fitCanvas() {
|
112 | const rect = this.$['spectrum-container'].getBoundingClientRect();
|
113 | this.$.canvas.width = rect.width;
|
114 | this.$.canvas.height = rect.height;
|
115 | this._canvasFitted = true;
|
116 | }
|
117 | _play() {
|
118 | if (this.cannotRenderSample || !this.buffer) {
|
119 | return;
|
120 | }
|
121 | this.source = this.context.createBufferSource();
|
122 | this.source.buffer = this.buffer;
|
123 | this.source.connect(this.context.destination);
|
124 |
|
125 | this.source.addEventListener('ended', this._onPlaybackEnded);
|
126 |
|
127 | if (this._playbackPosition) {
|
128 | this.source.start(0, this._playbackPosition);
|
129 | this._startedAt = this.context.currentTime - this._playbackPosition;
|
130 | } else {
|
131 | this.source.start(0);
|
132 | this._startedAt = this.context.currentTime;
|
133 | }
|
134 | this._playbackStatus = 'playing';
|
135 | this._render();
|
136 | }
|
137 | _pause() {
|
138 | this._playbackPosition = this.context.currentTime - this._startedAt;
|
139 | this.source.removeEventListener('ended', this._onPlaybackEnded);
|
140 | this.source.stop();
|
141 | this._playbackStatus = 'stopped';
|
142 | this._stopRendering();
|
143 | }
|
144 | _onPlaybackEnded() {
|
145 | this._playbackPosition = 0;
|
146 | this._playbackStatus = 'stopped';
|
147 | this._stopRendering();
|
148 | }
|
149 | _playbackButtonTapped() {
|
150 | if (this._playbackStatus === 'playing') {
|
151 | this._pause();
|
152 | } else {
|
153 | this._play();
|
154 | }
|
155 | }
|
156 | _updatePlaybackTime() {
|
157 | const position = this._startedAt ? this.context.currentTime - this._startedAt : 0;
|
158 | this.playbackTime = this.buffer.duration - position;
|
159 | }
|
160 | _shareChanged(share) {
|
161 | if (!share) {
|
162 | return;
|
163 | }
|
164 |
|
165 | const sampleUrl = share.sample_url;
|
166 |
|
167 | if (!sampleUrl) {
|
168 | this.cannotRenderSample = true;
|
169 | }
|
170 |
|
171 | if (this.cannotRenderSample) {
|
172 | return;
|
173 | }
|
174 | fetch(sampleUrl)
|
175 | .then(r => r.arrayBuffer())
|
176 | .then((ab) => {
|
177 | this.context.decodeAudioData(ab, (buffer) => {
|
178 | this.buffer = buffer;
|
179 | this._playbackPosition = 0;
|
180 | this._updatePlaybackTime();
|
181 | this._render(true);
|
182 | });
|
183 | });
|
184 | }
|
185 | _render(once) {
|
186 | const ctx = this.$.canvas.getContext('2d');
|
187 | if (!this._spectrumCache) {
|
188 | this._drawSpectrum();
|
189 | }
|
190 | if (!this._canvasFitted) {
|
191 | this._fitCanvas();
|
192 | }
|
193 | if (!this._playbackTimeInterval) {
|
194 | this._playbackTimeInterval = setInterval(this._updatePlaybackTime.bind(this), 1000);
|
195 | }
|
196 | ctx.save();
|
197 | ctx.fillStyle = 'white';
|
198 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
199 | const playbackTime = this.context.currentTime - this._startedAt;
|
200 | const playbackPosition = playbackTime / this.buffer.duration;
|
201 | ctx.fillRect(0, 0, playbackPosition * ctx.canvas.width, ctx.canvas.height);
|
202 | ctx.globalCompositeOperation = 'destination-atop';
|
203 | ctx.drawImage(this._spectrumCache, 0, 0, this.$.canvas.width, this.$.canvas.height);
|
204 | ctx.restore();
|
205 | if (!once) {
|
206 | this._renderId = requestAnimationFrame(this._render.bind(this, false));
|
207 | }
|
208 | }
|
209 | _stopRendering() {
|
210 | cancelAnimationFrame(this._renderId);
|
211 | clearInterval(this._playbackTimeInterval);
|
212 | this._playbackTimeInterval = null;
|
213 | }
|
214 | _drawSpectrum() {
|
215 | const data = this.buffer.getChannelData(0);
|
216 |
|
217 |
|
218 | const height = 140;
|
219 |
|
220 |
|
221 | const width = 600;
|
222 |
|
223 |
|
224 | let value;
|
225 |
|
226 | this._spectrumCache = document.createElement('canvas');
|
227 |
|
228 | this._spectrumCache.width = width;
|
229 | this._spectrumCache.height = height;
|
230 |
|
231 | const ctx = this._spectrumCache.getContext('2d');
|
232 |
|
233 | ctx.fillStyle = '#58afd4';
|
234 |
|
235 | ctx.fillRect(0, height / 2, width, 1);
|
236 |
|
237 | for (let i = 0; i < width; i += 1) {
|
238 | value = (height / 2) * data[Math.floor((data.length / width) * i)];
|
239 | ctx.fillRect(i, (height / 2) - (value / 2), 1, value);
|
240 | }
|
241 | }
|
242 | }
|
243 |
|
244 | customElements.define('kwc-music-player', KwcMusicPlayer);
|