index.vue 17 KB

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