PlaylistTabBase.vue 29 KB

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