Add full playlist functionality (Shuffle, loop, autoplay)

This commit is contained in:
Preston 2020-05-17 16:12:58 -04:00
parent 1faa075a7b
commit 8980dc74d2
24 changed files with 3094 additions and 1761 deletions

View File

@ -34,9 +34,9 @@ At this time, here is the list of things to do/need to do:
- [x] Playlist View - [x] Playlist View
- [x] Video Watch Page (Recommendations, Comments) - [x] Video Watch Page (Recommendations, Comments)
- [ ] Video player logic (Switching formats / quality, live video, fallback logic) - [ ] Video player logic (Switching formats / quality, live video, fallback logic)
- [ ] Playlist logic (Autoplay next video, shuffle list) - [x] Playlist logic (Autoplay next video, shuffle list)
- [ ] Database Setup and Logic (Updating and creating data) - [x] Database Setup and Logic (Updating and creating data)
- [ ] Settings Page - [x] Settings Page
- [ ] Subscriptions Page and Logic - [ ] Subscriptions Page and Logic
- [ ] Playlists Page (Will allow for creating user playlists. Will replace the "Favorites" Page) - [ ] Playlists Page (Will allow for creating user playlists. Will replace the "Favorites" Page)
- [ ] History Page and Logic - [ ] History Page and Logic

3858
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,18 +11,18 @@
"@fortawesome/fontawesome-svg-core": "^1.2.28", "@fortawesome/fontawesome-svg-core": "^1.2.28",
"@fortawesome/free-solid-svg-icons": "^5.13.0", "@fortawesome/free-solid-svg-icons": "^5.13.0",
"@fortawesome/vue-fontawesome": "^0.1.9", "@fortawesome/vue-fontawesome": "^0.1.9",
"@silvermine/videojs-quality-selector": "^1.2.3", "@silvermine/videojs-quality-selector": "^1.2.4",
"autolinker": "^3.14.1", "autolinker": "^3.14.1",
"bulma-pro": "^0.1.8", "bulma-pro": "^0.2.0",
"dateformat": "^3.0.3", "dateformat": "^3.0.3",
"electron-context-menu": "^1.0.0", "electron-context-menu": "^2.0.1",
"jquery": "^3.5.0", "jquery": "^3.5.1",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"mediaelement": "^4.2.16", "mediaelement": "^4.2.16",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"opml-to-json": "0.0.3", "opml-to-json": "0.0.3",
"video.js": "^7.7.5", "video.js": "^7.7.6",
"videojs-abloop": "^1.1.2", "videojs-abloop": "^1.1.2",
"videojs-contrib-quality-levels": "^2.0.9", "videojs-contrib-quality-levels": "^2.0.9",
"videojs-http-source-selector": "^1.1.6", "videojs-http-source-selector": "^1.1.6",
@ -31,38 +31,38 @@
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-electron": "^1.0.6", "vue-electron": "^1.0.6",
"vue-router": "^3.1.6", "vue-router": "^3.1.6",
"vuex": "^3.2.0", "vuex": "^3.4.0",
"xml2json": "^0.12.0", "xml2json": "^0.12.0",
"youtube-chat": "^1.0.2", "youtube-chat": "^1.1.0",
"youtube-comments-fetch": "^1.0.1", "youtube-comments-fetch": "^1.0.1",
"youtube-comments-task": "^1.3.14", "youtube-comments-task": "^1.3.14",
"youtube-suggest": "^1.1.0", "youtube-suggest": "^1.1.0",
"yt-xml2vtt": "^1.0.1", "yt-xml2vtt": "^1.0.1",
"ytdl-core": "^2.1.0", "ytdl-core": "^2.1.2",
"ytpl": "^0.1.20", "ytpl": "^0.1.21",
"ytsr": "^0.1.12" "ytsr": "^0.1.13"
}, },
"description": "A private YouTube client", "description": "A private YouTube client",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.9.6",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-object-rest-spread": "^7.9.5", "@babel/plugin-proposal-object-rest-spread": "^7.9.6",
"@babel/preset-env": "^7.9.5", "@babel/preset-env": "^7.9.6",
"@babel/preset-typescript": "^7.9.0", "@babel/preset-typescript": "^7.9.0",
"@typescript-eslint/eslint-plugin": "^2.29.0", "@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.29.0", "@typescript-eslint/parser": "^2.33.0",
"acorn": "^7.1.1", "acorn": "^7.2.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"copy-webpack-plugin": "^5.1.1", "copy-webpack-plugin": "^6.0.1",
"css-loader": "^3.5.2", "css-loader": "^3.5.3",
"devtron": "^1.4.0", "devtron": "^1.4.0",
"electron": "^8.2.3", "electron": "^8.3.0",
"electron-builder": "^22.5.1", "electron-builder": "^22.6.0",
"electron-debug": "^3.0.1", "electron-debug": "^3.0.1",
"electron-rebuild": "^1.10.1", "electron-rebuild": "^1.11.0",
"eslint": "^6.8.0", "eslint": "^7.0.0",
"eslint-config-prettier": "^6.10.1", "eslint-config-prettier": "^6.11.0",
"eslint-config-standard": "^14.1.1", "eslint-config-standard": "^14.1.1",
"eslint-plugin-import": "^2.20.2", "eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
@ -72,26 +72,26 @@
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^6.2.2",
"fast-glob": "^3.2.2", "fast-glob": "^3.2.2",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"html-webpack-plugin": "^4.2.0", "html-webpack-plugin": "^4.3.0",
"jest": "^25.4.0", "jest": "^26.0.1",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^2.0.4", "prettier": "^2.0.5",
"sass": "^1.26.3", "sass": "^1.26.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"style-loader": "^1.1.4", "style-loader": "^1.2.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"typescript": "^3.8.3", "typescript": "^3.9.2",
"url-loader": "^4.1.0", "url-loader": "^4.1.0",
"vue-devtools": "^5.1.3", "vue-devtools": "^5.1.3",
"vue-eslint-parser": "^7.0.0", "vue-eslint-parser": "^7.1.0",
"vue-loader": "^15.9.1", "vue-loader": "^15.9.2",
"vue-style-loader": "^4.1.2", "vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",
"webpack": "^4.43.0", "webpack": "^4.43.0",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3" "webpack-dev-server": "^3.11.0"
}, },
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"main": "./dist/main.js", "main": "./dist/main.js",

View File

@ -7,6 +7,10 @@ export default Vue.extend({
type: Object, type: Object,
required: true required: true
}, },
playlistId: {
type: String,
default: null
},
forceListType: { forceListType: {
type: String, type: String,
default: null default: null
@ -16,7 +20,6 @@ export default Vue.extend({
return { return {
id: '', id: '',
title: '', title: '',
thumbnail: '',
channelName: '', channelName: '',
channelId: '', channelId: '',
viewCount: 0, viewCount: 0,
@ -37,6 +40,19 @@ export default Vue.extend({
thumbnailPreference: function () { thumbnailPreference: function () {
return this.$store.getters.getThumbnailPreference return this.$store.getters.getThumbnailPreference
},
thumbnail: function () {
switch (this.thumbnailPreference) {
case 'start':
return `https://i.ytimg.com/vi/${this.id}/mq1.jpg`
case 'middle':
return `https://i.ytimg.com/vi/${this.id}/mq2.jpg`
case 'end':
return `https://i.ytimg.com/vi/${this.id}/mq3.jpg`
default:
return `https://i.ytimg.com/vi/${this.id}/mqdefault.jpg`
}
} }
}, },
mounted: function () { mounted: function () {
@ -54,11 +70,27 @@ export default Vue.extend({
}, },
methods: { methods: {
play: function () { play: function () {
this.$router.push({ path: `/watch/${this.id}` }) const playlistInfo = {
playlistId: this.playlistId
}
console.log('playlist info')
console.log(playlistInfo)
if (this.playlistId !== null) {
console.log('Sending playlist info')
this.$router.push(
{
path: `/watch/${this.id}`,
query: playlistInfo
}
)
} else {
console.log('no playlist found')
this.$router.push({ path: `/watch/${this.id}` })
}
}, },
goToChannel: function () { goToChannel: function () {
console.log(this.data)
this.$router.push({ path: `/channel/${this.channelId}` }) this.$router.push({ path: `/channel/${this.channelId}` })
}, },
@ -101,20 +133,7 @@ export default Vue.extend({
this.id = this.data.videoId this.id = this.data.videoId
this.title = this.data.title this.title = this.data.title
// this.thumbnail = this.data.videoThumbnails[4].url // this.thumbnail = this.data.videoThumbnails[4].url
switch (this.thumbnailPreference) {
case 'start':
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mq1.jpg`
break
case 'middle':
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mq2.jpg`
break
case 'end':
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mq3.jpg`
break
default:
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mqdefault.jpg`
break
}
this.channelName = this.data.author this.channelName = this.data.author
this.channelId = this.data.authorId this.channelId = this.data.authorId
this.duration = this.calculateVideoDuration(this.data.lengthSeconds) this.duration = this.calculateVideoDuration(this.data.lengthSeconds)
@ -142,22 +161,6 @@ export default Vue.extend({
} }
this.title = this.data.title this.title = this.data.title
// this.thumbnail = this.data.thumbnail
switch (this.thumbnailPreference) {
case 'start':
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mq1.jpg`
break
case 'middle':
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mq2.jpg`
break
case 'end':
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mq3.jpg`
break
default:
this.thumbnail = `https://i.ytimg.com/vi/${this.id}/mqdefault.jpg`
break
}
if (typeof (this.data.author) === 'string') { if (typeof (this.data.author) === 'string') {
this.channelName = this.data.author this.channelName = this.data.author
@ -188,7 +191,7 @@ export default Vue.extend({
this.hideViews = true this.hideViews = true
} }
if (typeof (this.data.uploaded_at) !== 'undefined' && this.data.uploaded_at.includes('watching')) { if (typeof (this.data.uploaded_at) !== 'undefined' && this.data.uploaded_at !== null && this.data.uploaded_at.includes('watching')) {
const uploadSplit = this.data.uploaded_at.split(' ') const uploadSplit = this.data.uploaded_at.split(' ')
this.viewCount = parseInt(uploadSplit[0]) this.viewCount = parseInt(uploadSplit[0])
this.isLive = true this.isLive = true

View File

@ -37,7 +37,10 @@
:style="{width: progressPercentage + '%'}" :style="{width: progressPercentage + '%'}"
/> />
</div> </div>
<p class="videoTitle"> <p
class="videoTitle"
@click="play(id)"
>
{{ title }} {{ title }}
</p> </p>
<p <p
@ -49,30 +52,35 @@
<span <span
v-if="!isLive && !hideViews" v-if="!isLive && !hideViews"
class="viewCount" class="viewCount"
@click="play(id)"
> >
{{ viewCount }} views {{ viewCount }} views
</span> </span>
<span <span
v-if="uploadedTime !== '' && !isLive" v-if="uploadedTime !== '' && !isLive"
class="uploadedTime" class="uploadedTime"
@click="play(id)"
> >
- {{ uploadedTime }} - {{ uploadedTime }}
</span> </span>
<span <span
v-if="isLive" v-if="isLive"
class="viewCount" class="viewCount"
@click="play(id)"
> >
{{ viewCount }} watching {{ viewCount }} watching
</span> </span>
<p <p
v-if="listType !== 'grid'" v-if="listType !== 'grid'"
class="description" class="description"
@click="play(id)"
> >
{{ description }} {{ description }}
</p> </p>
<span <span
v-if="isLive" v-if="isLive"
class="liveText" class="liveText"
@click="play(id)"
> >
LIVE NOW LIVE NOW
</span> </span>

View File

@ -100,12 +100,80 @@ export default Vue.extend({
return this.$store.getters.getDefaultPlayback return this.$store.getters.getDefaultPlayback
}, },
defaultQuality: function () {
return this.$store.getters.getDefaultQuality
},
defaultVideoFormat: function () { defaultVideoFormat: function () {
return this.$store.getters.getDefaultVideoFormat return this.$store.getters.getDefaultVideoFormat
}, },
autoplayVideos: function () { autoplayVideos: function () {
return this.$store.getters.getAutoplayVideos return this.$store.getters.getAutoplayVideos
},
selectedDefaultQuality: function () {
let selectedQuality = null
const maxAvailableQuality = parseInt(this.sourceList[this.sourceList.length - 1].qualityLabel.replace(/p|k/, ''))
switch (maxAvailableQuality) {
case 4:
if (this.defaultQuality >= 2160) {
return '4k'
}
break
case 8:
if (this.defaultQuality >= 4320) {
return '8k'
}
break
case 144:
if (this.defaultQuality >= 144) {
return '144p'
}
break
case 240:
if (this.defaultQuality >= 240) {
return '240p'
}
break
case 360:
if (this.defaultQuality >= 360) {
return '360p'
}
break
case 480:
if (this.defaultQuality >= 480) {
return '480p'
}
break
case 720:
if (this.defaultQuality >= 720) {
return '720p'
}
break
case 1080:
if (this.defaultQuality >= 1080) {
return '1080p'
}
break
case 1440:
if (this.defaultQuality >= 1440) {
return '1440p'
}
break
default:
return maxAvailableQuality + 'p'
}
this.activeSourceList.forEach((source) => {
if (this.determineDefaultQuality(source.qualityLabel)) {
selectedQuality = source.qualityLabel
}
})
return selectedQuality
} }
}, },
watch: { watch: {
@ -172,6 +240,10 @@ export default Vue.extend({
const v = this const v = this
this.player.on('ended', function () {
v.$emit('ended')
})
this.player.on('error', function (error, message) { this.player.on('error', function (error, message) {
v.$emit('error', error.target.player.error_) v.$emit('error', error.target.player.error_)
}) })
@ -191,6 +263,27 @@ export default Vue.extend({
} }
}, },
determineDefaultQuality: function (label) {
if (label.includes('p')) {
const selectedQuality = parseInt(label.replace('p', ''))
return this.defaultQuality === selectedQuality
} else if (label.includes('k')) {
const hdQuality = parseInt(label.replace('k', ''))
switch (hdQuality) {
case 4:
return this.defaultQuality === 2160
case 8:
return this.defaultQuality === 4320
default:
return false
}
} else {
console.log('Invalid label')
return false
}
},
enableDashFormat: function () { enableDashFormat: function () {
if (this.dashSrc === null) { if (this.dashSrc === null) {
console.log('No dash format available.') console.log('No dash format available.')

View File

@ -13,6 +13,7 @@
:src="source.url" :src="source.url"
:type="source.type || source.mimeType" :type="source.type || source.mimeType"
:label="source.qualityLabel" :label="source.qualityLabel"
:selected="source.qualityLabel === selectedDefaultQuality"
/> />
<track <track
v-for="(caption, index) in captionList" v-for="(caption, index) in captionList"

View File

@ -42,15 +42,15 @@ export default Vue.extend({
], ],
qualityValues: [ qualityValues: [
'auto', 'auto',
'144', 144,
'240', 240,
'360', 360,
'480', 480,
'720', 720,
'1080', 1080,
'1440', 1440,
'4k', 2160,
'8k' 4320
] ]
} }
}, },

View File

@ -16,8 +16,8 @@ export default Vue.extend({
data: function () { data: function () {
return { return {
id: '', id: '',
randomVideoId: '',
title: '', title: '',
thumbnail: '',
channelThumbnail: '', channelThumbnail: '',
channelName: '', channelName: '',
channelId: '', channelId: '',
@ -25,6 +25,7 @@ export default Vue.extend({
viewCount: 0, viewCount: 0,
lastUpdated: '', lastUpdated: '',
description: '', description: '',
infoSource: '',
shareHeaders: [ shareHeaders: [
'Copy YouTube Link', 'Copy YouTube Link',
'Open in YouTube', 'Open in YouTube',
@ -42,17 +43,35 @@ export default Vue.extend({
computed: { computed: {
listType: function () { listType: function () {
return this.$store.getters.getListType return this.$store.getters.getListType
},
thumbnailPreference: function () {
return this.$store.getters.getThumbnailPreference
},
thumbnail: function () {
switch (this.thumbnailPreference) {
case 'start':
return `https://i.ytimg.com/vi/${this.randomVideoId}/mq1.jpg`
case 'middle':
return `https://i.ytimg.com/vi/${this.randomVideoId}/mq2.jpg`
case 'end':
return `https://i.ytimg.com/vi/${this.randomVideoId}/mq3.jpg`
default:
return `https://i.ytimg.com/vi/${this.randomVideoId}/mqdefault.jpg`
}
} }
}, },
mounted: function () { mounted: function () {
console.log(this.data) console.log(this.data)
this.id = this.data.id this.id = this.data.id
this.randomVideoId = this.data.randomVideoId
this.title = this.data.title this.title = this.data.title
this.thumbnail = this.data.thumbnail
this.channelName = this.data.channelName this.channelName = this.data.channelName
this.channelThumbnail = this.data.channelThumbnail this.channelThumbnail = this.data.channelThumbnail
this.uploadedTime = this.data.uploaded_at this.uploadedTime = this.data.uploaded_at
this.description = this.data.description this.description = this.data.description
this.infoSource = this.data.infoSource
// Causes errors if not put inside of a check // Causes errors if not put inside of a check
if (typeof (this.data.viewCount) !== 'undefined') { if (typeof (this.data.viewCount) !== 'undefined') {

View File

@ -11,7 +11,11 @@
{{ title }} {{ title }}
</h2> </h2>
<p> <p>
{{ videoCount }} videos - {{ viewCount }} views - Last updated on {{ lastUpdated }} {{ videoCount }} videos - {{ viewCount }} views -
<span v-if="infoSource !== 'local'">
Last updated on
</span>
{{ lastUpdated }}
</p> </p>
<p> <p>
{{ description }} {{ description }}

View File

@ -44,11 +44,11 @@ export default Vue.extend({
}, },
likeCount: { likeCount: {
type: Number, type: Number,
required: true default: 0
}, },
dislikeCount: { dislikeCount: {
type: Number, type: Number,
required: true default: 0
} }
}, },
data: function () { data: function () {
@ -88,6 +88,10 @@ export default Vue.extend({
return this.$store.getters.getInvidiousInstance return this.$store.getters.getInvidiousInstance
}, },
usingElectron: function () {
return this.$store.getters.getUsingElectron
},
invidiousUrl: function () { invidiousUrl: function () {
return `${this.invidiousInstance}/watch?v=${this.id}` return `${this.invidiousInstance}/watch?v=${this.id}`
}, },
@ -118,7 +122,7 @@ export default Vue.extend({
}, },
methods: { methods: {
goToChannel: function () { goToChannel: function () {
console.log('TODO: Handle goToChannel') this.$router.push({ path: `/channel/${this.channelId}` })
}, },
handleSubscription: function () { handleSubscription: function () {
@ -126,9 +130,6 @@ export default Vue.extend({
}, },
handleFormatChange: function (format) { handleFormatChange: function (format) {
console.log('Handling share')
console.log(this)
switch (format) { switch (format) {
case 'dash': case 'dash':
this.$parent.enableDashFormat() this.$parent.enableDashFormat()
@ -147,19 +148,28 @@ export default Vue.extend({
navigator.clipboard.writeText(this.youtubeUrl) navigator.clipboard.writeText(this.youtubeUrl)
break break
case 'openYoutube': case 'openYoutube':
// shell.openExternal(this.youtubeUrl) if (this.usingElectron) {
const shell = require('electron').shell
shell.openExternal(this.youtubeUrl)
}
break break
case 'copyYoutubeEmbed': case 'copyYoutubeEmbed':
navigator.clipboard.writeText(this.youtubeEmbedUrl) navigator.clipboard.writeText(this.youtubeEmbedUrl)
break break
case 'openYoutubeEmbed': case 'openYoutubeEmbed':
// shell.openExternal(this.youtubeEmbedUrl) if (this.usingElectron) {
const shell = require('electron').shell
shell.openExternal(this.youtubeEmbedUrl)
}
break break
case 'copyInvidious': case 'copyInvidious':
navigator.clipboard.writeText(this.invidiousUrl) navigator.clipboard.writeText(this.invidiousUrl)
break break
case 'openInvidious': case 'openInvidious':
// shell.openExternal(this.invidiousUrl) if (this.usingElectron) {
const shell = require('electron').shell
shell.openExternal(this.invidiousUrl)
}
break break
} }
} }

View File

@ -0,0 +1,106 @@
.pointer {
cursor: pointer;
}
.channelName {
font-size: 15px;
cursor: pointer;
position: relative;
bottom: 15px;
}
.playlistIndex {
position: relative;
bottom: 15px;
color: var(--tertiary-text-color);
}
.playlistIcon {
font-size: 20px;
padding: 10px;
margin-top: -25px;
cursor: pointer;
border-radius: 50%;
color: var(--tertiary-text-color);
transition: background 0.2s ease-out;
}
.playlistIcon:hover {
background-color: var(--side-nav-hover-color);
transition: background 0.2s ease-in;
}
.playlistIconActive {
color: var(--accent-color)
}
.playlistItems {
overflow-y: auto;
height: 395px;
margin-top: -15px;
margin-left: -16px;
margin-right: -16px;
}
.playlistItem {
height: 75px;
width: 100%;
transition: background 0.2s ease-out;
}
.playlistItem:hover {
background-color: var(--side-nav-hover-color);
transition: background 0.2s ease-in;
}
.videoIndex {
float: left;
position: relative;
top: 15px;
left: 10px;
color: var(--tertiary-text-color);
}
.videoIndexIcon {
float: left;
position: relative;
font-size: 14px;
top: 32px;
left: 10px;
color: var(--tertiary-text-color);
}
.videoInfo {
margin-left: 30px;
position: relative;
bottom: 7px;
}
/deep/ .list {
height: 60px;
width: calc(100% - 30px);
}
/deep/ .list .videoThumbnail {
width: 100px;
height: 60px;
}
/deep/ .list .videoThumbnail img {
height: 60px;
}
/deep/ .list .videoTitle {
font-size: 12px;
margin-left: 105px;
margin-right: 30px;
}
/deep/ .list .channelName {
margin-left: 105px;
margin-right: 30px;
}
/deep/ .list .viewCount {
margin-left: 5px;
}

View File

@ -0,0 +1,252 @@
import Vue from 'vue'
import FtLoader from '../ft-loader/ft-loader.vue'
import FtCard from '../ft-card/ft-card.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtListVideo from '../ft-list-video/ft-list-video.vue'
export default Vue.extend({
name: 'WatchVideoPlaylist',
components: {
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-flex-box': FtFlexBox,
'ft-list-video': FtListVideo
},
props: {
playlistId: {
type: String,
required: true
},
videoId: {
type: String,
required: true
}
},
data: function () {
return {
isLoading: false,
shuffleEnabled: false,
loopEnabled: false,
channelName: '',
channelId: '',
channelThumbnail: '',
playlistTitle: '',
playlistItems: [],
playlistWatchedVideoList: []
}
},
computed: {
usingElectron: function () {
return this.$store.getters.getUsingElectron
},
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
currentVideoIndex: function () {
const index = this.playlistItems.findIndex((item) => {
return item.videoId === this.videoId
})
return index + 1
},
playlistVideoCount: function () {
return this.playlistItems.length
}
},
watch: {
videoId () {
this.playlistWatchedVideoList.push(this.videoId)
},
},
mounted: function () {
if (this.usingElectron) {
this.getPlaylistInformationInvidious()
} else {
switch (this.backendPreference) {
case 'local':
this.getPlaylistInformationLocal()
break
case 'invidious':
this.getPlaylistInformationInvidious()
break
}
}
},
methods: {
goToPlaylist: function () {
this.$router.push({ path: `/playlist/${this.playlistId}` })
},
goToChannel: function () {
this.$router.push({ path: `/channel/${this.channelId}` })
},
toggleLoop: function () {
if (this.loopEnabled) {
this.loopEnabled = false
console.log('Disabling loop')
} else {
this.loopEnabled = true
console.log('Enabling loop')
}
},
toggleShuffle: function () {
if (this.shuffleEnabled) {
this.shuffleEnabled = false
console.log('Disabling shuffle')
} else {
this.shuffleEnabled = true
console.log('Enabling shuffle')
}
},
playNextVideo: function () {
const playlistInfo = {
playlistId: this.playlistId
}
const videoIndex = this.playlistItems.findIndex((item) => {
return item.videoId === this.videoId
})
const videosRemain = this.playlistWatchedVideoList.length < this.playlistItems.length
if (this.shuffleEnabled && videosRemain) {
let runLoop = true
while (runLoop) {
const randomInt = Math.floor(Math.random() * this.playlistItems.length)
const randomVideoId = this.playlistItems[randomInt].videoId
const watchedIndex = this.playlistWatchedVideoList.findIndex((watchedVideo) => {
return watchedVideo === randomVideoId
})
if (watchedIndex === -1) {
runLoop = false
this.$router.push(
{
path: `/watch/${randomVideoId}`,
query: playlistInfo
}
)
break
}
}
} else if (this.shuffleEnabled && !videosRemain) {
if (this.loopEnabled) {
let runLoop = true
while (runLoop) {
const randomInt = Math.floor(Math.random() * this.playlistItems.length)
const randomVideoId = this.playlistItems[randomInt].videoId
if (this.videoId !== randomVideoId) {
this.playlistItems = []
runLoop = false
this.$router.push(
{
path: `/watch/${randomVideoId}`,
query: playlistInfo
}
)
break
}
}
}
} else if (this.loopEnabled && videoIndex === this.playlistItems.length - 1) {
this.$router.push(
{
path: `/watch/${this.playlistItems[0].videoId}`,
query: playlistInfo
}
)
} else if (videoIndex < this.playlistItems.length - 1 && !videosRemain) {
this.$router.push(
{
path: `/watch/${this.playlistItems[videoIndex + 1].videoId}`,
query: playlistInfo
}
)
}
},
getPlaylistInformationLocal: function () {
this.isLoading = true
this.$store.dispatch('ytGetPlaylistInfo', this.playlistId).then((result) => {
console.log('done')
console.log(result)
this.playlistTitle = result.title
this.playlistItems = result.items
this.videoCount = result.total_items
this.channelName = result.author.name
this.channelThumbnail = result.author.avatar
this.channelId = result.author.id
this.playlistItems = result.items
this.playlistWatchedVideoList.push(this.videoId)
this.isLoading = false
}).catch((err) => {
console.log(err)
if (this.backendPreference === 'local' && this.backendFallback) {
console.log('Falling back to Invidious API')
this.getPlaylistInformationInvidious()
} else {
this.isLoading = false
// TODO: Show toast with error message
}
})
},
getPlaylistInformationInvidious: function () {
this.isLoading = true
const payload = {
resource: 'playlists',
id: this.playlistId,
params: {
page: this.playlistPage
}
}
this.$store.dispatch('invidiousGetPlaylistInfo', payload).then((result) => {
console.log('done')
console.log(result)
this.playlistTitle = result.title
this.videoCount = result.videoCount
this.channelName = result.author
this.channelThumbnail = result.authorThumbnails[2].url
this.channelId = result.authorId
this.playlistItems = this.playlistItems.concat(result.videos)
if (this.playlistItems.length < result.videoCount) {
console.log('getting next page')
this.playlistPage++
this.getPlaylistInformationInvidious()
} else {
this.playlistWatchedVideoList.push(this.videoId)
this.isLoading = false
}
}).catch((err) => {
console.log(err)
if (this.backendPreference === 'invidious' && this.backendFallback) {
console.log('Error getting data with Invidious, falling back to local backend')
this.getPlaylistInformationLocal()
} else {
this.isLoading = false
// TODO: Show toast with error message
}
})
}
}
})

View File

@ -0,0 +1,73 @@
<template>
<ft-card class="relative">
<ft-loader
v-if="isLoading"
/>
<div
v-else
>
<h3
class="pointer"
@click="goToPlaylist"
>
{{ playlistTitle }}
</h3>
<span
class="channelName"
@click="goToChannel"
>
{{ channelName }}
</span>
<span
class="playlistIndex"
>
- {{ currentVideoIndex }} / {{ playlistVideoCount }}
</span>
<p>
<font-awesome-icon
class="playlistIcon"
:class="{ playlistIconActive: loopEnabled }"
icon="retweet"
@click="toggleLoop"
/>
<font-awesome-icon
class="playlistIcon"
:class="{ playlistIconActive: shuffleEnabled }"
icon="random"
@click="toggleShuffle"
/>
</p>
<ft-flex-box
v-if="!isLoading"
class="playlistItems"
>
<div
v-for="(item, index) in playlistItems"
:key="index"
class="playlistItem"
>
<font-awesome-icon
v-if="item.videoId === videoId"
class="videoIndexIcon"
icon="play"
/>
<p
v-else
class="videoIndex"
>
{{ index + 1 }}
</p>
<ft-list-video
:data="item"
:playlist-id="playlistId"
force-list-type="list"
class="videoInfo"
/>
</div>
</ft-flex-box>
</div>
</ft-card>
</template>
<script src="./watch-video-playlist.js" />
<style scoped src="./watch-video-playlist.css" />

View File

@ -5,3 +5,29 @@
.videoRecommendation { .videoRecommendation {
margin-bottom: -15px; margin-bottom: -15px;
} }
/deep/ .list {
height: 110px;
}
/deep/ .list .videoThumbnail {
width: 180px;
height: 100px;
}
/deep/ .list .videoThumbnail img {
height: 100px;
}
/deep/ .list .videoTitle {
font-size: 12px;
margin-left: 185px;
}
/deep/ .list .channelName {
margin-left: 185px;
}
/deep/ .list .viewCount {
margin-left: 5px;
}

View File

@ -14,11 +14,6 @@ export default Vue.extend({
required: true required: true
} }
}, },
data: function () {
return {
test: 'hello'
}
},
computed: { computed: {
listType: function () { listType: function () {
return this.$store.getters.getListType return this.$store.getters.getListType

View File

@ -0,0 +1,66 @@
import Datastore from 'nedb'
let dbLocation
if (window && window.process && window.process.type === 'renderer') {
// Electron is being used
let dbLocation = localStorage.getItem('dbLocation')
if (dbLocation === null) {
const electron = require('electron')
dbLocation = electron.remote.app.getPath('userData')
}
dbLocation += '/playlists.db'
} else {
dbLocation = 'playlists.db'
}
const subDb = new Datastore({
filename: dbLocation,
autoload: true
})
const state = {
activePlaylistId: '',
activePlaylistVideoList: [],
watchedVideosWithinPlaylist: []
}
const mutations = {
addSubscription (state, payload) {
state.subscriptions.push(payload)
},
setSubscriptions (state, payload) {
state.subscriptions = payload
}
}
const actions = {
addSubscriptions ({ commit }, payload) {
subDb.insert(payload, (err, payload) => {
if (!err) {
commit('addSubscription', payload)
}
})
},
getSubscriptions ({ commit }, payload) {
subDb.find({}, (err, payload) => {
if (!err) {
commit('setSubscriptions', payload)
}
})
},
removeSubscription ({ commit }, channelId) {
subDb.remove({ channelId: channelId }, {}, () => {
commit('setSubscriptions', this.state.subscriptions.filter(sub => sub.channelId !== channelId))
})
}
}
const getters = {}
export default {
state,
getters,
actions,
mutations
}

View File

@ -118,7 +118,7 @@ const actions = {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
console.log(playlistId) console.log(playlistId)
console.log('Getting playlist info please wait...') console.log('Getting playlist info please wait...')
ytpl(playlistId, (err, result) => { ytpl(playlistId, { limit: 0 }, (err, result) => {
if (err) { if (err) {
reject(err) reject(err)
} else { } else {

View File

@ -1,7 +1,7 @@
@charset "UTF-8"; @charset "UTF-8";
.vjs-modal-dialog .vjs-modal-dialog-content, .video-js .vjs-modal-dialog, .vjs-button > .vjs-icon-placeholder:before, .video-js .vjs-big-play-button .vjs-icon-placeholder:before { .vjs-modal-dialog .vjs-modal-dialog-content, .video-js .vjs-modal-dialog, .vjs-button > .vjs-icon-placeholder:before, .video-js .vjs-big-play-button .vjs-icon-placeholder:before {
position: absolute; position: absolute;
top: 3px; top: 0px;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;

View File

@ -18,7 +18,7 @@ export default Vue.extend({
data: function () { data: function () {
return { return {
isLoading: false, isLoading: false,
playlistId: '', playlistId: null,
nextPageRef: '', nextPageRef: '',
lastSearchQuery: '', lastSearchQuery: '',
playlistPage: 1, playlistPage: 1,
@ -82,13 +82,14 @@ export default Vue.extend({
id: result.id, id: result.id,
title: result.title, title: result.title,
description: result.description, description: result.description,
thumbnail: result.items[randomVideoIndex].thumbnail, randomVideoId: result.items[randomVideoIndex].id,
viewCount: result.views, viewCount: result.views,
videoCount: result.total_items, videoCount: result.total_items,
lastUpdated: result.last_updated, lastUpdated: result.last_updated,
channelName: result.author.name, channelName: result.author.name,
channelThumbnail: result.author.avatar, channelThumbnail: result.author.avatar,
channelId: result.author.id channelId: result.author.id,
infoSource: 'local'
} }
this.playlistItems = result.items this.playlistItems = result.items
@ -127,12 +128,13 @@ export default Vue.extend({
id: result.playlistId, id: result.playlistId,
title: result.title, title: result.title,
description: result.description, description: result.description,
thumbnail: result.videos[randomVideoIndex].videoThumbnails[0].url, randomVideoId: result.videos[randomVideoIndex].videoId,
viewCount: result.viewCount, viewCount: result.viewCount,
videoCount: result.videoCount, videoCount: result.videoCount,
channelName: result.author, channelName: result.author,
channelThumbnail: result.authorThumbnails[2].url, channelThumbnail: result.authorThumbnails[2].url,
channelId: result.authorId channelId: result.authorId,
infoSource: 'invidious'
} }
const dateString = new Date(result.updated * 1000) const dateString = new Date(result.updated * 1000)

View File

@ -9,18 +9,21 @@
:data="infoData" :data="infoData"
class="playlistInfo" class="playlistInfo"
/> />
<ft-flex-box <ft-card
v-if="!isLoading" v-if="!isLoading"
class="playlistItems" class="playlistItems"
> >
<ft-list-video <ft-flex-box>
v-for="(item, index) in playlistItems" <ft-list-video
:key="index" v-for="(item, index) in playlistItems"
:data="item" :key="index"
force-list-type="list" :data="item"
class="playlistItem" :playlist-id="playlistId"
/> force-list-type="list"
</ft-flex-box> class="playlistItem"
/>
</ft-flex-box>
</ft-card>
</div> </div>
</template> </template>

View File

@ -27,14 +27,40 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.watchVideoRecommendations { .watchVideoSideBar {
width: 27%; width: 27%;
max-width: 425px; max-width: 425px;
float: right; float: right;
margin-bottom: 10px; margin-bottom: 10px;
position: absolute; position: absolute;
top: 70px; }
.watchVideoPlaylist {
right: 10px; right: 10px;
top: 70px;
height: 500px;
}
.theatrePlaylist {
float: none;
margin: 0 auto;
width: 85%;
height: 500px;
margin-bottom: 10px;
max-width: none;
position: static;
}
.watchVideoRecommendations {
right: 10px;
}
.watchVideoRecommendationsNoCard {
top: 70px;
}
.watchVideoRecommendationsLowerCard {
top: 600px;
} }
.theatreRecommendations { .theatreRecommendations {
@ -78,6 +104,14 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.watchVideoPlaylist {
float: none;
margin: 0 auto;
width: 85%;
max-width: none;
position: static;
}
.watchVideoRecommendations { .watchVideoRecommendations {
float: none; float: none;
margin: 0 auto; margin: 0 auto;

View File

@ -8,6 +8,7 @@ import FtVideoPlayer from '../../components/ft-video-player/ft-video-player.vue'
import WatchVideoInfo from '../../components/watch-video-info/watch-video-info.vue' import WatchVideoInfo from '../../components/watch-video-info/watch-video-info.vue'
import WatchVideoDescription from '../../components/watch-video-description/watch-video-description.vue' import WatchVideoDescription from '../../components/watch-video-description/watch-video-description.vue'
import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.vue' import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.vue'
import WatchVideoPlaylist from '../../components/watch-video-playlist/watch-video-playlist.vue'
import WatchVideoRecommendations from '../../components/watch-video-recommendations/watch-video-recommendations.vue' import WatchVideoRecommendations from '../../components/watch-video-recommendations/watch-video-recommendations.vue'
export default Vue.extend({ export default Vue.extend({
@ -20,6 +21,7 @@ export default Vue.extend({
'watch-video-info': WatchVideoInfo, 'watch-video-info': WatchVideoInfo,
'watch-video-description': WatchVideoDescription, 'watch-video-description': WatchVideoDescription,
'watch-video-comments': WatchVideoComments, 'watch-video-comments': WatchVideoComments,
'watch-video-playlist': WatchVideoPlaylist,
'watch-video-recommendations': WatchVideoRecommendations, 'watch-video-recommendations': WatchVideoRecommendations,
}, },
data: function() { data: function() {
@ -31,6 +33,7 @@ export default Vue.extend({
showLegacyPlayer: false, showLegacyPlayer: false,
showYouTubeNoCookieEmbed: false, showYouTubeNoCookieEmbed: false,
hidePlayer: false, hidePlayer: false,
isLive: false,
activeFormat: 'legacy', activeFormat: 'legacy',
videoId: '', videoId: '',
videoTitle: '', videoTitle: '',
@ -49,6 +52,8 @@ export default Vue.extend({
videoSourceList: [], videoSourceList: [],
captionSourceList: [], captionSourceList: [],
recommendedVideos: [], recommendedVideos: [],
watchingPlaylist: false,
playlistId: '',
} }
}, },
computed: { computed: {
@ -111,6 +116,8 @@ export default Vue.extend({
this.firstLoad = true this.firstLoad = true
this.checkIfPlaylist()
switch (this.backendPreference) { switch (this.backendPreference) {
case 'local': case 'local':
this.getVideoInformationLocal(this.videoId) this.getVideoInformationLocal(this.videoId)
@ -125,13 +132,15 @@ export default Vue.extend({
} }
}, },
}, },
mounted: function() { mounted: function () {
this.videoId = this.$route.params.id this.videoId = this.$route.params.id
this.videoStoryboardSrc = `${this.invidiousInstance}/api/v1/storyboards/${this.videoId}?height=90` this.videoStoryboardSrc = `${this.invidiousInstance}/api/v1/storyboards/${this.videoId}?height=90`
this.activeFormat = this.defaultVideoFormat this.activeFormat = this.defaultVideoFormat
this.useTheatreMode = this.defaultTheatreMode this.useTheatreMode = this.defaultTheatreMode
this.checkIfPlaylist()
if (!this.usingElectron) { if (!this.usingElectron) {
this.getVideoInformationInvidious() this.getVideoInformationInvidious()
} else { } else {
@ -158,6 +167,7 @@ export default Vue.extend({
this.$store this.$store
.dispatch('ytGetVideoInformation', this.videoId) .dispatch('ytGetVideoInformation', this.videoId)
.then(result => { .then(result => {
console.log(result)
this.videoTitle = result.title this.videoTitle = result.title
this.videoViewCount = parseInt( this.videoViewCount = parseInt(
result.player_response.videoDetails.viewCount, result.player_response.videoDetails.viewCount,
@ -170,9 +180,24 @@ export default Vue.extend({
this.videoDescription = this.videoDescription =
result.player_response.videoDetails.shortDescription result.player_response.videoDetails.shortDescription
this.recommendedVideos = result.related_videos this.recommendedVideos = result.related_videos
this.videoSourceList = result.player_response.streamingData.formats
this.videoLikeCount = result.likes this.videoLikeCount = result.likes
this.videoDislikeCount = result.dislikes this.videoDislikeCount = result.dislikes
this.isLive = result.player_response.videoDetails.isLive
if (this.isLive) {
this.showLegacyPlayer = false
this.showDashPlayer = true
this.videoSourceList = [
{
url: 'https://invidious.snopyta.org/api/manifest/dash/id/EEIk7gwjgIM',
type: 'application/dash+xml',
label: 'Dash',
qualityLabel: 'Auto'
},
]
} else {
this.videoSourceList = result.player_response.streamingData.formats
}
// The response provides a storyboard, however it returns a 403 error. // The response provides a storyboard, however it returns a 403 error.
// Uncomment this line if that ever changes. // Uncomment this line if that ever changes.
@ -275,6 +300,22 @@ export default Vue.extend({
}) })
}, },
checkIfPlaylist: function () {
if (typeof (this.$route.query) !== 'undefined') {
console.log('defined')
console.log(this.$route.query)
this.playlistId = this.$route.query.playlistId
if (typeof (this.playlistId) !== 'undefined') {
this.watchingPlaylist = true
} else {
this.watchingPlaylist = false
}
} else {
this.watchingPlaylist = false
}
},
getLegacyFormats: function () { getLegacyFormats: function () {
this.$store this.$store
.dispatch('ytGetVideoInformation', this.videoId) .dispatch('ytGetVideoInformation', this.videoId)
@ -296,7 +337,7 @@ export default Vue.extend({
}, 100) }, 100)
}, },
enableLegacyFormat: function() { enableLegacyFormat: function () {
if (this.activeFormat === 'legacy') { if (this.activeFormat === 'legacy') {
return return
} }
@ -309,6 +350,15 @@ export default Vue.extend({
}, 100) }, 100)
}, },
handleVideoEnded: function () {
if (this.watchingPlaylist) {
console.log('Playlist next video in 5 seconds')
setTimeout(() => {
this.$refs.watchVideoPlaylist.playNextVideo()
}, 5000)
}
},
handleVideoError: function(error) { handleVideoError: function(error) {
console.log(error) console.log(error)
if (error.code === 4) { if (error.code === 4) {

View File

@ -14,6 +14,7 @@
class="videoPlayer" class="videoPlayer"
:class="{ theatrePlayer: useTheatreMode }" :class="{ theatrePlayer: useTheatreMode }"
ref="videoPlayer" ref="videoPlayer"
@ended="handleVideoEnded"
@error="handleVideoError" @error="handleVideoError"
/> />
<watch-video-info <watch-video-info
@ -45,11 +46,24 @@
class="watchVideo" class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }" :class="{ theatreWatchVideo: useTheatreMode }"
/> />
<watch-video-playlist
v-if="watchingPlaylist"
v-show="!isLoading"
:playlist-id="playlistId"
:video-id="videoId"
ref="watchVideoPlaylist"
class="watchVideoSideBar watchVideoPlaylist"
:class="{ theatrePlaylist: useTheatreMode }"
/>
<watch-video-recommendations <watch-video-recommendations
v-if="!isLoading" v-if="!isLoading"
:data="recommendedVideos" :data="recommendedVideos"
class="watchVideoRecommendations" class="watchVideoSideBar watchVideoRecommendations"
:class="{ theatreRecommendations: useTheatreMode }" :class="{
theatreRecommendations: useTheatreMode,
watchVideoRecommendationsLowerCard: watchingPlaylist,
watchVideoRecommendationsNoCard: !watchingPlaylist
}"
/> />
</div> </div>
</template> </template>