Merge branch 'Subscriptions'

This commit is contained in:
Preston 2020-09-01 23:22:02 -04:00
commit 52475877fe
44 changed files with 1867 additions and 83 deletions

57
package-lock.json generated
View File

@ -11136,6 +11136,14 @@
} }
} }
}, },
"javascript-time-ago": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.0.13.tgz",
"integrity": "sha512-zH+obXUQ4vlc9UlERFe637rNJQaVYLizwODUfGzYN/cNW/owkk5wzb327gAfEXFpI4yhFcStEaoqoJtMGAmrAg==",
"requires": {
"relative-time-format": "^0.1.3"
}
},
"jest": { "jest": {
"version": "26.4.2", "version": "26.4.2",
"resolved": "https://registry.npmjs.org/jest/-/jest-26.4.2.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-26.4.2.tgz",
@ -13318,6 +13326,11 @@
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=", "integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=",
"dev": true "dev": true
}, },
"lodash.uniqwith": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniqwith/-/lodash.uniqwith-4.5.0.tgz",
"integrity": "sha1-egy/ZfQ7WShiWp1NDcVLGMrcfvM="
},
"log-symbols": { "log-symbols": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz",
@ -16235,6 +16248,11 @@
"integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
"dev": true "dev": true
}, },
"relative-time-format": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-0.1.3.tgz",
"integrity": "sha512-0O6i4fKjsx8qhz57zorG+LrIDnF9pSvP5s7H9R1Nb5nSqih5dvRyKzNKs6MxhL3bv4iwsz4DuDwAyw+c47QFIA=="
},
"remove-trailing-separator": { "remove-trailing-separator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@ -16517,6 +16535,31 @@
"sprintf-js": "^1.1.2" "sprintf-js": "^1.1.2"
} }
}, },
"rss-parser": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.9.0.tgz",
"integrity": "sha512-wlRSfGrotOXuWo19Dtl2KmQt7o9i5zzCExUrxpechE0O54BAx7JD+xhWyGumPPqiJj771ndflV3sE3bTHen0HQ==",
"requires": {
"entities": "^2.0.3",
"xml2js": "^0.4.19"
},
"dependencies": {
"entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
}
}
},
"rss-to-json": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/rss-to-json/-/rss-to-json-1.1.1.tgz",
"integrity": "sha512-d+TwrFI5wAHbZ/fTd3Pvty14tadBjKHAjfMcUam9FWoWrC9g5rHJN9Slw10OZwk6Mey+hqdXwdmymO7d8ebVmw==",
"requires": {
"axios": "^0.19.2",
"xml2json": "^0.12.0"
}
},
"rsvp": { "rsvp": {
"version": "4.8.5", "version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@ -20244,6 +20287,15 @@
"integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==",
"dev": true "dev": true
}, },
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
},
"xml2json": { "xml2json": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/xml2json/-/xml2json-0.12.0.tgz", "resolved": "https://registry.npmjs.org/xml2json/-/xml2json-0.12.0.tgz",
@ -20254,6 +20306,11 @@
"node-expat": "^2.3.18" "node-expat": "^2.3.18"
} }
}, },
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
},
"xmlchars": { "xmlchars": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",

View File

@ -20,10 +20,12 @@
"js-yaml": "^3.14.0", "js-yaml": "^3.14.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.uniqwith": "^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",
"rss-parser": "^3.9.0",
"video.js": "7.6.6", "video.js": "7.6.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",

View File

@ -3,6 +3,7 @@ import { ObserveVisibility } from 'vue-observe-visibility'
import TopNav from './components/top-nav/top-nav.vue' import TopNav from './components/top-nav/top-nav.vue'
import SideNav from './components/side-nav/side-nav.vue' import SideNav from './components/side-nav/side-nav.vue'
import FtToast from './components/ft-toast/ft-toast.vue' import FtToast from './components/ft-toast/ft-toast.vue'
import FtProgressBar from './components/ft-progress-bar/ft-progress-bar.vue'
import $ from 'jquery' import $ from 'jquery'
let useElectron let useElectron
@ -23,16 +24,21 @@ export default Vue.extend({
components: { components: {
TopNav, TopNav,
SideNav, SideNav,
FtToast FtToast,
FtProgressBar
}, },
computed: { computed: {
isOpen: function () { isOpen: function () {
return this.$store.getters.getIsSideNavOpen return this.$store.getters.getIsSideNavOpen
},
showProgressBar: function () {
return this.$store.getters.getShowProgressBar
} }
}, },
mounted: function () { mounted: function () {
this.$store.dispatch('grabUserSettings') this.$store.dispatch('grabUserSettings')
this.$store.dispatch('grabHistory') this.$store.dispatch('grabHistory')
this.$store.dispatch('grabAllProfiles', this.$t('Profile.All Channels'))
this.$store.commit('setUsingElectron', useElectron) this.$store.commit('setUsingElectron', useElectron)
this.checkThemeSettings() this.checkThemeSettings()
this.checkLocale() this.checkLocale()

View File

@ -15,6 +15,9 @@
<!-- </keep-alive> --> <!-- </keep-alive> -->
</Transition> </Transition>
<ft-toast /> <ft-toast />
<ft-progress-bar
v-if="showProgressBar"
/>
</div> </div>
</template> </template>

View File

@ -23,6 +23,10 @@ export default Vue.extend({
type: Boolean, type: Boolean,
default: false default: false
}, },
disabled: {
type: Boolean,
default: false
},
dataList: { dataList: {
type: Array, type: Array,
default: () => { return [] } default: () => { return [] }
@ -47,6 +51,11 @@ export default Vue.extend({
return `${this.id}_datalist` return `${this.id}_datalist`
} }
}, },
watch: {
value: function (val) {
this.inputData = val
}
},
mounted: function () { mounted: function () {
this.id = this._uid this.id = this._uid
this.inputData = this.value this.inputData = this.value

View File

@ -20,6 +20,7 @@
type="text" type="text"
:placeholder="placeholder" :placeholder="placeholder"
@input="e => handleInput(e.target.value)" @input="e => handleInput(e.target.value)"
:disabled="disabled"
> >
<font-awesome-icon <font-awesome-icon
v-if="showArrow" v-if="showArrow"

View File

@ -255,7 +255,8 @@ export default Vue.extend({
liveStreamString: this.$t('Video.Watching'), liveStreamString: this.$t('Video.Watching'),
upcomingString: this.$t('Video.Published.Upcoming'), upcomingString: this.$t('Video.Published.Upcoming'),
isLive: this.isLive, isLive: this.isLive,
isUpcoming: this.data.isUpcoming isUpcoming: this.data.isUpcoming,
isRSS: this.data.isRSS
}).then((data) => { }).then((data) => {
this.uploadedTime = data this.uploadedTime = data
}).catch((error) => { }).catch((error) => {

View File

@ -0,0 +1,41 @@
.bubblePadding {
width: 100px;
height: 115px;
padding: 10px;
cursor: pointer;
-webkit-transition: background 0.2s ease-out;
-moz-transition: background 0.2s ease-out;
-o-transition: background 0.2s ease-out;
transition: background 0.2s ease-out;
}
.bubblePadding:hover {
background-color: var(--side-nav-hover-color);
-moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in;
transition: background 0.2s ease-in;
}
.bubble {
width: 50px;
height: 50px;
margin-top: 20px;
margin-bottom: 5px;
margin-left: 25px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.initial {
font-size: 25px;
text-align: center;
position: relative;
top: 12px;
}
.profileName {
font-size: 13px;
height: 60px;
overflow: hidden;
text-align: center;
}

View File

@ -0,0 +1,35 @@
import Vue from 'vue'
export default Vue.extend({
name: 'FtProfileBubble',
props: {
profileName: {
type: String,
required: true
},
profileId: {
type: String,
required: true
},
backgroundColor: {
type: String,
required: true
},
textColor: {
type: String,
required: true
}
},
computed: {
profileInitial: function () {
return this.profileName.slice(0, 1).toUpperCase()
}
},
methods: {
goToProfile: function () {
this.$router.push({
path: `/settings/profile/edit/${this.profileId}`
})
}
}
})

View File

@ -0,0 +1,21 @@
<template>
<div
class="bubblePadding"
@click="goToProfile"
>
<div
class="bubble"
:style="{ background: backgroundColor, color: textColor }"
>
<p class="initial">
{{ profileInitial }}
</p>
</div>
<div class="profileName">
{{ profileName }}
</div>
</div>
</template>
<script src="./ft-profile-bubble.js" />
<style scoped src="./ft-profile-bubble.css" />

View File

@ -0,0 +1,77 @@
.colorOption {
width: 40px;
height: 40px;
margin: 10px;
cursor: pointer;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.initial {
font-size: 20px;
text-align: center;
position: relative;
bottom: 30px;
}
#profileList {
display: none;
position: absolute;
top: 60px;
right: 10px;
min-width: 250px;
height: 300px;
padding: 5px;
background-color: var(--card-bg-color);
box-shadow: 0 1px 2px rgba(0,0,0,.1);
}
#profileList:focus {
display: inline;
outline: none;
}
.profileWrapper {
margin-top: 60px;
height: 240px;
overflow-y: auto;
}
.profile {
cursor: pointer;
height: 50px;
-webkit-transition: background 0.2s ease-out;
-moz-transition: background 0.2s ease-out;
-o-transition: background 0.2s ease-out;
transition: background 0.2s ease-out;
}
.profile:hover {
background-color: var(--side-nav-hover-color);
-moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in;
transition: background 0.2s ease-in;
}
.profile .colorOption {
float: left;
position: relative;
bottom: 5px;
}
.profileName {
line-height: 50px;
}
.profileListTitle {
position: absolute;
top: -15px;
left: 10px;
}
.profileSettings {
float: right;
position: absolute;
top: 10px;
right: 5px;
}

View File

@ -0,0 +1,90 @@
import Vue from 'vue'
import { mapActions } from 'vuex'
import $ from 'jquery'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
export default Vue.extend({
name: 'FtProfileSelector',
components: {
'ft-card': FtCard,
'ft-icon-button': FtIconButton
},
data: function () {
return {
showProfileList: false
}
},
computed: {
profileList: function () {
return this.$store.getters.getProfileList
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
defaultProfile: function () {
return this.$store.getters.getDefaultProfile
},
activeProfileInitial: function () {
return this.activeProfile.name.slice(0, 1).toUpperCase()
},
profileInitials: function () {
return this.profileList.map((profile) => {
return profile.name.slice(0, 1).toUpperCase()
})
}
},
mounted: function () {
setTimeout(() => {
const profileIndex = this.profileList.findIndex((profile) => {
return profile._id === this.defaultProfile
})
if (profileIndex !== -1) {
this.updateActiveProfile(profileIndex)
}
}, 100)
$('#profileList').focusout(() => {
$('#profileList')[0].style.display = 'none'
})
},
methods: {
toggleProfileList: function () {
$('#profileList')[0].style.display = 'inline'
$('#profileList').focus()
},
openProfileSettings: function () {
this.$router.push({
path: '/settings/profile/'
})
$('#profileList').focusout()
},
setActiveProfile: function (profile) {
if (this.profileList[this.activeProfile]._id === profile._id) {
return
}
const index = this.profileList.findIndex((x) => {
return x._id === profile._id
})
if (index === -1) {
return
}
this.updateActiveProfile(index)
const message = this.$t('Profile.$ is now the active profile').replace('$', profile.name)
this.showToast({
message: message
})
$('#profileList').focusout()
},
...mapActions([
'showToast',
'updateActiveProfile'
])
}
})

View File

@ -0,0 +1,59 @@
<template>
<div>
<div
class="colorOption"
:style="{ background: profileList[activeProfile].bgColor, color: profileList[activeProfile].textColor }"
@click="toggleProfileList"
>
<p
class="initial"
>
{{ profileInitials[activeProfile] }}
</p>
</div>
<ft-card
id="profileList"
tabindex="-1"
>
<h3
class="profileListTitle"
>
Profile Select
</h3>
<ft-icon-button
class="profileSettings"
icon="sliders-h"
@click="openProfileSettings"
/>
<div
class="profileWrapper"
>
<div
class="profile"
v-for="(profile, index) in profileList"
:key="index"
@click="setActiveProfile(profile)"
>
<div
class="colorOption"
:style="{ background: profile.bgColor, color: profile.textColor }"
>
<p
class="initial"
>
{{ profileInitials[index] }}
</p>
</div>
<p
class="profileName"
>
{{ profile.name }}
</p>
</div>
</div>
</ft-card>
</div>
</template>
<script src="./ft-profile-selector.js" />
<style scoped src="./ft-profile-selector.css" />

View File

@ -0,0 +1,9 @@
.progressBar {
position: fixed;
height: 3px;
bottom: 0px;
left: 0px;
background-color: var(--primary-color);
z-index: 1;
transition: width 0.5s;
}

View File

@ -0,0 +1,10 @@
import Vue from 'vue'
export default Vue.extend({
name: 'FtProgressBar',
computed: {
progressBarPercentage: function () {
return this.$store.getters.getProgressBarPercentage
}
}
})

View File

@ -0,0 +1,9 @@
<template>
<div
class="progressBar"
:style="{ width: progressBarPercentage + '%' }"
/>
</template>
<script src="./ft-progress-bar.js" />
<style scoped src="./ft-progress-bar.css" />

View File

@ -30,7 +30,7 @@
margin-top: 10px; margin-top: 10px;
} }
.navOption { .navOption, .navChannel {
position: relative; position: relative;
padding: 5px; padding: 5px;
cursor: pointer; cursor: pointer;
@ -40,14 +40,14 @@
display: none; display: none;
} }
.navOption:hover { .navOption:hover, .navChannel:hover {
background-color: var(--side-nav-hover-color); background-color: var(--side-nav-hover-color);
-moz-transition: background 0.2s ease-in; -moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in; -o-transition: background 0.2s ease-in;
transition: background 0.2s ease-in; transition: background 0.2s ease-in;
} }
.navOption:active { .navOption:active, .navChannel:active {
background-color: var(--side-nav-active-color); background-color: var(--side-nav-active-color);
-moz-transition: background 0.2s ease-in; -moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in; -o-transition: background 0.2s ease-in;
@ -63,6 +63,26 @@
display: inline-block; display: inline-block;
} }
.navChannel .navLabel {
font-size: 11px;
width: 150px;
margin-left: 40px;
}
.thumbnailContainer {
width: 35px;
margin: 0;
position: absolute;
top: 50%;
-ms-transform: translateY(-50%);
transform: translateY(-50%);
}
.channelThumbnail {
border-radius: 50%;
width: 35px;
}
.closed { .closed {
width: 80px; width: 80px;
} }
@ -107,6 +127,24 @@
font-size: 11px; font-size: 11px;
} }
.closed .navChannel {
width: 100%;
padding: 0px;
padding-top: 10px;
padding-bottom: 10px;
}
.closed .thumbnailContainer {
position: static;
display: block;
float: none;
margin-left: 0px;
margin: 0 auto;
top: 0px;
-ms-transform: none;
transform: none;
}
@media only screen and (max-width: 680px) { @media only screen and (max-width: 680px) {
.inner { .inner {
display: contents; /* sunglasses emoji */ display: contents; /* sunglasses emoji */

View File

@ -12,11 +12,35 @@ export default Vue.extend({
computed: { computed: {
isOpen: function () { isOpen: function () {
return this.$store.getters.getIsSideNavOpen return this.$store.getters.getIsSideNavOpen
},
profileList: function () {
return this.$store.getters.getProfileList
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
activeSubscriptions: function () {
const profile = JSON.parse(JSON.stringify(this.profileList[this.activeProfile]))
return profile.subscriptions.sort((a, b) => {
const nameA = a.name.toLowerCase()
const nameB = b.name.toLowerCase()
if (nameA < nameB) {
return -1
}
if (nameA > nameB) {
return 1
}
return 0
})
} }
}, },
methods: { methods: {
navigate: function (route) { navigate: function (route) {
router.push('/' + route) router.push('/' + route)
},
goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` })
} }
} }
}) })

View File

@ -16,10 +16,6 @@
<p class="navLabel"> <p class="navLabel">
{{ $t("Subscriptions.Subscriptions") }} {{ $t("Subscriptions.Subscriptions") }}
</p> </p>
<font-awesome-icon
class="refreshIcon"
icon="sync"
/>
</div> </div>
<div <div
class="navOption mobileHidden" class="navOption mobileHidden"
@ -98,6 +94,28 @@
</p> </p>
</div> </div>
<hr> <hr>
<div
v-for="(channel, index) in activeSubscriptions"
:key="index"
class="navChannel mobileHidden"
:title="channel.name"
@click="goToChannel(channel.id)"
>
<div
class="thumbnailContainer"
>
<img
class="channelThumbnail"
:src="channel.thumbnail"
/>
</div>
<p
class="navLabel"
v-if="isOpen"
>
{{ channel.name }}
</p>
</div>
</div> </div>
</ft-flex-box> </ft-flex-box>
</template> </template>

View File

@ -1,4 +1,5 @@
import Vue from 'vue' import Vue from 'vue'
import { mapActions } from 'vuex'
import FtCard from '../ft-card/ft-card.vue' import FtCard from '../ft-card/ft-card.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtButton from '../ft-button/ft-button.vue' import FtButton from '../ft-button/ft-button.vue'
@ -27,9 +28,18 @@ export default Vue.extend({
] ]
} }
}, },
methods: { computed: {
goToChannel: function () { hideWatchedSubs: function () {
console.log('TODO: Handle goToChannel') return this.$store.getters.getHideWatchedSubs
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
} }
},
methods: {
...mapActions([
'updateHideWatchedSubs',
'updateUseRssFeeds'
])
} }
}) })

View File

@ -5,16 +5,24 @@
<h3 <h3
class="videoTitle" class="videoTitle"
> >
{{ title }} {{ $t("Settings.Subscription Settings.Subscription Settings") }}
</h3> </h3>
<ft-flex-box class="subscriptionSettingsFlexBox"> <ft-flex-box class="subscriptionSettingsFlexBox">
<ft-toggle-switch <ft-toggle-switch
label="Hide Videos When Watched" :label="$t('Settings.Subscription Settings.Hide Videos on Watch')"
:default-value="hideWatchedSubs"
@change="updateHideWatchedSubs"
/>
<ft-toggle-switch
:label="$t('Settings.Subscription Settings.Fetch Feeds from RSS')"
:default-value="useRssFeeds"
@change="updateUseRssFeeds"
/> />
</ft-flex-box> </ft-flex-box>
<br> <br>
<ft-flex-box> <ft-flex-box>
<ft-select <ft-select
v-if="false"
placeholder="Subscription View Type" placeholder="Subscription View Type"
:value="viewValues[0]" :value="viewValues[0]"
:select-names="viewNames" :select-names="viewNames"
@ -24,6 +32,7 @@
<br> <br>
<ft-flex-box> <ft-flex-box>
<ft-button <ft-button
v-if="false"
label="Manage My Subscriptions" label="Manage My Subscriptions"
/> />
</ft-flex-box> </ft-flex-box>

View File

@ -1,6 +1,7 @@
import Vue from 'vue' import Vue from 'vue'
import FtInput from '../ft-input/ft-input.vue' import FtInput from '../ft-input/ft-input.vue'
import FtSearchFilters from '../ft-search-filters/ft-search-filters.vue' import FtSearchFilters from '../ft-search-filters/ft-search-filters.vue'
import FtProfileSelector from '../ft-profile-selector/ft-profile-selector.vue'
import $ from 'jquery' import $ from 'jquery'
import router from '../../router/index.js' import router from '../../router/index.js'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
@ -10,7 +11,8 @@ export default Vue.extend({
name: 'TopNav', name: 'TopNav',
components: { components: {
FtInput, FtInput,
FtSearchFilters FtSearchFilters,
FtProfileSelector
}, },
data: () => { data: () => {
return { return {

View File

@ -59,6 +59,9 @@
display: flex display: flex
align-items: center align-items: center
&.profiles
justify-content: flex-end
.navSearchIcon .navSearchIcon
@media only screen and (min-width: 681px) @media only screen and (min-width: 681px)
display: none display: none

View File

@ -55,7 +55,7 @@
:class="{ expand: !isSideNavOpen }" :class="{ expand: !isSideNavOpen }"
/> />
</div> </div>
<div class="side" /> <ft-profile-selector class="side profiles" />
</div> </div>
</template> </template>

View File

@ -79,6 +79,14 @@ export default Vue.extend({
return this.$store.getters.getUsingElectron return this.$store.getters.getUsingElectron
}, },
profileList: function () {
return this.$store.getters.getProfileList
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
formatTypeNames: function () { formatTypeNames: function () {
return [ return [
this.$t('Change Format.Use Dash Formats').toUpperCase(), this.$t('Change Format.Use Dash Formats').toUpperCase(),
@ -99,8 +107,24 @@ export default Vue.extend({
return this.viewCount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ` ${this.$t('Video.Views').toLowerCase()}` return this.viewCount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ` ${this.$t('Video.Views').toLowerCase()}`
}, },
isSubscribed: function () {
const subIndex = this.profileList[this.activeProfile].subscriptions.findIndex((channel) => {
return channel.id === this.channelId
})
if (subIndex === -1) {
return false
} else {
return true
}
},
subscribedText: function () { subscribedText: function () {
return `${this.$t('Channel.Subscribe').toUpperCase()} ${this.subscriptionCountText}` if (this.isSubscribed) {
return `${this.$t('Channel.Unsubscribe').toUpperCase()} ${this.subscriptionCountText}`
} else {
return `${this.$t('Channel.Subscribe').toUpperCase()} ${this.subscriptionCountText}`
}
}, },
dateString() { dateString() {
@ -116,9 +140,74 @@ export default Vue.extend({
}, },
handleSubscription: function () { handleSubscription: function () {
this.showToast({ const currentProfile = JSON.parse(JSON.stringify(this.profileList[this.activeProfile]))
message: 'Subscriptions have not yet been implemented' const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0]))
})
if (this.isSubscribed) {
currentProfile.subscriptions = currentProfile.subscriptions.filter((channel) => {
return channel.id !== this.channelId
})
this.updateProfile(currentProfile)
this.showToast({
message: 'Channel has been removed from your subscriptions'
})
if (this.activeProfile === 0) {
// Check if a subscription exists in a different profile.
// Remove from there as well.
let duplicateSubscriptions = 0
this.profileList.forEach((profile) => {
if (profile._id === 'allChannels') {
return
}
const parsedProfile = JSON.parse(JSON.stringify(profile))
const index = parsedProfile.subscriptions.findIndex((channel) => {
return channel.id === this.channelId
})
if (index !== -1) {
duplicateSubscriptions++
parsedProfile.subscriptions = parsedProfile.subscriptions.filter((x) => {
return x.id !== this.channelId
})
this.updateProfile(parsedProfile)
}
})
if (duplicateSubscriptions > 0) {
this.showToast({
message: `Removed subscription from ${duplicateSubscriptions} other channel(s)`
})
}
}
} else {
const subscription = {
id: this.channelId,
name: this.channelName,
thumbnail: this.channelThumbnail
}
currentProfile.subscriptions.push(subscription)
this.updateProfile(currentProfile)
this.showToast({
message: 'Added channel to your subscriptions'
})
if (this.activeProfile !== 0) {
const index = primaryProfile.subscriptions.findIndex((channel) => {
return channel.id === this.channelId
})
if (index === -1) {
primaryProfile.subscriptions.push(subscription)
this.updateProfile(primaryProfile)
}
}
}
}, },
handleFormatChange: function (format) { handleFormatChange: function (format) {
@ -136,7 +225,8 @@ export default Vue.extend({
}, },
...mapActions([ ...mapActions([
'showToast' 'showToast',
'updateProfile'
]) ])
} }
}) })

View File

@ -30,6 +30,7 @@
:label="subscribedText" :label="subscribedText"
class="subscribeButton" class="subscribeButton"
background-color="var(--primary-color)" background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="handleSubscription" @click="handleSubscription"
/> />
</div> </div>

View File

@ -1,6 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import Router from 'vue-router' import Router from 'vue-router'
import Subscriptions from '../views/Subscriptions/Subscriptions.vue' import Subscriptions from '../views/Subscriptions/Subscriptions.vue'
import ProfileSettings from '../views/ProfileSettings/ProfileSettings.vue'
import ProfileEdit from '../views/ProfileEdit/ProfileEdit.vue'
import Trending from '../views/Trending/Trending.vue' import Trending from '../views/Trending/Trending.vue'
import Popular from '../views/Popular/Popular.vue' import Popular from '../views/Popular/Popular.vue'
import UserPlaylists from '../views/UserPlaylists/UserPlaylists.vue' import UserPlaylists from '../views/UserPlaylists/UserPlaylists.vue'
@ -32,6 +34,32 @@ const router = new Router({
}, },
component: Subscriptions component: Subscriptions
}, },
{
path: '/settings/profile',
meta: {
title: 'Profile Settings',
icon: 'fa-home'
},
component: ProfileSettings
},
{
path: '/settings/profile/new',
name: 'newProfile',
meta: {
title: 'New Profile',
icon: 'fa-home'
},
component: ProfileEdit
},
{
path: '/settings/profile/edit/:id',
name: 'editProfile',
meta: {
title: 'Edit Profile',
icon: 'fa-home'
},
component: ProfileEdit
},
{ {
path: '/trending', path: '/trending',
meta: { meta: {

View File

@ -0,0 +1,146 @@
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')
} */
const electron = require('electron')
dbLocation = electron.remote.app.getPath('userData')
dbLocation = dbLocation + '/profiles.db'
} else {
dbLocation = 'profiles.db'
}
const profileDb = new Datastore({
filename: dbLocation,
autoload: true
})
const state = {
profileList: [{
_id: 'allChannels',
name: 'All Channels',
bgColor: '#000000',
textColor: '#FFFFFF',
subscriptions: []
}],
activeProfile: 0
}
const getters = {
getProfileList: () => {
return state.profileList
},
getActiveProfile: () => {
return state.activeProfile
}
}
const actions = {
grabAllProfiles ({ dispatch, commit }, defaultName = null) {
profileDb.find({}, (err, results) => {
if (!err) {
if (results.length === 0) {
dispatch('createDefaultProfile', defaultName)
} else {
// We want the primary profile to always be first
// So sort with that then sort alphabetically by profile name
const profiles = results.sort((a, b) => {
if (a._id === 'allChannels') {
return -1
}
if (b._id === 'allChannels') {
return 1
}
return b.name - a.name
})
commit('setProfileList', profiles)
}
}
})
},
grabProfileInfo (_, profileId) {
return new Promise((resolve, reject) => {
console.log(profileId)
profileDb.findOne({ _id: profileId }, (err, results) => {
if (!err) {
resolve(results)
}
})
})
},
async createDefaultProfile ({ dispatch }, defaultName) {
const randomColor = await dispatch('getRandomColor')
const textColor = await dispatch('calculateColorLuminance', randomColor)
const defaultProfile = {
_id: 'allChannels',
name: defaultName,
bgColor: randomColor,
textColor: textColor,
subscriptions: []
}
console.log(defaultProfile)
profileDb.update({ _id: 'allChannels' }, defaultProfile, { upsert: true }, (err, numReplaced) => {
if (!err) {
dispatch('grabAllProfiles')
}
})
},
updateProfile ({ dispatch }, profile) {
profileDb.update({ _id: profile._id }, profile, { upsert: true }, (err, numReplaced) => {
if (!err) {
dispatch('grabAllProfiles')
}
})
},
insertProfile ({ dispatch }, profile) {
profileDb.insert(profile, (err, newDocs) => {
if (!err) {
dispatch('grabAllProfiles')
}
})
},
removeProfile ({ dispatch }, profileId) {
profileDb.remove({ _id: profileId }, (err, numReplaced) => {
if (!err) {
dispatch('grabAllProfiles')
}
})
},
updateActiveProfile ({ commit }, index) {
commit('setActiveProfile', index)
}
}
const mutations = {
setProfileList (state, profileList) {
state.profileList = profileList
},
setActiveProfile (state, activeProfile) {
state.activeProfile = activeProfile
}
}
export default {
state,
getters,
actions,
mutations
}

View File

@ -36,6 +36,7 @@ const state = {
listType: 'grid', listType: 'grid',
thumbnailPreference: '', thumbnailPreference: '',
invidiousInstance: 'https://invidio.us', invidiousInstance: 'https://invidio.us',
defaultProfile: 'allChannels',
barColor: false, barColor: false,
enableSearchSuggestions: true, enableSearchSuggestions: true,
rememberHistory: true, rememberHistory: true,
@ -56,9 +57,8 @@ const state = {
debugMode: false, debugMode: false,
disctractionFreeMode: false, disctractionFreeMode: false,
hideWatchedSubs: false, hideWatchedSubs: false,
usingElectron: true, useRssFeeds: false,
profileList: [{ name: 'All Channels', color: '#304FFE' }], usingElectron: true
defaultProfile: 'All Channels'
} }
const getters = { const getters = {
@ -102,6 +102,10 @@ const getters = {
return state.invidiousInstance return state.invidiousInstance
}, },
getDefaultProfile: () => {
return state.defaultProfile
},
getRememberHistory: () => { getRememberHistory: () => {
return state.rememberHistory return state.rememberHistory
}, },
@ -154,13 +158,21 @@ const getters = {
return state.defaultQuality return state.defaultQuality
}, },
getHideWatchedSubs: () => {
return state.hideWatchedSubs
},
getUseRssFeeds: () => {
return state.useRssFeeds
},
getUsingElectron: () => { getUsingElectron: () => {
return state.usingElectron return state.usingElectron
} }
} }
const actions = { const actions = {
grabUserSettings ({ dispatch, commit }) { grabUserSettings ({ dispatch, commit, rootState }) {
settingsDb.find({}, (err, results) => { settingsDb.find({}, (err, results) => {
if (!err) { if (!err) {
console.log(results) console.log(results)
@ -176,6 +188,9 @@ const actions = {
case 'backendFallback': case 'backendFallback':
commit('setBackendFallback', result.value) commit('setBackendFallback', result.value)
break break
case 'defaultProfile':
commit('setDefaultProfile', result.value)
break
case 'checkForUpdates': case 'checkForUpdates':
commit('setCheckForUpdates', result.value) commit('setCheckForUpdates', result.value)
break break
@ -200,6 +215,12 @@ const actions = {
case 'barColor': case 'barColor':
commit('setBarColor', result.value) commit('setBarColor', result.value)
break break
case 'hideWatchedSubs':
commit('setHideWatchedSubs', result.value)
break
case 'useRssFeeds':
commit('setUseRssFeeds', result.value)
break
case 'rememberHistory': case 'rememberHistory':
commit('setRememberHistory', result.value) commit('setRememberHistory', result.value)
break break
@ -255,6 +276,14 @@ const actions = {
}) })
}, },
updateDefaultProfile ({ commit }, defaultProfile) {
settingsDb.update({ _id: 'defaultProfile' }, { _id: 'defaultProfile', value: defaultProfile }, { upsert: true }, (err, numReplaced) => {
if (!err) {
commit('setDefaultProfile', defaultProfile)
}
})
},
updateBackendFallback ({ commit }, backendFallback) { updateBackendFallback ({ commit }, backendFallback) {
settingsDb.update({ _id: 'backendFallback' }, { _id: 'backendFallback', value: backendFallback }, { upsert: true }, (err, numReplaced) => { settingsDb.update({ _id: 'backendFallback' }, { _id: 'backendFallback', value: backendFallback }, { upsert: true }, (err, numReplaced) => {
if (!err) { if (!err) {
@ -327,6 +356,22 @@ const actions = {
}) })
}, },
updateHideWatchedSubs ({ commit }, hideWatchedSubs) {
settingsDb.update({ _id: 'hideWatchedSubs' }, { _id: 'hideWatchedSubs', value: hideWatchedSubs }, { upsert: true }, (err, numReplaced) => {
if (!err) {
commit('setHideWatchedSubs', hideWatchedSubs)
}
})
},
updateUseRssFeeds ({ commit }, useRssFeeds) {
settingsDb.update({ _id: 'useRssFeeds' }, { _id: 'useRssFeeds', value: useRssFeeds }, { upsert: true }, (err, numReplaced) => {
if (!err) {
commit('setUseRssFeeds', useRssFeeds)
}
})
},
updateRememberHistory ({ commit }, history) { updateRememberHistory ({ commit }, history) {
settingsDb.update({ _id: 'rememberHistory' }, { _id: 'rememberHistory', value: history }, { upsert: true }, (err, numReplaced) => { settingsDb.update({ _id: 'rememberHistory' }, { _id: 'rememberHistory', value: history }, { upsert: true }, (err, numReplaced) => {
if (!err) { if (!err) {
@ -448,6 +493,9 @@ const mutations = {
setCurrentTheme (state, currentTheme) { setCurrentTheme (state, currentTheme) {
state.barColor = currentTheme state.barColor = currentTheme
}, },
setDefaultProfile (state, defaultProfile) {
state.defaultProfile = defaultProfile
},
setBackendFallback (state, backendFallback) { setBackendFallback (state, backendFallback) {
state.backendFallback = backendFallback state.backendFallback = backendFallback
}, },
@ -529,6 +577,9 @@ const mutations = {
setHideWatchedSubs (state, hideWatchedSubs) { setHideWatchedSubs (state, hideWatchedSubs) {
state.hideWatchedSubs = hideWatchedSubs state.hideWatchedSubs = hideWatchedSubs
}, },
setUseRssFeeds (state, useRssFeeds) {
state.useRssFeeds = useRssFeeds
},
setUsingElectron (state, usingElectron) { setUsingElectron (state, usingElectron) {
state.usingElectron = usingElectron state.usingElectron = usingElectron
}, },
@ -537,9 +588,6 @@ const mutations = {
}, },
setProfileList (state, profileList) { setProfileList (state, profileList) {
state.profileList = profileList state.profileList = profileList
},
setDefaultProfile (state, defaultProfile) {
state.defaultProfile = defaultProfile
} }
} }

View File

@ -1,61 +1,38 @@
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 += '/subscriptions.db'
} else {
dbLocation = 'subscriptions.db'
}
const subDb = new Datastore({
filename: dbLocation,
autoload: true
})
const state = { const state = {
subscriptions: [] allSubscriptionsList: [],
profileSubscriptions: {
activeProfile: 0,
videoList: []
}
} }
const mutations = { const getters = {
addSubscription (state, payload) { getAllSubscriptionsList: () => {
state.subscriptions.push(payload) return state.allSubscriptionsList
}, },
setSubscriptions (state, payload) { getProfileSubscriptions: () => {
state.subscriptions = payload return state.profileSubscriptions
} }
} }
const actions = { const actions = {
addSubscriptions ({ commit }, payload) { updateAllSubscriptionsList ({ commit }, subscriptions) {
subDb.insert(payload, (err, payload) => { commit('setAllSubscriptionsList', subscriptions)
if (!err) {
commit('addSubscription', payload)
}
})
}, },
getSubscriptions ({ commit }, payload) { updateProfileSubscriptions ({ commit }, subscriptions) {
subDb.find({}, (err, payload) => { commit('setProfileSubscriptions', subscriptions)
if (!err) {
commit('setSubscriptions', payload)
}
})
},
removeSubscription ({ commit }, channelId) {
subDb.remove({ channelId: channelId }, {}, () => {
commit('setSubscriptions', this.state.subscriptions.filter(sub => sub.channelId !== channelId))
})
} }
} }
const getters = {}
const mutations = {
setAllSubscriptionsList (state, allSubscriptionsList) {
state.allSubscriptionsList = allSubscriptionsList
},
setProfileSubscriptions (state, profileSubscriptions) {
state.profileSubscriptions = profileSubscriptions
}
}
export default { export default {
state, state,
getters, getters,

View File

@ -5,6 +5,8 @@ const state = {
sessionSearchHistory: [], sessionSearchHistory: [],
popularCache: null, popularCache: null,
trendingCache: null, trendingCache: null,
showProgressBar: false,
progressBarPercentage: 0,
searchSettings: { searchSettings: {
sortBy: 'relevance', sortBy: 'relevance',
time: '', time: '',
@ -28,6 +30,24 @@ const state = {
'mainAmber', 'mainAmber',
'mainOrange', 'mainOrange',
'mainDeepOrange' 'mainDeepOrange'
],
colorValues: [
'#d50000',
'#C51162',
'#AA00FF',
'#6200EA',
'#304FFE',
'#2962FF',
'#0091EA',
'#00B8D4',
'#00BFA5',
'#00C853',
'#64DD17',
'#AEEA00',
'#FFD600',
'#FFAB00',
'#FF6D00',
'#DD2C00'
] ]
} }
@ -54,15 +74,78 @@ const getters = {
getSearchSettings () { getSearchSettings () {
return state.searchSettings return state.searchSettings
},
getColorValues () {
return state.colorValues
},
getShowProgressBar () {
return state.showProgressBar
},
getProgressBarPercentage () {
return state.progressBarPercentage
} }
} }
const actions = { const actions = {
updateShowProgressBar ({ commit }, value) {
commit('setShowProgressBar', value)
},
getRandomColorClass () { getRandomColorClass () {
const randomInt = Math.floor(Math.random() * state.colorClasses.length) const randomInt = Math.floor(Math.random() * state.colorClasses.length)
return state.colorClasses[randomInt] return state.colorClasses[randomInt]
}, },
getRandomColor () {
const randomInt = Math.floor(Math.random() * state.colorValues.length)
return state.colorValues[randomInt]
},
calculateColorLuminance (_, colorValue) {
const cutHex = colorValue.substring(1, 7)
const colorValueR = parseInt(cutHex.substring(0, 2), 16)
const colorValueG = parseInt(cutHex.substring(2, 4), 16)
const colorValueB = parseInt(cutHex.substring(4, 6), 16)
const luminance = (0.299 * colorValueR + 0.587 * colorValueG + 0.114 * colorValueB) / 255
if (luminance > 0.5) {
return '#000000'
} else {
return '#FFFFFF'
}
},
calculatePublishedDate(_, publishedText) {
const date = new Date()
const textSplit = publishedText.split(' ')
const timeFrame = textSplit[1]
const timeAmount = parseInt(textSplit[0])
let timeSpan = null
if (timeFrame.indexOf('second') > -1) {
timeSpan = timeAmount * 1000
} else if (timeFrame.indexOf('minute') > -1) {
timeSpan = timeAmount * 60000
} else if (timeFrame.indexOf('hour') > -1) {
timeSpan = timeAmount * 3600000
} else if (timeFrame.indexOf('day') > -1) {
timeSpan = timeAmount * 86400000
} else if (timeFrame.indexOf('week') > -1) {
timeSpan = timeAmount * 604800000
} else if (timeFrame.indexOf('month') > -1) {
timeSpan = timeAmount * 2592000000
} else if (timeFrame.indexOf('year') > -1) {
timeSpan = timeAmount * 31556952000
}
return date.getTime() - timeSpan
},
getVideoIdFromUrl (_, url) { getVideoIdFromUrl (_, url) {
/** @type {URL} */ /** @type {URL} */
let urlObject let urlObject
@ -182,6 +265,8 @@ const actions = {
} else if (payload.isUpcoming || payload.publishText === null) { } else if (payload.isUpcoming || payload.publishText === null) {
// the check for null is currently just an inferring of knowledge, because there is no other possibility left // the check for null is currently just an inferring of knowledge, because there is no other possibility left
return payload.upcomingString return payload.upcomingString
} else if (payload.isRSS) {
return payload.publishText
} }
const strings = payload.publishText.split(' ') const strings = payload.publishText.split(' ')
const singular = (strings[0] === '1') const singular = (strings[0] === '1')
@ -254,6 +339,14 @@ const mutations = {
state.isSideNavOpen = !state.isSideNavOpen state.isSideNavOpen = !state.isSideNavOpen
}, },
setShowProgressBar (state, value) {
state.showProgressBar = value
},
setProgressBarPercentage (state, value) {
state.progressBarPercentage = value
},
setSessionSearchHistory (state, history) { setSessionSearchHistory (state, history) {
state.sessionSearchHistory = history state.sessionSearchHistory = history
}, },

View File

@ -78,6 +78,34 @@ export default Vue.extend({
return this.$store.getters.getSessionSearchHistory return this.$store.getters.getSessionSearchHistory
}, },
profileList: function () {
return this.$store.getters.getProfileList
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
isSubscribed: function () {
const subIndex = this.profileList[this.activeProfile].subscriptions.findIndex((channel) => {
return channel.id === this.id
})
if (subIndex === -1) {
return false
} else {
return true
}
},
subscribedText: function () {
if (this.isSubscribed) {
return this.$t('Channel.Unsubscribe').toUpperCase()
} else {
return this.$t('Channel.Subscribe').toUpperCase()
}
},
videoSelectNames: function () { videoSelectNames: function () {
return [ return [
this.$t('Channel.Videos.Sort Types.Newest'), this.$t('Channel.Videos.Sort Types.Newest'),
@ -408,7 +436,74 @@ export default Vue.extend({
}, },
handleSubscription: function () { handleSubscription: function () {
console.log('TODO: Channel handleSubscription') const currentProfile = JSON.parse(JSON.stringify(this.profileList[this.activeProfile]))
const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0]))
if (this.isSubscribed) {
currentProfile.subscriptions = currentProfile.subscriptions.filter((channel) => {
return channel.id !== this.id
})
this.updateProfile(currentProfile)
this.showToast({
message: 'Channel has been removed from your subscriptions'
})
if (this.activeProfile === 0) {
// Check if a subscription exists in a different profile.
// Remove from there as well.
let duplicateSubscriptions = 0
this.profileList.forEach((profile) => {
if (profile._id === 'allChannels') {
return
}
const parsedProfile = JSON.parse(JSON.stringify(profile))
const index = parsedProfile.subscriptions.findIndex((channel) => {
return channel.id === this.id
})
if (index !== -1) {
duplicateSubscriptions++
parsedProfile.subscriptions = parsedProfile.subscriptions.filter((x) => {
return x.id !== this.channelId
})
this.updateProfile(parsedProfile)
}
})
if (duplicateSubscriptions > 0) {
this.showToast({
message: `Removed subscription from ${duplicateSubscriptions} other channel(s)`
})
}
}
} else {
const subscription = {
id: this.id,
name: this.channelName,
thumbnail: this.thumbnailUrl
}
currentProfile.subscriptions.push(subscription)
this.updateProfile(currentProfile)
this.showToast({
message: 'Added channel to your subscriptions'
})
if (this.activeProfile !== 0) {
const index = primaryProfile.subscriptions.findIndex((channel) => {
return channel.id === this.id
})
if (index === -1) {
primaryProfile.subscriptions.push(subscription)
this.updateProfile(primaryProfile)
}
}
}
}, },
handleFetchMore: function () { handleFetchMore: function () {
@ -549,7 +644,8 @@ export default Vue.extend({
}, },
...mapActions([ ...mapActions([
'showToast' 'showToast',
'updateProfile'
]) ])
} }
}) })

View File

@ -40,7 +40,7 @@
</span> </span>
</div> </div>
<ft-button <ft-button
:label="$t('Channel.Subscribe')" :label="subscribedText"
background-color="var(--primary-color)" background-color="var(--primary-color)"
text-color="var(--text-with-main-color)" text-color="var(--text-with-main-color)"
class="subscribeButton" class="subscribeButton"

View File

@ -0,0 +1,45 @@
.card {
width: 85%;
margin: 0 auto;
margin-bottom: 60px;
}
.message {
color: var(--tertiary-text-color);
}
.profileName {
width: 400px;
}
.bottomMargin {
margin-bottom: 30px;
}
.colorOptions {
max-width: 1000px;
margin: 0 auto;
margin-bottom: 30px;
}
.colorOption {
width: 100px;
height: 100px;
margin: 10px;
cursor: pointer;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.initial {
font-size: 50px;
text-align: center;
position: relative;
bottom: 27px;
}
@media only screen and (max-width: 680px) {
.card {
width: 90%;
}
}

View File

@ -0,0 +1,183 @@
import Vue from 'vue'
import { mapActions } from 'vuex'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtPrompt from '../../components/ft-prompt/ft-prompt.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtInput from '../../components/ft-input/ft-input.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
export default Vue.extend({
name: 'ProfileEdit',
components: {
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-prompt': FtPrompt,
'ft-flex-box': FtFlexBox,
'ft-input': FtInput,
'ft-button': FtButton
},
data: function () {
return {
isLoading: false,
showDeletePrompt: false,
deletePromptLabel: '',
isNew: false,
profileId: '',
profileName: '',
profileBgColor: '',
profileTextColor: '',
profileSubscriptions: [],
deletePromptValues: [
'yes',
'no'
]
}
},
computed: {
colorValues: function () {
return this.$store.getters.getColorValues
},
profileInitial: function () {
return this.profileName.slice(0, 1).toUpperCase()
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
defaultProfile: function () {
return this.$store.getters.getDefaultProfile
},
deletePromptNames: function () {
return [
this.$t('Yes'),
this.$t('No')
]
}
},
watch: {
profileBgColor: async function (val) {
this.profileTextColor = await this.calculateColorLuminance(val)
}
},
mounted: async function () {
this.isLoading = true
const profileType = this.$route.name
this.deletePromptLabel = `${this.$t('Profile.Are you sure you want to delete this profile?')} ${this.$t('Profile["All subscriptions will also be deleted."]')}`
if (profileType === 'newProfile') {
this.isNew = true
this.profileBgColor = await this.getRandomColor()
this.isLoading = false
} else {
this.isNew = false
this.profileId = this.$route.params.id
console.log(this.profileId)
console.log(this.$route.name)
this.grabProfileInfo(this.profileId).then((profile) => {
if (profile === null) {
this.showToast({
message: this.$t('Profile.Profile could not be found')
})
this.$router.push({
path: '/settings/profile/'
})
}
this.profileName = profile.name
this.profileBgColor = profile.bgColor
this.profileTextColor = profile.textColor
this.profileSubscriptions = profile.subscriptions
this.isLoading = false
})
}
},
methods: {
openDeletePrompt: function () {
this.showDeletePrompt = true
},
handleDeletePrompt: function (response) {
if (response === 'yes') {
this.deleteProfile()
} else {
this.showDeletePrompt = false
}
},
saveProfile: function () {
if (this.profileName === '') {
this.showToast({
message: this.$t('Profile.Your profile name cannot be empty')
})
return
}
const profile = {
name: this.profileName,
bgColor: this.profileBgColor,
textColor: this.profileTextColor,
subscriptions: this.profileSubscriptions
}
if (!this.isNew) {
profile._id = this.profileId
}
console.log(profile)
this.updateProfile(profile)
if (this.isNew) {
this.showToast({
message: this.$t('Profile.Profile has been created')
})
this.$router.push({
path: '/settings/profile/'
})
} else {
this.showToast({
message: this.$t('Profile.Profile has been updated')
})
}
},
setDefaultProfile: function () {
this.updateDefaultProfile(this.profileId)
const message = this.$t('Profile.Your default profile has been set to $').replace('$', this.profileName)
this.showToast({
message: message
})
},
deleteProfile: function () {
this.removeProfile(this.profileId)
const message = this.$t('Profile.Removed $ from your profiles').replace('$', this.profileName)
this.showToast({
message: message
})
if (this.defaultProfile === this.profileId) {
this.updateDefaultProfile('allChannels')
this.showToast({
message: this.$t('Profile.Your default profile has been changed to your primary profile')
})
}
if (this.activeProfile._id === this.profileId) {
this.updateActiveProfile('allChannels')
}
this.$router.push({
path: '/settings/profile/'
})
},
...mapActions([
'showToast',
'grabProfileInfo',
'updateProfile',
'removeProfile',
'updateDefaultProfile',
'updateActiveProfile',
'calculateColorLuminance',
'getRandomColor'
])
}
})

View File

@ -0,0 +1,107 @@
<template>
<div>
<ft-loader
v-if="isLoading"
:fullscreen="true"
/>
<div
v-else
>
<ft-card class="card">
<h2>{{ $t("Profile.Edit Profile") }}</h2>
<ft-flex-box>
<ft-input
class="profileName"
placeholder="Profile Name"
:value="profileName"
:show-arrow="false"
@input="e => profileName = e"
/>
</ft-flex-box>
<h3>{{ $t("Profile.Color Picker") }}</h3>
<ft-flex-box
class="bottomMargin colorOptions"
>
<div
v-for="(color, index) in colorValues"
:key="index"
class="colorOption"
:style="{ background: color }"
@click="profileBgColor = color"
/>
</ft-flex-box>
<ft-flex-box
class="bottomMargin"
>
<div>
<label for="colorPicker">{{ $t("Profile.Custom Color") }}</label>
<input
id="colorPicker"
type="color"
v-model="profileBgColor"
/>
</div>
</ft-flex-box>
<ft-flex-box>
<ft-input
class="profileName"
placeholder=""
:value="profileBgColor"
:show-arrow="false"
:disabled="true"
/>
</ft-flex-box>
<h3>{{ $t("Profile.Profile Preview") }}</h3>
<ft-flex-box
class="bottomMargin"
>
<div
class="colorOption"
:style="{ background: profileBgColor, color: profileTextColor }"
style="cursor: default"
>
<p
class="initial"
>
{{ profileInitial }}
</p>
</div>
</ft-flex-box>
<ft-flex-box>
<ft-button
v-if="isNew"
:label="$t('Profile.Create Profile')"
@click="saveProfile"
/>
<ft-button
v-if="!isNew"
:label="$t('Profile.Update Profile')"
@click="saveProfile"
/>
<ft-button
v-if="!isNew"
:label="$t('Profile.Make Default Profile')"
@click="setDefaultProfile"
/>
<ft-button
v-if="profileId !== 'allChannels' && !isNew"
:label="$t('Profile.Delete Profile')"
text-color="var(--text-with-main-color)"
background-color="var(--primary-color)"
@click="openDeletePrompt"
/>
</ft-flex-box>
</ft-card>
</div>
<ft-prompt
v-if="showDeletePrompt"
:label="deletePromptLabel"
:option-names="deletePromptNames"
:option-values="deletePromptValues"
@click="handleDeletePrompt"
/>
</div>
</template>
<script src="./ProfileEdit.js" />
<style scoped src="./ProfileEdit.css" />

View File

@ -0,0 +1,21 @@
.card {
width: 85%;
margin: 0 auto;
margin-bottom: 60px;
}
.message {
color: var(--tertiary-text-color);
}
.profileList {
max-width: 1000px;
margin: 0 auto;
margin-bottom: 10px;
}
@media only screen and (max-width: 680px) {
.card {
width: 90%;
}
}

View File

@ -0,0 +1,30 @@
import Vue from 'vue'
import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtProfileBubble from '../../components/ft-profile-bubble/ft-profile-bubble.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
export default Vue.extend({
name: 'ProfileSettings',
components: {
'ft-card': FtCard,
'ft-flex-box': FtFlexBox,
'ft-profile-bubble': FtProfileBubble,
'ft-button': FtButton
},
computed: {
profileList: function () {
return this.$store.getters.getProfileList
}
},
mounted: function () {
console.log(this.profileList)
},
methods: {
newProfile: function () {
this.$router.push({
path: `/settings/profile/new/`
})
}
}
})

View File

@ -0,0 +1,28 @@
<template>
<div>
<ft-card class="card">
<h3>{{ $t("Profile.Profile Manager") }}</h3>
<ft-flex-box
class="profileList"
>
<ft-profile-bubble
v-for="(profile, index) in profileList"
:key="index"
:profile-id="profile._id"
:profile-name="profile.name"
:background-color="profile.bgColor"
:text-color="profile.textColor"
/>
</ft-flex-box>
<ft-flex-box>
<ft-button
:label="$t('Profile.Create New Profile')"
@click="newProfile"
/>
</ft-flex-box>
</ft-card>
</div>
</template>
<script src="./ProfileSettings.js" />
<style scoped src="./ProfileSettings.css" />

View File

@ -3,6 +3,7 @@
<general-settings /> <general-settings />
<theme-settings /> <theme-settings />
<player-settings /> <player-settings />
<subscription-settings />
<privacy-settings /> <privacy-settings />
</div> </div>
</template> </template>

View File

@ -8,6 +8,12 @@
color: var(--tertiary-text-color); color: var(--tertiary-text-color);
} }
.floatingTopButton {
position: absolute;
top: 70px;
right: 10px;
}
@media only screen and (max-width: 680px) { @media only screen and (max-width: 680px) {
.card { .card {
width: 90%; width: 90%;

View File

@ -1,15 +1,307 @@
import Vue from 'vue' import Vue from 'vue'
import { mapActions, mapMutations } from 'vuex'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtCard from '../../components/ft-card/ft-card.vue' import FtCard from '../../components/ft-card/ft-card.vue'
import FtButton from '../../components/ft-button/ft-button.vue'
import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import ytch from 'yt-channel-info'
import Parser from 'rss-parser'
export default Vue.extend({ export default Vue.extend({
name: 'Subscriptions', name: 'Subscriptions',
components: { components: {
'ft-loader': FtLoader,
'ft-card': FtCard, 'ft-card': FtCard,
'ft-button': FtButton,
'ft-icon-button': FtIconButton,
'ft-flex-box': FtFlexBox, 'ft-flex-box': FtFlexBox,
'ft-element-list': FtElementList 'ft-element-list': FtElementList
}, },
mounted: function () { data: function () {
return {
isLoading: false,
dataLimit: 100,
videoList: []
}
},
computed: {
usingElectron: function () {
return this.$store.getters.getUsingElectron
},
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
invidiousInstance: function () {
return this.$store.getters.getInvidiousInstance
},
hideWatchedSubs: function () {
return this.$store.getters.getHideWatchedSubs
},
useRssFeeds: function () {
return this.$store.getters.getUseRssFeeds
},
profileList: function () {
return this.$store.getters.getProfileList
},
activeVideoList: function () {
if (this.videoList.length < this.dataLimit) {
return this.videoList
} else {
return this.videoList.slice(0, this.dataLimit)
}
},
activeProfile: function () {
return this.$store.getters.getActiveProfile
},
profileSubscriptions: function () {
return this.$store.getters.getProfileSubscriptions
},
allSubscriptionsList: function () {
return this.$store.getters.getAllSubscriptionsList
},
historyCache: function () {
return this.$store.getters.getHistoryCache
},
activeSubscriptionList: function () {
return this.profileList[this.activeProfile].subscriptions
}
},
watch: {
activeProfile: async function (val) {
if (this.allSubscriptionsList.length !== 0) {
this.isLoading = true
this.videoList = await Promise.all(this.allSubscriptionsList.filter((video) => {
const channelIndex = this.activeSubscriptionList.findIndex((x) => {
return x.id === video.authorId
})
const historyIndex = this.historyCache.findIndex((x) => {
return x.videoId === video.videoId
})
if (this.hideWatchedSubs) {
return channelIndex !== -1 && historyIndex === -1
} else {
return channelIndex !== -1
}
}))
this.isLoading = false
} else {
this.getSubscriptions()
}
}
},
mounted: async function () {
this.isLoading = true
const dataLimit = sessionStorage.getItem('subscriptionLimit')
if (dataLimit !== null) {
this.dataLimit = dataLimit
}
setTimeout(async () => {
if (this.profileSubscriptions.videoList.length === 0) {
this.getSubscriptions()
} else {
const subscriptionList = JSON.parse(JSON.stringify(this.profileSubscriptions))
if (this.hideWatchedSubs) {
this.videoList = await Promise.all(subscriptionList.videoList.filter((video) => {
const historyIndex = this.historyCache.findIndex((x) => {
return x.videoId === video.videoId
})
return historyIndex === -1
}))
} else {
this.videoList = subscriptionList.videoList
}
this.isLoading = false
}
}, 200)
},
methods: {
getSubscriptions: function () {
if (this.activeSubscriptionList.length === 0) {
this.isLoading = false
this.videoList = []
return
}
this.isLoading = true
this.updateShowProgressBar(true)
this.setProgressBarPercentage(0)
let videoList = []
let channelCount = 0
this.activeSubscriptionList.forEach(async (channel) => {
let videos = []
if (!this.usingElectron || this.backendPreference === 'invidious') {
if (this.useRssFeeds) {
videos = await this.getChannelVideosInvidiousRSS(channel.id)
} else {
videos = await this.getChannelVideosInvidiousScraper(channel.id)
}
} else {
if (this.useRssFeeds) {
videos = await this.getChannelVideosLocalRSS(channel.id)
} else {
videos = await this.getChannelVideosLocalScraper(channel.id)
}
}
videoList = videoList.concat(videos)
channelCount++
const percentageComplete = (channelCount / this.activeSubscriptionList.length) * 100
this.setProgressBarPercentage(percentageComplete)
if (channelCount === this.activeSubscriptionList.length) {
videoList = await Promise.all(videoList.sort((a, b) => {
return b.publishedDate - a.publishedDate
}))
const profileSubscriptions = {
activeProfile: this.activeProfile,
videoList: videoList
}
this.videoList = await Promise.all(videoList.filter((video) => {
if (this.hideWatchedSubs) {
const historyIndex = this.historyCache.findIndex((x) => {
return x.videoId === video.videoId
})
return historyIndex === -1
} else {
return true
}
}))
this.updateProfileSubscriptions(profileSubscriptions)
this.isLoading = false
this.updateShowProgressBar(false)
if (this.activeProfile === 0) {
this.updateAllSubscriptionsList(profileSubscriptions.videoList)
}
}
})
},
getChannelVideosLocalScraper: function (channelId) {
return new Promise((resolve, reject) => {
ytch.getChannelVideos(channelId, 'latest').then(async (response) => {
const videos = await Promise.all(response.items.map(async (video) => {
video.publishedDate = await this.calculatePublishedDate(video.publishedText)
return video
}))
resolve(videos)
}).catch((err) => {
console.log(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
this.showToast({
message: `${errorMessage}: ${err}`,
time: 10000,
action: () => {
navigator.clipboard.writeText(err)
}
})
resolve([])
})
})
},
getChannelVideosLocalRSS: function (channelId) {
return new Promise((resolve, reject) => {
const parser = new Parser()
const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`
parser.parseURL(feedUrl).then(async (feed) => {
resolve(await Promise.all(feed.items.map((video) => {
video.authorId = channelId
video.videoId = video.id.replace('yt:video:', '')
video.type = 'video'
video.publishedDate = new Date(video.pubDate)
video.publishedText = video.publishedDate.toLocaleString()
video.lengthSeconds = '0:00'
video.isRSS = true
return video
})))
}).catch((err) => {
console.log(err)
resolve([])
})
})
},
getChannelVideosInvidiousScraper: function (channelId) {
return new Promise((resolve, reject) => {
const subscriptionsPayload = {
resource: 'channels/latest',
id: channelId,
params: {}
}
this.invidiousAPICall(subscriptionsPayload).then((result) => {
resolve(result)
})
})
},
getChannelVideosInvidiousRSS: function (channelId) {
return new Promise((resolve, reject) => {
const parser = new Parser()
const feedUrl = `${this.invidiousInstance}/feed/channel/${channelId}`
parser.parseURL(feedUrl).then(async (feed) => {
resolve(await Promise.all(feed.items.map((video) => {
video.authorId = channelId
video.videoId = video.id.replace('yt:video:', '')
video.type = 'video'
video.publishedDate = new Date(video.pubDate)
video.publishedText = video.publishedDate.toLocaleString()
video.lengthSeconds = '0:00'
video.isRSS = true
return video
})))
})
})
},
increaseLimit: function () {
this.dataLimit += 100
sessionStorage.setItem('subscriptionLimit', this.dataLimit)
},
...mapActions([
'showToast',
'invidiousAPICall',
'updateShowProgressBar',
'updateProfileSubscriptions',
'updateAllSubscriptionsList',
'calculatePublishedDate'
]),
...mapMutations([
'setProgressBarPercentage'
])
} }
}) })

View File

@ -1,13 +1,45 @@
<template> <template>
<div> <div>
<ft-card class="card"> <ft-loader
v-if="isLoading"
:fullscreen="true"
/>
<ft-card
v-else
class="card"
>
<h3>{{ $t("Subscriptions.Subscriptions") }}</h3> <h3>{{ $t("Subscriptions.Subscriptions") }}</h3>
<ft-flex-box> <ft-flex-box
v-if="activeVideoList.length === 0"
>
<p class="message"> <p class="message">
{{ $t("This part of the app is not ready yet. Come back later when progress has been made.") }} {{ $t("Subscriptions['Your Subscription list is currently empty. Start adding subscriptions to see them here.']") }}
</p> </p>
</ft-flex-box> </ft-flex-box>
<ft-element-list
v-else
:data="activeVideoList"
/>
<ft-flex-box
>
<ft-button
v-if="videoList.length > dataLimit"
label="Load More"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@click="increaseLimit"
/>
</ft-flex-box>
</ft-card> </ft-card>
<ft-icon-button
v-if="!isLoading"
icon="sync"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"
:size="12"
theme="primary"
@click="getSubscriptions"
/>
</div> </div>
</template> </template>

View File

@ -70,7 +70,8 @@ Subscriptions:
Latest Subscriptions: Latest Subscriptions Latest Subscriptions: Latest Subscriptions
'Your Subscription list is currently empty. Start adding subscriptions to see them here.': Your 'Your Subscription list is currently empty. Start adding subscriptions to see them here.': Your
Subscription list is currently empty. Start adding subscriptions to see them here. Subscription list is currently empty. Start adding subscriptions to see them here.
'Getting Subscriptions. Please wait.': Getting Subscriptions. Please wait. 'Getting Subscriptions. Please wait.': Getting Subscriptions. Please wait.
Refresh Subscriptions: Refresh Subscriptions
Trending: Trending Trending: Trending
Most Popular: Most Popular Most Popular: Most Popular
Playlists: Playlists Playlists: Playlists
@ -180,6 +181,7 @@ Settings:
Subscription Settings: Subscription Settings:
Subscription Settings: Subscription Settings Subscription Settings: Subscription Settings
Hide Videos on Watch: Hide Videos on Watch Hide Videos on Watch: Hide Videos on Watch
Fetch Feeds from RSS: Fetch Feeds from RSS
Subscriptions Export Format: Subscriptions Export Format:
Subscriptions Export Format: Subscriptions Export Format Subscriptions Export Format: Subscriptions Export Format
#& Freetube #& Freetube
@ -246,6 +248,28 @@ About:
Latest FreeTube News: Latest FreeTube News Latest FreeTube News: Latest FreeTube News
Profile:
All Channels: All Channels
Profile Manager: Profile Manager
Create New Profile: Create New Profile
Edit Profile: Edit Profile
Color Picker: Color Picker
Custom Color: Custom Color
Profile Preview: Profile Preview
Create Profile: Create Profile
Update Profile: Update Profile
Make Default Profile: Make Default Profile
Delete Profile: Delete Profile
Are you sure you want to delete this profile?: Are you sure you want to delete this profile?
All subscriptions will also be deleted.: All subscriptions will also be deleted.
Profile could not be found: Profile could not be found
Your profile name cannot be empty: Your profile name cannot be empty
Profile has been created: Profile has been created
Profile has been updated: Profile has been updated
Your default profile has been set to $: Your default profile has been set to $
Removed $ from your profiles: Removed $ from your profiles
Your default profile has been changed to your primary profile: Your default profile has been changed to your primary profile
$ is now the active profile: $ is now the active profile
#On Channel Page #On Channel Page
Channel: Channel:
Subscriber: Subscriber Subscriber: Subscriber
@ -321,6 +345,8 @@ Video:
Dec: Dec Dec: Dec
Second: Second Second: Second
Seconds: Seconds Seconds: Seconds
Minute: Minute
Minutes: Minutes
Hour: Hour Hour: Hour
Hours: Hours Hours: Hours
Day: Day Day: Day