0
0
Fork 0
mirror of https://github.com/VueTubeApp/VueTube synced 2024-11-24 04:05:16 +00:00

feat: implemented local playlist (#611)

This commit is contained in:
Fergus Lai 2023-04-17 23:17:23 +01:00 committed by GitHub
parent f7ce9a62ea
commit b1254b7376
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 433 additions and 42 deletions

View file

@ -18,6 +18,7 @@ module.exports = {
"vue/multi-word-component-names": 0,
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
"prettier/prettier": ["error", {endOfLine: "auto"}]
// 'prettier/prettier': ['error', { semi: false }],
// semi: [2, 'never'],
},

View file

@ -312,6 +312,7 @@
:controls="controls"
:sources="sources"
:seeking="seeking"
:disabled="disabled"
@seeking="seeking = !seeking"
@scrub="
($refs.player.currentTime = $event), ($refs.audio.currentTime = $event)
@ -384,6 +385,10 @@ export default {
return [];
},
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {

View file

@ -13,6 +13,7 @@
hide-details
height="2"
dense
:disabled="disabled"
color="transparent"
thumb-color="primary"
track-color="transparent"
@ -85,6 +86,10 @@ export default {
type: Boolean,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["scrub", "seeking"],
data: () => ({

View file

@ -0,0 +1,41 @@
<template>
<v-dialog v-if="dialog" width="500">
<v-card
class="rounded-lg"
:class="
$vuetify.theme.dark ? 'background lighten-1' : 'background darken-1'
"
>
<v-card-title class="text-h5">Save To Playlist</v-card-title>
<v-checkbox
v-for="(playlist, index) in playlist"
:key="index"
:v-model="
!(
playlist.videos.findIndex(
(playlistVideo) => playlistVideo.id !== currentVideo.id
) === -1
)
"
:label="playlist.name"
/>
<v-divider />
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="$emit('close')"> Done </v-btn>
</v-card-actions>
</v-card></v-dialog
>
</template>
<script>
export default {
props: { dialog: Boolean, currentVideo: { type: Object, required: true } },
computed: {
playlists() {
return this.$store.state.playlist.playlists;
},
},
methods: {},
};
</script>

View file

@ -1,7 +1,9 @@
<template>
<v-card
<v-btn
v-ripple
class="background d-flex flex-row overflow-hidden mb-4 mx-4"
text
class="background d-flex flex-row overflow-hidden mb-4 mx-7 mainCard px-0"
to="/playlist"
style="height: 6rem !important"
:class="
$store.state.tweaks.roundThumb && $store.state.tweaks.roundTweak > 0
@ -15,11 +17,11 @@
? `${$store.state.tweaks.roundTweak / 3}rem`
: '0',
}"
flat
@click.native="clickHandler"
>
<v-img
contain
src="/dev.svg"
:src="thumbnail"
class="background"
style="position: relative; max-width: 8rem !important"
:class="$vuetify.theme.dark ? 'lighten-3' : 'darken-3'"
@ -33,24 +35,24 @@
class="d-flex flex-column justify-center align-center background-opaque"
style="position: absolute; top: 0; right: 0; width: 50%; height: 100%"
>
<div>420</div>
<div>{{ playlist.videos.length }}</div>
<v-icon>mdi-playlist-play</v-icon>
</div>
</v-img>
<div class="pa-4" v-emoji style="font-size: 0.75rem !important">
<b>Work in Progress</b>
<div v-emoji class="pa-4 text-left" style="font-size: 0.75rem !important">
<b>{{ playlist.name }}</b>
<div
class="background--text caption mt-2"
:class="$vuetify.theme.dark ? 'text--lighten-4' : 'text--darken-4'"
>
Bottom Text <br />
420 videos
{{ playlist.videos.length }} videos
</div>
</div>
<v-spacer></v-spacer>
<div class="d-flex flex-column">
<v-btn
<!-- <v-btn
text
tile
elevation="0"
@ -67,7 +69,39 @@
style="width: 2rem !important"
>
<v-icon>mdi-playlist-plus</v-icon>
</v-btn>
</v-btn> -->
</div>
</v-card>
</v-btn>
</template>
<script>
export default {
props: {
playlist: { type: Object, required: true },
},
computed: {
thumbnail() {
try {
const videoId =
this.playlist.videos.length === 0 ? "" : this.playlist.videos[0].id;
return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
} catch (e) {
alert(e.message);
return `https://img.youtube.com/vi//maxresdefault.jpg`;
}
},
},
methods: {
clickHandler() {
this.$emit("click");
},
},
};
</script>
<style scoped>
.mainCard {
text-transform: none !important;
letter-spacing: normal !important;
}
</style>

View file

@ -0,0 +1,101 @@
<template>
<v-card
v-ripple
class="background d-flex flex-row overflow-hidden mb-4 mx-4"
style="height: 4.5rem !important"
:class="
$store.state.tweaks.roundThumb && $store.state.tweaks.roundTweak > 0
? $vuetify.theme.dark
? 'lighten-1'
: 'darken-1'
: ''
"
:style="{
borderRadius: $store.state.tweaks.roundThumb
? `${$store.state.tweaks.roundTweak / 3}rem`
: '0',
}"
flat
>
<v-card
class="d-flex flex-row w-50 elevation-0"
:to="`/watch?v=${video.id}`"
>
<v-img
:src="thumbnail"
aspect-ratio="1.7778"
style="position: relative; width: 8rem"
:class="$vuetify.theme.dark ? 'lighten-3' : 'darken-3'"
:style="{
borderRadius: $store.state.tweaks.roundThumb
? `${$store.state.tweaks.roundTweak / 3}rem`
: '0',
}"
>
</v-img>
<div
v-emoji
style="font-size: 0.75rem !important"
class="d-flex flex-column ml-2"
>
<b
class="text-left"
style="
{
height: 1.5rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-wrap: break-word;
white-space: normal;
}
"
>{{ video.title }}
</b>
<div
class="background--text caption mt-2 text-left d-inline-block text-truncate"
:class="$vuetify.theme.dark ? 'text--lighten-4' : 'text--darken-4'"
style="
{
height: 0.75rem;
}
"
>
{{ video.channel }}
</div>
</div>
</v-card>
<v-spacer></v-spacer>
<div class="d-flex w-50 flex-column">
<v-btn
text
tile
elevation="0"
class="flex-grow-1"
style="width: 2rem !important"
@click="deleted"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
</v-card>
</template>
<script>
export default {
props: { video: { type: Object, required: true } },
computed: {
thumbnail() {
return `https://img.youtube.com/vi/${this.video.id}/maxresdefault.jpg`;
},
},
methods: {
deleted() {
this.$emit("deleted");
},
},
};
</script>

View file

@ -6,7 +6,11 @@
v-show="!search"
class="my-auto ml-4"
v-text="
$route.path.includes('channel') ? $store.state.channel.title : page
$route.path.includes('channel')
? $store.state.channel.title
: $route.path.includes('playlist')
? $store.state.playlist.currentPlaylist.name
: page
"
/>

View file

@ -14,6 +14,7 @@
"@capacitor/status-bar": "^1.0.8",
"core-js": "^3.25.0",
"nuxt": "^2.15.8",
"uuid": "^9.0.0",
"vue": "^2.7.10",
"vue-server-renderer": "^2.7.10",
"vue-template-compiler": "^2.7.10",

View file

@ -1,14 +1,7 @@
<template>
<div>
<playlist-card />
</div>
<div></div>
</template>
<script>
import playlistCard from "../../components/playlistCard.vue";
export default {
components: {
playlistCard,
},
};
export default {};
</script>

View file

@ -1,12 +1,25 @@
<template>
<div>
<h4
class="ml-7 mb-2 background--text"
:class="$vuetify.theme.dark ? 'text--lighten-3' : 'text--darken-3'"
>
Local Playlists
</h4>
<playlist-card />
<div class="d-flex justify-space-between mb-2 mx-7">
<h4
class="background--text w-50"
:class="$vuetify.theme.dark ? 'text--lighten-3' : 'text--darken-3'"
>
Local Playlists
</h4>
<v-btn text tile elevation="0" class="w-5-0" @click="dialog = true">
<v-icon>mdi-plus</v-icon>
</v-btn>
</div>
<div class="d-flex flex-column-reverse">
<playlist-card
v-for="(playlist, index) in playlists"
:key="index"
:playlist="playlist"
@click="changeToPlaylist(index)"
/>
</div>
<v-btn
text
class="entry text-left setting-btn no-spacing"
@ -27,13 +40,62 @@
>
History
</v-btn>
<!-- Create Playlist Dialog -->
<v-dialog v-model="dialog" width="500">
<v-card
class="rounded-lg"
:class="
$vuetify.theme.dark ? 'background lighten-1' : 'background darken-1'
"
>
<v-card-title class="text-h5">Create Playlist</v-card-title>
<v-card-text>
<v-text-field v-model="playlistName" label="Playlist Name" solo />
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="dialog = false">
{{ lang.cancel }}
</v-btn>
<v-btn color="primary" text @click="createPlaylist()">
{{ lang.create }}
</v-btn>
</v-card-actions>
</v-card></v-dialog
>
</div>
</template>
<script>
import playlistCard from "../components/playlistCard.vue";
import playlistCard from "~/components/Playlist/playlistCard.vue";
export default {
components: { playlistCard },
data() {
return {
dialog: false,
lang: {},
playlistName: null,
};
},
computed: {
playlists() {
return this.$store.state.playlist.playlists;
},
},
mounted() {
const lang = this.$lang();
this.lang = lang.mods.developer;
},
methods: {
createPlaylist: function () {
this.$store.commit("playlist/createPlaylist", this.playlistName);
this.dialog = false;
},
changeToPlaylist: function (videoIndex) {
this.$store.commit("playlist/changeToPlaylist", videoIndex);
},
},
};
</script>

30
NUXT/pages/playlist.vue Normal file
View file

@ -0,0 +1,30 @@
<template>
<div>
<playlist-video-card
v-for="(video, index) in playlist.videos"
:key="index"
:video="video"
@deleted="deletePlaylistVideo(video)"
/>
</div>
</template>
<script>
import playlistVideoCard from "~/components/playlist/playlistVideoCard.vue";
export default {
components: { playlistVideoCard },
computed: {
playlist() {
return this.$store.state.playlist.currentPlaylist;
},
},
methods: {
deletePlaylistVideo(target) {
this.$store.commit("playlist/removeFromPlaylist", {
playlistIndex: this.playlist.index,
video: target,
});
},
},
};
</script>

View file

@ -9,6 +9,7 @@
:video="video"
:sources="sources"
:recommends="recommends"
:disabled="saveDialog"
/>
</div>
@ -273,6 +274,30 @@
}"
/>
</div>
<v-dialog v-model="saveDialog" width="500">
<v-card
class="rounded-lg"
:class="
$vuetify.theme.dark ? 'background lighten-1' : 'background darken-1'
"
>
<v-card-title class="text-h5">Save To Playlist</v-card-title>
<v-spacer></v-spacer>
<v-checkbox
v-for="(playlist, index) in playlists"
:key="index"
v-model="playlistsCheckbox[index]"
class="mx-5"
:label="playlist.name"
@change="updatePlaylist($event, index)"
/>
<v-divider />
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="saveDialog = false"> Done </v-btn>
</v-card-actions>
</v-card></v-dialog
>
</div>
</template>
@ -304,6 +329,12 @@ export default {
data: function () {
return this.initializeState();
},
computed: {
playlists() {
return this.$store.state.playlist.playlists;
},
},
watch: {
// Watch for change in the route query string (in this case, ?v=xxxxxxxx to ?v=yyyyyyyy)
$route: {
@ -356,6 +387,14 @@ export default {
title: this.video.title,
channel: this.video.channelName,
});
this.playlistsCheckbox = this.playlists.map(
(playlist) =>
playlist.videos.findIndex(
(playlistVideo) => playlistVideo.id === this.video.id
) !== -1
);
//--- API WatchTime call ---//
if (this.$store.state.watchTelemetry) {
this.playbackTracking = result.playbackTracking;
@ -459,8 +498,9 @@ export default {
{
name: "Save",
icon: "mdi-plus-box-multiple-outline",
actionName: "enqueue",
disabled: true,
// action: this.save()
actionName: "save",
disabled: false,
},
// {
// name: "Quality",
@ -484,13 +524,14 @@ export default {
interval: null,
video: null,
backHierarchy: [],
saveDialog: false,
playlistsCheckbox: [],
};
},
mountedInit() {
this.startTime = Math.floor(Date.now() / 1000);
this.getVideo();
// Reset vertical scrolling
const scrollableList = document.querySelectorAll(".overflow-y-auto");
scrollableList.forEach((scrollable) => {
@ -514,6 +555,28 @@ export default {
this.$vuetube.addBackAction(dismissComment);
}
},
save() {
this.saveDialog = true;
},
updatePlaylist(event, index) {
if (event) {
this.$store.commit("playlist/addToPlaylist", {
video: {
id: this.video.id,
title: this.video.title,
channel: this.video.channelName,
},
index,
});
} else {
this.$store.commit("playlist/removeFromPlaylist", {
video: this.video,
playlistIndex: index,
});
}
},
},
};
</script>

View file

@ -0,0 +1,51 @@
export const state = () => ({
playlists: [],
currentPlaylist: null,
});
// Shape of playlists
// [playlist, playlist]
// Shape of playlist
// {name: string, videos: []}
// Shape of currentPlaylist
// {index: number, name: string, videos: []}
export const mutations = {
initPlaylists(state) {
if (process.client) {
// read local storage and parse the list of objects
state.playlists = JSON.parse(localStorage.getItem("playlists"));
}
},
createPlaylist(state, name) {
state.playlists.push({ name, videos: [] });
localStorage.setItem("playlists", JSON.stringify(state.playlists));
},
removePlaylist(state, index) {
state.playlists.splice(index, 1);
localStorage.setItem("playlists", JSON.stringify(state.playlists));
},
addToPlaylist(state, { index, video }) {
state.playlists[index].videos.unshift(video);
localStorage.setItem("playlists", JSON.stringify(state.playlists));
},
removeFromPlaylist(state, { playlistIndex, video }) {
const videoIndex = state.playlists[playlistIndex].videos.findIndex(
(playlistVideo) => playlistVideo.id === video.id
);
if (videoIndex === -1) throw new Error("Unable To Find Video");
state.playlists[playlistIndex].videos.splice(videoIndex, 1);
localStorage.setItem("playlists", JSON.stringify(state.playlists));
},
changeToPlaylist(state, videoIndex) {
state.currentPlaylist = {
index: videoIndex,
...state.playlists[videoIndex],
};
},
exitPlaylist(state) {
state.currentPlaylist = null;
},
};

View file

@ -9,16 +9,16 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunityHttp', :path => '../../node_modules/@capacitor-community/http'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapacitorToast', :path => '../../node_modules/@capacitor/toast'
pod 'HugotomaziCapacitorNavigationBar', :path => '../../node_modules/@hugotomazi/capacitor-navigation-bar'
pod 'CapacitorCommunityHttp', :path => '..\..\node_modules\@capacitor-community\http'
pod 'CapacitorApp', :path => '..\..\node_modules\@capacitor\app'
pod 'CapacitorDevice', :path => '..\..\node_modules\@capacitor\device'
pod 'CapacitorFilesystem', :path => '..\..\node_modules\@capacitor\filesystem'
pod 'CapacitorHaptics', :path => '..\..\node_modules\@capacitor\haptics'
pod 'CapacitorShare', :path => '..\..\node_modules\@capacitor\share'
pod 'CapacitorSplashScreen', :path => '..\..\node_modules\@capacitor\splash-screen'
pod 'CapacitorStatusBar', :path => '..\..\node_modules\@capacitor\status-bar'
pod 'CapacitorToast', :path => '..\..\node_modules\@capacitor\toast'
pod 'HugotomaziCapacitorNavigationBar', :path => '..\..\node_modules\@hugotomazi\capacitor-navigation-bar'
pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins'
end