MediaPlayer.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <script lang="ts" setup>
  2. import { storeToRefs } from "pinia";
  3. import {
  4. computed,
  5. defineAsyncComponent,
  6. onBeforeUnmount,
  7. onMounted,
  8. ref,
  9. watch
  10. } from "vue";
  11. import { Duration } from "dayjs/plugin/duration";
  12. import dayjs, { Dayjs } from "@/dayjs";
  13. import { useConfigStore } from "@/stores/config";
  14. import { Song } from "@/types/song";
  15. const YoutubePlayer = defineAsyncComponent(
  16. () => import("@/pages/NewStation/Components/YoutubePlayer.vue")
  17. );
  18. /**
  19. * TODO:
  20. * - provide youtubePlayer with youtubeId during createPlayer
  21. * - should source be tracked and loaded/cued in YT?
  22. * - would also need to track/calc timeElapsed and skipDuration
  23. * - volume
  24. * - autoPlay
  25. * - UI
  26. * - seekTo on progress bar for standalone
  27. * - Experimental: Soundcloud, listenMode, mediaSession
  28. * - activityWatch
  29. * */
  30. const props = defineProps<{
  31. source?: Song;
  32. sourceStartedAt?: Dayjs;
  33. sourcePausedAt?: Dayjs;
  34. sourceTimePaused?: Duration;
  35. sourceTimeOffset?: Duration;
  36. syncPlayerTimeEnabled?: boolean;
  37. }>();
  38. const emit = defineEmits(["error", "notFound", "notAllowed"]);
  39. const configStore = useConfigStore();
  40. const { experimental } = storeToRefs(configStore);
  41. const updateTimeElapsedTimeout = ref();
  42. const syncPlayerTimeWithElapsedTimeout = ref();
  43. const isYoutubeReady = ref(false);
  44. const isYoutubeLoading = ref(false);
  45. const youtubePlayer = ref<typeof YoutubePlayer>();
  46. const soundcloudPlayer = ref();
  47. const timeElapsed = ref(dayjs.duration(0));
  48. const isAutomaticallyPaused = ref(false);
  49. const playerStartedAt = ref(dayjs());
  50. const playerPausedAt = ref<Dayjs>();
  51. const playerTimePaused = ref(dayjs.duration(0));
  52. const sourceType = computed(() => props.source?.mediaSource.split(":")[0]);
  53. const sourceId = computed(() => props.source?.mediaSource.split(":")[1]);
  54. const isSourceControlled = computed(() => !!props.sourceStartedAt);
  55. const mediaStartedAt = computed(() =>
  56. isSourceControlled.value ? props.sourceStartedAt : playerStartedAt.value
  57. );
  58. const mediaPausedAt = computed(() =>
  59. isSourceControlled.value ? props.sourcePausedAt : playerPausedAt.value
  60. );
  61. const mediaTimePaused = computed(() =>
  62. isSourceControlled.value ? props.sourceTimePaused : playerTimePaused.value
  63. );
  64. const isSourcePaused = computed(
  65. () => isSourceControlled.value && !!props.sourcePausedAt
  66. );
  67. const isMediaPaused = computed(
  68. () => !!playerPausedAt.value || isSourcePaused.value
  69. );
  70. const playerState = computed(() => {
  71. if (!props.source) return "no_song";
  72. // if (
  73. // experimentalChangableListenModeEnabled.value &&
  74. // experimentalChangableListenMode.value === "participate"
  75. // )
  76. // return "participate";
  77. if (!isYoutubeReady.value || isYoutubeLoading.value) return "buffering";
  78. if (isAutomaticallyPaused.value) return "unavailable";
  79. if (isMediaPaused.value) return "local_paused";
  80. // if (volumeSliderValue.value === 0 || muted.value) return "muted";
  81. return "playing";
  82. });
  83. const getTimeElapsed = () => {
  84. if (!props.source) return dayjs.duration(0);
  85. let currentTime = dayjs().valueOf();
  86. if (props.sourceTimeOffset) {
  87. currentTime += props.sourceTimeOffset.asMilliseconds();
  88. }
  89. let timePaused = mediaTimePaused.value.asMilliseconds();
  90. if (mediaPausedAt.value) {
  91. timePaused += currentTime - mediaPausedAt.value.valueOf();
  92. }
  93. return dayjs.duration(
  94. currentTime - mediaStartedAt.value.valueOf() - timePaused
  95. );
  96. };
  97. const updateTimeElapsed = () => {
  98. clearTimeout(updateTimeElapsedTimeout.value);
  99. if (!props.source) {
  100. timeElapsed.value = dayjs.duration(0);
  101. updateTimeElapsedTimeout.value = setTimeout(updateTimeElapsed, 150);
  102. return;
  103. }
  104. const elapsed = getTimeElapsed();
  105. if (elapsed.asSeconds() > props.source.duration) {
  106. timeElapsed.value = dayjs.duration(props.source.duration, "s");
  107. } else {
  108. timeElapsed.value = elapsed;
  109. }
  110. updateTimeElapsedTimeout.value = setTimeout(updateTimeElapsed, 150);
  111. };
  112. const cueMedia = () => {
  113. console.log("CUEMEDIA");
  114. youtubePlayer.value.cue(
  115. sourceId.value,
  116. timeElapsed.value.asSeconds() + props.source.skipDuration
  117. );
  118. };
  119. const loadMedia = () => {
  120. console.log("LOADMEDIA");
  121. youtubePlayer.value.load(
  122. sourceId.value,
  123. timeElapsed.value.asSeconds() + props.source.skipDuration
  124. );
  125. };
  126. const resumeMedia = () => {
  127. console.log("RESUMEMEDIA");
  128. youtubePlayer.value.play();
  129. };
  130. const pauseMedia = () => {
  131. console.log("PAUSEMEDIA");
  132. youtubePlayer.value.pause();
  133. };
  134. const stopMedia = () => {
  135. youtubePlayer.value.stop();
  136. };
  137. const getMediaCurrentTime = () => youtubePlayer.value.getCurrentTime();
  138. const seekPlayer = () => {
  139. console.log(
  140. "SEEK",
  141. timeElapsed.value.asSeconds(),
  142. props.source.skipDuration
  143. );
  144. youtubePlayer.value.seekTo(
  145. timeElapsed.value.asSeconds() + props.source.skipDuration
  146. );
  147. };
  148. const getPlayerPlaybackRate = () => youtubePlayer.value.getPlaybackRate();
  149. const setPlayerPlaybackRate = (playbackRate: number) => {
  150. if (getPlayerPlaybackRate() === playbackRate) return;
  151. console.log("PLAYBACKRATE", playbackRate);
  152. youtubePlayer.value.setPlaybackRate(playbackRate);
  153. };
  154. const applySourceState = () => {
  155. if (!isYoutubeReady.value || isYoutubeLoading.value) return;
  156. console.log("APPLYSOURCESTATE", isMediaPaused.value);
  157. if (isMediaPaused.value) {
  158. pauseMedia();
  159. return;
  160. }
  161. seekPlayer();
  162. resumeMedia();
  163. };
  164. const applySource = () => {
  165. if (!isYoutubeReady.value) return;
  166. console.log("APPLYSOURCE", isMediaPaused.value);
  167. playerStartedAt.value = dayjs();
  168. updateTimeElapsed();
  169. isYoutubeLoading.value = true;
  170. if (isMediaPaused.value) cueMedia();
  171. else loadMedia();
  172. };
  173. const resumePlayer = () => {
  174. playerTimePaused.value = playerTimePaused.value.add(
  175. playerPausedAt.value?.diff() ?? 0
  176. );
  177. playerPausedAt.value = null;
  178. isAutomaticallyPaused.value = false;
  179. applySourceState();
  180. };
  181. const pausePlayer = () => {
  182. playerPausedAt.value = dayjs();
  183. applySourceState();
  184. };
  185. const stopPlayer = () => {
  186. playerStartedAt.value = dayjs();
  187. playerPausedAt.value = dayjs();
  188. playerTimePaused.value = dayjs.duration(0);
  189. stopMedia();
  190. updateTimeElapsed();
  191. };
  192. const onYoutubeReady = () => {
  193. isYoutubeReady.value = true;
  194. applySource();
  195. };
  196. const onYoutubeError = (event: YT.OnErrorEvent) => {
  197. isAutomaticallyPaused.value = true;
  198. switch (event.data) {
  199. case 100:
  200. emit("notFound");
  201. break;
  202. case 101:
  203. case 150:
  204. emit("notAllowed");
  205. break;
  206. default:
  207. emit("error", "There has been an error with the YouTube Embed.");
  208. break;
  209. }
  210. };
  211. const onYoutubeStateChange = (event: YT.OnStateChangeEvent) => {
  212. console.log("STATECHANGE", event.data, isYoutubeLoading.value);
  213. if (isYoutubeLoading.value) {
  214. const loadedStates = [
  215. YT.PlayerState.ENDED,
  216. YT.PlayerState.PLAYING,
  217. YT.PlayerState.PAUSED,
  218. YT.PlayerState.CUED
  219. ];
  220. if (!loadedStates.includes(event.data)) return;
  221. console.log("LOADED");
  222. isYoutubeLoading.value = false;
  223. applySourceState();
  224. return;
  225. }
  226. if (
  227. event.data !== YT.PlayerState.PAUSED &&
  228. event.data !== YT.PlayerState.PLAYING
  229. ) {
  230. return;
  231. }
  232. if (isSourcePaused.value) {
  233. seekPlayer();
  234. pauseMedia();
  235. return;
  236. }
  237. if (event.data === YT.PlayerState.PAUSED) {
  238. pausePlayer();
  239. return;
  240. }
  241. if (!playerPausedAt.value && !isAutomaticallyPaused.value) {
  242. return;
  243. }
  244. resumePlayer();
  245. };
  246. const syncPlayerTimeWithElapsed = () => {
  247. clearTimeout(syncPlayerTimeWithElapsedTimeout.value);
  248. if (
  249. !props.syncPlayerTimeEnabled ||
  250. !isYoutubeReady.value ||
  251. isYoutubeLoading.value ||
  252. isMediaPaused.value
  253. ) {
  254. syncPlayerTimeWithElapsedTimeout.value = setTimeout(
  255. syncPlayerTimeWithElapsed,
  256. 150
  257. );
  258. return;
  259. }
  260. const difference = timeElapsed.value
  261. .subtract(
  262. Math.max(getMediaCurrentTime() - props.source.skipDuration, 0),
  263. "s"
  264. )
  265. .asMilliseconds();
  266. // console.log("DIFFERENCE", difference);
  267. if (difference < -2000 || difference > 2000) {
  268. seekPlayer();
  269. } else if (difference < -200) {
  270. setPlayerPlaybackRate(0.8);
  271. } else if (difference < -50) {
  272. setPlayerPlaybackRate(0.9);
  273. } else if (difference < -25) {
  274. setPlayerPlaybackRate(0.95);
  275. } else if (difference > 200) {
  276. setPlayerPlaybackRate(1.2);
  277. } else if (difference > 50) {
  278. setPlayerPlaybackRate(1.1);
  279. } else if (difference > 25) {
  280. setPlayerPlaybackRate(1.05);
  281. } else {
  282. setPlayerPlaybackRate(1.0);
  283. }
  284. syncPlayerTimeWithElapsedTimeout.value = setTimeout(
  285. syncPlayerTimeWithElapsed,
  286. 150
  287. );
  288. };
  289. watch(() => props.source, applySource);
  290. watch(() => [props.sourceStartedAt, props.sourcePausedAt], applySourceState);
  291. defineExpose({
  292. isMediaPaused,
  293. playerState,
  294. resumePlayer,
  295. pausePlayer,
  296. stopPlayer
  297. });
  298. onMounted(() => {
  299. updateTimeElapsed();
  300. syncPlayerTimeWithElapsed();
  301. });
  302. onBeforeUnmount(() => {
  303. clearTimeout(updateTimeElapsedTimeout.value);
  304. clearTimeout(syncPlayerTimeWithElapsedTimeout.value);
  305. });
  306. </script>
  307. <template>
  308. <div class="media-player">
  309. <div class="media-player__player">
  310. <YoutubePlayer
  311. v-show="sourceType === 'youtube'"
  312. ref="youtubePlayer"
  313. :video-id="sourceId"
  314. @ready="onYoutubeReady"
  315. @error="onYoutubeError"
  316. @state-change="onYoutubeStateChange"
  317. />
  318. <iframe
  319. v-if="experimental.soundcloud"
  320. v-show="sourceType === 'soundcloud'"
  321. ref="soundcloudPlayer"
  322. style="width: 100%; height: 100%; min-height: 200px"
  323. scrolling="no"
  324. frameborder="no"
  325. allow="autoplay"
  326. ></iframe>
  327. <div v-if="isSourcePaused" class="media-player__overlay">
  328. <slot name="sourcePausedReason" />
  329. </div>
  330. <div
  331. v-else-if="playerPausedAt"
  332. class="media-player__overlay"
  333. @click.prevent="resumePlayer"
  334. >
  335. <p><strong>Playback paused</strong></p>
  336. <p>Click here to continue playback.</p>
  337. </div>
  338. <div
  339. v-else-if="isAutomaticallyPaused"
  340. class="media-player__overlay"
  341. >
  342. <img
  343. class="media-player__bouncer"
  344. src="/assets/notes-transparent.png"
  345. />
  346. <p><strong>Unable to play</strong></p>
  347. <p>
  348. This media is unavailable for you, please try another
  349. source.
  350. </p>
  351. </div>
  352. </div>
  353. <div class="media-player__controls">
  354. <button
  355. v-if="playerPausedAt || isAutomaticallyPaused"
  356. @click.prevent="resumePlayer"
  357. >
  358. Resume
  359. </button>
  360. <button v-else @click.prevent="pausePlayer">Pause</button>
  361. </div>
  362. <div class="media-player__controls">
  363. {{ timeElapsed.formatDuration() }} /
  364. {{ dayjs.duration(source.duration, "s").formatDuration() }}
  365. <progress :value="timeElapsed.asSeconds()" :max="source.duration" />
  366. </div>
  367. </div>
  368. </template>
  369. <style lang="less" scoped>
  370. .media-player {
  371. display: flex;
  372. flex-direction: column;
  373. flex-grow: 1;
  374. &__player {
  375. position: relative;
  376. display: flex;
  377. flex-direction: column;
  378. aspect-ratio: 16/9;
  379. overflow: hidden;
  380. border: solid 1px var(--light-grey-1);
  381. border-radius: 5px;
  382. }
  383. &__overlay {
  384. position: absolute;
  385. top: 0;
  386. left: 0;
  387. right: 0;
  388. bottom: 0;
  389. width: 100%;
  390. height: 100%;
  391. background: var(--primary-color);
  392. display: flex;
  393. flex-direction: column;
  394. align-items: center;
  395. justify-content: center;
  396. padding: 10px;
  397. gap: 5px;
  398. :deep(p) {
  399. color: var(--white);
  400. text-align: center;
  401. }
  402. }
  403. &__controls {
  404. display: flex;
  405. flex-grow: 1;
  406. gap: 5px;
  407. align-items: center;
  408. progress {
  409. flex-grow: 1;
  410. }
  411. }
  412. }
  413. </style>