index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  1. <script lang="ts" setup>
  2. import {
  3. computed,
  4. defineAsyncComponent,
  5. onBeforeUnmount,
  6. onMounted,
  7. ref,
  8. watch
  9. } from "vue";
  10. import { useRoute, useRouter } from "vue-router";
  11. import Toast from "toasters";
  12. import { useWebsocketsStore } from "@/stores/websockets";
  13. import { useUserAuthStore } from "@/stores/userAuth";
  14. import { useConfigStore } from "@/stores/config";
  15. import { Station } from "@/types/station";
  16. import dayjs from "@/dayjs";
  17. const MainHeader = defineAsyncComponent(
  18. () => import("@/components/MainHeader.vue")
  19. );
  20. const MainFooter = defineAsyncComponent(
  21. () => import("@/components/MainFooter.vue")
  22. );
  23. const MediaItem = defineAsyncComponent(
  24. () => import("@/pages/NewStation/Components/MediaItem.vue")
  25. );
  26. const MediaPlayer = defineAsyncComponent(
  27. () => import("@/pages/NewStation/Components/MediaPlayer.vue")
  28. );
  29. const LeftSidebar = defineAsyncComponent(
  30. () => import("@/pages/NewStation/LeftSidebar.vue")
  31. );
  32. const RightSidebar = defineAsyncComponent(
  33. () => import("@/pages/NewStation/Components/Sidebar.vue")
  34. );
  35. const Queue = defineAsyncComponent(
  36. () => import("@/pages/NewStation/Queue.vue")
  37. );
  38. const Button = defineAsyncComponent(
  39. () => import("@/pages/NewStation/Components/Button.vue")
  40. );
  41. const Tabs = defineAsyncComponent(
  42. () => import("@/pages/NewStation/Components/Tabs.vue")
  43. );
  44. const Search = defineAsyncComponent(
  45. () => import("@/pages/NewStation/Search.vue")
  46. );
  47. const props = defineProps<{
  48. id: string;
  49. }>();
  50. const { primaryColor } = useConfigStore();
  51. const { userId, hasPermissionForStation, updatePermissionsForStation } =
  52. useUserAuthStore();
  53. const { socket } = useWebsocketsStore();
  54. const router = useRouter();
  55. const route = useRoute();
  56. const station = ref<Station>();
  57. const canVoteToSkip = ref(false);
  58. const votedToSkip = ref(false);
  59. const votesToSkip = ref(0);
  60. const reportStationStateInterval = ref();
  61. const mediaPlayer = ref<typeof MediaPlayer>();
  62. const systemTimeDifference = ref<{
  63. timeout?: number;
  64. current: number;
  65. last: number;
  66. consecutiveHighDifferenceCount: number;
  67. }>({
  68. timeout: null,
  69. current: 0,
  70. last: 0,
  71. consecutiveHighDifferenceCount: 0
  72. });
  73. const startedAt = computed(() => dayjs(station.value.startedAt));
  74. const pausedAt = computed(() => {
  75. if (station.value.paused) {
  76. return dayjs(station.value.pausedAt || station.value.startedAt);
  77. }
  78. return null;
  79. });
  80. const timePaused = computed(() => dayjs.duration(station.value.timePaused));
  81. const timeOffset = computed(() =>
  82. dayjs.duration(systemTimeDifference.value.current)
  83. );
  84. const stationState = computed(() => {
  85. if (!station.value?.currentSong) return "no_song";
  86. if (station.value?.paused) return "station_paused";
  87. if (mediaPlayer.value) return mediaPlayer.value.playerState;
  88. return "buffering";
  89. });
  90. const votesRequiredToSkip = computed(() => {
  91. if (!station.value || !station.value.users.loggedIn) return 0;
  92. return Math.max(
  93. 1,
  94. Math.round(
  95. station.value.users.loggedIn.length *
  96. (station.value.skipVoteThreshold / 100)
  97. )
  98. );
  99. });
  100. const refreshSkipVotes = () => {
  101. const { currentSong } = station.value;
  102. if (!currentSong) {
  103. canVoteToSkip.value = false;
  104. votedToSkip.value = false;
  105. votesToSkip.value = 0;
  106. return;
  107. }
  108. socket.dispatch(
  109. "stations.getSkipVotes",
  110. station.value._id,
  111. currentSong._id,
  112. res => {
  113. if (res.status === "success") {
  114. if (
  115. station.value.currentSong &&
  116. station.value.currentSong._id === currentSong._id
  117. ) {
  118. const { skipVotes, skipVotesCurrent, voted } = res.data;
  119. canVoteToSkip.value = skipVotesCurrent;
  120. votedToSkip.value = voted;
  121. votesToSkip.value = skipVotes;
  122. }
  123. }
  124. }
  125. );
  126. };
  127. const redirectAwayUnauthorizedUser = () => {
  128. if (
  129. !hasPermissionForStation(station.value._id, "stations.view") &&
  130. station.value.privacy === "private"
  131. )
  132. router.push({
  133. path: "/",
  134. query: {
  135. toast: "You no longer have access to the station you were in."
  136. }
  137. });
  138. };
  139. const updateStationState = () => {
  140. socket.dispatch("stations.setStationState", stationState.value, () => {});
  141. };
  142. const setDocumentPrimaryColor = (theme: string) => {
  143. document.getElementsByTagName("html")[0].style.cssText =
  144. `--primary-color: var(--${theme})`;
  145. };
  146. const resume = () => {
  147. socket.dispatch("stations.resume", station.value._id, data => {
  148. if (data.status !== "success") new Toast(`Error: ${data.message}`);
  149. else new Toast("Successfully resumed the station.");
  150. });
  151. };
  152. const pause = () => {
  153. socket.dispatch("stations.pause", station.value._id, data => {
  154. if (data.status !== "success") new Toast(`Error: ${data.message}`);
  155. else new Toast("Successfully paused the station.");
  156. });
  157. };
  158. const forceSkip = () => {
  159. socket.dispatch("stations.forceSkip", station.value._id, data => {
  160. if (data.status !== "success") new Toast(`Error: ${data.message}`);
  161. else new Toast("Successfully skipped the station's current song.");
  162. });
  163. };
  164. const toggleSkipVote = (toastMessage?: string) => {
  165. if (!station.value.currentSong?._id) return;
  166. socket.dispatch(
  167. "stations.toggleSkipVote",
  168. station.value._id,
  169. station.value.currentSong._id,
  170. data => {
  171. if (data.status !== "success") new Toast(`Error: ${data.message}`);
  172. else
  173. new Toast(
  174. toastMessage ??
  175. "Successfully toggled vote to skip the current song."
  176. );
  177. }
  178. );
  179. };
  180. const automaticallySkipVote = () => {
  181. if (mediaPlayer.value?.isMediaPaused || station.value.currentSong.voted) {
  182. return;
  183. }
  184. toggleSkipVote(
  185. "Automatically voted to skip as this song isn't available for you."
  186. );
  187. // TODO: Persistent toast
  188. };
  189. const calculateTimeDifference = () => {
  190. if (localStorage.getItem("stationNoSystemTimeDifference") === "true") {
  191. console.log(
  192. "Not calculating time different because 'stationNoSystemTimeDifference' is 'true' in localStorage"
  193. );
  194. return;
  195. }
  196. clearTimeout(systemTimeDifference.value.timeout);
  197. // Store the current time in ms before we send a ping to the backend
  198. const beforePing = Date.now();
  199. socket.dispatch("ping", serverDate => {
  200. // Store the current time in ms after we receive a pong from the backend
  201. const afterPing = Date.now();
  202. // Calculate the approximate latency between the client and the backend, by taking the time the request took and dividing it in 2
  203. // This is not perfect, as the request could take longer to get to the server than be sent back, or the other way around
  204. let connectionLatency = (afterPing - beforePing) / 2;
  205. // If we have a station latency in localStorage, use that. Can be used for debugging.
  206. if (localStorage.getItem("stationLatency")) {
  207. connectionLatency = parseInt(
  208. localStorage.getItem("stationLatency")
  209. );
  210. }
  211. // Calculates the approximate different in system time that the current client has, compared to the system time of the backend
  212. // Takes into account the approximate latency, so if it took approximately 500ms between the backend sending the pong, and the client receiving the pong,
  213. // the system time from the backend has to have 500ms added for it to be correct
  214. const difference = serverDate + connectionLatency - afterPing;
  215. if (Math.abs(difference) > 3000) {
  216. console.warn("System time difference is bigger than 3 seconds.");
  217. }
  218. // Gets how many ms. difference there is between the last time this function was called and now
  219. const differenceBetweenLastTime = Math.abs(
  220. systemTimeDifference.value.last - difference
  221. );
  222. const differenceBetweenCurrent = Math.abs(
  223. systemTimeDifference.value.current - difference
  224. );
  225. // By default, we want to re-run this function every 5 minutes
  226. let timeoutTime = 1000 * 300;
  227. if (differenceBetweenCurrent > 250) {
  228. // If the calculated difference is more than 250ms, there might be something wrong
  229. if (differenceBetweenLastTime > 250) {
  230. // If there's more than 250ms difference between the last calculated difference, reset the difference in a row count to 1
  231. systemTimeDifference.value.consecutiveHighDifferenceCount = 1;
  232. } else if (
  233. systemTimeDifference.value.consecutiveHighDifferenceCount < 3
  234. ) {
  235. systemTimeDifference.value.consecutiveHighDifferenceCount += 1;
  236. } else {
  237. // If we're on the third attempt in a row where the difference between last time is less than 250ms, accept it as the difference
  238. systemTimeDifference.value.consecutiveHighDifferenceCount = 0;
  239. systemTimeDifference.value.current = difference;
  240. }
  241. timeoutTime = 1000 * 10;
  242. } else {
  243. // Calculated difference is less than 250ms, so we just accept that it's correct
  244. systemTimeDifference.value.consecutiveHighDifferenceCount = 0;
  245. systemTimeDifference.value.current = difference;
  246. }
  247. if (systemTimeDifference.value.consecutiveHighDifferenceCount > 0) {
  248. console.warn(
  249. `System difference high difference in a row count: ${systemTimeDifference.value.consecutiveHighDifferenceCount}`
  250. );
  251. }
  252. systemTimeDifference.value.last = difference;
  253. systemTimeDifference.value.timeout = setTimeout(() => {
  254. calculateTimeDifference();
  255. }, timeoutTime);
  256. });
  257. };
  258. watch(
  259. () => station.value?.djs,
  260. (djs, oldDjs) => {
  261. if (!djs || !oldDjs) return;
  262. const wasDj = oldDjs.find(dj => dj._id === userId);
  263. const isDj = djs.find(dj => dj._id === userId);
  264. if (wasDj !== isDj)
  265. updatePermissionsForStation(station.value._id).then(
  266. redirectAwayUnauthorizedUser
  267. );
  268. }
  269. );
  270. watch(
  271. () => station.value?.name,
  272. async (name, oldName) => {
  273. if (!name || !oldName) return;
  274. await router.push(
  275. `${name}?${Object.keys(route.query)
  276. .map(
  277. key =>
  278. `${encodeURIComponent(key)}=${encodeURIComponent(
  279. JSON.stringify(route.query[key])
  280. )}`
  281. )
  282. .join("&")}`
  283. );
  284. // eslint-disable-next-line no-restricted-globals
  285. window.history.replaceState({ ...window.history.state, ...{} }, null);
  286. }
  287. );
  288. watch(
  289. () => station.value?.privacy,
  290. (privacy, oldPrivacy) => {
  291. if (!privacy || !oldPrivacy) return;
  292. if (privacy === "private") redirectAwayUnauthorizedUser();
  293. }
  294. );
  295. watch(
  296. () => station.value?.theme,
  297. theme => {
  298. if (!theme) return;
  299. setDocumentPrimaryColor(theme);
  300. }
  301. );
  302. watch(
  303. () => station.value?.currentSong?._id,
  304. () => {
  305. votedToSkip.value = false;
  306. votesToSkip.value = 0;
  307. }
  308. );
  309. onMounted(() => {
  310. socket.onConnect(() => {
  311. socket.dispatch("stations.join", props.id, ({ status, data }) => {
  312. if (status !== "success") {
  313. station.value = null;
  314. router.push("/404");
  315. return;
  316. }
  317. station.value = data;
  318. refreshSkipVotes();
  319. updatePermissionsForStation(station.value._id);
  320. updateStationState();
  321. reportStationStateInterval.value = setInterval(
  322. updateStationState,
  323. 5000
  324. );
  325. calculateTimeDifference();
  326. });
  327. });
  328. socket.on("event:station.updated", res => {
  329. redirectAwayUnauthorizedUser();
  330. station.value = {
  331. ...station.value,
  332. ...res.data.station
  333. };
  334. });
  335. socket.on("event:station.pause", res => {
  336. station.value.pausedAt = res.data.pausedAt;
  337. station.value.paused = true;
  338. });
  339. socket.on("event:station.resume", res => {
  340. station.value.timePaused = res.data.timePaused;
  341. station.value.paused = false;
  342. });
  343. socket.on("event:station.toggleSkipVote", res => {
  344. console.log("toggleSkipVote", res);
  345. if (res.data.currentSongId !== station.value.currentSong?._id) return;
  346. if (res.data.voted) votesToSkip.value += 1;
  347. else votesToSkip.value -= 1;
  348. if (res.data.userId === userId) votedToSkip.value = res.data.voted;
  349. });
  350. socket.on("event:station.nextSong", res => {
  351. console.log("nextSong", res);
  352. station.value.currentSong = res.data.currentSong;
  353. station.value.startedAt = res.data.startedAt;
  354. station.value.paused = res.data.paused;
  355. station.value.timePaused = res.data.timePaused;
  356. station.value.pausedAt = 0;
  357. });
  358. socket.on("event:station.users.updated", res => {
  359. station.value.users = res.data.users;
  360. });
  361. socket.on("event:station.userCount.updated", res => {
  362. station.value.userCount = res.data.userCount;
  363. });
  364. socket.on("event:station.djs.added", res => {
  365. station.value.djs.push(res.data.user);
  366. });
  367. socket.on("event:station.djs.removed", res => {
  368. station.value.djs.forEach((dj, index) => {
  369. if (dj._id === res.data.user._id) {
  370. station.value.djs.splice(index, 1);
  371. }
  372. });
  373. });
  374. socket.on("keep.event:user.role.updated", redirectAwayUnauthorizedUser);
  375. socket.on("event:user.station.favorited", res => {
  376. if (res.data.stationId === station.value._id)
  377. station.value.isFavorited = true;
  378. });
  379. socket.on("event:user.station.unfavorited", res => {
  380. if (res.data.stationId === station.value._id)
  381. station.value.isFavorited = false;
  382. });
  383. socket.on("event:station.deleted", () => {
  384. router.push({
  385. path: "/",
  386. query: {
  387. toast: "The station you were in was deleted."
  388. }
  389. });
  390. });
  391. });
  392. onBeforeUnmount(() => {
  393. document.getElementsByTagName("html")[0].style.cssText =
  394. `--primary-color: ${primaryColor}`;
  395. clearInterval(reportStationStateInterval.value);
  396. clearTimeout(systemTimeDifference.value.timeout);
  397. if (station.value) {
  398. socket.dispatch("stations.leave", station.value._id, () => {});
  399. }
  400. });
  401. </script>
  402. <template>
  403. <div v-if="station" class="app">
  404. <page-metadata :title="station.displayName" />
  405. <MainHeader />
  406. <div class="station-container">
  407. <LeftSidebar
  408. :station="station"
  409. :can-vote-to-skip="canVoteToSkip"
  410. :voted-to-skip="votedToSkip"
  411. :votes-to-skip="votesToSkip"
  412. :votes-required-to-skip="votesRequiredToSkip"
  413. />
  414. <section
  415. style="
  416. display: flex;
  417. flex-grow: 1;
  418. gap: 40px;
  419. padding: 40px;
  420. max-height: calc(100vh - 64px);
  421. overflow: auto;
  422. "
  423. >
  424. <section
  425. style="
  426. display: flex;
  427. flex-direction: column;
  428. flex: 0 0 450px;
  429. gap: 10px;
  430. max-width: 450px;
  431. padding: 20px;
  432. background-color: var(--white);
  433. border-radius: 5px;
  434. border: solid 1px var(--light-grey-1);
  435. "
  436. >
  437. <MediaPlayer
  438. v-if="station.currentSong"
  439. ref="mediaPlayer"
  440. :source="station.currentSong"
  441. :source-started-at="startedAt"
  442. :source-paused-at="pausedAt"
  443. :source-time-paused="timePaused"
  444. :source-time-offset="timeOffset"
  445. sync-player-time-enabled
  446. @not-allowed="automaticallySkipVote"
  447. @not-found="automaticallySkipVote"
  448. >
  449. <template #sourcePausedReason>
  450. <p>
  451. <strong
  452. >This station is currently paused.</strong
  453. >
  454. </p>
  455. <p
  456. v-if="
  457. hasPermissionForStation(
  458. station._id,
  459. 'stations.playback.toggle'
  460. )
  461. "
  462. >
  463. To continue playback click the resume station
  464. button below.
  465. </p>
  466. <p v-else>
  467. It can only be resumed by a station owner,
  468. station DJ or a site admin/moderator.
  469. </p>
  470. </template>
  471. </MediaPlayer>
  472. <h3
  473. style="
  474. margin: 0px;
  475. font-size: 16px;
  476. font-weight: 600 !important;
  477. line-height: 30px;
  478. display: inline-flex;
  479. align-items: center;
  480. gap: 5px;
  481. "
  482. >
  483. Currently Playing
  484. <span style="margin-right: auto"></span>
  485. <Button
  486. v-if="
  487. hasPermissionForStation(
  488. station._id,
  489. 'stations.playback.toggle'
  490. ) && !station.paused
  491. "
  492. icon="pause"
  493. square
  494. danger
  495. @click.prevent="pause"
  496. title="Pause station"
  497. />
  498. <Button
  499. v-if="
  500. hasPermissionForStation(
  501. station._id,
  502. 'stations.playback.toggle'
  503. ) && station.paused
  504. "
  505. icon="play_arrow"
  506. square
  507. danger
  508. @click.prevent="resume"
  509. title="Resume station"
  510. />
  511. <Button
  512. v-if="
  513. hasPermissionForStation(
  514. station._id,
  515. 'stations.skip'
  516. )
  517. "
  518. icon="skip_next"
  519. square
  520. danger
  521. @click.prevent="forceSkip"
  522. title="Force skip station"
  523. />
  524. <Button
  525. v-if="canVoteToSkip"
  526. icon="skip_next"
  527. :inverse="!votedToSkip"
  528. @click.prevent="toggleSkipVote"
  529. :title="
  530. votedToSkip
  531. ? 'Remove vote to skip'
  532. : 'Vote to skip'
  533. "
  534. >
  535. {{ votesToSkip }}
  536. <small>/ {{ votesRequiredToSkip }}</small>
  537. </Button>
  538. </h3>
  539. <MediaItem
  540. v-if="station.currentSong"
  541. :media="station.currentSong"
  542. show-requested
  543. />
  544. <h3
  545. style="
  546. margin: 0px;
  547. font-size: 16px;
  548. font-weight: 600 !important;
  549. line-height: 30px;
  550. "
  551. >
  552. Upcoming Queue
  553. </h3>
  554. <Queue :station="station" />
  555. </section>
  556. <Tabs
  557. :tabs="['Search', 'Explore', 'Settings']"
  558. style="flex-grow: 1"
  559. >
  560. <template #Search>
  561. <Search :station="station" />
  562. </template>
  563. </Tabs>
  564. </section>
  565. <RightSidebar />
  566. </div>
  567. <MainFooter />
  568. </div>
  569. </template>
  570. <style lang="less" scoped>
  571. /* inter-300 - latin */
  572. @font-face {
  573. font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  574. font-family: "Inter";
  575. font-style: normal;
  576. font-weight: 300;
  577. src: url("/fonts/inter-v18-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
  578. }
  579. /* inter-regular - latin */
  580. @font-face {
  581. font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  582. font-family: "Inter";
  583. font-style: normal;
  584. font-weight: 400;
  585. src: url("/fonts/inter-v18-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
  586. }
  587. /* inter-500 - latin */
  588. @font-face {
  589. font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  590. font-family: "Inter";
  591. font-style: normal;
  592. font-weight: 500;
  593. src: url("/fonts/inter-v18-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
  594. }
  595. /* inter-600 - latin */
  596. @font-face {
  597. font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
  598. font-family: "Inter";
  599. font-style: normal;
  600. font-weight: 600;
  601. src: url("/fonts/inter-v18-latin-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
  602. }
  603. .station-container {
  604. position: relative;
  605. display: flex;
  606. flex: 1 0 auto;
  607. min-height: calc(100vh - 64px);
  608. background-color: var(--light-grey-2);
  609. color: var(--black);
  610. }
  611. :deep(.station-container) {
  612. --dark-grey-1: #515151;
  613. --light-grey-1: #d4d4d4;
  614. --light-grey-2: #ececec;
  615. --red: rgb(249, 49, 0);
  616. &,
  617. h1,
  618. h2,
  619. h3,
  620. h4,
  621. h5,
  622. h6,
  623. p,
  624. button,
  625. input,
  626. select,
  627. textarea {
  628. font-family: "Inter";
  629. font-style: normal;
  630. font-weight: normal;
  631. }
  632. p,
  633. button,
  634. input,
  635. select,
  636. textarea {
  637. font-size: 16px;
  638. }
  639. *,
  640. *:before,
  641. *:after {
  642. box-sizing: border-box;
  643. }
  644. }
  645. </style>