PlaylistTabBase.vue 28 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187
  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("playlists.indexFeaturedPlaylists", res => {
  275. if (res.status === "success")
  276. featuredPlaylists.value = res.data.playlists;
  277. });
  278. if (props.type === "autofill")
  279. socket.dispatch(
  280. `stations.getStationAutofillPlaylistsById`,
  281. station.value._id,
  282. res => {
  283. if (res.status === "success") {
  284. station.value.autofill.playlists = res.data.playlists;
  285. }
  286. }
  287. );
  288. socket.dispatch(
  289. `stations.getStationBlacklistById`,
  290. station.value._id,
  291. res => {
  292. if (res.status === "success") {
  293. station.value.blacklist = res.data.playlists;
  294. }
  295. }
  296. );
  297. const autorequestLocalStorageItem = localStorage.getItem(
  298. `autorequest-${station.value._id}`
  299. );
  300. if (
  301. autorequestLocalStorageItem &&
  302. station.value.requests &&
  303. station.value.requests.allowAutorequest &&
  304. autoRequest.value.length === 0
  305. ) {
  306. const autorequestParsedItem = JSON.parse(
  307. autorequestLocalStorageItem
  308. );
  309. const autorequestUpdatedAt = new Date(
  310. autorequestParsedItem.updatedAt
  311. );
  312. const fiveMinutesAgo = new Date(
  313. new Date().getTime() - 5 * 60 * 1000
  314. );
  315. if (autorequestUpdatedAt > fiveMinutesAgo) {
  316. const playlists = [];
  317. const promises = autorequestParsedItem.playlistIds.map(
  318. playlistId =>
  319. new Promise<void>(resolve => {
  320. socket.dispatch(
  321. "playlists.getPlaylist",
  322. playlistId,
  323. res => {
  324. if (res.status === "success") {
  325. playlists.push(res.data.playlist);
  326. }
  327. resolve();
  328. }
  329. );
  330. })
  331. );
  332. Promise.all(promises).then(() => {
  333. addAutorequestPlaylists(playlists);
  334. });
  335. } else updateAutorequestLocalStorage();
  336. }
  337. });
  338. });
  339. </script>
  340. <template>
  341. <div class="playlist-tab-base">
  342. <div v-if="$slots.info" class="top-info has-text-centered">
  343. <slot name="info" />
  344. </div>
  345. <div class="tabs-container">
  346. <div class="tab-selection">
  347. <button
  348. v-if="
  349. type === 'autorequest' || station.type === 'community'
  350. "
  351. class="button is-default"
  352. :ref="el => (tabs['my-playlists-tab'] = el)"
  353. :class="{ selected: tab === 'my-playlists' }"
  354. @click="showTab('my-playlists')"
  355. >
  356. My Playlists
  357. </button>
  358. <button
  359. class="button is-default"
  360. :ref="el => (tabs['search-tab'] = el)"
  361. :class="{ selected: tab === 'search' }"
  362. @click="showTab('search')"
  363. >
  364. Search
  365. </button>
  366. <button
  367. class="button is-default"
  368. :ref="el => (tabs['current-tab'] = el)"
  369. :class="{ selected: tab === 'current' }"
  370. @click="showTab('current')"
  371. >
  372. Current
  373. <span class="tag" v-if="selectedPlaylists().length > 0">{{
  374. selectedPlaylists().length
  375. }}</span>
  376. </button>
  377. </div>
  378. <div class="tab" v-show="tab === 'search'">
  379. <div v-if="featuredPlaylists.length > 0">
  380. <label class="label"> Featured playlists </label>
  381. <playlist-item
  382. v-for="featuredPlaylist in featuredPlaylists"
  383. :key="`featuredKey-${featuredPlaylist._id}`"
  384. :playlist="featuredPlaylist"
  385. :show-owner="true"
  386. >
  387. <template #item-icon>
  388. <i
  389. class="material-icons blacklisted-icon"
  390. v-if="
  391. isSelected(
  392. featuredPlaylist._id,
  393. 'blacklist'
  394. )
  395. "
  396. :content="`This playlist is currently ${label(
  397. 'past',
  398. 'blacklist'
  399. )}`"
  400. v-tippy
  401. >
  402. block
  403. </i>
  404. <i
  405. class="material-icons"
  406. v-else-if="isSelected(featuredPlaylist._id)"
  407. :content="`This playlist is currently ${label(
  408. 'past'
  409. )}`"
  410. v-tippy
  411. >
  412. play_arrow
  413. </i>
  414. <i
  415. class="material-icons"
  416. v-else
  417. :content="`This playlist is currently not ${label(
  418. 'past'
  419. )}`"
  420. v-tippy
  421. >
  422. {{
  423. type === "blacklist"
  424. ? "block"
  425. : "play_disabled"
  426. }}
  427. </i>
  428. </template>
  429. <template #actions>
  430. <i
  431. v-if="
  432. type !== 'blacklist' &&
  433. isSelected(
  434. featuredPlaylist._id,
  435. 'blacklist'
  436. )
  437. "
  438. class="material-icons stop-icon"
  439. :content="`This playlist is ${label(
  440. 'past',
  441. 'blacklist'
  442. )} in this station`"
  443. v-tippy="{ theme: 'info' }"
  444. >play_disabled</i
  445. >
  446. <quick-confirm
  447. v-if="
  448. type !== 'blacklist' &&
  449. isSelected(featuredPlaylist._id)
  450. "
  451. @confirm="
  452. deselectPlaylist(featuredPlaylist._id)
  453. "
  454. >
  455. <i
  456. class="material-icons stop-icon"
  457. :content="`Stop ${label(
  458. 'present'
  459. )} songs from this playlist`"
  460. v-tippy
  461. >
  462. stop
  463. </i>
  464. </quick-confirm>
  465. <i
  466. v-if="
  467. type !== 'blacklist' &&
  468. !isSelected(featuredPlaylist._id) &&
  469. !isSelected(
  470. featuredPlaylist._id,
  471. 'blacklist'
  472. )
  473. "
  474. @click="selectPlaylist(featuredPlaylist)"
  475. class="material-icons play-icon"
  476. :content="`${label(
  477. 'future',
  478. null,
  479. true
  480. )} songs from this playlist`"
  481. v-tippy
  482. >play_arrow</i
  483. >
  484. <quick-confirm
  485. v-if="
  486. type === 'blacklist' &&
  487. !isSelected(
  488. featuredPlaylist._id,
  489. 'blacklist'
  490. )
  491. "
  492. @confirm="
  493. selectPlaylist(
  494. featuredPlaylist,
  495. 'blacklist'
  496. )
  497. "
  498. >
  499. <i
  500. class="material-icons stop-icon"
  501. :content="`${label(
  502. 'future',
  503. null,
  504. true
  505. )} Playlist`"
  506. v-tippy
  507. >block</i
  508. >
  509. </quick-confirm>
  510. <quick-confirm
  511. v-if="
  512. type === 'blacklist' &&
  513. isSelected(
  514. featuredPlaylist._id,
  515. 'blacklist'
  516. )
  517. "
  518. @confirm="
  519. deselectPlaylist(featuredPlaylist._id)
  520. "
  521. >
  522. <i
  523. class="material-icons stop-icon"
  524. :content="`Stop ${label(
  525. 'present'
  526. )} songs from this playlist`"
  527. v-tippy
  528. >
  529. stop
  530. </i>
  531. </quick-confirm>
  532. <i
  533. v-if="featuredPlaylist.createdBy === myUserId"
  534. @click="
  535. openModal({
  536. modal: 'editPlaylist',
  537. props: {
  538. playlistId: featuredPlaylist._id
  539. }
  540. })
  541. "
  542. class="material-icons edit-icon"
  543. content="Edit Playlist"
  544. v-tippy
  545. >edit</i
  546. >
  547. <i
  548. v-if="
  549. featuredPlaylist.createdBy !== myUserId &&
  550. (featuredPlaylist.privacy === 'public' ||
  551. hasPermission('playlists.view.others'))
  552. "
  553. @click="
  554. openModal({
  555. modal: 'editPlaylist',
  556. props: {
  557. playlistId: featuredPlaylist._id
  558. }
  559. })
  560. "
  561. class="material-icons edit-icon"
  562. content="View Playlist"
  563. v-tippy
  564. >visibility</i
  565. >
  566. </template>
  567. </playlist-item>
  568. <br />
  569. </div>
  570. <label class="label">Search for a playlist</label>
  571. <div class="control is-grouped input-with-button">
  572. <p class="control is-expanded">
  573. <input
  574. class="input"
  575. type="text"
  576. placeholder="Enter your playlist query here..."
  577. v-model="search.query"
  578. @keyup.enter="searchForPlaylists(1)"
  579. />
  580. </p>
  581. <p class="control">
  582. <a class="button is-info" @click="searchForPlaylists(1)"
  583. ><i class="material-icons icon-with-button"
  584. >search</i
  585. >Search</a
  586. >
  587. </p>
  588. </div>
  589. <div v-if="search.results.length > 0">
  590. <playlist-item
  591. v-for="playlist in search.results"
  592. :key="`searchKey-${playlist._id}`"
  593. :playlist="playlist"
  594. :show-owner="true"
  595. >
  596. <template #item-icon>
  597. <i
  598. class="material-icons blacklisted-icon"
  599. v-if="isSelected(playlist._id, 'blacklist')"
  600. :content="`This playlist is currently ${label(
  601. 'past',
  602. 'blacklist'
  603. )}`"
  604. v-tippy
  605. >
  606. block
  607. </i>
  608. <i
  609. class="material-icons"
  610. v-else-if="isSelected(playlist._id)"
  611. :content="`This playlist is currently ${label(
  612. 'past'
  613. )}`"
  614. v-tippy
  615. >
  616. play_arrow
  617. </i>
  618. <i
  619. class="material-icons"
  620. v-else
  621. :content="`This playlist is currently not ${label(
  622. 'past'
  623. )}`"
  624. v-tippy
  625. >
  626. {{
  627. type === "blacklist"
  628. ? "block"
  629. : "play_disabled"
  630. }}
  631. </i>
  632. </template>
  633. <template #actions>
  634. <i
  635. v-if="
  636. type !== 'blacklist' &&
  637. isSelected(playlist._id, 'blacklist')
  638. "
  639. class="material-icons stop-icon"
  640. :content="`This playlist is ${label(
  641. 'past',
  642. 'blacklist'
  643. )} in this station`"
  644. v-tippy="{ theme: 'info' }"
  645. >play_disabled</i
  646. >
  647. <quick-confirm
  648. v-if="
  649. type !== 'blacklist' &&
  650. isSelected(playlist._id)
  651. "
  652. @confirm="deselectPlaylist(playlist._id)"
  653. >
  654. <i
  655. class="material-icons stop-icon"
  656. :content="`Stop ${label(
  657. 'present'
  658. )} songs from this playlist`"
  659. v-tippy
  660. >
  661. stop
  662. </i>
  663. </quick-confirm>
  664. <i
  665. v-if="
  666. type !== 'blacklist' &&
  667. !isSelected(playlist._id) &&
  668. !isSelected(playlist._id, 'blacklist')
  669. "
  670. @click="selectPlaylist(playlist)"
  671. class="material-icons play-icon"
  672. :content="`${label(
  673. 'future',
  674. null,
  675. true
  676. )} songs from this playlist`"
  677. v-tippy
  678. >play_arrow</i
  679. >
  680. <quick-confirm
  681. v-if="
  682. type === 'blacklist' &&
  683. !isSelected(playlist._id, 'blacklist')
  684. "
  685. @confirm="selectPlaylist(playlist, 'blacklist')"
  686. >
  687. <i
  688. class="material-icons stop-icon"
  689. :content="`${label(
  690. 'future',
  691. null,
  692. true
  693. )} Playlist`"
  694. v-tippy
  695. >block</i
  696. >
  697. </quick-confirm>
  698. <quick-confirm
  699. v-if="
  700. type === 'blacklist' &&
  701. isSelected(playlist._id, 'blacklist')
  702. "
  703. @confirm="deselectPlaylist(playlist._id)"
  704. >
  705. <i
  706. class="material-icons stop-icon"
  707. :content="`Stop ${label(
  708. 'present'
  709. )} songs from this playlist`"
  710. v-tippy
  711. >
  712. stop
  713. </i>
  714. </quick-confirm>
  715. <i
  716. v-if="playlist.createdBy === myUserId"
  717. @click="
  718. openModal({
  719. modal: 'editPlaylist',
  720. props: { playlistId: playlist._id }
  721. })
  722. "
  723. class="material-icons edit-icon"
  724. content="Edit Playlist"
  725. v-tippy
  726. >edit</i
  727. >
  728. <i
  729. v-if="
  730. playlist.createdBy !== myUserId &&
  731. (playlist.privacy === 'public' ||
  732. hasPermission('playlists.view.others'))
  733. "
  734. @click="
  735. openModal({
  736. modal: 'editPlaylist',
  737. props: { playlistId: playlist._id }
  738. })
  739. "
  740. class="material-icons edit-icon"
  741. content="View Playlist"
  742. v-tippy
  743. >visibility</i
  744. >
  745. </template>
  746. </playlist-item>
  747. <button
  748. v-if="resultsLeftCount > 0"
  749. class="button is-primary load-more-button"
  750. @click="searchForPlaylists(search.page + 1)"
  751. >
  752. Load {{ nextPageResultsCount }} more results
  753. </button>
  754. </div>
  755. </div>
  756. <div class="tab" v-show="tab === 'current'">
  757. <p
  758. class="has-text-centered scrollable-list"
  759. v-if="
  760. selectedPlaylists().length > 0 && type === 'autorequest'
  761. "
  762. >
  763. You are currently autorequesting a mix of
  764. {{ totalUniqueAutorequestableMediaSources.length }}
  765. different songs. Of these, we can currently autorequest
  766. {{ actuallyAutorequestingMediaSources.length }} songs.
  767. <br />
  768. Songs that
  769. <span
  770. v-if="
  771. station.requests
  772. .autorequestDisallowRecentlyPlayedEnabled
  773. "
  774. >were played recently or</span
  775. >
  776. are currently in the queue or playing will not be
  777. autorequested. Spotify
  778. <span v-if="!experimental.soundcloud">and SoundCloud</span>
  779. songs will also not be autorequested.
  780. <span v-if="autoSkipDisliked"
  781. >Disliked songs will also not be autorequested due to
  782. your preferences.</span
  783. >
  784. <br />
  785. <br />
  786. </p>
  787. <div v-if="selectedPlaylists().length > 0">
  788. <playlist-item
  789. v-for="playlist in selectedPlaylists()"
  790. :key="`key-${playlist._id}`"
  791. :playlist="playlist"
  792. :show-owner="true"
  793. >
  794. <template #item-icon>
  795. <i
  796. class="material-icons"
  797. :class="{
  798. 'blacklisted-icon': type === 'blacklist'
  799. }"
  800. :content="`This playlist is currently ${label(
  801. 'past'
  802. )}`"
  803. v-tippy
  804. >
  805. {{
  806. type === "blacklist"
  807. ? "block"
  808. : "play_arrow"
  809. }}
  810. </i>
  811. </template>
  812. <template #actions>
  813. <quick-confirm
  814. @confirm="deselectPlaylist(playlist._id)"
  815. >
  816. <i
  817. class="material-icons stop-icon"
  818. :content="`Stop ${label(
  819. 'present'
  820. )} songs from this playlist`"
  821. v-tippy
  822. >
  823. stop
  824. </i>
  825. </quick-confirm>
  826. <i
  827. v-if="playlist.createdBy === myUserId"
  828. @click="
  829. openModal({
  830. modal: 'editPlaylist',
  831. props: { playlistId: playlist._id }
  832. })
  833. "
  834. class="material-icons edit-icon"
  835. content="Edit Playlist"
  836. v-tippy
  837. >edit</i
  838. >
  839. <i
  840. v-if="
  841. playlist.createdBy !== myUserId &&
  842. (playlist.privacy === 'public' ||
  843. hasPermission('playlists.view.others'))
  844. "
  845. @click="
  846. openModal({
  847. modal: 'editPlaylist',
  848. props: { playlistId: playlist._id }
  849. })
  850. "
  851. class="material-icons edit-icon"
  852. content="View Playlist"
  853. v-tippy
  854. >visibility</i
  855. >
  856. </template>
  857. </playlist-item>
  858. </div>
  859. <p v-else class="has-text-centered scrollable-list">
  860. No playlists currently {{ label("present") }}.
  861. </p>
  862. </div>
  863. <div
  864. v-if="type === 'autorequest' || station.type === 'community'"
  865. class="tab"
  866. v-show="tab === 'my-playlists'"
  867. >
  868. <div
  869. class="menu-list scrollable-list"
  870. v-if="playlists.length > 0"
  871. >
  872. <draggable-list
  873. v-model:list="playlists"
  874. item-key="_id"
  875. @start="drag = true"
  876. @end="drag = false"
  877. @update="savePlaylistOrder"
  878. >
  879. <template #item="{ element }">
  880. <playlist-item :playlist="element">
  881. <template #item-icon>
  882. <i
  883. class="material-icons blacklisted-icon"
  884. v-if="
  885. isSelected(element._id, 'blacklist')
  886. "
  887. :content="`This playlist is currently ${label(
  888. 'past',
  889. 'blacklist'
  890. )}`"
  891. v-tippy
  892. >
  893. block
  894. </i>
  895. <i
  896. class="material-icons"
  897. v-else-if="isSelected(element._id)"
  898. :content="`This playlist is currently ${label(
  899. 'past'
  900. )}`"
  901. v-tippy
  902. >
  903. play_arrow
  904. </i>
  905. <i
  906. class="material-icons"
  907. v-else
  908. :content="`This playlist is currently not ${label(
  909. 'past'
  910. )}`"
  911. v-tippy
  912. >
  913. {{
  914. type === "blacklist"
  915. ? "block"
  916. : "play_disabled"
  917. }}
  918. </i>
  919. </template>
  920. <template #actions>
  921. <i
  922. v-if="
  923. type !== 'blacklist' &&
  924. isSelected(element._id, 'blacklist')
  925. "
  926. class="material-icons stop-icon"
  927. :content="`This playlist is ${label(
  928. 'past',
  929. 'blacklist'
  930. )} in this station`"
  931. v-tippy="{ theme: 'info' }"
  932. >play_disabled</i
  933. >
  934. <quick-confirm
  935. v-if="
  936. type !== 'blacklist' &&
  937. isSelected(element._id)
  938. "
  939. @confirm="deselectPlaylist(element._id)"
  940. >
  941. <i
  942. class="material-icons stop-icon"
  943. :content="`Stop ${label(
  944. 'present'
  945. )} songs from this playlist`"
  946. v-tippy
  947. >
  948. stop
  949. </i>
  950. </quick-confirm>
  951. <i
  952. v-if="
  953. type !== 'blacklist' &&
  954. !isSelected(element._id) &&
  955. !isSelected(
  956. element._id,
  957. 'blacklist'
  958. )
  959. "
  960. :class="{
  961. 'play-icon':
  962. type !== 'autorequest' ||
  963. !autoSkipDisliked ||
  964. element.type !== 'user-disliked'
  965. }"
  966. @click="
  967. type !== 'autorequest' ||
  968. !autoSkipDisliked ||
  969. element.type !== 'user-disliked'
  970. ? selectPlaylist(element)
  971. : null
  972. "
  973. class="material-icons"
  974. :content="
  975. type !== 'autorequest' ||
  976. !autoSkipDisliked ||
  977. element.type !== 'user-disliked'
  978. ? `${label(
  979. 'future',
  980. null,
  981. true
  982. )} songs from this playlist`
  983. : 'Your preferences are set to skip disliked songs'
  984. "
  985. v-tippy
  986. >play_arrow</i
  987. >
  988. <quick-confirm
  989. v-if="
  990. type === 'blacklist' &&
  991. !isSelected(
  992. element._id,
  993. 'blacklist'
  994. )
  995. "
  996. @confirm="
  997. selectPlaylist(element, 'blacklist')
  998. "
  999. >
  1000. <i
  1001. class="material-icons stop-icon"
  1002. :content="`${label(
  1003. 'future',
  1004. null,
  1005. true
  1006. )} Playlist`"
  1007. v-tippy
  1008. >block</i
  1009. >
  1010. </quick-confirm>
  1011. <quick-confirm
  1012. v-if="
  1013. type === 'blacklist' &&
  1014. isSelected(element._id, 'blacklist')
  1015. "
  1016. @confirm="deselectPlaylist(element._id)"
  1017. >
  1018. <i
  1019. class="material-icons stop-icon"
  1020. :content="`Stop ${label(
  1021. 'present'
  1022. )} songs from this playlist`"
  1023. v-tippy
  1024. >
  1025. stop
  1026. </i>
  1027. </quick-confirm>
  1028. <i
  1029. @click="
  1030. openModal({
  1031. modal: 'editPlaylist',
  1032. props: {
  1033. playlistId: element._id
  1034. }
  1035. })
  1036. "
  1037. class="material-icons edit-icon"
  1038. content="Edit Playlist"
  1039. v-tippy
  1040. >edit</i
  1041. >
  1042. </template>
  1043. </playlist-item>
  1044. </template>
  1045. </draggable-list>
  1046. </div>
  1047. <p v-else class="has-text-centered scrollable-list">
  1048. You don't have any playlists
  1049. </p>
  1050. <button
  1051. class="button is-primary"
  1052. id="create-new-playlist-button"
  1053. @click="openModal('createPlaylist')"
  1054. >
  1055. Create new playlist
  1056. </button>
  1057. </div>
  1058. </div>
  1059. </div>
  1060. </template>
  1061. <style lang="less" scoped>
  1062. .night-mode {
  1063. .tabs-container .tab-selection .button {
  1064. background: var(--dark-grey) !important;
  1065. color: var(--white) !important;
  1066. }
  1067. }
  1068. .blacklisted-icon {
  1069. color: var(--dark-red);
  1070. }
  1071. .playlist-tab-base {
  1072. .top-info {
  1073. font-size: 15px;
  1074. margin-bottom: 15px;
  1075. }
  1076. .tabs-container {
  1077. .tab-selection {
  1078. display: flex;
  1079. overflow-x: auto;
  1080. .button {
  1081. border-radius: 0;
  1082. border: 0;
  1083. text-transform: uppercase;
  1084. font-size: 14px;
  1085. color: var(--dark-grey-3);
  1086. background-color: var(--light-grey-2);
  1087. flex-grow: 1;
  1088. height: 32px;
  1089. &:not(:first-of-type) {
  1090. margin-left: 5px;
  1091. }
  1092. }
  1093. .selected {
  1094. background-color: var(--primary-color) !important;
  1095. color: var(--white) !important;
  1096. font-weight: 600;
  1097. }
  1098. }
  1099. .tab {
  1100. padding: 15px 0;
  1101. border-radius: 0;
  1102. .playlist-item:not(:last-of-type) {
  1103. margin-bottom: 10px;
  1104. }
  1105. .load-more-button {
  1106. width: 100%;
  1107. margin-top: 10px;
  1108. }
  1109. }
  1110. }
  1111. }
  1112. .draggable-list-transition-move {
  1113. transition: transform 0.5s;
  1114. }
  1115. .draggable-list-ghost {
  1116. opacity: 0.5;
  1117. filter: brightness(95%);
  1118. }
  1119. </style>