PlaylistTabBase.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191
  1. <script setup lang="ts">
  2. import { defineAsyncComponent, ref, reactive, computed, onMounted } from "vue";
  3. import Toast from "toasters";
  4. import { storeToRefs } from "pinia";
  5. import { useWebsocketsStore } from "@/stores/websockets";
  6. import { useStationStore } from "@/stores/station";
  7. import { useUserPlaylistsStore } from "@/stores/userPlaylists";
  8. import { useModalsStore } from "@/stores/modals";
  9. import { useManageStationStore } from "@/stores/manageStation";
  10. import { useConfigStore } from "@/stores/config";
  11. import { useUserPreferencesStore } from "@/stores/userPreferences";
  12. import { useSortablePlaylists } from "@/composables/useSortablePlaylists";
  13. const PlaylistItem = defineAsyncComponent(
  14. () => import("@/components/PlaylistItem.vue")
  15. );
  16. const QuickConfirm = defineAsyncComponent(
  17. () => import("@/components/QuickConfirm.vue")
  18. );
  19. const props = defineProps({
  20. modalUuid: { type: String, default: null },
  21. type: {
  22. type: String,
  23. default: ""
  24. },
  25. sector: {
  26. type: String,
  27. default: "manageStation"
  28. }
  29. });
  30. const emit = defineEmits(["selected"]);
  31. const configStore = useConfigStore();
  32. const { socket } = useWebsocketsStore();
  33. const stationStore = useStationStore();
  34. const userPreferencesStore = useUserPreferencesStore();
  35. const tab = ref("current");
  36. const search = reactive({
  37. query: "",
  38. searchedQuery: "",
  39. page: 0,
  40. count: 0,
  41. resultsLeft: 0,
  42. pageSize: 0,
  43. results: []
  44. });
  45. const featuredPlaylists = ref([]);
  46. const tabs = ref({});
  47. const {
  48. DraggableList,
  49. drag,
  50. playlists,
  51. savePlaylistOrder,
  52. orderOfPlaylists,
  53. myUserId,
  54. calculatePlaylistOrder
  55. } = useSortablePlaylists();
  56. const { experimental } = storeToRefs(configStore);
  57. const { autoSkipDisliked } = storeToRefs(userPreferencesStore);
  58. const { autoRequest, autorequestExcludedMediaSources } =
  59. storeToRefs(stationStore);
  60. const manageStationStore = useManageStationStore({
  61. modalUuid: props.modalUuid
  62. });
  63. const { autofill } = storeToRefs(manageStationStore);
  64. const station = computed({
  65. get() {
  66. if (props.sector === "manageStation") return manageStationStore.station;
  67. return stationStore.station;
  68. },
  69. set(value) {
  70. if (props.sector === "manageStation")
  71. manageStationStore.updateStation(value);
  72. else stationStore.updateStation(value);
  73. }
  74. });
  75. const blacklist = computed({
  76. get() {
  77. if (props.sector === "manageStation")
  78. return manageStationStore.blacklist;
  79. return stationStore.blacklist;
  80. },
  81. set(value) {
  82. if (props.sector === "manageStation")
  83. manageStationStore.setBlacklist(value);
  84. else stationStore.setBlacklist(value);
  85. }
  86. });
  87. const resultsLeftCount = computed(() => search.count - search.results.length);
  88. const nextPageResultsCount = computed(() =>
  89. Math.min(search.pageSize, resultsLeftCount.value)
  90. );
  91. const totalUniqueAutorequestableMediaSources = computed<string[]>(() => {
  92. if (!autoRequest.value) return [];
  93. const uniqueMediaSources = new Set();
  94. autoRequest.value.forEach(playlist => {
  95. playlist.songs.forEach(song => {
  96. uniqueMediaSources.add(song.mediaSource);
  97. });
  98. });
  99. return Array.from(uniqueMediaSources);
  100. });
  101. const actuallyAutorequestingMediaSources = computed(() => {
  102. const excluded = autorequestExcludedMediaSources.value;
  103. const remaining = totalUniqueAutorequestableMediaSources.value.filter(
  104. mediaSource => {
  105. if (excluded.indexOf(mediaSource) !== -1) return false;
  106. if (mediaSource.startsWith("spotify:")) return false;
  107. if (
  108. !experimental.value.soundcloud &&
  109. mediaSource.startsWith("soundcloud:")
  110. )
  111. return false;
  112. return true;
  113. }
  114. );
  115. return remaining;
  116. });
  117. const hasPermission = permission =>
  118. props.sector === "manageStation"
  119. ? manageStationStore.hasPermission(permission)
  120. : stationStore.hasPermission(permission);
  121. const { openModal } = useModalsStore();
  122. const { setPlaylists } = useUserPlaylistsStore();
  123. const {
  124. addAutorequestPlaylists,
  125. addPlaylistToAutoRequest,
  126. removePlaylistFromAutoRequest,
  127. updateAutorequestLocalStorage
  128. } = stationStore;
  129. const showTab = _tab => {
  130. if (tabs.value[`${_tab}-tab`])
  131. tabs.value[`${_tab}-tab`].scrollIntoView({ block: "nearest" });
  132. tab.value = _tab;
  133. };
  134. const label = (tense = "future", typeOverwrite = null, capitalize = false) => {
  135. let label = typeOverwrite || props.type;
  136. if (tense === "past") label = `${label}ed`;
  137. if (tense === "present") label = `${label}ing`;
  138. if (capitalize) label = `${label.charAt(0).toUpperCase()}${label.slice(1)}`;
  139. return label;
  140. };
  141. const selectedPlaylists = (typeOverwrite?: string) => {
  142. const type = typeOverwrite || props.type;
  143. if (type === "autofill") return autofill.value;
  144. if (type === "blacklist") return blacklist.value;
  145. if (type === "autorequest") return autoRequest.value;
  146. return [];
  147. };
  148. const isSelected = (playlistId, typeOverwrite?: string) => {
  149. const type = typeOverwrite || props.type;
  150. let selected = false;
  151. selectedPlaylists(type).forEach(playlist => {
  152. if (playlist._id === playlistId) selected = true;
  153. });
  154. return selected;
  155. };
  156. const deselectPlaylist = (playlistId, typeOverwrite?: string) => {
  157. const type = typeOverwrite || props.type;
  158. if (type === "autofill")
  159. return new Promise(resolve => {
  160. socket.dispatch(
  161. "stations.removeAutofillPlaylist",
  162. station.value._id,
  163. playlistId,
  164. res => {
  165. new Toast(res.message);
  166. resolve(true);
  167. }
  168. );
  169. });
  170. if (type === "blacklist")
  171. return new Promise(resolve => {
  172. socket.dispatch(
  173. "stations.removeBlacklistedPlaylist",
  174. station.value._id,
  175. playlistId,
  176. res => {
  177. new Toast(res.message);
  178. resolve(true);
  179. }
  180. );
  181. });
  182. if (type === "autorequest")
  183. return new Promise(resolve => {
  184. removePlaylistFromAutoRequest(playlistId);
  185. new Toast("Successfully deselected playlist.");
  186. resolve(true);
  187. });
  188. return false;
  189. };
  190. const selectPlaylist = async (playlist, typeOverwrite?: string) => {
  191. const type = typeOverwrite || props.type;
  192. if (isSelected(playlist._id, type))
  193. return new Toast(`Error: Playlist already ${label("past", type)}.`);
  194. if (type === "autofill")
  195. return new Promise(resolve => {
  196. socket.dispatch(
  197. "stations.autofillPlaylist",
  198. station.value._id,
  199. playlist._id,
  200. res => {
  201. new Toast(res.message);
  202. emit("selected");
  203. resolve(true);
  204. }
  205. );
  206. });
  207. if (type === "blacklist") {
  208. if (props.type !== "blacklist" && isSelected(playlist._id))
  209. await deselectPlaylist(playlist._id);
  210. return new Promise(resolve => {
  211. socket.dispatch(
  212. "stations.blacklistPlaylist",
  213. station.value._id,
  214. playlist._id,
  215. res => {
  216. new Toast(res.message);
  217. emit("selected");
  218. resolve(true);
  219. }
  220. );
  221. });
  222. }
  223. if (type === "autorequest")
  224. return new Promise(resolve => {
  225. addPlaylistToAutoRequest(playlist);
  226. new Toast("Successfully selected playlist to auto request songs.");
  227. emit("selected");
  228. resolve(true);
  229. });
  230. return false;
  231. };
  232. const searchForPlaylists = page => {
  233. if (search.page >= page || search.searchedQuery !== search.query) {
  234. search.results = [];
  235. search.page = 0;
  236. search.count = 0;
  237. search.resultsLeft = 0;
  238. search.pageSize = 0;
  239. }
  240. const { query } = search;
  241. const action =
  242. station.value.type === "official" && props.type !== "autorequest"
  243. ? "playlists.searchOfficial"
  244. : "playlists.searchCommunity";
  245. search.searchedQuery = search.query;
  246. socket.dispatch(action, query, page, res => {
  247. const { data } = res;
  248. if (res.status === "success") {
  249. const { count, pageSize, playlists } = data;
  250. search.results = [...search.results, ...playlists];
  251. search.page = page;
  252. search.count = count;
  253. search.resultsLeft = count - search.results.length;
  254. search.pageSize = pageSize;
  255. } else if (res.status === "error") {
  256. search.results = [];
  257. search.page = 0;
  258. search.count = 0;
  259. search.resultsLeft = 0;
  260. search.pageSize = 0;
  261. new Toast(res.message);
  262. }
  263. });
  264. };
  265. onMounted(() => {
  266. if (props.type === "autorequest" || station.value.type === "community")
  267. showTab("my-playlists");
  268. else showTab("search");
  269. socket.onConnect(() => {
  270. socket.dispatch("playlists.indexMyPlaylists", res => {
  271. if (res.status === "success") setPlaylists(res.data.playlists);
  272. orderOfPlaylists.value = calculatePlaylistOrder(); // order in regards to the database
  273. });
  274. socket.dispatch(
  275. "playlists.indexFeaturedPlaylists",
  276. station.value.type === "community",
  277. res => {
  278. if (res.status === "success")
  279. featuredPlaylists.value = res.data.playlists;
  280. }
  281. );
  282. if (props.type === "autofill")
  283. socket.dispatch(
  284. `stations.getStationAutofillPlaylistsById`,
  285. station.value._id,
  286. res => {
  287. if (res.status === "success") {
  288. station.value.autofill.playlists = res.data.playlists;
  289. }
  290. }
  291. );
  292. socket.dispatch(
  293. `stations.getStationBlacklistById`,
  294. station.value._id,
  295. res => {
  296. if (res.status === "success") {
  297. station.value.blacklist = res.data.playlists;
  298. }
  299. }
  300. );
  301. const autorequestLocalStorageItem = localStorage.getItem(
  302. `autorequest-${station.value._id}`
  303. );
  304. if (
  305. autorequestLocalStorageItem &&
  306. station.value.requests &&
  307. station.value.requests.allowAutorequest &&
  308. autoRequest.value.length === 0
  309. ) {
  310. const autorequestParsedItem = JSON.parse(
  311. autorequestLocalStorageItem
  312. );
  313. const autorequestUpdatedAt = new Date(
  314. autorequestParsedItem.updatedAt
  315. );
  316. const fiveMinutesAgo = new Date(
  317. new Date().getTime() - 5 * 60 * 1000
  318. );
  319. if (autorequestUpdatedAt > fiveMinutesAgo) {
  320. const playlists = [];
  321. const promises = autorequestParsedItem.playlistIds.map(
  322. playlistId =>
  323. new Promise<void>(resolve => {
  324. socket.dispatch(
  325. "playlists.getPlaylist",
  326. playlistId,
  327. res => {
  328. if (res.status === "success") {
  329. playlists.push(res.data.playlist);
  330. }
  331. resolve();
  332. }
  333. );
  334. })
  335. );
  336. Promise.all(promises).then(() => {
  337. addAutorequestPlaylists(playlists);
  338. });
  339. } else updateAutorequestLocalStorage();
  340. }
  341. });
  342. });
  343. </script>
  344. <template>
  345. <div class="playlist-tab-base">
  346. <div v-if="$slots.info" class="top-info has-text-centered">
  347. <slot name="info" />
  348. </div>
  349. <div class="tabs-container">
  350. <div class="tab-selection">
  351. <button
  352. v-if="
  353. type === 'autorequest' || station.type === 'community'
  354. "
  355. class="button is-default"
  356. :ref="el => (tabs['my-playlists-tab'] = el)"
  357. :class="{ selected: tab === 'my-playlists' }"
  358. @click="showTab('my-playlists')"
  359. >
  360. My Playlists
  361. </button>
  362. <button
  363. class="button is-default"
  364. :ref="el => (tabs['search-tab'] = el)"
  365. :class="{ selected: tab === 'search' }"
  366. @click="showTab('search')"
  367. >
  368. Search
  369. </button>
  370. <button
  371. class="button is-default"
  372. :ref="el => (tabs['current-tab'] = el)"
  373. :class="{ selected: tab === 'current' }"
  374. @click="showTab('current')"
  375. >
  376. Current
  377. <span class="tag" v-if="selectedPlaylists().length > 0">{{
  378. selectedPlaylists().length
  379. }}</span>
  380. </button>
  381. </div>
  382. <div class="tab" v-show="tab === 'search'">
  383. <div v-if="featuredPlaylists.length > 0">
  384. <label class="label"> Featured playlists </label>
  385. <playlist-item
  386. v-for="featuredPlaylist in featuredPlaylists"
  387. :key="`featuredKey-${featuredPlaylist._id}`"
  388. :playlist="featuredPlaylist"
  389. :show-owner="true"
  390. >
  391. <template #item-icon>
  392. <i
  393. class="material-icons blacklisted-icon"
  394. v-if="
  395. isSelected(
  396. featuredPlaylist._id,
  397. 'blacklist'
  398. )
  399. "
  400. :content="`This playlist is currently ${label(
  401. 'past',
  402. 'blacklist'
  403. )}`"
  404. v-tippy
  405. >
  406. block
  407. </i>
  408. <i
  409. class="material-icons"
  410. v-else-if="isSelected(featuredPlaylist._id)"
  411. :content="`This playlist is currently ${label(
  412. 'past'
  413. )}`"
  414. v-tippy
  415. >
  416. play_arrow
  417. </i>
  418. <i
  419. class="material-icons"
  420. v-else
  421. :content="`This playlist is currently not ${label(
  422. 'past'
  423. )}`"
  424. v-tippy
  425. >
  426. {{
  427. type === "blacklist"
  428. ? "block"
  429. : "play_disabled"
  430. }}
  431. </i>
  432. </template>
  433. <template #actions>
  434. <i
  435. v-if="
  436. type !== 'blacklist' &&
  437. isSelected(
  438. featuredPlaylist._id,
  439. 'blacklist'
  440. )
  441. "
  442. class="material-icons stop-icon"
  443. :content="`This playlist is ${label(
  444. 'past',
  445. 'blacklist'
  446. )} in this station`"
  447. v-tippy="{ theme: 'info' }"
  448. >play_disabled</i
  449. >
  450. <quick-confirm
  451. v-if="
  452. type !== 'blacklist' &&
  453. isSelected(featuredPlaylist._id)
  454. "
  455. @confirm="
  456. deselectPlaylist(featuredPlaylist._id)
  457. "
  458. >
  459. <i
  460. class="material-icons stop-icon"
  461. :content="`Stop ${label(
  462. 'present'
  463. )} songs from this playlist`"
  464. v-tippy
  465. >
  466. stop
  467. </i>
  468. </quick-confirm>
  469. <i
  470. v-if="
  471. type !== 'blacklist' &&
  472. !isSelected(featuredPlaylist._id) &&
  473. !isSelected(
  474. featuredPlaylist._id,
  475. 'blacklist'
  476. )
  477. "
  478. @click="selectPlaylist(featuredPlaylist)"
  479. class="material-icons play-icon"
  480. :content="`${label(
  481. 'future',
  482. null,
  483. true
  484. )} songs from this playlist`"
  485. v-tippy
  486. >play_arrow</i
  487. >
  488. <quick-confirm
  489. v-if="
  490. type === 'blacklist' &&
  491. !isSelected(
  492. featuredPlaylist._id,
  493. 'blacklist'
  494. )
  495. "
  496. @confirm="
  497. selectPlaylist(
  498. featuredPlaylist,
  499. 'blacklist'
  500. )
  501. "
  502. >
  503. <i
  504. class="material-icons stop-icon"
  505. :content="`${label(
  506. 'future',
  507. null,
  508. true
  509. )} Playlist`"
  510. v-tippy
  511. >block</i
  512. >
  513. </quick-confirm>
  514. <quick-confirm
  515. v-if="
  516. type === 'blacklist' &&
  517. isSelected(
  518. featuredPlaylist._id,
  519. 'blacklist'
  520. )
  521. "
  522. @confirm="
  523. deselectPlaylist(featuredPlaylist._id)
  524. "
  525. >
  526. <i
  527. class="material-icons stop-icon"
  528. :content="`Stop ${label(
  529. 'present'
  530. )} songs from this playlist`"
  531. v-tippy
  532. >
  533. stop
  534. </i>
  535. </quick-confirm>
  536. <i
  537. v-if="featuredPlaylist.createdBy === myUserId"
  538. @click="
  539. openModal({
  540. modal: 'editPlaylist',
  541. props: {
  542. playlistId: featuredPlaylist._id
  543. }
  544. })
  545. "
  546. class="material-icons edit-icon"
  547. content="Edit Playlist"
  548. v-tippy
  549. >edit</i
  550. >
  551. <i
  552. v-if="
  553. featuredPlaylist.createdBy !== myUserId &&
  554. (featuredPlaylist.privacy === 'public' ||
  555. hasPermission('playlists.view.others'))
  556. "
  557. @click="
  558. openModal({
  559. modal: 'editPlaylist',
  560. props: {
  561. playlistId: featuredPlaylist._id
  562. }
  563. })
  564. "
  565. class="material-icons edit-icon"
  566. content="View Playlist"
  567. v-tippy
  568. >visibility</i
  569. >
  570. </template>
  571. </playlist-item>
  572. <br />
  573. </div>
  574. <label class="label">Search for a playlist</label>
  575. <div class="control is-grouped input-with-button">
  576. <p class="control is-expanded">
  577. <input
  578. class="input"
  579. type="text"
  580. placeholder="Enter your playlist query here..."
  581. v-model="search.query"
  582. @keyup.enter="searchForPlaylists(1)"
  583. />
  584. </p>
  585. <p class="control">
  586. <a class="button is-info" @click="searchForPlaylists(1)"
  587. ><i class="material-icons icon-with-button"
  588. >search</i
  589. >Search</a
  590. >
  591. </p>
  592. </div>
  593. <div v-if="search.results.length > 0">
  594. <playlist-item
  595. v-for="playlist in search.results"
  596. :key="`searchKey-${playlist._id}`"
  597. :playlist="playlist"
  598. :show-owner="true"
  599. >
  600. <template #item-icon>
  601. <i
  602. class="material-icons blacklisted-icon"
  603. v-if="isSelected(playlist._id, 'blacklist')"
  604. :content="`This playlist is currently ${label(
  605. 'past',
  606. 'blacklist'
  607. )}`"
  608. v-tippy
  609. >
  610. block
  611. </i>
  612. <i
  613. class="material-icons"
  614. v-else-if="isSelected(playlist._id)"
  615. :content="`This playlist is currently ${label(
  616. 'past'
  617. )}`"
  618. v-tippy
  619. >
  620. play_arrow
  621. </i>
  622. <i
  623. class="material-icons"
  624. v-else
  625. :content="`This playlist is currently not ${label(
  626. 'past'
  627. )}`"
  628. v-tippy
  629. >
  630. {{
  631. type === "blacklist"
  632. ? "block"
  633. : "play_disabled"
  634. }}
  635. </i>
  636. </template>
  637. <template #actions>
  638. <i
  639. v-if="
  640. type !== 'blacklist' &&
  641. isSelected(playlist._id, 'blacklist')
  642. "
  643. class="material-icons stop-icon"
  644. :content="`This playlist is ${label(
  645. 'past',
  646. 'blacklist'
  647. )} in this station`"
  648. v-tippy="{ theme: 'info' }"
  649. >play_disabled</i
  650. >
  651. <quick-confirm
  652. v-if="
  653. type !== 'blacklist' &&
  654. isSelected(playlist._id)
  655. "
  656. @confirm="deselectPlaylist(playlist._id)"
  657. >
  658. <i
  659. class="material-icons stop-icon"
  660. :content="`Stop ${label(
  661. 'present'
  662. )} songs from this playlist`"
  663. v-tippy
  664. >
  665. stop
  666. </i>
  667. </quick-confirm>
  668. <i
  669. v-if="
  670. type !== 'blacklist' &&
  671. !isSelected(playlist._id) &&
  672. !isSelected(playlist._id, 'blacklist')
  673. "
  674. @click="selectPlaylist(playlist)"
  675. class="material-icons play-icon"
  676. :content="`${label(
  677. 'future',
  678. null,
  679. true
  680. )} songs from this playlist`"
  681. v-tippy
  682. >play_arrow</i
  683. >
  684. <quick-confirm
  685. v-if="
  686. type === 'blacklist' &&
  687. !isSelected(playlist._id, 'blacklist')
  688. "
  689. @confirm="selectPlaylist(playlist, 'blacklist')"
  690. >
  691. <i
  692. class="material-icons stop-icon"
  693. :content="`${label(
  694. 'future',
  695. null,
  696. true
  697. )} Playlist`"
  698. v-tippy
  699. >block</i
  700. >
  701. </quick-confirm>
  702. <quick-confirm
  703. v-if="
  704. type === 'blacklist' &&
  705. isSelected(playlist._id, 'blacklist')
  706. "
  707. @confirm="deselectPlaylist(playlist._id)"
  708. >
  709. <i
  710. class="material-icons stop-icon"
  711. :content="`Stop ${label(
  712. 'present'
  713. )} songs from this playlist`"
  714. v-tippy
  715. >
  716. stop
  717. </i>
  718. </quick-confirm>
  719. <i
  720. v-if="playlist.createdBy === myUserId"
  721. @click="
  722. openModal({
  723. modal: 'editPlaylist',
  724. props: { playlistId: playlist._id }
  725. })
  726. "
  727. class="material-icons edit-icon"
  728. content="Edit Playlist"
  729. v-tippy
  730. >edit</i
  731. >
  732. <i
  733. v-if="
  734. playlist.createdBy !== myUserId &&
  735. (playlist.privacy === 'public' ||
  736. hasPermission('playlists.view.others'))
  737. "
  738. @click="
  739. openModal({
  740. modal: 'editPlaylist',
  741. props: { playlistId: playlist._id }
  742. })
  743. "
  744. class="material-icons edit-icon"
  745. content="View Playlist"
  746. v-tippy
  747. >visibility</i
  748. >
  749. </template>
  750. </playlist-item>
  751. <button
  752. v-if="resultsLeftCount > 0"
  753. class="button is-primary load-more-button"
  754. @click="searchForPlaylists(search.page + 1)"
  755. >
  756. Load {{ nextPageResultsCount }} more results
  757. </button>
  758. </div>
  759. </div>
  760. <div class="tab" v-show="tab === 'current'">
  761. <p
  762. class="has-text-centered scrollable-list"
  763. v-if="
  764. selectedPlaylists().length > 0 && type === 'autorequest'
  765. "
  766. >
  767. You are currently autorequesting a mix of
  768. {{ totalUniqueAutorequestableMediaSources.length }}
  769. different songs. Of these, we can currently autorequest
  770. {{ actuallyAutorequestingMediaSources.length }} songs.
  771. <br />
  772. Songs that
  773. <span
  774. v-if="
  775. station.requests
  776. .autorequestDisallowRecentlyPlayedEnabled
  777. "
  778. >were played recently or</span
  779. >
  780. are currently in the queue or playing will not be
  781. autorequested. Spotify
  782. <span v-if="!experimental.soundcloud">and SoundCloud</span>
  783. songs will also not be autorequested.
  784. <span v-if="autoSkipDisliked"
  785. >Disliked songs will also not be autorequested due to
  786. your preferences.</span
  787. >
  788. <br />
  789. <br />
  790. </p>
  791. <div v-if="selectedPlaylists().length > 0">
  792. <playlist-item
  793. v-for="playlist in selectedPlaylists()"
  794. :key="`key-${playlist._id}`"
  795. :playlist="playlist"
  796. :show-owner="true"
  797. >
  798. <template #item-icon>
  799. <i
  800. class="material-icons"
  801. :class="{
  802. 'blacklisted-icon': type === 'blacklist'
  803. }"
  804. :content="`This playlist is currently ${label(
  805. 'past'
  806. )}`"
  807. v-tippy
  808. >
  809. {{
  810. type === "blacklist"
  811. ? "block"
  812. : "play_arrow"
  813. }}
  814. </i>
  815. </template>
  816. <template #actions>
  817. <quick-confirm
  818. @confirm="deselectPlaylist(playlist._id)"
  819. >
  820. <i
  821. class="material-icons stop-icon"
  822. :content="`Stop ${label(
  823. 'present'
  824. )} songs from this playlist`"
  825. v-tippy
  826. >
  827. stop
  828. </i>
  829. </quick-confirm>
  830. <i
  831. v-if="playlist.createdBy === myUserId"
  832. @click="
  833. openModal({
  834. modal: 'editPlaylist',
  835. props: { playlistId: playlist._id }
  836. })
  837. "
  838. class="material-icons edit-icon"
  839. content="Edit Playlist"
  840. v-tippy
  841. >edit</i
  842. >
  843. <i
  844. v-if="
  845. playlist.createdBy !== myUserId &&
  846. (playlist.privacy === 'public' ||
  847. hasPermission('playlists.view.others'))
  848. "
  849. @click="
  850. openModal({
  851. modal: 'editPlaylist',
  852. props: { playlistId: playlist._id }
  853. })
  854. "
  855. class="material-icons edit-icon"
  856. content="View Playlist"
  857. v-tippy
  858. >visibility</i
  859. >
  860. </template>
  861. </playlist-item>
  862. </div>
  863. <p v-else class="has-text-centered scrollable-list">
  864. No playlists currently {{ label("present") }}.
  865. </p>
  866. </div>
  867. <div
  868. v-if="type === 'autorequest' || station.type === 'community'"
  869. class="tab"
  870. v-show="tab === 'my-playlists'"
  871. >
  872. <div
  873. class="menu-list scrollable-list"
  874. v-if="playlists.length > 0"
  875. >
  876. <draggable-list
  877. v-model:list="playlists"
  878. item-key="_id"
  879. @start="drag = true"
  880. @end="drag = false"
  881. @update="savePlaylistOrder"
  882. >
  883. <template #item="{ element }">
  884. <playlist-item :playlist="element">
  885. <template #item-icon>
  886. <i
  887. class="material-icons blacklisted-icon"
  888. v-if="
  889. isSelected(element._id, 'blacklist')
  890. "
  891. :content="`This playlist is currently ${label(
  892. 'past',
  893. 'blacklist'
  894. )}`"
  895. v-tippy
  896. >
  897. block
  898. </i>
  899. <i
  900. class="material-icons"
  901. v-else-if="isSelected(element._id)"
  902. :content="`This playlist is currently ${label(
  903. 'past'
  904. )}`"
  905. v-tippy
  906. >
  907. play_arrow
  908. </i>
  909. <i
  910. class="material-icons"
  911. v-else
  912. :content="`This playlist is currently not ${label(
  913. 'past'
  914. )}`"
  915. v-tippy
  916. >
  917. {{
  918. type === "blacklist"
  919. ? "block"
  920. : "play_disabled"
  921. }}
  922. </i>
  923. </template>
  924. <template #actions>
  925. <i
  926. v-if="
  927. type !== 'blacklist' &&
  928. isSelected(element._id, 'blacklist')
  929. "
  930. class="material-icons stop-icon"
  931. :content="`This playlist is ${label(
  932. 'past',
  933. 'blacklist'
  934. )} in this station`"
  935. v-tippy="{ theme: 'info' }"
  936. >play_disabled</i
  937. >
  938. <quick-confirm
  939. v-if="
  940. type !== 'blacklist' &&
  941. isSelected(element._id)
  942. "
  943. @confirm="deselectPlaylist(element._id)"
  944. >
  945. <i
  946. class="material-icons stop-icon"
  947. :content="`Stop ${label(
  948. 'present'
  949. )} songs from this playlist`"
  950. v-tippy
  951. >
  952. stop
  953. </i>
  954. </quick-confirm>
  955. <i
  956. v-if="
  957. type !== 'blacklist' &&
  958. !isSelected(element._id) &&
  959. !isSelected(
  960. element._id,
  961. 'blacklist'
  962. )
  963. "
  964. :class="{
  965. 'play-icon':
  966. type !== 'autorequest' ||
  967. !autoSkipDisliked ||
  968. element.type !== 'user-disliked'
  969. }"
  970. @click="
  971. type !== 'autorequest' ||
  972. !autoSkipDisliked ||
  973. element.type !== 'user-disliked'
  974. ? selectPlaylist(element)
  975. : null
  976. "
  977. class="material-icons"
  978. :content="
  979. type !== 'autorequest' ||
  980. !autoSkipDisliked ||
  981. element.type !== 'user-disliked'
  982. ? `${label(
  983. 'future',
  984. null,
  985. true
  986. )} songs from this playlist`
  987. : 'Your preferences are set to skip disliked songs'
  988. "
  989. v-tippy
  990. >play_arrow</i
  991. >
  992. <quick-confirm
  993. v-if="
  994. type === 'blacklist' &&
  995. !isSelected(
  996. element._id,
  997. 'blacklist'
  998. )
  999. "
  1000. @confirm="
  1001. selectPlaylist(element, 'blacklist')
  1002. "
  1003. >
  1004. <i
  1005. class="material-icons stop-icon"
  1006. :content="`${label(
  1007. 'future',
  1008. null,
  1009. true
  1010. )} Playlist`"
  1011. v-tippy
  1012. >block</i
  1013. >
  1014. </quick-confirm>
  1015. <quick-confirm
  1016. v-if="
  1017. type === 'blacklist' &&
  1018. isSelected(element._id, 'blacklist')
  1019. "
  1020. @confirm="deselectPlaylist(element._id)"
  1021. >
  1022. <i
  1023. class="material-icons stop-icon"
  1024. :content="`Stop ${label(
  1025. 'present'
  1026. )} songs from this playlist`"
  1027. v-tippy
  1028. >
  1029. stop
  1030. </i>
  1031. </quick-confirm>
  1032. <i
  1033. @click="
  1034. openModal({
  1035. modal: 'editPlaylist',
  1036. props: {
  1037. playlistId: element._id
  1038. }
  1039. })
  1040. "
  1041. class="material-icons edit-icon"
  1042. content="Edit Playlist"
  1043. v-tippy
  1044. >edit</i
  1045. >
  1046. </template>
  1047. </playlist-item>
  1048. </template>
  1049. </draggable-list>
  1050. </div>
  1051. <p v-else class="has-text-centered scrollable-list">
  1052. You don't have any playlists
  1053. </p>
  1054. <button
  1055. class="button is-primary"
  1056. id="create-new-playlist-button"
  1057. @click="openModal('createPlaylist')"
  1058. >
  1059. Create new playlist
  1060. </button>
  1061. </div>
  1062. </div>
  1063. </div>
  1064. </template>
  1065. <style lang="less" scoped>
  1066. .night-mode {
  1067. .tabs-container .tab-selection .button {
  1068. background: var(--dark-grey) !important;
  1069. color: var(--white) !important;
  1070. }
  1071. }
  1072. .blacklisted-icon {
  1073. color: var(--dark-red);
  1074. }
  1075. .playlist-tab-base {
  1076. .top-info {
  1077. font-size: 15px;
  1078. margin-bottom: 15px;
  1079. }
  1080. .tabs-container {
  1081. .tab-selection {
  1082. display: flex;
  1083. overflow-x: auto;
  1084. .button {
  1085. border-radius: 0;
  1086. border: 0;
  1087. text-transform: uppercase;
  1088. font-size: 14px;
  1089. color: var(--dark-grey-3);
  1090. background-color: var(--light-grey-2);
  1091. flex-grow: 1;
  1092. height: 32px;
  1093. &:not(:first-of-type) {
  1094. margin-left: 5px;
  1095. }
  1096. }
  1097. .selected {
  1098. background-color: var(--primary-color) !important;
  1099. color: var(--white) !important;
  1100. font-weight: 600;
  1101. }
  1102. }
  1103. .tab {
  1104. padding: 15px 0;
  1105. border-radius: 0;
  1106. .playlist-item:not(:last-of-type) {
  1107. margin-bottom: 10px;
  1108. }
  1109. .load-more-button {
  1110. width: 100%;
  1111. margin-top: 10px;
  1112. }
  1113. }
  1114. }
  1115. }
  1116. .draggable-list-transition-move {
  1117. transition: transform 0.5s;
  1118. }
  1119. .draggable-list-ghost {
  1120. opacity: 0.5;
  1121. filter: brightness(95%);
  1122. }
  1123. </style>