SoundcloudPlayer.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, onMounted, ref } from "vue";
  3. import Toast from "toasters";
  4. import { useSoundcloudPlayer } from "@/composables/useSoundcloudPlayer";
  5. import { useConfigStore } from "@/stores/config";
  6. import { useStationStore } from "@/stores/station";
  7. import aw from "@/aw";
  8. const props = defineProps<{
  9. song: {
  10. mediaSource: string;
  11. title: string;
  12. artists: string[];
  13. duration: number;
  14. };
  15. }>();
  16. const TAG = "[SP]";
  17. const {
  18. soundcloudIframeElement: playerElement,
  19. soundcloudGetDuration,
  20. soundcloudLoadTrack,
  21. soundcloudSetVolume,
  22. soundcloudPlay,
  23. soundcloudPause,
  24. soundcloudSeekTo,
  25. soundcloudOnTrackStateChange,
  26. soundcloudBindListener,
  27. soundcloudGetPosition,
  28. soundcloudGetCurrentSound,
  29. soundcloudGetTrackState,
  30. soundcloudUnload
  31. } = useSoundcloudPlayer();
  32. const configStore = useConfigStore();
  33. const stationStore = useStationStore();
  34. const { updateMediaModalPlayingAudio } = stationStore;
  35. const interval = ref(null);
  36. const durationCanvas = ref(null);
  37. const activityWatchMediaDataInterval = ref(null);
  38. const activityWatchMediaLastStatus = ref("");
  39. const activityWatchMediaLastStartDuration = ref(0);
  40. const canvasWidth = ref(760);
  41. const player = ref<{
  42. error: boolean;
  43. errorMessage: string;
  44. paused: boolean;
  45. currentTime: number;
  46. duration: number;
  47. muted: boolean;
  48. volume: number;
  49. }>({
  50. error: false,
  51. errorMessage: "",
  52. paused: true,
  53. currentTime: 0,
  54. duration: 0,
  55. muted: false,
  56. volume: 20
  57. });
  58. const playerVolumeControlIcon = computed(() => {
  59. const { muted, volume } = player.value;
  60. if (muted) return "volume_mute";
  61. if (volume >= 50) return "volume_up";
  62. return "volume_down";
  63. });
  64. const soundcloudTrackId = computed(() => props.song.mediaSource.split(":")[1]);
  65. const playerPlay = () => {
  66. console.debug(TAG, "PLAYER PLAY");
  67. soundcloudPlay();
  68. };
  69. const playerPause = () => {
  70. console.debug(TAG, "PLAYER PAUSE");
  71. soundcloudPause();
  72. };
  73. const playerStop = () => {
  74. console.debug(TAG, "PLAYER STOP");
  75. soundcloudPause();
  76. soundcloudSeekTo(0);
  77. };
  78. const playerHardStop = () => {
  79. console.debug(TAG, "PLAYER HARD STOP");
  80. playerStop();
  81. };
  82. const playerSetTrackPosition = event => {
  83. console.debug(TAG, "PLAYER SET TRACK POSITION");
  84. playerPlay();
  85. soundcloudGetDuration(duration => {
  86. soundcloudSeekTo(
  87. Number(
  88. Number(duration / 1000) *
  89. ((event.pageX - event.target.getBoundingClientRect().left) /
  90. canvasWidth.value)
  91. ) * 1000
  92. );
  93. });
  94. };
  95. const playerToggleMute = () => {
  96. console.debug(TAG, "PLAYER TOGGLE MUTE");
  97. player.value.muted = !player.value.muted;
  98. const { muted, volume } = player.value;
  99. localStorage.setItem("muted", `${muted}`);
  100. if (muted) {
  101. soundcloudSetVolume(0);
  102. player.value.volume = 0;
  103. } else if (volume > 0) {
  104. soundcloudSetVolume(volume);
  105. player.value.volume = volume;
  106. localStorage.setItem("volume", `${volume}`);
  107. } else {
  108. soundcloudSetVolume(20);
  109. player.value.volume = 20;
  110. localStorage.setItem("volume", `${20}`);
  111. }
  112. };
  113. const playerChangeVolume = () => {
  114. console.debug(TAG, "PLAYER CHANGE VOLUME");
  115. const { muted, volume } = player.value;
  116. localStorage.setItem("volume", `${volume}`);
  117. soundcloudSetVolume(volume);
  118. if (muted && volume > 0) {
  119. player.value.muted = false;
  120. localStorage.setItem("muted", `${false}`);
  121. } else if (!muted && volume === 0) {
  122. player.value.muted = true;
  123. localStorage.setItem("muted", `${true}`);
  124. }
  125. };
  126. const drawCanvas = () => {
  127. const canvasElement = durationCanvas.value;
  128. if (!canvasElement) return;
  129. const ctx = canvasElement.getContext("2d");
  130. const videoDuration = Number(player.value.duration);
  131. const _duration = Number(player.value.duration);
  132. const afterDuration = videoDuration - _duration;
  133. canvasWidth.value = Math.min(document.body.clientWidth - 40, 760);
  134. const width = canvasWidth.value;
  135. const { currentTime } = player.value;
  136. const widthDuration = (_duration / videoDuration) * width;
  137. const widthAfterDuration = (afterDuration / videoDuration) * width;
  138. const widthCurrentTime = (currentTime / videoDuration) * width;
  139. const durationColor = configStore.primaryColor;
  140. const afterDurationColor = "#41E841";
  141. const currentDurationColor = "#3b25e8";
  142. ctx.fillStyle = durationColor;
  143. ctx.fillRect(0, 0, widthDuration, 20);
  144. ctx.fillStyle = afterDurationColor;
  145. ctx.fillRect(widthDuration, 0, widthAfterDuration, 20);
  146. ctx.fillStyle = currentDurationColor;
  147. ctx.fillRect(widthCurrentTime, 0, 1, 20);
  148. };
  149. const formatDuration = duration => duration.toFixed(3);
  150. const sendActivityWatchMediaData = () => {
  151. if (!player.value.paused && soundcloudGetTrackState() === "playing") {
  152. if (activityWatchMediaLastStatus.value !== "playing") {
  153. activityWatchMediaLastStatus.value = "playing";
  154. soundcloudGetPosition(position => {
  155. activityWatchMediaLastStartDuration.value = Math.floor(
  156. Number(position / 1000)
  157. );
  158. });
  159. }
  160. const videoData = {
  161. title: props.song.title,
  162. artists: props.song.artists?.join(", ") || "",
  163. mediaSource: props.song.mediaSource,
  164. muted: player.value.muted,
  165. volume: player.value.volume,
  166. startedDuration:
  167. activityWatchMediaLastStartDuration.value <= 0
  168. ? 0
  169. : activityWatchMediaLastStartDuration.value,
  170. source: `viewMedia#${props.song.mediaSource}`,
  171. hostname: window.location.hostname,
  172. playerState: "",
  173. playbackRate: 1
  174. };
  175. aw.sendMediaData(videoData);
  176. } else {
  177. activityWatchMediaLastStatus.value = "not_playing";
  178. }
  179. };
  180. onMounted(() => {
  181. console.debug(TAG, "ON MOUNTED");
  182. // Generic
  183. let volume = parseFloat(localStorage.getItem("volume"));
  184. volume = typeof volume === "number" && !Number.isNaN(volume) ? volume : 20;
  185. localStorage.setItem("volume", `${volume}`);
  186. player.value.volume = volume;
  187. let muted: boolean | string = localStorage.getItem("muted");
  188. muted = muted === "true";
  189. localStorage.setItem("muted", `${muted}`);
  190. player.value.muted = muted;
  191. if (muted) player.value.volume = 0;
  192. soundcloudSetVolume(volume);
  193. // SoundCloud specific
  194. soundcloudBindListener("ready", value => {
  195. console.debug(TAG, "Bind on ready", value);
  196. soundcloudGetCurrentSound(sound => {
  197. player.value.duration = sound.duration / 1000;
  198. });
  199. soundcloudOnTrackStateChange(newState => {
  200. console.debug(TAG, `New state: ${newState}`);
  201. const { paused } = player.value;
  202. if (
  203. newState === "attempting_to_play" ||
  204. newState === "failed_to_play"
  205. ) {
  206. if (!paused) {
  207. if (newState === "failed_to_play")
  208. new Toast(
  209. "Failed to start SoundCloud player. Please try to manually start it."
  210. );
  211. else if (newState === "sound_unavailable")
  212. new Toast("Sound is currently unavailable.");
  213. player.value.paused = true;
  214. }
  215. } else if (newState === "paused") {
  216. player.value.paused = true;
  217. } else if (newState === "playing") {
  218. player.value.paused = false;
  219. } else if (newState === "finished") {
  220. player.value.paused = true;
  221. } else if (newState === "error") {
  222. player.value.paused = true;
  223. }
  224. if (player.value.paused) updateMediaModalPlayingAudio(false);
  225. else updateMediaModalPlayingAudio(true);
  226. });
  227. soundcloudBindListener("seek", () => {
  228. console.debug(TAG, "Bind on seek");
  229. });
  230. soundcloudBindListener("error", value => {
  231. console.debug(TAG, "Bind on error", value);
  232. });
  233. });
  234. soundcloudLoadTrack(soundcloudTrackId.value, 0, true);
  235. interval.value = setInterval(() => {
  236. soundcloudGetPosition(position => {
  237. player.value.currentTime = position / 1000;
  238. drawCanvas();
  239. });
  240. }, 200);
  241. activityWatchMediaDataInterval.value = setInterval(() => {
  242. sendActivityWatchMediaData();
  243. }, 1000);
  244. });
  245. onBeforeUnmount(() => {
  246. clearInterval(interval.value);
  247. clearInterval(activityWatchMediaDataInterval.value);
  248. updateMediaModalPlayingAudio(false);
  249. soundcloudUnload();
  250. });
  251. </script>
  252. <template>
  253. <div class="player-section">
  254. <div class="player-container">
  255. <iframe
  256. ref="playerElement"
  257. style="width: 100%; height: 100%; min-height: 426px"
  258. scrolling="no"
  259. frameborder="no"
  260. allow="autoplay"
  261. ></iframe>
  262. </div>
  263. <div v-show="player.error" class="player-error">
  264. <h2>{{ player.errorMessage }}</h2>
  265. </div>
  266. <canvas
  267. ref="durationCanvas"
  268. class="duration-canvas"
  269. v-show="!player.error"
  270. height="20"
  271. :width="canvasWidth"
  272. @click="playerSetTrackPosition($event)"
  273. ></canvas>
  274. <div class="player-footer">
  275. <div class="player-footer-left">
  276. <button
  277. v-if="player.paused"
  278. class="button is-primary"
  279. @click="playerPlay()"
  280. @keyup.enter="playerPlay()"
  281. content="Resume Playback"
  282. v-tippy
  283. >
  284. <i class="material-icons">play_arrow</i>
  285. </button>
  286. <button
  287. v-else
  288. class="button is-primary"
  289. @click="playerPause()"
  290. @keyup.enter="playerPause()"
  291. content="Pause Playback"
  292. v-tippy
  293. >
  294. <i class="material-icons">pause</i>
  295. </button>
  296. <button
  297. class="button is-danger"
  298. @click.exact="playerStop()"
  299. @click.shift="playerHardStop()"
  300. @keyup.enter.exact="playerStop()"
  301. @keyup.shift.enter="playerHardStop()"
  302. content="Stop Playback"
  303. v-tippy
  304. >
  305. <i class="material-icons">stop</i>
  306. </button>
  307. </div>
  308. <div class="player-footer-center">
  309. <span>
  310. <span>
  311. {{ formatDuration(player.currentTime) }}
  312. </span>
  313. /
  314. <span>
  315. {{ formatDuration(player.duration) }}
  316. </span>
  317. </span>
  318. </div>
  319. <div class="player-footer-right">
  320. <p id="volume-control">
  321. <i
  322. class="material-icons"
  323. @click="playerToggleMute()"
  324. :content="`${player.muted ? 'Unmute' : 'Mute'}`"
  325. v-tippy
  326. >{{ playerVolumeControlIcon }}</i
  327. >
  328. <input
  329. v-model.number="player.volume"
  330. type="range"
  331. min="0"
  332. max="100"
  333. class="volume-slider active"
  334. @change="playerChangeVolume()"
  335. @input="playerChangeVolume()"
  336. />
  337. </p>
  338. </div>
  339. </div>
  340. </div>
  341. </template>
  342. <style lang="less" scoped>
  343. .night-mode {
  344. .player-section {
  345. background-color: var(--dark-grey-3) !important;
  346. border: 0 !important;
  347. .duration-canvas {
  348. background-color: var(--dark-grey-2) !important;
  349. }
  350. }
  351. }
  352. .player-section {
  353. display: flex;
  354. flex-direction: column;
  355. margin: 10px auto 0 auto;
  356. border: 1px solid var(--light-grey-3);
  357. border-radius: @border-radius;
  358. overflow: hidden;
  359. .player-container {
  360. position: relative;
  361. aspect-ratio: 16/9;
  362. overflow: hidden;
  363. :deep(iframe) {
  364. position: absolute;
  365. top: 0;
  366. left: 0;
  367. width: 100%;
  368. height: 100%;
  369. min-height: 426px;
  370. }
  371. }
  372. .duration-canvas {
  373. background-color: var(--light-grey-2);
  374. }
  375. .player-error {
  376. display: flex;
  377. height: 428px;
  378. align-items: center;
  379. * {
  380. margin: 0;
  381. flex: 1;
  382. font-size: 30px;
  383. text-align: center;
  384. }
  385. }
  386. .player-footer {
  387. display: flex;
  388. justify-content: space-between;
  389. height: 54px;
  390. padding-left: 10px;
  391. padding-right: 10px;
  392. > * {
  393. width: 33.3%;
  394. display: flex;
  395. align-items: center;
  396. }
  397. .player-footer-left {
  398. flex: 1;
  399. & > .button:not(:first-child) {
  400. margin-left: 5px;
  401. }
  402. & > .playerRateDropdown {
  403. margin-left: 5px;
  404. margin-bottom: unset !important;
  405. .control.has-addons {
  406. margin-bottom: unset !important;
  407. & > .button {
  408. font-size: 24px;
  409. }
  410. }
  411. }
  412. :deep(.tippy-box[data-theme~="dropdown"]) {
  413. max-width: 100px !important;
  414. .nav-dropdown-items .nav-item {
  415. justify-content: center !important;
  416. border-radius: @border-radius !important;
  417. &.active {
  418. background-color: var(--primary-color);
  419. color: var(--white);
  420. }
  421. }
  422. }
  423. }
  424. .player-footer-center {
  425. justify-content: center;
  426. align-items: center;
  427. flex: 2;
  428. font-size: 18px;
  429. font-weight: 400;
  430. width: 200px;
  431. margin: 0 5px;
  432. img {
  433. height: 21px;
  434. margin-right: 12px;
  435. filter: invert(26%) sepia(54%) saturate(6317%) hue-rotate(2deg)
  436. brightness(92%) contrast(115%);
  437. }
  438. }
  439. .player-footer-right {
  440. justify-content: right;
  441. flex: 1;
  442. #volume-control {
  443. margin: 3px;
  444. margin-top: 0;
  445. display: flex;
  446. align-items: center;
  447. cursor: pointer;
  448. .volume-slider {
  449. width: 100%;
  450. padding: 0 15px;
  451. background: transparent;
  452. min-width: 100px;
  453. }
  454. input[type="range"] {
  455. -webkit-appearance: none;
  456. margin: 7.3px 0;
  457. }
  458. input[type="range"]:focus {
  459. outline: none;
  460. }
  461. input[type="range"]::-webkit-slider-runnable-track {
  462. width: 100%;
  463. height: 5.2px;
  464. cursor: pointer;
  465. box-shadow: 0;
  466. background: var(--light-grey-3);
  467. border-radius: @border-radius;
  468. border: 0;
  469. }
  470. input[type="range"]::-webkit-slider-thumb {
  471. box-shadow: 0;
  472. border: 0;
  473. height: 19px;
  474. width: 19px;
  475. border-radius: 100%;
  476. background: var(--primary-color);
  477. cursor: pointer;
  478. -webkit-appearance: none;
  479. margin-top: -6.5px;
  480. }
  481. input[type="range"]::-moz-range-track {
  482. width: 100%;
  483. height: 5.2px;
  484. cursor: pointer;
  485. box-shadow: 0;
  486. background: var(--light-grey-3);
  487. border-radius: @border-radius;
  488. border: 0;
  489. }
  490. input[type="range"]::-moz-range-thumb {
  491. box-shadow: 0;
  492. border: 0;
  493. height: 19px;
  494. width: 19px;
  495. border-radius: 100%;
  496. background: var(--primary-color);
  497. cursor: pointer;
  498. -webkit-appearance: none;
  499. margin-top: -6.5px;
  500. }
  501. input[type="range"]::-ms-track {
  502. width: 100%;
  503. height: 5.2px;
  504. cursor: pointer;
  505. box-shadow: 0;
  506. background: var(--light-grey-3);
  507. border-radius: @border-radius;
  508. }
  509. input[type="range"]::-ms-fill-lower {
  510. background: var(--light-grey-3);
  511. border: 0;
  512. border-radius: 0;
  513. box-shadow: 0;
  514. }
  515. input[type="range"]::-ms-fill-upper {
  516. background: var(--light-grey-3);
  517. border: 0;
  518. border-radius: 0;
  519. box-shadow: 0;
  520. }
  521. input[type="range"]::-ms-thumb {
  522. box-shadow: 0;
  523. border: 0;
  524. height: 15px;
  525. width: 15px;
  526. border-radius: 100%;
  527. background: var(--primary-color);
  528. cursor: pointer;
  529. -webkit-appearance: none;
  530. margin-top: 1.5px;
  531. }
  532. }
  533. }
  534. }
  535. }
  536. </style>