Merge pull request #955 from TeamPiped/playlists

Add support for custom playlists
This commit is contained in:
Kavin 2022-04-07 03:50:15 +01:00 committed by GitHub
commit 9d48611851
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 266 additions and 4 deletions

View file

@ -39,6 +39,9 @@
<li v-if="shouldShowHistory"> <li v-if="shouldShowHistory">
<router-link v-t="'titles.history'" to="/history" /> <router-link v-t="'titles.history'" to="/history" />
</li> </li>
<li v-if="authenticated">
<router-link v-t="'titles.playlists'" to="/playlists" />
</li>
<li v-if="authenticated"> <li v-if="authenticated">
<router-link v-t="'titles.feed'" to="/feed" /> <router-link v-t="'titles.feed'" to="/feed" />
</li> </li>

View file

@ -0,0 +1,94 @@
<template>
<div class="modal">
<div>
<div class="modal-container">
<div class="flex">
<span class="text-2xl w-max inline-block" v-t="'actions.select_playlist'" />
<button class="ml-3" @click="$emit('close')"><font-awesome-icon icon="xmark" /></button>
</div>
<select class="select w-full" v-model="selectedPlaylist">
<option
v-for="playlist in playlists"
:value="playlist.id"
:key="playlist.id"
v-text="playlist.name"
/>
</select>
<button
class="btn mt-2"
@click="handleClick(selectedPlaylist)"
ref="addButton"
v-t="'actions.add_to_playlist'"
/>
</div>
</div>
</div>
</template>
<style scoped>
.modal {
@apply fixed z-50 top-0 left-0 w-full h-full bg-dark-900 bg-opacity-80 transition-opacity table;
}
.modal > div {
@apply table-cell align-middle;
}
.modal-container {
@apply w-min m-auto px-8 bg-dark-700 p-6;
}
</style>
<script>
export default {
props: {
videoId: {
type: String,
required: true,
},
},
data() {
return {
playlists: [],
selectedPlaylist: null,
};
},
mounted() {
this.fetchPlaylists();
},
methods: {
handleClick(playlistId) {
if (!playlistId) {
alert(this.$t("actions.please_select_playlist"));
return;
}
this.$refs.addButton.disabled = true;
this.fetchJson(this.apiUrl() + "/user/playlists/add", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
videoId: this.videoId,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
this.$emit("close");
if (json.error) alert(json.error);
});
},
async fetchPlaylists() {
this.fetchJson(this.apiUrl() + "/user/playlists", null, {
headers: {
Authorization: this.getAuthToken(),
},
}).then(json => {
this.playlists = json;
});
},
},
};
</script>

View file

@ -26,9 +26,13 @@
<div class="video-grid"> <div class="video-grid">
<VideoItem <VideoItem
v-for="video in playlist.relatedStreams" v-for="(video, index) in playlist.relatedStreams"
:key="video.url" :key="video.url"
:video="video" :video="video"
:index="index"
:playlist-id="$route.query.list"
:admin="admin"
@remove="removeVideo(index)"
height="94" height="94"
width="168" width="168"
/> />
@ -48,6 +52,7 @@ export default {
data() { data() {
return { return {
playlist: null, playlist: null,
admin: false,
}; };
}, },
computed: { computed: {
@ -57,6 +62,16 @@ export default {
}, },
mounted() { mounted() {
this.getPlaylistData(); this.getPlaylistData();
const playlistId = this.$route.query.list;
if (this.authenticated && playlistId?.length == 36)
this.fetchJson(this.apiUrl() + "/user/playlists", null, {
headers: {
Authorization: this.getAuthToken(),
},
}).then(json => {
if (json.error) alert(json.error);
else if (json.filter(playlist => playlist.id === playlistId).length > 0) this.admin = true;
});
}, },
activated() { activated() {
window.addEventListener("scroll", this.handleScroll); window.addEventListener("scroll", this.handleScroll);
@ -87,6 +102,9 @@ export default {
}); });
} }
}, },
removeVideo(index) {
this.playlist.relatedStreams.splice(index, 1);
},
}, },
}; };
</script> </script>

View file

@ -0,0 +1,83 @@
<template>
<h1 class="font-bold text-center" v-t="'titles.playlists'" />
<hr />
<button v-t="'actions.create_playlist'" class="btn" @click="createPlaylist" />
<div class="video-grid">
<div v-for="playlist in playlists" :key="playlist.id">
<router-link :to="`/playlist?list=${playlist.id}`">
<img class="w-full" :src="playlist.thumbnail" alt="thumbnail" />
<p
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
class="my-2 overflow-hidden flex link"
:title="playlist.name"
v-text="playlist.name"
/>
</router-link>
<button class="btn h-auto" @click="deletePlaylist(playlist.id)" v-t="'actions.delete_playlist'" />
</div>
</div>
<br />
</template>
<script>
export default {
data() {
return {
playlists: [],
};
},
mounted() {
if (this.authenticated) this.fetchPlaylists();
else this.$router.push("/login");
},
activated() {
document.title = this.$t("titles.playlists") + " - Piped";
},
methods: {
fetchPlaylists() {
this.fetchJson(this.apiUrl() + "/user/playlists", null, {
headers: {
Authorization: this.getAuthToken(),
},
}).then(json => {
this.playlists = json;
});
},
deletePlaylist(id) {
if (confirm(this.$t("actions.delete_playlist_confirm")))
this.fetchJson(this.apiUrl() + "/user/playlists/delete", null, {
method: "POST",
body: JSON.stringify({
playlistId: id,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error);
else this.playlists = this.playlists.filter(playlist => playlist.id !== id);
});
},
createPlaylist() {
const name = prompt(this.$t("actions.create_playlist"));
this.fetchJson(this.apiUrl() + "/user/playlists/create", null, {
method: "POST",
body: JSON.stringify({
name: name,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error);
else this.fetchPlaylists();
});
},
},
};
</script>

View file

@ -24,7 +24,7 @@
</div> </div>
</router-link> </router-link>
<div class="float-right m-0 inline-block"> <div class="float-right m-0 inline-block children:px-1">
<router-link <router-link
:to="video.url + '&listen=1'" :to="video.url + '&listen=1'"
:aria-label="'Listen to ' + video.title" :aria-label="'Listen to ' + video.title"
@ -32,6 +32,18 @@
> >
<font-awesome-icon icon="headphones" /> <font-awesome-icon icon="headphones" />
</router-link> </router-link>
<button v-if="authenticated" :title="$t('actions.add_to_playlist')" @click="showModal = !showModal">
<font-awesome-icon icon="circle-plus" />
</button>
<button
v-if="admin"
:title="$t('actions.remove_from_playlist')"
ref="removeButton"
@click="removeVideo(video.url.substr(-11))"
>
<font-awesome-icon icon="circle-minus" />
</button>
<PlaylistAddModal v-if="showModal" :video-id="video.url.substr(-11)" @close="showModal = !showModal" />
</div> </div>
<div class="flex"> <div class="flex">
@ -82,6 +94,8 @@
</style> </style>
<script> <script>
import PlaylistAddModal from "./PlaylistAddModal.vue";
export default { export default {
props: { props: {
video: { video: {
@ -93,6 +107,36 @@ export default {
height: { type: String, default: "118" }, height: { type: String, default: "118" },
width: { type: String, default: "210" }, width: { type: String, default: "210" },
hideChannel: { type: Boolean, default: false }, hideChannel: { type: Boolean, default: false },
index: { type: Number, default: -1 },
playlistId: { type: String, default: null },
admin: { type: Boolean, default: false },
}, },
data() {
return {
showModal: false,
};
},
methods: {
removeVideo() {
if (confirm(this.$t("actions.delete_playlist_video_confirm"))) {
this.$refs.removeButton.disabled = true;
this.fetchJson(this.apiUrl() + "/user/playlists/remove", null, {
method: "POST",
body: JSON.stringify({
playlistId: this.playlistId,
index: this.index,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
}).then(json => {
if (json.error) alert(json.error);
else this.$emit("remove");
});
}
},
},
components: { PlaylistAddModal },
}; };
</script> </script>

View file

@ -6,7 +6,8 @@
"feed": "Feed", "feed": "Feed",
"preferences": "Preferences", "preferences": "Preferences",
"history": "History", "history": "History",
"subscriptions": "Subscriptions" "subscriptions": "Subscriptions",
"playlists": "Playlists"
}, },
"player": { "player": {
"watch_on": "Watch on {0}" "watch_on": "Watch on {0}"
@ -70,7 +71,15 @@
"clear_history": "Clear History", "clear_history": "Clear History",
"show_replies": "Show Replies", "show_replies": "Show Replies",
"hide_replies": "Hide Replies", "hide_replies": "Hide Replies",
"load_more_replies": "Load more Replies" "load_more_replies": "Load more Replies",
"add_to_playlist": "Add to playlist",
"remove_from_playlist": "Remove from playlist",
"delete_playlist_video_confirm": "Are you sure you would like to remove this video from this playlist?",
"create_playlist": "Create Playlist",
"delete_playlist": "Delete Playlist",
"select_playlist": "Select a Playlist",
"delete_playlist_confirm": "Are you sure you want to delete this playlist?",
"please_select_playlist": "Please select a playlist"
}, },
"comment": { "comment": {
"pinned_by": "Pinned by" "pinned_by": "Pinned by"

View file

@ -14,6 +14,9 @@ import {
faTv, faTv,
faLevelUpAlt, faLevelUpAlt,
faBroadcastTower, faBroadcastTower,
faCirclePlus,
faCircleMinus,
faXmark,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { faGithub, faBitcoin, faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faGithub, faBitcoin, faYoutube } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
@ -34,6 +37,9 @@ library.add(
faLevelUpAlt, faLevelUpAlt,
faTv, faTv,
faBroadcastTower, faBroadcastTower,
faCirclePlus,
faCircleMinus,
faXmark,
); );
import router from "@/router/router.js"; import router from "@/router/router.js";

View file

@ -70,6 +70,11 @@ const routes = [
name: "Watch History", name: "Watch History",
component: () => import("../components/HistoryPage.vue"), component: () => import("../components/HistoryPage.vue"),
}, },
{
path: "/playlists",
name: "Playlists",
component: () => import("../components/PlaylistsPage.vue"),
},
]; ];
const router = createRouter({ const router = createRouter({