index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  1. <script setup lang="ts">
  2. import {
  3. defineAsyncComponent,
  4. ref,
  5. computed,
  6. onMounted,
  7. onBeforeUnmount
  8. } from "vue";
  9. import Toast from "toasters";
  10. import { storeToRefs } from "pinia";
  11. import { useWebsocketsStore } from "@/stores/websockets";
  12. import { useEditPlaylistStore } from "@/stores/editPlaylist";
  13. import { useStationStore } from "@/stores/station";
  14. import { useUserAuthStore } from "@/stores/userAuth";
  15. import { useModalsStore } from "@/stores/modals";
  16. import ws from "@/ws";
  17. import utils from "@/utils";
  18. const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
  19. const SongItem = defineAsyncComponent(
  20. () => import("@/components/SongItem.vue")
  21. );
  22. const Settings = defineAsyncComponent(() => import("./Tabs/Settings.vue"));
  23. const AddSongs = defineAsyncComponent(() => import("./Tabs/AddSongs.vue"));
  24. const ImportPlaylists = defineAsyncComponent(
  25. () => import("./Tabs/ImportPlaylists.vue")
  26. );
  27. const QuickConfirm = defineAsyncComponent(
  28. () => import("@/components/QuickConfirm.vue")
  29. );
  30. const Draggable = defineAsyncComponent(
  31. () => import("@/components/Draggable.vue")
  32. );
  33. const props = defineProps({
  34. modalUuid: { type: String, default: "" }
  35. });
  36. const { socket } = useWebsocketsStore();
  37. const editPlaylistStore = useEditPlaylistStore(props);
  38. const stationStore = useStationStore();
  39. const userAuthStore = useUserAuthStore();
  40. const { station } = storeToRefs(stationStore);
  41. const { loggedIn, userId, role: userRole } = storeToRefs(userAuthStore);
  42. const drag = ref(false);
  43. const apiDomain = ref("");
  44. const gettingSongs = ref(false);
  45. const tabs = ref([]);
  46. const songItems = ref([]);
  47. const playlistSongs = computed({
  48. get: () => editPlaylistStore.playlist.songs,
  49. set: value => {
  50. editPlaylistStore.updatePlaylistSongs(value);
  51. }
  52. });
  53. const { playlistId, tab, playlist } = storeToRefs(editPlaylistStore);
  54. const { setPlaylist, clearPlaylist, addSong, removeSong, repositionedSong } =
  55. editPlaylistStore;
  56. const { closeCurrentModal } = useModalsStore();
  57. const showTab = payload => {
  58. tabs.value[`${payload}-tab`].scrollIntoView({ block: "nearest" });
  59. editPlaylistStore.showTab(payload);
  60. };
  61. const isEditable = () =>
  62. (playlist.value.type === "user" ||
  63. playlist.value.type === "user-liked" ||
  64. playlist.value.type === "user-disliked") &&
  65. (userId.value === playlist.value.createdBy || userRole.value === "admin");
  66. const init = () => {
  67. gettingSongs.value = true;
  68. socket.dispatch("playlists.getPlaylist", playlistId.value, res => {
  69. if (res.status === "success") {
  70. setPlaylist(res.data.playlist);
  71. } else new Toast(res.message);
  72. gettingSongs.value = false;
  73. });
  74. };
  75. const isAdmin = () => userRole.value === "admin";
  76. const isOwner = () =>
  77. loggedIn.value && userId.value === playlist.value.createdBy;
  78. const repositionSong = ({ moved }) => {
  79. const { oldIndex, newIndex } = moved;
  80. if (oldIndex === newIndex) return; // we only need to update when song is moved
  81. const song = playlistSongs.value[oldIndex];
  82. socket.dispatch(
  83. "playlists.repositionSong",
  84. playlist.value._id,
  85. {
  86. ...song,
  87. oldIndex,
  88. newIndex
  89. },
  90. res => {
  91. if (res.status !== "success")
  92. repositionedSong({
  93. ...song,
  94. newIndex: oldIndex,
  95. oldIndex: newIndex
  96. });
  97. }
  98. );
  99. };
  100. const moveSongToTop = index => {
  101. songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
  102. playlistSongs.value.splice(0, 0, playlistSongs.value.splice(index, 1)[0]);
  103. repositionSong({
  104. moved: {
  105. oldIndex: index,
  106. newIndex: 0
  107. }
  108. });
  109. };
  110. const moveSongToBottom = index => {
  111. songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
  112. playlistSongs.value.splice(
  113. playlistSongs.value.length - 1,
  114. 0,
  115. playlistSongs.value.splice(index, 1)[0]
  116. );
  117. repositionSong({
  118. moved: {
  119. oldIndex: index,
  120. newIndex: playlistSongs.value.length - 1
  121. }
  122. });
  123. };
  124. const totalLength = () => {
  125. let length = 0;
  126. playlist.value.songs.forEach(song => {
  127. length += song.duration;
  128. });
  129. return utils.formatTimeLong(length);
  130. };
  131. // const shuffle = () => {
  132. // socket.dispatch("playlists.shuffle", playlist.value._id, res => {
  133. // new Toast(res.message);
  134. // if (res.status === "success") {
  135. // updatePlaylistSongs(
  136. // res.data.playlist.songs.sort((a, b) => a.position - b.position)
  137. // );
  138. // }
  139. // });
  140. // };
  141. const removeSongFromPlaylist = id =>
  142. socket.dispatch(
  143. "playlists.removeSongFromPlaylist",
  144. id,
  145. playlist.value._id,
  146. res => {
  147. new Toast(res.message);
  148. }
  149. );
  150. const removePlaylist = () => {
  151. if (isOwner()) {
  152. socket.dispatch("playlists.remove", playlist.value._id, res => {
  153. new Toast(res.message);
  154. if (res.status === "success") closeCurrentModal();
  155. });
  156. } else if (isAdmin()) {
  157. socket.dispatch("playlists.removeAdmin", playlist.value._id, res => {
  158. new Toast(res.message);
  159. if (res.status === "success") closeCurrentModal();
  160. });
  161. }
  162. };
  163. const downloadPlaylist = async () => {
  164. if (apiDomain.value === "")
  165. apiDomain.value = await lofig.get("backend.apiDomain");
  166. fetch(`${apiDomain.value}/export/playlist/${playlist.value._id}`, {
  167. credentials: "include"
  168. })
  169. .then(res => res.blob())
  170. .then(blob => {
  171. const url = window.URL.createObjectURL(blob);
  172. const a = document.createElement("a");
  173. a.style.display = "none";
  174. a.href = url;
  175. a.download = `musare-playlist-${
  176. playlist.value._id
  177. }-${new Date().toISOString()}.json`;
  178. document.body.appendChild(a);
  179. a.click();
  180. window.URL.revokeObjectURL(url);
  181. new Toast("Successfully downloaded playlist.");
  182. })
  183. .catch(() => new Toast("Failed to export and download playlist."));
  184. };
  185. const addSongToQueue = youtubeId => {
  186. socket.dispatch(
  187. "stations.addToQueue",
  188. station.value._id,
  189. youtubeId,
  190. data => {
  191. if (data.status !== "success")
  192. new Toast({
  193. content: `Error: ${data.message}`,
  194. timeout: 8000
  195. });
  196. else new Toast({ content: data.message, timeout: 4000 });
  197. }
  198. );
  199. };
  200. const clearAndRefillStationPlaylist = () => {
  201. socket.dispatch(
  202. "playlists.clearAndRefillStationPlaylist",
  203. playlist.value._id,
  204. data => {
  205. if (data.status !== "success")
  206. new Toast({
  207. content: `Error: ${data.message}`,
  208. timeout: 8000
  209. });
  210. else new Toast({ content: data.message, timeout: 4000 });
  211. }
  212. );
  213. };
  214. const clearAndRefillGenrePlaylist = () => {
  215. socket.dispatch(
  216. "playlists.clearAndRefillGenrePlaylist",
  217. playlist.value._id,
  218. data => {
  219. if (data.status !== "success")
  220. new Toast({
  221. content: `Error: ${data.message}`,
  222. timeout: 8000
  223. });
  224. else new Toast({ content: data.message, timeout: 4000 });
  225. }
  226. );
  227. };
  228. onMounted(() => {
  229. ws.onConnect(init);
  230. socket.on(
  231. "event:playlist.song.added",
  232. res => {
  233. if (playlist.value._id === res.data.playlistId)
  234. addSong(res.data.song);
  235. },
  236. { modalUuid: props.modalUuid }
  237. );
  238. socket.on(
  239. "event:playlist.song.removed",
  240. res => {
  241. if (playlist.value._id === res.data.playlistId) {
  242. // remove song from array of playlists
  243. removeSong(res.data.youtubeId);
  244. }
  245. },
  246. { modalUuid: props.modalUuid }
  247. );
  248. socket.on(
  249. "event:playlist.displayName.updated",
  250. res => {
  251. if (playlist.value._id === res.data.playlistId) {
  252. setPlaylist({
  253. displayName: res.data.displayName,
  254. ...playlist.value
  255. });
  256. }
  257. },
  258. { modalUuid: props.modalUuid }
  259. );
  260. socket.on(
  261. "event:playlist.song.repositioned",
  262. res => {
  263. if (playlist.value._id === res.data.playlistId) {
  264. const { song, playlistId } = res.data;
  265. if (playlist.value._id === playlistId) {
  266. repositionedSong(song);
  267. }
  268. }
  269. },
  270. { modalUuid: props.modalUuid }
  271. );
  272. });
  273. onBeforeUnmount(() => {
  274. clearPlaylist();
  275. // Delete the Pinia store that was created for this modal, after all other cleanup tasks are performed
  276. editPlaylistStore.$dispose();
  277. });
  278. </script>
  279. <template>
  280. <modal
  281. :title="
  282. userId === playlist.createdBy ? 'Edit Playlist' : 'View Playlist'
  283. "
  284. :class="{
  285. 'edit-playlist-modal': true,
  286. 'view-only': !isEditable()
  287. }"
  288. :size="isEditable() ? 'wide' : null"
  289. :split="true"
  290. >
  291. <template #body>
  292. <div class="left-section">
  293. <div id="playlist-info-section" class="section">
  294. <h3>{{ playlist.displayName }}</h3>
  295. <h5>Song Count: {{ playlist.songs.length }}</h5>
  296. <h5>Duration: {{ totalLength() }}</h5>
  297. </div>
  298. <div class="tabs-container">
  299. <div class="tab-selection">
  300. <button
  301. class="button is-default"
  302. :class="{ selected: tab === 'settings' }"
  303. :ref="el => (tabs['settings-tab'] = el)"
  304. @click="showTab('settings')"
  305. v-if="
  306. userId === playlist.createdBy ||
  307. isEditable() ||
  308. (playlist.type === 'genre' && isAdmin())
  309. "
  310. >
  311. Settings
  312. </button>
  313. <button
  314. class="button is-default"
  315. :class="{ selected: tab === 'add-songs' }"
  316. :ref="el => (tabs['add-songs-tab'] = el)"
  317. @click="showTab('add-songs')"
  318. v-if="isEditable()"
  319. >
  320. Add Songs
  321. </button>
  322. <button
  323. class="button is-default"
  324. :class="{
  325. selected: tab === 'import-playlists'
  326. }"
  327. :ref="el => (tabs['import-playlists-tab'] = el)"
  328. @click="showTab('import-playlists')"
  329. v-if="isEditable()"
  330. >
  331. Import Playlists
  332. </button>
  333. </div>
  334. <settings
  335. class="tab"
  336. v-show="tab === 'settings'"
  337. v-if="
  338. userId === playlist.createdBy ||
  339. isEditable() ||
  340. (playlist.type === 'genre' && isAdmin())
  341. "
  342. :modal-uuid="modalUuid"
  343. />
  344. <add-songs
  345. class="tab"
  346. v-show="tab === 'add-songs'"
  347. v-if="isEditable()"
  348. :modal-uuid="modalUuid"
  349. />
  350. <import-playlists
  351. class="tab"
  352. v-show="tab === 'import-playlists'"
  353. v-if="isEditable()"
  354. :modal-uuid="modalUuid"
  355. />
  356. </div>
  357. </div>
  358. <div class="right-section">
  359. <div id="rearrange-songs-section" class="section">
  360. <div v-if="isEditable()">
  361. <h4 class="section-title">Rearrange Songs</h4>
  362. <p class="section-description">
  363. Drag and drop songs to change their order
  364. </p>
  365. <hr class="section-horizontal-rule" />
  366. </div>
  367. <aside class="menu">
  368. <draggable
  369. :component-data="{
  370. name: !drag ? 'draggable-list-transition' : null
  371. }"
  372. v-if="playlistSongs.length > 0"
  373. :name="`edit-playlist-${modalUuid}`"
  374. v-model:list="playlistSongs"
  375. item-key="_id"
  376. @start="drag = true"
  377. @end="drag = false"
  378. @update="repositionSong"
  379. :disabled="!isEditable()"
  380. >
  381. <template #item="{ element, index }">
  382. <div class="menu-list scrollable-list">
  383. <song-item
  384. :song="element"
  385. :ref="
  386. el =>
  387. (songItems[
  388. `song-item-${index}`
  389. ] = el)
  390. "
  391. >
  392. <template #tippyActions>
  393. <i
  394. class="material-icons add-to-queue-icon"
  395. v-if="
  396. station &&
  397. station.requests &&
  398. station.requests.enabled &&
  399. (station.requests.access ===
  400. 'user' ||
  401. (station.requests
  402. .access ===
  403. 'owner' &&
  404. (userRole ===
  405. 'admin' ||
  406. station.owner ===
  407. userId)))
  408. "
  409. @click="
  410. addSongToQueue(
  411. element.youtubeId
  412. )
  413. "
  414. content="Add Song to Queue"
  415. v-tippy
  416. >queue</i
  417. >
  418. <quick-confirm
  419. v-if="
  420. userId ===
  421. playlist.createdBy ||
  422. isEditable()
  423. "
  424. placement="left"
  425. @confirm="
  426. removeSongFromPlaylist(
  427. element.youtubeId
  428. )
  429. "
  430. >
  431. <i
  432. class="material-icons delete-icon"
  433. content="Remove Song from Playlist"
  434. v-tippy
  435. >delete_forever</i
  436. >
  437. </quick-confirm>
  438. <i
  439. class="material-icons"
  440. v-if="isEditable() && index > 0"
  441. @click="moveSongToTop(index)"
  442. content="Move to top of Playlist"
  443. v-tippy
  444. >vertical_align_top</i
  445. >
  446. <i
  447. v-if="
  448. isEditable() &&
  449. playlistSongs.length - 1 !==
  450. index
  451. "
  452. @click="moveSongToBottom(index)"
  453. class="material-icons"
  454. content="Move to bottom of Playlist"
  455. v-tippy
  456. >vertical_align_bottom</i
  457. >
  458. </template>
  459. </song-item>
  460. </div>
  461. </template>
  462. </draggable>
  463. <p v-else-if="gettingSongs" class="nothing-here-text">
  464. Loading songs...
  465. </p>
  466. <p v-else class="nothing-here-text">
  467. This playlist doesn't have any songs.
  468. </p>
  469. </aside>
  470. </div>
  471. </div>
  472. </template>
  473. <template #footer>
  474. <button
  475. class="button is-default"
  476. v-if="isOwner() || isAdmin() || playlist.privacy === 'public'"
  477. @click="downloadPlaylist()"
  478. >
  479. Download Playlist
  480. </button>
  481. <div class="right">
  482. <quick-confirm
  483. v-if="playlist.type === 'station'"
  484. @confirm="clearAndRefillStationPlaylist()"
  485. >
  486. <a class="button is-danger">
  487. Clear and refill station playlist
  488. </a>
  489. </quick-confirm>
  490. <quick-confirm
  491. v-if="playlist.type === 'genre'"
  492. @confirm="clearAndRefillGenrePlaylist()"
  493. >
  494. <a class="button is-danger">
  495. Clear and refill genre playlist
  496. </a>
  497. </quick-confirm>
  498. <quick-confirm
  499. v-if="
  500. isEditable() &&
  501. !(
  502. playlist.type === 'user-liked' ||
  503. playlist.type === 'user-disliked'
  504. )
  505. "
  506. @confirm="removePlaylist()"
  507. >
  508. <a class="button is-danger"> Remove Playlist </a>
  509. </quick-confirm>
  510. </div>
  511. </template>
  512. </modal>
  513. </template>
  514. <style lang="less" scoped>
  515. .night-mode {
  516. .label,
  517. p,
  518. strong {
  519. color: var(--light-grey-2);
  520. }
  521. .edit-playlist-modal.modal .modal-card-body {
  522. .left-section {
  523. #playlist-info-section {
  524. background-color: var(--dark-grey-3) !important;
  525. border: 0;
  526. }
  527. .tabs-container {
  528. background-color: transparent !important;
  529. .tab-selection .button {
  530. background: var(--dark-grey);
  531. color: var(--white);
  532. }
  533. .tab {
  534. background-color: var(--dark-grey-3) !important;
  535. border: 0 !important;
  536. }
  537. }
  538. }
  539. .right-section .section {
  540. border-radius: @border-radius;
  541. }
  542. }
  543. }
  544. .menu-list li {
  545. display: flex;
  546. justify-content: space-between;
  547. &:not(:last-of-type) {
  548. margin-bottom: 10px;
  549. }
  550. a {
  551. display: flex;
  552. }
  553. }
  554. .controls {
  555. display: flex;
  556. a {
  557. display: flex;
  558. align-items: center;
  559. }
  560. }
  561. .tabs-container {
  562. .tab-selection {
  563. display: flex;
  564. margin: 24px 10px 0 10px;
  565. max-width: 100%;
  566. .button {
  567. border-radius: @border-radius @border-radius 0 0;
  568. border: 0;
  569. text-transform: uppercase;
  570. font-size: 14px;
  571. color: var(--dark-grey-3);
  572. background-color: var(--light-grey-2);
  573. flex-grow: 1;
  574. height: 32px;
  575. &:not(:first-of-type) {
  576. margin-left: 5px;
  577. }
  578. }
  579. .selected {
  580. background-color: var(--primary-color) !important;
  581. color: var(--white) !important;
  582. font-weight: 600;
  583. }
  584. }
  585. .tab {
  586. border: 1px solid var(--light-grey-3);
  587. border-radius: 0 0 @border-radius @border-radius;
  588. }
  589. }
  590. .edit-playlist-modal {
  591. &.view-only {
  592. height: auto !important;
  593. .left-section {
  594. flex-basis: 100% !important;
  595. }
  596. .right-section {
  597. max-height: unset !important;
  598. }
  599. :deep(.section) {
  600. max-width: 100% !important;
  601. }
  602. }
  603. .nothing-here-text {
  604. display: flex;
  605. align-items: center;
  606. justify-content: center;
  607. }
  608. .label {
  609. font-size: 1rem;
  610. font-weight: normal;
  611. }
  612. .input-with-button .button {
  613. width: 150px;
  614. }
  615. .left-section {
  616. #playlist-info-section {
  617. border: 1px solid var(--light-grey-3);
  618. border-radius: @border-radius;
  619. padding: 15px !important;
  620. h3 {
  621. font-weight: 600;
  622. font-size: 30px;
  623. }
  624. h5 {
  625. font-size: 18px;
  626. }
  627. h3,
  628. h5 {
  629. margin: 0;
  630. }
  631. }
  632. }
  633. .right-section {
  634. #rearrange-songs-section {
  635. .scrollable-list:not(:last-of-type) {
  636. margin-bottom: 10px;
  637. }
  638. }
  639. }
  640. }
  641. </style>