Merge pull request 'Redo in Vuetify' (#1) from feature-port_to_vuetify into master
Reviewed-on: https://git.maharshi.ninja/root/Piped-redesign/pulls/1
@@ -0,0 +1,5 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -1,62 +1,75 @@
|
||||
{
|
||||
"name": "piped",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"name": "piped-redesign",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.6.5",
|
||||
"dompurify": "^2.3.0",
|
||||
"hotkeys-js": "^3.8.7",
|
||||
"javascript-time-ago": "^2.3.8",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^2.1.3",
|
||||
"mux.js": "^5.12.2",
|
||||
"shaka-player": "^3.2.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-router": "^3.2.0",
|
||||
"vuetify": "^2.4.0",
|
||||
"vuex": "^3.4.0",
|
||||
"xml-js": "^1.6.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-eslint": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "~4.5.0",
|
||||
"@vue/cli-plugin-vuex": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/eslint-config-standard": "^5.1.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"lint-staged": "^9.5.0",
|
||||
"sass": "~1.32.0",
|
||||
"sass-loader": "^10.0.0",
|
||||
"vue-cli-plugin-vuetify": "~2.4.1",
|
||||
"vue-template-compiler": "^2.6.11",
|
||||
"vuetify-loader": "^1.7.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.35",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.3",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.3",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.0-4",
|
||||
"core-js": "^3.15.2",
|
||||
"css-loader": "^6.1.0",
|
||||
"dompurify": "^2.3.0",
|
||||
"hotkeys-js": "^3.8.7",
|
||||
"javascript-time-ago": "^2.3.8",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mux.js": "^5.12.2",
|
||||
"register-service-worker": "^1.7.1",
|
||||
"shaka-player": "3.2.0",
|
||||
"uikit": "3.7.1",
|
||||
"vue": "^3.1.5",
|
||||
"vue-router": "^4.0.10",
|
||||
"xml-js": "^1.6.11"
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended",
|
||||
"@vue/standard"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.5.13",
|
||||
"@vue/cli-plugin-eslint": "^4.5.13",
|
||||
"@vue/cli-plugin-pwa": "^4.5.13",
|
||||
"@vue/cli-service": "^4.5.13",
|
||||
"@vue/compiler-sfc": "^3.1.5",
|
||||
"@vue/eslint-config-standard": "^6.0.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^7.31.0",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"eslint-plugin-vue": "^7.14.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/standard"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1% in IN",
|
||||
"> 1% in US"
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead"
|
||||
],
|
||||
"gitHooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,vue}": [
|
||||
"vue-cli-service lint",
|
||||
"git add"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 790 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -1,26 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html style="background: #0b0e0f" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||
<link
|
||||
title="Piped"
|
||||
type="application/opensearchdescription+xml"
|
||||
rel="search"
|
||||
href="<%= BASE_URL %>opensearch.xml"
|
||||
/>
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong
|
||||
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript
|
||||
enabled. Please enable it to continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
||||
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>Piped</ShortName>
|
||||
<LongName>Piped Search</LongName>
|
||||
<Description>Search for videos, channels, and playlists on Piped</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Image width="48" height="48" type="image/x-icon">https://piped.kavin.rocks/favicon.ico</Image>
|
||||
<Url type="text/html" method="get" template="https://piped.kavin.rocks/results?search_query={searchTerms}" />
|
||||
</OpenSearchDescription>
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -1,82 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
class="uk-container uk-container-expand uk-height-viewport"
|
||||
:style="[{ background: backgroundColor, colour: foregroundColor }]"
|
||||
:class="{ 'uk-light': darkMode }"
|
||||
<v-app id="inspire">
|
||||
<v-app-bar
|
||||
app
|
||||
color="primary"
|
||||
dark
|
||||
flat
|
||||
>
|
||||
<Navigation />
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :max="5">
|
||||
<component :key="$route.fullPath" :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
<v-container class="py-0 fill-height">
|
||||
<v-btn
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
text
|
||||
link
|
||||
:to="link.to"
|
||||
>
|
||||
{{ link.name }}
|
||||
</v-btn>
|
||||
|
||||
<div style="text-align: center">
|
||||
<a aria-label="GitHub" href="https://github.com/TeamPiped/Piped">
|
||||
<font-awesome-icon :icon="['fab', 'github']"></font-awesome-icon>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/TeamPiped/Piped#donations">
|
||||
<font-awesome-icon :icon="['fab', 'bitcoin']"></font-awesome-icon>
|
||||
Donations
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-responsive max-width="720">
|
||||
<SearchMenu />
|
||||
</v-responsive>
|
||||
</v-container>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main class="grey lighten-3">
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Navigation from '@/components/Navigation'
|
||||
import SearchMenu from '@/routes/SearchMenu'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Navigation
|
||||
SearchMenu
|
||||
},
|
||||
mounted () {
|
||||
if (window.location.pathname === '/' || window.location.pathname.length == 0) {
|
||||
switch (this.getPreferenceString('homepage', 'trending')) {
|
||||
case 'trending':
|
||||
break
|
||||
case 'feed':
|
||||
this.$router.replace('/feed')
|
||||
break
|
||||
default:
|
||||
break
|
||||
data: () => ({
|
||||
links: [
|
||||
{
|
||||
id: 'prefs',
|
||||
name: 'Preferences',
|
||||
to: '/preferences'
|
||||
},
|
||||
{
|
||||
id: 'trending',
|
||||
name: 'Trending',
|
||||
to: '/'
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
created () {
|
||||
this.$store.dispatch('loadState')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
h1,
|
||||
p,
|
||||
a,
|
||||
b {
|
||||
unicode-bidi: plaintext;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
background-color: #15191a;
|
||||
color: #c5bcae;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #4b4f52;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #5b6469;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background-color: #485053;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: #0b0e0f;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-color: #15191a #444a4e;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.5 100"><defs><style>.cls-1{fill:#1697f6;}.cls-2{fill:#7bc6ff;}.cls-3{fill:#1867c0;}.cls-4{fill:#aeddff;}</style></defs><title>Artboard 46</title><polyline class="cls-1" points="43.75 0 23.31 0 43.75 48.32"/><polygon class="cls-2" points="43.75 62.5 43.75 100 0 14.58 22.92 14.58 43.75 62.5"/><polyline class="cls-3" points="43.75 0 64.19 0 43.75 48.32"/><polygon class="cls-4" points="64.58 14.58 87.5 14.58 43.75 100 43.75 62.5 64.58 14.58"/></svg>
|
||||
|
After Width: | Height: | Size: 539 B |
@@ -1,116 +0,0 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="channel && channel.error" :message="channel.message" :error="channel.error" />
|
||||
|
||||
<div v-if="channel" v-show="!channel.error">
|
||||
<h1 class="uk-text-center"><img height="48" width="48" v-bind:src="channel.avatarUrl" />{{ channel.name }}</h1>
|
||||
<img v-if="channel.bannerUrl" v-bind:src="channel.bannerUrl" style="width: 100%" loading="lazy" />
|
||||
<p style="white-space: pre-wrap">{{ channel.description }}</p>
|
||||
|
||||
<button
|
||||
v-if="authenticated"
|
||||
@click="subscribeHandler"
|
||||
class="uk-button uk-button-small"
|
||||
style="background: #222"
|
||||
type="button"
|
||||
>
|
||||
{{ subscribed ? "Unsubscribe" : "Subscribe" }}
|
||||
</button>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="uk-grid-xl" uk-grid="parallax: 0">
|
||||
<div
|
||||
class="uk-width-1-2 uk-width-1-3@m uk-width-1-4@l uk-width-1-5@xl"
|
||||
v-bind:key="video.url"
|
||||
v-for="video in this.channel.relatedStreams"
|
||||
>
|
||||
<VideoItem :video="video" height="94" width="168" hideChannel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorHandler from '@/components/ErrorHandler.vue'
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
channel: null,
|
||||
subscribed: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getChannelData()
|
||||
},
|
||||
activated () {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
deactivated () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
methods: {
|
||||
async fetchSubscribedStatus () {
|
||||
this.fetchJson(
|
||||
this.apiUrl() + '/subscribed',
|
||||
{
|
||||
channelId: this.channel.id
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.getAuthToken()
|
||||
}
|
||||
}
|
||||
).then(json => {
|
||||
this.subscribed = json.subscribed
|
||||
})
|
||||
},
|
||||
async fetchChannel () {
|
||||
const url = this.apiUrl() + '/' + this.$route.params.path + '/' + this.$route.params.channelId
|
||||
return await this.fetchJson(url)
|
||||
},
|
||||
async getChannelData () {
|
||||
this.fetchChannel()
|
||||
.then(data => (this.channel = data))
|
||||
.then(() => {
|
||||
if (!this.channel.error) {
|
||||
document.title = this.channel.name + ' - Piped'
|
||||
if (this.authenticated) this.fetchSubscribedStatus()
|
||||
}
|
||||
})
|
||||
},
|
||||
handleScroll () {
|
||||
if (this.loading || !this.channel || !this.channel.nextpage) return
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||
this.loading = true
|
||||
this.fetchJson(this.apiUrl() + '/nextpage/channel/' + this.channel.id, {
|
||||
nextpage: this.channel.nextpage
|
||||
}).then(json => {
|
||||
this.channel.relatedStreams.concat(json.relatedStreams)
|
||||
this.channel.nextpage = json.nextpage
|
||||
this.loading = false
|
||||
json.relatedStreams.map(stream => this.channel.relatedStreams.push(stream))
|
||||
})
|
||||
}
|
||||
},
|
||||
subscribeHandler () {
|
||||
this.fetchJson(this.apiUrl() + (this.subscribed ? '/unsubscribe' : '/subscribe'), null, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelId: this.channel.id
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
this.subscribed = !this.subscribed
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ErrorHandler,
|
||||
VideoItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>{{ message }}</p>
|
||||
<button uk-toggle="target: #stacktrace" class="uk-button uk-button-small" style="background: #222" type="button">
|
||||
Show More
|
||||
</button>
|
||||
<p id="stacktrace" style="white-space: pre-wrap" hidden>{{ error }}</p>
|
||||
<v-btn x-large color="warning" @click="showStacktrace = !showStacktrace">Show More</v-btn>
|
||||
<p style="white-space: pre-wrap" v-if="showStacktrace">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -11,6 +11,9 @@ export default {
|
||||
props: {
|
||||
error: String,
|
||||
message: String
|
||||
}
|
||||
},
|
||||
data: () => ({
|
||||
showStacktrace: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
<template>
|
||||
<h1 class="uk-text-bold uk-text-center">Feed</h1>
|
||||
|
||||
<small>You can import subscriptions from <router-link to="/import">here</router-link>.</small>
|
||||
|
||||
<div class="uk-align-right">
|
||||
<a :href="getRssUrl"><font-awesome-icon icon="rss"></font-awesome-icon></a>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="uk-grid-xl" uk-grid="parallax: 0">
|
||||
<div
|
||||
:style="[{ background: backgroundColor }]"
|
||||
class="uk-width-1-2 uk-width-1-3@s uk-width-1-4@m uk-width-1-5@l uk-width-1-6@xl"
|
||||
v-bind:key="video.url"
|
||||
v-for="video in videos"
|
||||
>
|
||||
<div class="uk-text-secondary" :style="[{ background: backgroundColor }]">
|
||||
<router-link class="uk-text-emphasis" v-bind:to="'/watch?v=' + video.id">
|
||||
<img style="width: 100%" v-bind:src="video.thumbnail" alt="thumbnail" loading="lazy" />
|
||||
<p>{{ video.title }}</p>
|
||||
</router-link>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<router-link class="uk-link-muted" :to="'/channel/' + video.uploader_id">
|
||||
<a>{{ video.uploader }}</a>
|
||||
</router-link>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b class="uk-text-small uk-align-left">
|
||||
<div v-if="video.views >= 0">
|
||||
<font-awesome-icon icon="eye"></font-awesome-icon>
|
||||
{{ numberFormat(video.views) }} views
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
{{ timeAgo(video.uploaded) }}
|
||||
</div>
|
||||
</b>
|
||||
<div class="uk-align-right">
|
||||
<b class="uk-text-small">{{ timeFormat(video.duration) }}</b>
|
||||
|
||||
<br />
|
||||
|
||||
<router-link :to="'/watch?v=' + video.id + '&listen=1'">
|
||||
<font-awesome-icon icon="headphones"></font-awesome-icon>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
videos: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
document.title = 'Feed - Piped'
|
||||
|
||||
this.fetchFeed().then(videos => (this.videos = videos))
|
||||
},
|
||||
methods: {
|
||||
async fetchFeed () {
|
||||
return await this.fetchJson(this.apiUrl() + '/feed', {
|
||||
authToken: this.getAuthToken()
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
getRssUrl (_this) {
|
||||
return _this.apiUrl() + '/feed/rss?authToken=' + _this.getAuthToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<div class="uk-vertical-align uk-text-center uk-height-1-1 ">
|
||||
<form class="uk-panel uk-panel-box">
|
||||
<div class="uk-form-row">
|
||||
<input type="file" @change="fileChange" ref="fileSelector" />
|
||||
</div>
|
||||
<div class="uk-form-row">
|
||||
<b>Selected Subscriptions: {{ selectedSubscriptions }}</b>
|
||||
</div>
|
||||
<div class="uk-form-row">
|
||||
<a
|
||||
class="uk-width-1-1 uk-button uk-button-primary uk-button-large uk-width-auto"
|
||||
style="background: #222"
|
||||
@click="handleImport"
|
||||
>Import</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<br />
|
||||
<b>Importing Subscriptions from YouTube</b>
|
||||
<br />
|
||||
<div>
|
||||
Open
|
||||
<a href="https://takeout.google.com/takeout/custom/youtube">takeout.google.com/takeout/custom/youtube</a>
|
||||
<br />
|
||||
In "Select data to include", click on "All YouTube data included" and select only "subscriptions".
|
||||
<br />
|
||||
Create the export and download the zip file.
|
||||
<br />
|
||||
Extract subscriptions.json from the zip file.
|
||||
<br />
|
||||
Select and import the file above.
|
||||
</div>
|
||||
<br />
|
||||
<b>Importing Subscriptions from Invidious</b>
|
||||
<br />
|
||||
<div>
|
||||
Open
|
||||
<a href="https://invidio.us/data_control">invidiou.us/data_control</a>
|
||||
<br />
|
||||
Click on any of the export options.
|
||||
<br />
|
||||
Select and import the file above.
|
||||
</div>
|
||||
<br />
|
||||
<b>Importing Subscriptions from NewPipe</b>
|
||||
<br />
|
||||
<div>
|
||||
Go to the Feed tab.
|
||||
<br />
|
||||
Click on the arrow on where it says "Subscriptions".
|
||||
<br />
|
||||
Save the file somewhere.
|
||||
<br />
|
||||
Select and import the file above.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
subscriptions: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedSubscriptions () {
|
||||
return this.subscriptions.length
|
||||
}
|
||||
},
|
||||
activated () {
|
||||
if (!this.authenticated) this.$router.push('/login')
|
||||
},
|
||||
methods: {
|
||||
fileChange () {
|
||||
this.$refs.fileSelector.files[0].text().then(text => {
|
||||
this.subscriptions = []
|
||||
|
||||
// Invidious
|
||||
if (text.indexOf('opml') !== -1) {
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(text, 'text/xml')
|
||||
xmlDoc.querySelectorAll('outline[xmlUrl]').forEach(item => {
|
||||
const url = item.getAttribute('xmlUrl')
|
||||
const id = url.substr(-24)
|
||||
this.subscriptions.push(id)
|
||||
})
|
||||
}
|
||||
// NewPipe
|
||||
if (text.indexOf('app_version') !== -1) {
|
||||
const json = JSON.parse(text)
|
||||
json.subscriptions
|
||||
.filter(item => item.service_id === 0)
|
||||
.forEach(item => {
|
||||
const url = item.url
|
||||
const id = url.substr(-24)
|
||||
this.subscriptions.push(id)
|
||||
})
|
||||
}
|
||||
// Invidious JSON
|
||||
if (text.indexOf('thin_mode') !== -1) {
|
||||
const json = JSON.parse(text)
|
||||
this.subscriptions = json.subscriptions
|
||||
}
|
||||
// Google Takeout
|
||||
if (text.indexOf('contentDetails') !== -1) {
|
||||
const json = JSON.parse(text)
|
||||
json.forEach(item => {
|
||||
const id = item.snippet.resourceId.channelId
|
||||
this.subscriptions.push(id)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
handleImport () {
|
||||
this.fetchJson(this.apiUrl() + '/import', null, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: this.getAuthToken()
|
||||
},
|
||||
body: JSON.stringify(this.subscriptions)
|
||||
}).then(json => {
|
||||
if (json.message === 'ok') window.location = '/feed'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="uk-vertical-align uk-text-center uk-height-1-1 ">
|
||||
<form class="uk-panel uk-panel-box">
|
||||
<div class="uk-form-row">
|
||||
<input
|
||||
class="uk-width-1-1 uk-form-large uk-input uk-width-auto"
|
||||
type="text"
|
||||
v-model="username"
|
||||
autocomplete="username"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</div>
|
||||
<div class="uk-form-row">
|
||||
<input
|
||||
class="uk-width-1-1 uk-form-large uk-input uk-width-auto"
|
||||
type="password"
|
||||
v-model="password"
|
||||
autocomplete="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
<div class="uk-form-row">
|
||||
<a
|
||||
class="uk-width-1-1 uk-button uk-button-primary uk-button-large uk-width-auto"
|
||||
style="background: #222"
|
||||
@click="login"
|
||||
>Login</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
username: null,
|
||||
password: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// TODO: Add Server Side check
|
||||
if (this.getAuthToken()) {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login () {
|
||||
console.log('authToken' + this.hashCode(this.apiUrl()))
|
||||
this.fetchJson(this.apiUrl() + '/login', null, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: this.username,
|
||||
password: this.password
|
||||
})
|
||||
}).then(resp => {
|
||||
if (resp.token) {
|
||||
this.setPreference('authToken' + this.hashCode(this.apiUrl()), resp.token)
|
||||
window.location = '/' // done to bypass cache
|
||||
} else alert(resp.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,104 +0,0 @@
|
||||
<template>
|
||||
<nav
|
||||
class="uk-navbar-container uk-container-expand uk-position-relative"
|
||||
:style="[{ background: backgroundColor, colour: foregroundColor }]"
|
||||
uk-navbar
|
||||
>
|
||||
<div class="uk-navbar-left">
|
||||
<router-link class="uk-navbar-item uk-logo uk-text-bold" :style="[{ colour: foregroundColor }]" to="/"
|
||||
><img alt="logo" src="/img/icons/logo.svg" height="32" width="32" style="margin-bottom: 6px; margin-right: -13px" />iped</router-link
|
||||
>
|
||||
</div>
|
||||
<div class="uk-navbar-center uk-flex uk-visible@m">
|
||||
<input
|
||||
class="uk-input uk-width-medium"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
v-model="searchText"
|
||||
@keyup="onKeyUp"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
</div>
|
||||
<div class="uk-navbar-right">
|
||||
<ul class="uk-navbar-nav">
|
||||
<li>
|
||||
<router-link to="/preferences">Preferences</router-link>
|
||||
</li>
|
||||
<li v-if="shouldShowLogin">
|
||||
<router-link to="/login">Login</router-link>
|
||||
</li>
|
||||
<li v-if="shouldShowLogin">
|
||||
<router-link to="/register">Register</router-link>
|
||||
</li>
|
||||
<li v-if="authenticated">
|
||||
<router-link to="/feed">Feed</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="uk-container-expand uk-hidden@m">
|
||||
<input
|
||||
class="uk-input"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
v-model="searchText"
|
||||
@keyup="onKeyUp"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
</div>
|
||||
<SearchSuggestions
|
||||
v-show="searchText && suggestionsVisible"
|
||||
:searchText="searchText"
|
||||
@searchchange="onSearchTextChange"
|
||||
ref="searchSuggestions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SearchSuggestions from '@/components/SearchSuggestions'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SearchSuggestions
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
searchText: '',
|
||||
suggestionsVisible: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
shouldShowLogin (_this) {
|
||||
return _this.getAuthToken() == null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onKeyUp (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur()
|
||||
this.$router.push({
|
||||
name: 'SearchResults',
|
||||
query: { search_query: this.searchText }
|
||||
})
|
||||
return
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
}
|
||||
this.$refs.searchSuggestions.onKeyUp(e)
|
||||
},
|
||||
onInputFocus () {
|
||||
this.suggestionsVisible = true
|
||||
},
|
||||
onInputBlur () {
|
||||
this.suggestionsVisible = false
|
||||
},
|
||||
onSearchTextChange (searchText) {
|
||||
this.searchText = searchText
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -1,25 +1,25 @@
|
||||
<template>
|
||||
<div class="uk-container-expand">
|
||||
<div
|
||||
data-shaka-player-container
|
||||
style="width: 100%; height: 100%; max-height: 75vh; min-height: 250px; background: #000"
|
||||
ref="container"
|
||||
>
|
||||
<video
|
||||
data-shaka-player
|
||||
class="uk-width-expand"
|
||||
:autoplay="shouldAutoPlay"
|
||||
:loop="selectedAutoLoop"
|
||||
ref="videoEl"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-shaka-player-container
|
||||
style="width: 100%; height: 100%; max-height: 75vh; min-height: calc(100vh - 64px); background: #000"
|
||||
ref="container"
|
||||
>
|
||||
<video
|
||||
data-shaka-player
|
||||
style="min-width: 100%;"
|
||||
:autoplay="shouldAutoPlay"
|
||||
:loop="selectedAutoLoop"
|
||||
ref="videoEl"
|
||||
></video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import muxjs from 'mux.js'
|
||||
import shaka from 'shaka-player/dist/shaka-player.ui.js'
|
||||
import('shaka-player/dist/controls.css')
|
||||
import 'shaka-player/dist/controls.css'
|
||||
import { DashUtils } from '@/tools/DashUtils'
|
||||
|
||||
window.muxjs = muxjs
|
||||
|
||||
export default {
|
||||
@@ -35,13 +35,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
shouldAutoPlay: _this => {
|
||||
return _this.getPreferenceBoolean('playerAutoPlay', true)
|
||||
shouldAutoPlay () {
|
||||
return this.$store.getters.getPreferenceBoolean('playerAutoPlay', true)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (!this.shaka) this.shakaPromise = shaka.then(shaka => shaka.default).then(shaka => (this.shaka = shaka))
|
||||
},
|
||||
methods: {
|
||||
loadVideo () {
|
||||
const component = this
|
||||
@@ -65,7 +62,7 @@ export default {
|
||||
if (this.video.livestream) {
|
||||
uri = this.video.hls
|
||||
} else if (this.video.audioStreams.length > 0 && MseSupport) {
|
||||
const dash = require('@/utils/DashUtils.js').default.generate_dash_file_from_formats(
|
||||
const dash = DashUtils.generate_dash_file_from_formats(
|
||||
streams,
|
||||
this.video.duration
|
||||
)
|
||||
@@ -76,29 +73,27 @@ export default {
|
||||
}
|
||||
|
||||
if (noPrevPlayer) {
|
||||
this.shakaPromise.then(() => {
|
||||
this.shaka.polyfill.installAll()
|
||||
shaka.polyfill.installAll()
|
||||
|
||||
const localPlayer = new this.shaka.Player(videoEl)
|
||||
const localPlayer = new shaka.Player(videoEl)
|
||||
|
||||
localPlayer.getNetworkingEngine().registerRequestFilter((_type, request) => {
|
||||
const uri = request.uris[0]
|
||||
const url = new URL(uri)
|
||||
if (url.host.endsWith('.googlevideo.com')) {
|
||||
url.searchParams.set('host', url.host)
|
||||
url.host = new URL(component.video.proxyUrl).host
|
||||
request.uris[0] = url.toString()
|
||||
}
|
||||
})
|
||||
|
||||
localPlayer.configure(
|
||||
'streaming.bufferingGoal',
|
||||
Math.max(this.getPreferenceNumber('bufferGoal', 10), 10)
|
||||
)
|
||||
|
||||
this.setPlayerAttrs(localPlayer, videoEl, uri, this.shaka)
|
||||
localPlayer.getNetworkingEngine().registerRequestFilter((_type, request) => {
|
||||
const uri = request.uris[0]
|
||||
const url = new URL(uri)
|
||||
if (url.host.endsWith('.googlevideo.com')) {
|
||||
url.searchParams.set('host', url.host)
|
||||
url.host = new URL(component.video.proxyUrl).host
|
||||
request.uris[0] = url.toString()
|
||||
}
|
||||
})
|
||||
} else this.setPlayerAttrs(this.player, videoEl, uri, this.shaka)
|
||||
|
||||
localPlayer.configure(
|
||||
'streaming.bufferingGoal',
|
||||
Math.max(this.$store.getters.getPreferenceNumber('bufferGoal', 10), 10)
|
||||
)
|
||||
|
||||
this.setPlayerAttrs(localPlayer, videoEl, uri, shaka)
|
||||
} else this.setPlayerAttrs(this.player, videoEl, uri, shaka)
|
||||
|
||||
if (noPrevPlayer) {
|
||||
videoEl.addEventListener('timeupdate', () => {
|
||||
@@ -165,7 +160,7 @@ export default {
|
||||
|
||||
this.player = player
|
||||
|
||||
const disableVideo = this.getPreferenceBoolean('listen', false) && !this.video.livestream
|
||||
const disableVideo = this.$store.getters.getPreferenceBoolean('listen', false) && !this.video.livestream
|
||||
|
||||
this.player.configure({
|
||||
preferredVideoCodecs: ['av01', 'vp9', 'avc1'],
|
||||
@@ -175,7 +170,7 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
const quality = this.getPreferenceNumber('quality', 0)
|
||||
const quality = this.$store.getters.getPreferenceNumber('quality', 0)
|
||||
const qualityConds =
|
||||
quality > 0 && (this.video.audioStreams.length > 0 || this.video.livestream) && !disableVideo
|
||||
if (qualityConds) this.player.configure('abr.enabled', false)
|
||||
@@ -207,7 +202,7 @@ export default {
|
||||
subtitle.name
|
||||
)
|
||||
})
|
||||
videoEl.volume = this.getPreferenceNumber('volume', 1)
|
||||
videoEl.volume = this.$store.getters.getPreferenceNumber('volume', 1)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="playlist && playlist.error" :message="playlist.message" :error="playlist.error" />
|
||||
|
||||
<div v-if="playlist" v-show="!playlist.error">
|
||||
<h1 class="uk-text-center">
|
||||
<img v-bind:src="playlist.avatarUrl" height="48" width="48" loading="lazy" />
|
||||
{{ playlist.name }}
|
||||
</h1>
|
||||
|
||||
<b
|
||||
><router-link class="uk-text-justify" v-bind:to="playlist.uploaderUrl || '/'">
|
||||
<img v-bind:src="playlist.uploaderAvatar" loading="lazy" />
|
||||
{{ playlist.uploader }}</router-link
|
||||
></b
|
||||
>
|
||||
|
||||
<div class="uk-align-right">
|
||||
<b>{{ playlist.videos }} Videos</b>
|
||||
<br />
|
||||
<a :href="getRssUrl"><font-awesome-icon icon="rss"></font-awesome-icon></a>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="uk-grid-xl" uk-grid="parallax: 0">
|
||||
<div
|
||||
class="uk-width-1-2 uk-width-1-3@m uk-width-1-4@l uk-width-1-5@xl"
|
||||
v-bind:key="video.url"
|
||||
v-for="video in this.playlist.relatedStreams"
|
||||
>
|
||||
<VideoItem :video="video" height="94" width="168" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorHandler from '@/components/ErrorHandler.vue'
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
playlist: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getPlaylistData()
|
||||
},
|
||||
activated () {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
deactivated () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
computed: {
|
||||
getRssUrl: _this => {
|
||||
return _this.apiUrl() + '/rss/playlists/' + _this.$route.query.list
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchPlaylist () {
|
||||
return await await this.fetchJson(this.apiUrl() + '/playlists/' + this.$route.query.list)
|
||||
},
|
||||
async getPlaylistData () {
|
||||
this.fetchPlaylist()
|
||||
.then(data => (this.playlist = data))
|
||||
.then(() => (document.title = this.playlist.name + ' - Piped'))
|
||||
},
|
||||
handleScroll () {
|
||||
if (this.loading || !this.playlist || !this.playlist.nextpage) return
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||
this.loading = true
|
||||
this.fetchJson(this.apiUrl() + '/nextpage/playlists/' + this.$route.query.list, {
|
||||
nextpage: this.playlist.nextpage
|
||||
}).then(json => {
|
||||
this.playlist.relatedStreams.concat(json.relatedStreams)
|
||||
this.playlist.nextpage = json.nextpage
|
||||
this.loading = false
|
||||
json.relatedStreams.map(stream => this.playlist.relatedStreams.push(stream))
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ErrorHandler,
|
||||
VideoItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,250 +0,0 @@
|
||||
<template>
|
||||
<h1 class="uk-text-bold uk-text-center">Preferences</h1>
|
||||
<hr />
|
||||
<h2>SponsorBlock</h2>
|
||||
<p>Uses the API from <a href="https://sponsor.ajay.app/">sponsor.ajay.app</a></p>
|
||||
<b>Enable Sponsorblock</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="sponsorBlock" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Sponsors</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipSponsor" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Intermission/Intro Animation</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipIntro" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Endcards/Credits</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipOutro" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Preview/Recap</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipPreview" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Interaction Reminder (Subscribe)</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipInteraction" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Unpaid/Self Promotion</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipSelfPromo" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Skip Music: Non-Music Section</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="skipMusicOffTopic" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Theme</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="selectedTheme" @change="onChange($event)">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Autoplay Video</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="autoPlayVideo" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Audio Only</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="listen" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Default Quality</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="defaultQuality" @change="onChange($event)">
|
||||
<option value="0">Auto</option>
|
||||
<option :key="resolution" v-for="resolution in resolutions" :value="resolution">{{ resolution }}p</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Buffering Goal</b>
|
||||
<br />
|
||||
<input class="uk-input uk-width-auto" v-model="bufferingGoal" @change="onChange($event)" type="text" />
|
||||
<br />
|
||||
<b>Country Selection</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="country" @change="onChange($event)">
|
||||
<option :key="country.code" v-for="country in countryMap" :value="country.code">{{ country.name }}</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Default Homepage</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="defaultHomepage" @change="onChange($event)">
|
||||
<option value="trending">Trending</option>
|
||||
<option value="feed">Feed</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Show Comments</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="showComments" @change="onChange($event)" type="checkbox" />
|
||||
<h2>Instances List</h2>
|
||||
<table class="uk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instance Name</th>
|
||||
<th>Instance Locations</th>
|
||||
<th>Has CDN?</th>
|
||||
<th>SSL Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-bind:key="instance.name" v-for="instance in instances">
|
||||
<tr>
|
||||
<td>{{ instance.name }}</td>
|
||||
<td>{{ instance.locations }}</td>
|
||||
<td>{{ instance.cdn }}</td>
|
||||
<td>
|
||||
<a :href="sslScore(instance.apiurl)" target="_blank">Click Here</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<b>Instance Selection:</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="selectedInstance" @change="onChange($event)">
|
||||
<option v-bind:key="instance.name" v-for="instance in instances" v-bind:value="instance.apiurl">
|
||||
{{ instance.name }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CountryMap from '@/utils/CountryMap.js'
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
selectedInstance: null,
|
||||
instances: [],
|
||||
sponsorBlock: true,
|
||||
skipSponsor: true,
|
||||
skipIntro: false,
|
||||
skipOutro: false,
|
||||
skipPreview: false,
|
||||
skipInteraction: true,
|
||||
skipSelfPromo: true,
|
||||
skipMusicOffTopic: true,
|
||||
selectedTheme: 'dark',
|
||||
autoPlayVideo: true,
|
||||
listen: false,
|
||||
resolutions: [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320],
|
||||
defaultQuality: 0,
|
||||
bufferingGoal: 10,
|
||||
countryMap: CountryMap.COUNTRIES,
|
||||
country: 'US',
|
||||
defaultHomepage: 'trending',
|
||||
showComments: true
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (Object.keys(this.$route.query).length > 0) this.$router.replace({ query: {} })
|
||||
|
||||
fetch('https://raw.githubusercontent.com/wiki/TeamPiped/Piped-Frontend/Instances.md')
|
||||
.then(resp => resp.text())
|
||||
.then(body => {
|
||||
let skipped = 0
|
||||
const lines = body.split('\n')
|
||||
lines.forEach(line => {
|
||||
const split = line.split('|')
|
||||
if (split.length === 4) {
|
||||
if (skipped < 2) {
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
this.instances.push({
|
||||
name: split[0].trim(),
|
||||
apiurl: split[1].trim(),
|
||||
locations: split[2].trim(),
|
||||
cdn: split[3].trim()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (localStorage) {
|
||||
this.selectedInstance = this.getPreferenceString('instance', 'https://pipedapi.kavin.rocks')
|
||||
|
||||
this.sponsorBlock = this.getPreferenceBoolean('sponsorblock', true)
|
||||
if (localStorage.getItem('selectedSkip') !== null) {
|
||||
const skipList = localStorage.getItem('selectedSkip').split(',')
|
||||
this.skipSponsor = this.skipIntro = this.skipOutro = this.skipPreview = this.skipInteraction = this.skipSelfPromo = this.skipMusicOffTopic = false
|
||||
skipList.forEach(skip => {
|
||||
switch (skip) {
|
||||
case 'sponsor':
|
||||
this.skipSponsor = true
|
||||
break
|
||||
case 'intro':
|
||||
this.skipIntro = true
|
||||
break
|
||||
case 'outro':
|
||||
this.skipOutro = true
|
||||
break
|
||||
case 'preview':
|
||||
this.skipPreview = true
|
||||
break
|
||||
case 'interaction':
|
||||
this.skipInteraction = true
|
||||
break
|
||||
case 'selfpromo':
|
||||
this.skipSelfPromo = true
|
||||
break
|
||||
case 'music_offtopic':
|
||||
this.skipMusicOffTopic = true
|
||||
break
|
||||
default:
|
||||
console.log('Unknown sponsor type: ' + skip)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.selectedTheme = this.getPreferenceString('theme', 'dark')
|
||||
this.autoPlayVideo = this.getPreferenceBoolean(localStorage.getItem('playerAutoPlay'), true)
|
||||
this.listen = this.getPreferenceBoolean('listen', false)
|
||||
this.defaultQuality = Number(localStorage.getItem('quality'))
|
||||
this.bufferingGoal = Math.max(Number(localStorage.getItem('bufferGoal')), 10)
|
||||
this.country = this.getPreferenceString('region', 'US')
|
||||
this.defaultHomepage = this.getPreferenceString('homepage', 'trending')
|
||||
this.showComments = this.getPreferenceBoolean('comments', true)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onChange () {
|
||||
if (localStorage) {
|
||||
let shouldReload = false
|
||||
|
||||
if (this.getPreferenceString('theme', 'dark') !== this.selectedTheme) shouldReload = true
|
||||
|
||||
localStorage.setItem('instance', this.selectedInstance)
|
||||
localStorage.setItem('sponsorblock', this.sponsorBlock)
|
||||
|
||||
const sponsorSelected = []
|
||||
if (this.skipSponsor) sponsorSelected.push('sponsor')
|
||||
if (this.skipIntro) sponsorSelected.push('intro')
|
||||
if (this.skipOutro) sponsorSelected.push('outro')
|
||||
if (this.skipPreview) sponsorSelected.push('preview')
|
||||
if (this.skipInteraction) sponsorSelected.push('interaction')
|
||||
if (this.skipSelfPromo) sponsorSelected.push('selfpromo')
|
||||
if (this.skipMusicOffTopic) sponsorSelected.push('music_offtopic')
|
||||
localStorage.setItem('selectedSkip', sponsorSelected)
|
||||
|
||||
localStorage.setItem('theme', this.selectedTheme)
|
||||
localStorage.setItem('playerAutoPlay', this.autoPlayVideo)
|
||||
localStorage.setItem('listen', this.listen)
|
||||
localStorage.setItem('quality', this.defaultQuality)
|
||||
localStorage.setItem('bufferGoal', this.bufferingGoal)
|
||||
localStorage.setItem('region', this.country)
|
||||
localStorage.setItem('homepage', this.defaultHomepage)
|
||||
localStorage.setItem('comments', this.showComments)
|
||||
|
||||
if (shouldReload) window.location.reload()
|
||||
}
|
||||
},
|
||||
sslScore (url) {
|
||||
return 'https://www.ssllabs.com/ssltest/analyze.html?d=' + new URL(url).host + '&latest'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="uk-vertical-align uk-text-center uk-height-1-1 ">
|
||||
<form class="uk-panel uk-panel-box">
|
||||
<div class="uk-form-row">
|
||||
<input
|
||||
class="uk-width-1-1 uk-form-large uk-input uk-width-auto"
|
||||
type="text"
|
||||
v-model="username"
|
||||
autocomplete="username"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</div>
|
||||
<div class="uk-form-row">
|
||||
<input
|
||||
class="uk-width-1-1 uk-form-large uk-input uk-width-auto"
|
||||
type="password"
|
||||
v-model="password"
|
||||
autocomplete="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
<div class="uk-form-row">
|
||||
<a
|
||||
class="uk-width-1-1 uk-button uk-button-primary uk-button-large uk-width-auto"
|
||||
style="background: #222"
|
||||
@click="register"
|
||||
>Register</a
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
username: null,
|
||||
password: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// TODO: Add Server Side check
|
||||
if (this.getAuthToken()) {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
register () {
|
||||
console.log('authToken' + this.hashCode(this.apiUrl()))
|
||||
this.fetchJson(this.apiUrl() + '/register', null, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: this.username,
|
||||
password: this.password
|
||||
})
|
||||
}).then(resp => {
|
||||
if (resp.token) {
|
||||
this.setPreference('authToken' + this.hashCode(this.apiUrl()), resp.token)
|
||||
window.location = '/' // done to bypass cache
|
||||
} else alert(resp.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,125 +0,0 @@
|
||||
<template>
|
||||
<h1 class="uk-text-center">
|
||||
{{ $route.query.search_query }}
|
||||
</h1>
|
||||
|
||||
<b>Filter: </b>
|
||||
<select
|
||||
default="all"
|
||||
class="uk-select uk-width-auto"
|
||||
style="height: 100%"
|
||||
v-model="selectedFilter"
|
||||
@change="updateResults()"
|
||||
>
|
||||
<option v-bind:key="filter" v-for="filter in availableFilters" v-bind:value="filter">
|
||||
{{ filter.replace("_", " ") }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<hr />
|
||||
|
||||
<div v-if="results" class="uk-grid-xl" uk-grid="parallax: 0">
|
||||
<div
|
||||
:style="[{ background: backgroundColor }]"
|
||||
class="uk-width-1-2 uk-width-1-3@s uk-width-1-4@m uk-width-1-5@l uk-width-1-6@xl"
|
||||
v-bind:key="result.url"
|
||||
v-for="result in results.items"
|
||||
>
|
||||
<div class="uk-text-secondary">
|
||||
<router-link class="uk-text-emphasis" v-bind:to="result.url">
|
||||
<img style="width: 100%" v-bind:src="result.thumbnail" loading="lazy" />
|
||||
<p>
|
||||
{{ result.name }} <font-awesome-icon
|
||||
v-if="result.verified"
|
||||
icon="check"
|
||||
></font-awesome-icon>
|
||||
</p>
|
||||
</router-link>
|
||||
<p v-if="result.description">{{ result.description }}</p>
|
||||
<router-link class="uk-link-muted" v-if="result.uploaderUrl" v-bind:to="result.uploaderUrl">
|
||||
<p>
|
||||
{{ result.uploader }} <font-awesome-icon
|
||||
v-if="result.uploaderVerified"
|
||||
icon="check"
|
||||
></font-awesome-icon>
|
||||
</p>
|
||||
</router-link>
|
||||
<b v-if="result.duration" class="uk-text-small uk-align-right uk-text-align-right">
|
||||
{{ timeFormat(result.duration) }}
|
||||
</b>
|
||||
|
||||
<b v-if="result.uploadDate">
|
||||
{{ result.uploadDate }}
|
||||
</b>
|
||||
|
||||
<a v-if="result.uploaderName" class="uk-text-muted">{{ result.uploaderName }}</a>
|
||||
<b v-if="result.videos >= 0"><br v-if="result.uploaderName" />{{ result.videos }} Videos</b>
|
||||
|
||||
<br />
|
||||
|
||||
<b v-if="result.views >= 0" class="uk-text-small">
|
||||
<font-awesome-icon icon="eye"></font-awesome-icon>
|
||||
{{ numberFormat(result.views) }} views
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
results: null,
|
||||
availableFilters: [
|
||||
'all',
|
||||
'videos',
|
||||
'channels',
|
||||
'playlists',
|
||||
'music_songs',
|
||||
'music_videos',
|
||||
'music_albums',
|
||||
'music_playlists'
|
||||
],
|
||||
selectedFilter: 'all'
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateResults()
|
||||
},
|
||||
activated () {
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
deactivated () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
methods: {
|
||||
async fetchResults () {
|
||||
return await await this.fetchJson(this.apiUrl() + '/search', {
|
||||
q: this.$route.query.search_query,
|
||||
filter: this.selectedFilter
|
||||
})
|
||||
},
|
||||
async updateResults () {
|
||||
document.title = this.$route.query.search_query + ' - Piped'
|
||||
this.results = this.fetchResults().then(json => (this.results = json))
|
||||
},
|
||||
handleScroll () {
|
||||
if (this.loading || !this.results || !this.results.nextpage) return
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||
this.loading = true
|
||||
this.fetchJson(this.apiUrl() + '/nextpage/search', {
|
||||
nextpage: this.results.nextpage,
|
||||
q: this.$route.query.search_query,
|
||||
filter: this.selectedFilter
|
||||
}).then(json => {
|
||||
this.results.nextpage = json.nextpage
|
||||
this.results.id = json.id
|
||||
this.loading = false
|
||||
json.items.map(stream => this.results.items.push(stream))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="uk-position-absolute uk-panel uk-box-shadow-large suggestions-container"
|
||||
:style="[{ background: secondaryBackgroundColor }]"
|
||||
>
|
||||
<ul class="uk-list uk-margin-remove uk-text-secondary">
|
||||
<li
|
||||
v-for="(suggestion, i) in searchSuggestions"
|
||||
:key="i"
|
||||
:style="[selected === i ? { background: secondaryForegroundColor } : {}]"
|
||||
@mouseover="onMouseOver(i)"
|
||||
@mousedown.stop="onClick(i)"
|
||||
class="uk-margin-remove suggestion"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
searchText: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
selected: 0,
|
||||
searchSuggestions: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onKeyUp (e) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
if (this.selected <= 0) {
|
||||
this.setSelected(this.searchSuggestions.length - 1)
|
||||
} else {
|
||||
this.setSelected(this.selected - 1)
|
||||
}
|
||||
e.preventDefault()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (this.selected >= this.searchSuggestions.length - 1) {
|
||||
this.setSelected(0)
|
||||
} else {
|
||||
this.setSelected(this.selected + 1)
|
||||
}
|
||||
e.preventDefault()
|
||||
} else {
|
||||
this.refreshSuggestions()
|
||||
}
|
||||
},
|
||||
async refreshSuggestions () {
|
||||
this.searchSuggestions = await this.fetchJson(this.apiUrl() + '/suggestions', {
|
||||
query: this.searchText
|
||||
})
|
||||
this.searchSuggestions.unshift(this.searchText)
|
||||
this.setSelected(0)
|
||||
},
|
||||
onMouseOver (i) {
|
||||
if (i !== this.selected) {
|
||||
this.selected = i
|
||||
}
|
||||
},
|
||||
onClick (i) {
|
||||
this.setSelected(i)
|
||||
this.$router.push({
|
||||
name: 'SearchResults',
|
||||
query: { search_query: this.searchSuggestions[i] }
|
||||
})
|
||||
},
|
||||
setSelected (val) {
|
||||
this.selected = val
|
||||
this.$emit('searchchange', this.searchSuggestions[this.selected])
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.suggestions-container {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.suggestion {
|
||||
padding: 4px 15px;
|
||||
}
|
||||
@media screen and (max-width: 959px) {
|
||||
.suggestions-container {
|
||||
max-width: calc(100% - 60px);
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 639px) {
|
||||
.suggestions-container {
|
||||
max-width: calc(100% - 30px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<h1 class="uk-text-bold uk-text-center">Trending</h1>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="uk-grid-xl" uk-grid="parallax: 0">
|
||||
<div
|
||||
:style="[{ background: backgroundColor }]"
|
||||
class="uk-width-1-2 uk-width-1-3@s uk-width-1-4@m uk-width-1-5@l uk-width-1-6@xl"
|
||||
v-bind:key="video.url"
|
||||
v-for="video in videos"
|
||||
>
|
||||
<VideoItem :video="video" height="118" width="210" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
videos: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
document.title = 'Trending - Piped'
|
||||
|
||||
const region = this.getPreferenceString('region', 'US')
|
||||
|
||||
this.fetchTrending(region).then(videos => (this.videos = videos))
|
||||
},
|
||||
methods: {
|
||||
async fetchTrending (region) {
|
||||
return await this.fetchJson(this.apiUrl() + '/trending', {
|
||||
region: region || 'US'
|
||||
})
|
||||
}
|
||||
},
|
||||
components: {
|
||||
VideoItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<v-card elevation="4" class="pa-4">
|
||||
<v-row>
|
||||
<v-col md="1">
|
||||
<v-avatar size="96">
|
||||
<v-img :src="comment.thumbnail"></v-img>
|
||||
</v-avatar>
|
||||
</v-col>
|
||||
<v-col md="11">
|
||||
<v-card-title class="subtitle-1">
|
||||
<v-icon v-if="comment.pinned" class="mx-2">mdi-pin</v-icon>
|
||||
<router-link :to="comment.commentorUrl" class="text-decoration-none black--text">
|
||||
<b>{{ comment.author }}</b><v-icon v-if="comment.verified">mdi-account-check</v-icon>
|
||||
</router-link>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-html="renderedCommentTxt" />
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<v-icon>mdi-thumb-up</v-icon> {{ numberFormat(comment.likeCount) }}
|
||||
<v-icon v-if="comment.hearted" class="ml-4">mdi-heart</v-icon>
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { LibPiped } from '@/tools/libpiped'
|
||||
import marked from 'marked'
|
||||
|
||||
export default {
|
||||
name: 'VideoComment',
|
||||
props: ['comment', 'video'],
|
||||
methods: {
|
||||
numberFormat (...args) {
|
||||
return LibPiped.numberFormat(...args)
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
renderedCommentTxt () {
|
||||
return LibPiped.purifyHTML(marked.parseInline(this.comment.commentText, {
|
||||
breaks: true
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,55 +1,42 @@
|
||||
<template>
|
||||
<div class="uk-text-secondary" :style="[{ background: backgroundColor }]">
|
||||
<router-link class="uk-text-emphasis" v-bind:to="video.url">
|
||||
<img
|
||||
:height="height"
|
||||
:width="width"
|
||||
style="width: 100%"
|
||||
v-bind:src="video.thumbnail"
|
||||
alt="thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p>{{ video.title }}</p>
|
||||
<v-card link :to="video.url">
|
||||
<v-img
|
||||
:height="height"
|
||||
:width="width"
|
||||
:src="video.thumbnail"
|
||||
alt="thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<v-card-title class="subtitle-1">{{ video.title }}</v-card-title>
|
||||
<v-card-text>
|
||||
<router-link :to="video.uploaderUrl" class="subtitle-1 text-decoration-none" v-if="video.uploaderUrl && video.uploaderName && !hideChannel" custom v-slot="{ navigate }">
|
||||
<h5 @click="navigate" @keypress.enter="navigate" role="link">{{ video.uploaderName }}</h5>
|
||||
</router-link>
|
||||
|
||||
<div :class="{ 'uk-align-left': !(video.views >= 0 || video.uploadedDate) }">
|
||||
<div v-if="video.uploaderUrl && video.uploaderName && !hideChannel">
|
||||
<router-link class="uk-link-muted" :to="video.uploaderUrl">
|
||||
<a>{{ video.uploaderName }}</a>
|
||||
</router-link>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b v-if="video.views >= 0 || video.uploadedDate" class="uk-text-small uk-align-left">
|
||||
<div v-if="video.views >= 0">
|
||||
<font-awesome-icon icon="eye"></font-awesome-icon>
|
||||
{{ numberFormat(video.views) }} views
|
||||
<br />
|
||||
</div>
|
||||
<div v-if="video.uploadedDate">
|
||||
{{ video.uploadedDate }}
|
||||
</div>
|
||||
</b>
|
||||
<div class="uk-align-right">
|
||||
<b class="uk-text-small">{{ timeFormat(video.duration) }}</b>
|
||||
|
||||
<br />
|
||||
|
||||
<router-link :to="video.url + '&listen=1'">
|
||||
<font-awesome-icon icon="headphones"></font-awesome-icon>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
{{ numberFormat(video.views) }} views <br />
|
||||
{{ video.uploadedDate }} <br />
|
||||
{{ timeFormat(video.duration) }}
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { LibPiped } from '@/tools/libpiped'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
video: Object,
|
||||
height: String,
|
||||
width: String,
|
||||
height: Number,
|
||||
width: Number,
|
||||
hideChannel: Boolean
|
||||
},
|
||||
methods: {
|
||||
numberFormat (...args) {
|
||||
return LibPiped.numberFormat(...args)
|
||||
},
|
||||
|
||||
timeFormat (...args) {
|
||||
return LibPiped.timeFormat(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
<template>
|
||||
<div class="uk-container uk-container-xlarge" v-if="video">
|
||||
<ErrorHandler v-if="video && video.error" :message="video.message" :error="video.error" />
|
||||
|
||||
<div v-show="!video.error">
|
||||
<Player
|
||||
ref="videoPlayer"
|
||||
:video="video"
|
||||
:sponsors="sponsors"
|
||||
:selectedAutoPlay="selectedAutoPlay"
|
||||
:selectedAutoLoop="selectedAutoLoop"
|
||||
/>
|
||||
<div class="uk-text-bold uk-margin-small-top uk-text-large uk-text-emphasis">{{ video.title }}</div>
|
||||
|
||||
<div class="uk-flex uk-flex-middle">
|
||||
<div class="uk-margin-small-right">{{ addCommas(video.views) }} views</div>
|
||||
<div class="uk-margin-small-right">{{ video.uploadDate }}</div>
|
||||
<div class="uk-flex-1"></div>
|
||||
<div class="uk-margin-small-left">
|
||||
<font-awesome-icon class="uk-margin-small-right" icon="thumbs-up"></font-awesome-icon>
|
||||
<b>{{ addCommas(video.likes) }}</b>
|
||||
</div>
|
||||
<div class="uk-margin-small-left">
|
||||
<font-awesome-icon class="uk-margin-small-right" icon="thumbs-down"></font-awesome-icon>
|
||||
<b>{{ addCommas(video.dislikes) }}</b>
|
||||
</div>
|
||||
<a
|
||||
:href="'https://youtu.be/' + getVideoId()"
|
||||
class="uk-margin-small-left uk-button uk-button-small"
|
||||
style="background: #222"
|
||||
>
|
||||
<font-awesome-icon class="uk-margin-small-right" :icon="['fab', 'youtube']"></font-awesome-icon>
|
||||
<b>Watch on</b>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="uk-flex uk-flex-middle uk-margin-small-top">
|
||||
<img :src="video.uploaderAvatar" loading="lazy" />
|
||||
<router-link class="uk-text-bold uk-margin-small-left" v-if="video.uploaderUrl" :to="video.uploaderUrl">
|
||||
<a>{{ video.uploader }}</a>
|
||||
</router-link>
|
||||
<div class="uk-flex-1"></div>
|
||||
<button
|
||||
v-if="authenticated"
|
||||
@click="subscribeHandler"
|
||||
class="uk-button uk-button-small"
|
||||
style="background: #222"
|
||||
type="button"
|
||||
>
|
||||
{{ subscribed ? "Unsubscribe" : "Subscribe" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<a class="uk-button uk-button-small" style="background: #222" @click="showDesc = !showDesc">
|
||||
{{ showDesc ? "Minimize Description" : "Show Description" }}
|
||||
</a>
|
||||
<p v-show="showDesc" :style="[{ colour: foregroundColor }]" v-html="video.description"></p>
|
||||
<div v-if="showDesc && sponsors && sponsors.segments">
|
||||
Sponsors Segments: {{ sponsors.segments.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<b>Loop this Video:</b>
|
||||
<input class="uk-checkbox" v-model="selectedAutoLoop" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Auto Play next Video:</b>
|
||||
<input class="uk-checkbox" v-model="selectedAutoPlay" @change="onChange($event)" type="checkbox" />
|
||||
|
||||
<hr />
|
||||
|
||||
<div uk-grid>
|
||||
<div class="uk-width-4-5@xl uk-width-3-4@l uk-width-1" v-if="comments" ref="comments">
|
||||
<div
|
||||
class="uk-tile-default uk-align-left uk-width-expand"
|
||||
:style="[{ background: backgroundColor }]"
|
||||
v-bind:key="comment.commentId"
|
||||
v-for="comment in comments.comments"
|
||||
>
|
||||
<div align="left">
|
||||
<div v-if="comment.pinned">
|
||||
<font-awesome-icon icon="thumbtack"></font-awesome-icon> Pinned by
|
||||
{{ video.uploader }}
|
||||
</div>
|
||||
<img
|
||||
:src="comment.thumbnail"
|
||||
style="width: 10vmin"
|
||||
height="176"
|
||||
width="176"
|
||||
loading="lazy"
|
||||
alt="avatar"
|
||||
/>
|
||||
<br />
|
||||
<router-link class="uk-link-muted" v-bind:to="comment.commentorUrl">
|
||||
{{ comment.author }} </router-link
|
||||
> <font-awesome-icon v-if="comment.verified" icon="check"></font-awesome-icon>
|
||||
</div>
|
||||
<p style="white-space: pre-wrap">{{ comment.commentText }}</p>
|
||||
<div>
|
||||
<b>{{ numberFormat(comment.likeCount) }}</b>
|
||||
|
||||
<font-awesome-icon icon="thumbs-up"></font-awesome-icon>
|
||||
|
||||
<font-awesome-icon v-if="comment.hearted" icon="heart"></font-awesome-icon>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="uk-width-1-5@xl uk-width-1-4@l uk-width-1 uk-flex-last@l uk-flex-first" v-if="video">
|
||||
<div
|
||||
class="uk-tile-default uk-width-auto"
|
||||
:style="[{ background: backgroundColor }]"
|
||||
v-bind:key="related.url"
|
||||
v-for="related in video.relatedStreams"
|
||||
>
|
||||
<VideoItem :video="related" height="94" width="168" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Player from '@/components/Player.vue'
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
import ErrorHandler from '@/components/ErrorHandler.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
data () {
|
||||
return {
|
||||
video: {
|
||||
title: 'Loading...'
|
||||
},
|
||||
sponsors: null,
|
||||
selectedAutoLoop: false,
|
||||
selectedAutoPlay: null,
|
||||
showDesc: true,
|
||||
comments: null,
|
||||
subscribed: false,
|
||||
channelId: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getVideoData().then(() => {
|
||||
this.$refs.videoPlayer.loadVideo()
|
||||
})
|
||||
this.getSponsors()
|
||||
if (this.getPreferenceBoolean('comments', true)) this.getComments()
|
||||
},
|
||||
activated () {
|
||||
this.selectedAutoPlay = this.getPreferenceBoolean('autoplay', true)
|
||||
if (this.video.duration) this.$refs.videoPlayer.loadVideo()
|
||||
window.addEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
deactivated () {
|
||||
window.removeEventListener('scroll', this.handleScroll)
|
||||
},
|
||||
watch: {
|
||||
'$route.query.v': function (v) {
|
||||
if (v) {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchVideo () {
|
||||
return this.fetchJson(this.apiUrl() + '/streams/' + this.getVideoId())
|
||||
},
|
||||
async fetchSponsors () {
|
||||
return await this.fetchJson(this.apiUrl() + '/sponsors/' + this.getVideoId(), {
|
||||
category:
|
||||
'["' +
|
||||
this.getPreferenceString('selectedSkip', 'sponsor,interaction,selfpromo,music_offtopic').replaceAll(
|
||||
',',
|
||||
'","'
|
||||
) +
|
||||
'"]'
|
||||
})
|
||||
},
|
||||
fetchComments () {
|
||||
return this.fetchJson(this.apiUrl() + '/comments/' + this.getVideoId())
|
||||
},
|
||||
onChange () {
|
||||
this.setPreference('autoplay', this.selectedAutoPlay)
|
||||
},
|
||||
async getVideoData () {
|
||||
await this.fetchVideo()
|
||||
.then(data => {
|
||||
this.video = data
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.video.error) {
|
||||
document.title = this.video.title + ' - Piped'
|
||||
this.channelId = this.video.uploaderUrl.split('/')[2]
|
||||
this.fetchSubscribedStatus()
|
||||
|
||||
this.video.description = this.purifyHTML(
|
||||
this.video.description
|
||||
.replaceAll('http://www.youtube.com', '')
|
||||
.replaceAll('https://www.youtube.com', '')
|
||||
.replaceAll('\n', '<br>')
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
async getSponsors () {
|
||||
if (this.getPreferenceBoolean('sponsorblock', true)) { this.fetchSponsors().then(data => (this.sponsors = data)) }
|
||||
},
|
||||
async getComments () {
|
||||
this.fetchComments().then(data => (this.comments = data))
|
||||
},
|
||||
async fetchSubscribedStatus () {
|
||||
if (!this.channelId) return
|
||||
|
||||
this.fetchJson(
|
||||
this.apiUrl() + '/subscribed',
|
||||
{
|
||||
channelId: this.channelId
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.getAuthToken()
|
||||
}
|
||||
}
|
||||
).then(json => {
|
||||
this.subscribed = json.subscribed
|
||||
})
|
||||
},
|
||||
subscribeHandler () {
|
||||
this.fetchJson(this.apiUrl() + (this.subscribed ? '/unsubscribe' : '/subscribe'), null, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelId: this.channelId
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
this.subscribed = !this.subscribed
|
||||
},
|
||||
handleScroll () {
|
||||
if (this.loading || !this.comments || !this.comments.nextpage) return
|
||||
if (window.innerHeight + window.scrollY >= this.$refs.comments.offsetHeight - window.innerHeight) {
|
||||
this.loading = true
|
||||
this.fetchJson(this.apiUrl() + '/nextpage/comments/' + this.getVideoId(), {
|
||||
url: this.comments.nextpage
|
||||
}).then(json => {
|
||||
this.comments.nextpage = json.nextpage
|
||||
this.loading = false
|
||||
json.comments.map(comment => this.comments.comments.push(comment))
|
||||
})
|
||||
}
|
||||
},
|
||||
getVideoId () {
|
||||
return this.$route.query.v || this.$route.params.v
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Player,
|
||||
VideoItem,
|
||||
ErrorHandler
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,186 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { keys as _keys } from 'lodash-es'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faThumbsUp,
|
||||
faThumbsDown,
|
||||
faEye,
|
||||
faThumbtack,
|
||||
faCheck,
|
||||
faHeart,
|
||||
faHeadphones,
|
||||
faRss
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { faGithub, faBitcoin, faYoutube } from '@fortawesome/free-brands-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
import router from '@/router/router'
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import vuetify from './plugins/vuetify'
|
||||
import store from './store'
|
||||
import router from './router'
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
Vue.config.productionTip = false
|
||||
|
||||
import TimeAgo from 'javascript-time-ago'
|
||||
|
||||
import en from 'javascript-time-ago/locale/en'
|
||||
library.add(
|
||||
faThumbsUp,
|
||||
faThumbsDown,
|
||||
faEye,
|
||||
faGithub,
|
||||
faBitcoin,
|
||||
faThumbtack,
|
||||
faCheck,
|
||||
faHeart,
|
||||
faHeadphones,
|
||||
faYoutube,
|
||||
faRss
|
||||
)
|
||||
|
||||
TimeAgo.addDefaultLocale(en)
|
||||
|
||||
const timeAgo = new TimeAgo('en-US')
|
||||
|
||||
import('./registerServiceWorker')
|
||||
|
||||
const mixin = {
|
||||
methods: {
|
||||
timeFormat: function (duration) {
|
||||
const pad = function (num, size) {
|
||||
return ('000' + num).slice(size * -1)
|
||||
}
|
||||
|
||||
const time = parseFloat(duration).toFixed(3)
|
||||
const hours = Math.floor(time / 60 / 60)
|
||||
const minutes = Math.floor(time / 60) % 60
|
||||
const seconds = Math.floor(time - minutes * 60)
|
||||
|
||||
let str = ''
|
||||
|
||||
if (hours > 0) str += hours + ':'
|
||||
|
||||
str += pad(minutes, 2) + ':' + pad(seconds, 2)
|
||||
|
||||
return str
|
||||
},
|
||||
numberFormat (num) {
|
||||
const digits = 2
|
||||
const si = [
|
||||
{ value: 1, symbol: '' },
|
||||
{ value: 1e3, symbol: 'K' },
|
||||
{ value: 1e6, symbol: 'M' },
|
||||
{ value: 1e9, symbol: 'B' }
|
||||
]
|
||||
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/
|
||||
let i
|
||||
for (i = si.length - 1; i > 0; i--) {
|
||||
if (num >= si[i].value) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return (num / si[i].value).toFixed(digits).replace(rx, '$1') + si[i].symbol
|
||||
},
|
||||
addCommas (num) {
|
||||
num = parseInt(num)
|
||||
return num.toLocaleString('en-US')
|
||||
},
|
||||
fetchJson: function (url, params, options) {
|
||||
if (params) {
|
||||
url = new URL(url)
|
||||
for (const param of _keys(params)) url.searchParams.set(param, params[param])
|
||||
}
|
||||
return fetch(url, options).then(response => {
|
||||
return response.json()
|
||||
})
|
||||
},
|
||||
purifyHTML (original) {
|
||||
return DOMPurify.sanitize(original)
|
||||
},
|
||||
setPreference (key, value) {
|
||||
if (localStorage) localStorage.setItem(key, value)
|
||||
},
|
||||
getPreferenceBoolean (key, defaultVal) {
|
||||
let value
|
||||
if (
|
||||
(value = this.$route.query[key]) !== undefined ||
|
||||
(localStorage && (value = localStorage.getItem(key)) !== null)
|
||||
) {
|
||||
switch (String(value)) {
|
||||
case 'true':
|
||||
case '1':
|
||||
case 'on':
|
||||
case 'yes':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
} else return defaultVal
|
||||
},
|
||||
getPreferenceString (key, defaultVal) {
|
||||
let value
|
||||
if (
|
||||
(value = this.$route.query[key]) !== undefined ||
|
||||
(localStorage && (value = localStorage.getItem(key)) !== null)
|
||||
) {
|
||||
return value
|
||||
} else return defaultVal
|
||||
},
|
||||
getPreferenceNumber (key, defaultVal) {
|
||||
let value
|
||||
if (
|
||||
(value = this.$route.query[key]) !== undefined ||
|
||||
(localStorage && (value = localStorage.getItem(key)) !== null)
|
||||
) {
|
||||
return Number(value)
|
||||
} else return defaultVal
|
||||
},
|
||||
apiUrl () {
|
||||
return this.getPreferenceString('instance', 'https://pipedapi.kavin.rocks')
|
||||
},
|
||||
getEffectiveTheme () {
|
||||
let theme = this.getPreferenceString('theme', 'dark')
|
||||
if (theme === 'auto') {
|
||||
theme =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
},
|
||||
getAuthToken () {
|
||||
return this.getPreferenceString('authToken' + this.hashCode(this.apiUrl()))
|
||||
},
|
||||
hashCode (s) {
|
||||
return s.split('').reduce(function (a, b) {
|
||||
a = (a << 5) - a + b.charCodeAt(0)
|
||||
return a & a
|
||||
}, 0)
|
||||
},
|
||||
timeAgo (time) {
|
||||
return timeAgo.format(time)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
backgroundColor () {
|
||||
return this.getEffectiveTheme() === 'light' ? '#fff' : '#0b0e0f'
|
||||
},
|
||||
secondaryBackgroundColor () {
|
||||
return this.getEffectiveTheme() === 'light' ? '#e5e5e5' : '#242727'
|
||||
},
|
||||
foregroundColor () {
|
||||
return this.getEffectiveTheme() === 'light' ? '#15191a' : '#0b0e0f'
|
||||
},
|
||||
secondaryForegroundColor () {
|
||||
return this.getEffectiveTheme() === 'light' ? '#666' : '#393d3d'
|
||||
},
|
||||
darkMode () {
|
||||
return this.getEffectiveTheme() !== 'light'
|
||||
},
|
||||
authenticated (_this) {
|
||||
return _this.getAuthToken() !== undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mixin(mixin)
|
||||
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||
app.mount('#app')
|
||||
new Vue({
|
||||
vuetify,
|
||||
store,
|
||||
router,
|
||||
render: h => h(App)
|
||||
}).$mount('#app')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Vue from 'vue'
|
||||
import Vuetify from 'vuetify/lib/framework'
|
||||
import colors from 'vuetify/es5/util/colors'
|
||||
|
||||
Vue.use(Vuetify)
|
||||
|
||||
export default new Vuetify({
|
||||
theme: {
|
||||
themes: {
|
||||
light: {
|
||||
primary: colors.deepPurple.base,
|
||||
secondary: colors.purple.base,
|
||||
accent: colors.pink.base,
|
||||
error: colors.red.base,
|
||||
warning: colors.deepOrange.base,
|
||||
info: colors.blue.base,
|
||||
success: colors.green.base
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { register } from "register-service-worker";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
register(`/service-worker.js`, {
|
||||
ready() {
|
||||
console.log(
|
||||
"App is being served from cache by a service worker.\n" +
|
||||
"For more details, visit https://goo.gl/AFskqB",
|
||||
);
|
||||
},
|
||||
registered() {
|
||||
console.log("Service worker has been registered.");
|
||||
},
|
||||
cached() {
|
||||
console.log("Content has been cached for offline use.");
|
||||
},
|
||||
updatefound() {
|
||||
console.log("New content is downloading.");
|
||||
},
|
||||
updated() {
|
||||
console.log("New content is available; please refresh.");
|
||||
window.location.reload();
|
||||
},
|
||||
offline() {
|
||||
console.log("No internet connection found. App is running in offline mode.");
|
||||
},
|
||||
error(error) {
|
||||
console.error("Error during service worker registration:", error);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
|
||||
import TrendingPage from '@/routes/TrendingPage'
|
||||
import Preferences from '@/routes/Preferences'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Trending',
|
||||
component: TrendingPage
|
||||
},
|
||||
{
|
||||
path: '/preferences',
|
||||
name: 'Preferences',
|
||||
component: Preferences
|
||||
},
|
||||
{
|
||||
path: '/results',
|
||||
name: 'SearchResults',
|
||||
component: () => import('@/routes/SearchResults')
|
||||
},
|
||||
{
|
||||
path: '/playlist',
|
||||
name: 'Playlist',
|
||||
component: () => import('@/routes/Playlist')
|
||||
},
|
||||
{
|
||||
path: '/:path(v|w|embed|shorts|watch)/:v?',
|
||||
name: 'WatchVideo',
|
||||
component: () => import('@/routes/WatchVideo')
|
||||
},
|
||||
{
|
||||
path: '/:path(channel|user|c)/:channelId/:videos?',
|
||||
name: 'Channel',
|
||||
component: () => import('@/routes/Channel')
|
||||
},
|
||||
{
|
||||
path: '/:videoId([a-zA-Z0-9_-]{11})',
|
||||
component: () => import('@/components/VideoRedirect')
|
||||
}
|
||||
]
|
||||
|
||||
const router = new VueRouter({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Trending',
|
||||
component: () => import('@/components/TrendingPage')
|
||||
},
|
||||
{
|
||||
path: '/preferences',
|
||||
name: 'Preferences',
|
||||
component: () => import('@/components/Preferences')
|
||||
},
|
||||
{
|
||||
path: '/results',
|
||||
name: 'SearchResults',
|
||||
component: () => import('@/components/SearchResults')
|
||||
},
|
||||
{
|
||||
path: '/playlist',
|
||||
name: 'Playlist',
|
||||
component: () => import('@/components/Playlist')
|
||||
},
|
||||
{
|
||||
path: '/:path(v|w|embed|shorts|watch)/:v?',
|
||||
name: 'WatchVideo',
|
||||
component: () => import('@/components/WatchVideo')
|
||||
},
|
||||
{
|
||||
path: '/:path(channel|user|c)/:channelId/:videos?',
|
||||
name: 'Channel',
|
||||
component: () => import('@/components/Channel')
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/components/LoginPage')
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/components/RegisterPage')
|
||||
},
|
||||
{
|
||||
path: '/feed',
|
||||
name: 'Feed',
|
||||
component: () => import('@/components/FeedPage')
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
name: 'Import',
|
||||
component: () => import('@/components/ImportPage')
|
||||
},
|
||||
{
|
||||
path: '/:videoId([a-zA-Z0-9_-]{11})',
|
||||
component: () => import('@/components/VideoRedirect')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior: function (_to, _from, savedPosition) {
|
||||
return savedPosition || window.scrollTo(0, 0)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="channel && channel.error" :message="channel.message" :error="channel.error" />
|
||||
<v-container fluid v-else-if="channel">
|
||||
<v-row>
|
||||
<v-col md="8" offset-md="2">
|
||||
<v-card class="pa-4">
|
||||
<div
|
||||
style="justify-items: center; align-items: center; vertical-align: center; display: flex;"
|
||||
>
|
||||
<div>
|
||||
<v-img :src="channel.avatarUrl" height="48" width="48" class="rounded-circle" />
|
||||
</div>
|
||||
<div class="text-h5 ml-4">
|
||||
{{ channel.name }}
|
||||
</div>
|
||||
</div>
|
||||
<v-card-text>
|
||||
<div v-html="renderedDescription" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div v-if="this.channel && this.channel.relatedStreams">
|
||||
<v-row v-for="(row, rowId) in chunkedByFour" :key="rowId">
|
||||
<v-col md="3" v-for="(video, videoId) in row" :key="videoId">
|
||||
<VideoItem :height="270" :width="480" :video="video" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-progress-linear indeterminate v-intersect="onRelatedStreamsEndIntersect" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import marked from 'marked'
|
||||
import { chunk as _chunk } from 'lodash-es'
|
||||
|
||||
import ErrorHandler from '@/components/ErrorHandler'
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
|
||||
import { LibPiped } from '@/tools/libpiped'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
channel: null,
|
||||
subscribed: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getChannelData()
|
||||
},
|
||||
methods: {
|
||||
async fetchChannel () {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: '/' + this.$route.params.path + '/' + this.$route.params.channelId
|
||||
})
|
||||
},
|
||||
async getChannelData () {
|
||||
this.fetchChannel()
|
||||
.then(data => (this.channel = data))
|
||||
.then(() => {
|
||||
if (!this.channel.error) {
|
||||
document.title = this.channel.name + ' - Piped'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onRelatedStreamsEndIntersect (entries) {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.fetchMoreVideos()
|
||||
}
|
||||
},
|
||||
|
||||
fetchMoreVideos () {
|
||||
this.$store.dispatch('fetchJson', {
|
||||
path: '/nextpage/channel/' + this.channel.id,
|
||||
params: {
|
||||
nextpage: this.channel.nextpage
|
||||
}
|
||||
}).then(j => {
|
||||
this.channel.relatedStreams = this.channel.relatedStreams.concat(j.relatedStreams)
|
||||
this.channel.nextpage = j.nextpage
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedDescription () {
|
||||
return LibPiped.purifyHTML(marked.parseInline(this.channel.description, {
|
||||
breaks: true
|
||||
}))
|
||||
},
|
||||
|
||||
chunkedByFour () {
|
||||
return _chunk(this.channel.relatedStreams, 4)
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ErrorHandler,
|
||||
VideoItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="playlist && playlist.error" :message="playlist.message" :error="playlist.error" />
|
||||
|
||||
<v-container fluid v-else-if="playlist">
|
||||
<v-row>
|
||||
<v-col md="8" offset-md="2">
|
||||
<v-card>
|
||||
<v-img v-if="playlist.avatarUrl" :src="playlist.avatarUrl" />
|
||||
<v-card-title class="display-1">
|
||||
{{ playlist.name }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<router-link v-if="playlist.uploaderUrl" :to="playlist.uploaderUrl" custom v-slot="{ navigate }">
|
||||
<div
|
||||
style="justify-items: center; align-items: center; vertical-align: center; display: flex; cursor: pointer"
|
||||
@click="navigate" @keypress.enter="navigate" role="link"
|
||||
>
|
||||
<div>
|
||||
<v-img :src="playlist.uploaderAvatar" height="48" width="48" class="rounded-circle" />
|
||||
</div>
|
||||
<div class="text-h5 ml-4">
|
||||
{{ playlist.uploader }}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<v-btn
|
||||
v-if="$store.state.isAuthenticated"
|
||||
@click="subscribeHandler"
|
||||
color="primary"
|
||||
>
|
||||
{{ subscribed ? "Unsubscribe" : "Subscribe" }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<h5 class="text-h5 ml-16">{{ playlist.videos }} Videos</h5>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn icon x-large link :href="getRssUrl"><v-icon x-large>mdi-rss-box</v-icon></v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<v-row v-for="(chunk, chunkId) in chunkedByFour" :key="chunkId" class="mt-4">
|
||||
<v-col md="3" v-for="video in chunk" :key="video.url">
|
||||
<VideoItem :video="video" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<div v-else-if="playlist" v-show="!playlist.error">
|
||||
<h1 class="uk-text-center">
|
||||
<img v-bind:src="playlist.avatarUrl" height="48" width="48" loading="lazy" />
|
||||
{{ playlist.name }}
|
||||
</h1>
|
||||
|
||||
<b
|
||||
><router-link class="uk-text-justify" v-bind:to="playlist.uploaderUrl || '/'">
|
||||
<img v-bind:src="playlist.uploaderAvatar" loading="lazy" />
|
||||
{{ playlist.uploader }}</router-link
|
||||
></b
|
||||
>
|
||||
|
||||
<div class="uk-align-right">
|
||||
<b>{{ playlist.videos }} Videos</b>
|
||||
<br />
|
||||
<a :href="getRssUrl"><font-awesome-icon icon="rss"></font-awesome-icon></a>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="uk-grid-xl" uk-grid="parallax: 0">
|
||||
<div
|
||||
class="uk-width-1-2 uk-width-1-3@m uk-width-1-4@l uk-width-1-5@xl"
|
||||
v-bind:key="video.url"
|
||||
v-for="video in this.playlist.relatedStreams"
|
||||
>
|
||||
<VideoItem :video="video" height="94" width="168" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorHandler from '@/components/ErrorHandler.vue'
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
|
||||
import { chunk as _chunk } from 'lodash-es'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
playlist: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.getPlaylistData()
|
||||
},
|
||||
computed: {
|
||||
getRssUrl () {
|
||||
return this.$store.getters.apiUrl + '/rss/playlists/' + this.$route.query.list
|
||||
},
|
||||
|
||||
chunkedByFour () {
|
||||
return _chunk(this.playlist.relatedStreams, 4)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchPlaylist () {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: '/playlists/' + this.$route.query.list
|
||||
})
|
||||
},
|
||||
async getPlaylistData () {
|
||||
this.fetchPlaylist()
|
||||
.then(data => (this.playlist = data))
|
||||
.then(() => (document.title = this.playlist.name + ' - Piped'))
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ErrorHandler,
|
||||
VideoItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<h1 class="display-1 text-center">Preferences</h1>
|
||||
<v-divider class="ma-4" />
|
||||
<div style="display: flex;" v-for="(opt, optId) in options" :key="optId">
|
||||
<v-simple-checkbox v-if="opt.type === 'bool'" :value="$store.getters.getPreferenceBoolean(opt.id, opt.default)" @input="setValue(opt.id, $event)" />
|
||||
<v-select v-else-if="opt.type === 'select'" :label="opt.label" :value="$store.getters.getPreferenceString(opt.id, opt.default)" @input="setValue(opt.id, $event)" :items="opt.options" />
|
||||
<p v-if="opt.type === 'bool'">{{ opt.label }}</p><br />
|
||||
</div>
|
||||
<h5 class="text-h5">Instances</h5>
|
||||
<v-data-table :headers="tableHeaders" :items="instances" />
|
||||
<!-- <br />
|
||||
<b>Audio Only</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="listen" @change="onChange($event)" type="checkbox" />
|
||||
<br />
|
||||
<b>Default Quality</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="defaultQuality" @change="onChange($event)">
|
||||
<option value="0">Auto</option>
|
||||
<option :key="resolution" v-for="resolution in resolutions" :value="resolution">{{ resolution }}p</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Buffering Goal</b>
|
||||
<br />
|
||||
<input class="uk-input uk-width-auto" v-model="bufferingGoal" @change="onChange($event)" type="text" />
|
||||
<br />
|
||||
<b>Country Selection</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="country" @change="onChange($event)">
|
||||
<option :key="country.code" v-for="country in countryMap" :value="country.code">{{ country.name }}</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Default Homepage</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="defaultHomepage" @change="onChange($event)">
|
||||
<option value="trending">Trending</option>
|
||||
<option value="feed">Feed</option>
|
||||
</select>
|
||||
<br />
|
||||
<b>Show Comments</b>
|
||||
<br />
|
||||
<input class="uk-checkbox" v-model="showComments" @change="onChange($event)" type="checkbox" />
|
||||
<h2>Instances List</h2>
|
||||
<table class="uk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instance Name</th>
|
||||
<th>Instance Locations</th>
|
||||
<th>Has CDN?</th>
|
||||
<th>SSL Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-bind:key="instance.name" v-for="instance in instances">
|
||||
<tr>
|
||||
<td>{{ instance.name }}</td>
|
||||
<td>{{ instance.locations }}</td>
|
||||
<td>{{ instance.cdn }}</td>
|
||||
<td>
|
||||
<a :href="sslScore(instance.apiurl)" target="_blank">Click Here</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<b>Instance Selection:</b>
|
||||
<br />
|
||||
<select class="uk-select uk-width-auto" v-model="selectedInstance" @change="onChange($event)">
|
||||
<option v-bind:key="instance.name" v-for="instance in instances" v-bind:value="instance.apiurl">
|
||||
{{ instance.name }}
|
||||
</option>
|
||||
</select> -->
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CountryMap from '@/tools/CountryMap'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
instances: [],
|
||||
countryMap: CountryMap.COUNTRIES,
|
||||
|
||||
options: [
|
||||
{
|
||||
id: 'playerAutoplay',
|
||||
type: 'bool',
|
||||
default: true,
|
||||
label: 'Autoplay Video'
|
||||
},
|
||||
{
|
||||
id: 'listen',
|
||||
type: 'bool',
|
||||
default: false,
|
||||
label: 'Audio Only'
|
||||
},
|
||||
{
|
||||
id: 'quality',
|
||||
type: 'select',
|
||||
label: 'Default resolution',
|
||||
default: 0,
|
||||
options: [
|
||||
{
|
||||
text: 'Auto',
|
||||
value: 0
|
||||
},
|
||||
...([144, 240, 360, 480, 720, 1080, 1440, 2160, 4320].map(i => ({
|
||||
text: i.toString() + 'p',
|
||||
value: i
|
||||
})))
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
type: 'select',
|
||||
label: 'Country',
|
||||
default: 'US',
|
||||
options: CountryMap.COUNTRIES.map(co => ({
|
||||
text: co.name,
|
||||
value: co.code
|
||||
})).sort((a, b) => {
|
||||
return a.text.localeCompare(b.text)
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'homepage',
|
||||
type: 'select',
|
||||
label: 'Default Homepage',
|
||||
options: [
|
||||
{
|
||||
text: 'Trending',
|
||||
value: 'trending'
|
||||
},
|
||||
{
|
||||
text: 'Feed',
|
||||
value: 'feed'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
tableHeaders: [
|
||||
{
|
||||
text: 'Name',
|
||||
value: 'name'
|
||||
},
|
||||
{
|
||||
text: 'API URL',
|
||||
value: 'apiurl'
|
||||
},
|
||||
{
|
||||
text: 'Locations',
|
||||
value: 'locations'
|
||||
},
|
||||
{
|
||||
text: 'CDN enabled?',
|
||||
value: 'cdn'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (Object.keys(this.$route.query).length > 0) this.$router.replace({ query: {} })
|
||||
|
||||
fetch('https://raw.githubusercontent.com/wiki/TeamPiped/Piped-Frontend/Instances.md')
|
||||
.then(resp => resp.text())
|
||||
.then(body => {
|
||||
let skipped = 0
|
||||
const lines = body.split('\n')
|
||||
lines.forEach(line => {
|
||||
const split = line.split('|')
|
||||
if (split.length === 4) {
|
||||
if (skipped < 2) {
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
this.instances.push({
|
||||
name: split[0].trim(),
|
||||
apiurl: split[1].trim(),
|
||||
locations: split[2].trim(),
|
||||
cdn: split[3].trim()
|
||||
})
|
||||
}
|
||||
})
|
||||
this.options.push({
|
||||
id: 'instance',
|
||||
type: 'select',
|
||||
default: 'https://pipedapi.kavin.rocks',
|
||||
label: 'Instance',
|
||||
options: this.instances.map(i => ({
|
||||
text: i.name,
|
||||
value: i.apiurl
|
||||
}))
|
||||
})
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
setValue (k, v) {
|
||||
this.$store.commit('setPrefs', {
|
||||
id: k,
|
||||
value: v
|
||||
})
|
||||
},
|
||||
|
||||
sslScore (url) {
|
||||
return 'https://www.ssllabs.com/ssltest/analyze.html?d=' + new URL(url).host + '&latest'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<v-autocomplete
|
||||
v-model="select"
|
||||
:loading="requestInProgress"
|
||||
:items="searchSuggestions"
|
||||
:search-input.sync="searchText"
|
||||
cache-items
|
||||
class="mx-4"
|
||||
flat
|
||||
hide-no-data
|
||||
hide-details
|
||||
label="What are you looking for?"
|
||||
solo-inverted
|
||||
></v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SearchMenu',
|
||||
data () {
|
||||
return {
|
||||
selected: 0,
|
||||
select: '',
|
||||
searchText: '',
|
||||
requestInProgress: false,
|
||||
searchSuggestions: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchText (val) {
|
||||
if (val && val !== this.select) {
|
||||
this.refreshSuggestions()
|
||||
}
|
||||
},
|
||||
|
||||
async select (val) {
|
||||
if (val === '') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.$router.push({
|
||||
name: 'SearchResults',
|
||||
query: { search_query: val }
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('???', e)
|
||||
} finally {
|
||||
this.select = ''
|
||||
this.searchText = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async refreshSuggestions () {
|
||||
this.requestInProgress = true
|
||||
this.searchSuggestions = await this.$store.dispatch('fetchJson', {
|
||||
path: '/suggestions',
|
||||
params: {
|
||||
query: this.searchText
|
||||
}
|
||||
})
|
||||
this.searchSuggestions.unshift(this.searchText)
|
||||
this.requestInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<h5 class="text-h5 text-center">
|
||||
{{ $route.query.search_query }}
|
||||
</h5>
|
||||
|
||||
<v-select
|
||||
label="Filter videos"
|
||||
v-model="selectedFilter"
|
||||
:items="availableFilters"
|
||||
/>
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<div v-if="results && results.items">
|
||||
<v-row v-for="(row, rowId) in chunkedByFour" :key="rowId">
|
||||
<v-col md="3" v-for="(video, videoId) in row" :key="videoId">
|
||||
<VideoItem :height="270" :width="480" :video="video" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-progress-linear indeterminate v-intersect="onSearchResultsEndIntersect" />
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { chunk as _chunk } from 'lodash-es'
|
||||
|
||||
import { LibPiped } from '@/tools/libpiped'
|
||||
import VideoItem from '@/components/VideoItem'
|
||||
|
||||
export default {
|
||||
components: { VideoItem },
|
||||
data () {
|
||||
return {
|
||||
results: null,
|
||||
availableFilters: [
|
||||
'all',
|
||||
'videos',
|
||||
'channels',
|
||||
'playlists',
|
||||
'music_songs',
|
||||
'music_videos',
|
||||
'music_albums',
|
||||
'music_playlists'
|
||||
],
|
||||
selectedFilter: 'all'
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateResults()
|
||||
},
|
||||
computed: {
|
||||
chunkedByFour () {
|
||||
return _chunk(this.results.items, 4)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedFilter () {
|
||||
this.updateResults()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
rationalizeSearchResult (sr) {
|
||||
return {
|
||||
title: sr.name,
|
||||
uploaderName: sr.uploader,
|
||||
uploadedDate: sr.uploadDate,
|
||||
...sr
|
||||
}
|
||||
},
|
||||
|
||||
async fetchResults () {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: 'search',
|
||||
params: {
|
||||
q: this.$route.query.search_query,
|
||||
filter: this.selectedFilter
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
numberFormat (...args) {
|
||||
return LibPiped.numberFormat(...args)
|
||||
},
|
||||
|
||||
timeFormat (...args) {
|
||||
return LibPiped.timeFormat(...args)
|
||||
},
|
||||
async updateResults () {
|
||||
document.title = this.$route.query.search_query + ' - Piped'
|
||||
this.results = this.fetchResults().then(json => {
|
||||
json.items = json.items.map(this.rationalizeSearchResult)
|
||||
this.results = json
|
||||
})
|
||||
},
|
||||
onSearchResultsEndIntersect (entries) {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.fetchMoreResults()
|
||||
}
|
||||
},
|
||||
|
||||
fetchMoreResults () {
|
||||
this.$store.dispatch('fetchJson', {
|
||||
path: '/nextpage/search',
|
||||
params: {
|
||||
nextpage: this.results.nextpage,
|
||||
q: this.$route.query.search_query,
|
||||
filter: this.selectedFilter
|
||||
}
|
||||
}).then(json => {
|
||||
this.results.nextpage = json.nextpage
|
||||
this.results.id = json.id
|
||||
this.results.items = this.results.items.concat(json.items.map(this.rationalizeSearchResult))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<h3 class="display-1 justify-center">Trending</h3>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<v-row v-for="(row, rowId) in chunkedByFour" :key="rowId">
|
||||
<v-col md="3" v-for="(video, videoId) in row" :key="videoId">
|
||||
<VideoItem :video="video" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { chunk as _chunk } from 'lodash-es'
|
||||
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
videos: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
// Reimplement using vue-meta
|
||||
document.title = 'Trending - Piped'
|
||||
|
||||
const region = this.$store.getters.getPreferenceString('region', 'US')
|
||||
|
||||
this.fetchTrending(region).then(videos => (this.videos = videos))
|
||||
},
|
||||
methods: {
|
||||
async fetchTrending (region) {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: '/trending',
|
||||
params: {
|
||||
region: region || 'US'
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
chunkedByFour () {
|
||||
return _chunk(this.videos, 4)
|
||||
}
|
||||
},
|
||||
components: {
|
||||
VideoItem
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="video && video.error" :message="video.message" :error="video.error" />
|
||||
<div v-else>
|
||||
<Player
|
||||
ref="videoPlayer"
|
||||
:video="video"
|
||||
:sponsors="sponsors"
|
||||
:selectedAutoPlay="this.$store.getters.getPreferenceBoolean('autoplay')"
|
||||
:selectedAutoLoop="selectedAutoLoop"
|
||||
/>
|
||||
|
||||
<v-row dense class="my-2">
|
||||
<v-col md="10" offset-md="1">
|
||||
<v-card outlined>
|
||||
<v-card-title class="text-h5">{{ video.title }}</v-card-title>
|
||||
<v-card-subtitle class="subtitle-1">
|
||||
<v-row>
|
||||
<v-col md="4" align-self="start">
|
||||
{{ addCommas(video.views) }} views
|
||||
•
|
||||
{{ video.uploadDate }}
|
||||
</v-col>
|
||||
<v-col offset-md="6" md="2" align-self="end">
|
||||
<v-icon>mdi-thumb-up</v-icon>
|
||||
<b class="ml-2">{{ addCommas(video.likes) }}</b>
|
||||
<v-icon class="ml-2">mdi-thumb-down</v-icon>
|
||||
<b class="ml-2">{{ addCommas(video.dislikes) }}</b>
|
||||
<v-btn icon class="ml-2" link :href="'https://youtu.be/' + getVideoId()" target="_blank">
|
||||
<v-icon large>
|
||||
mdi-youtube
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-subtitle>
|
||||
<v-divider />
|
||||
<v-card-text>
|
||||
<router-link v-if="video.uploaderUrl" :to="video.uploaderUrl" custom v-slot="{ navigate }">
|
||||
<div
|
||||
style="justify-items: center; align-items: center; vertical-align: center; display: flex; cursor: pointer"
|
||||
@click="navigate" @keypress.enter="navigate" role="link"
|
||||
>
|
||||
<div>
|
||||
<v-img :src="video.uploaderAvatar" height="48" width="48" class="rounded-circle" />
|
||||
</div>
|
||||
<div class="text-h5 ml-4">
|
||||
{{ video.uploader }}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<v-btn
|
||||
v-if="$store.state.isAuthenticated"
|
||||
@click="subscribeHandler"
|
||||
color="primary"
|
||||
>
|
||||
{{ subscribed ? "Unsubscribe" : "Subscribe" }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<div class="mt-4" v-html="video.description" />
|
||||
<v-divider class="my-4" />
|
||||
<div class="mt-4" v-if="showDesc && sponsors && sponsors.segments">
|
||||
Sponsors Segments: {{ sponsors.segments.length }}
|
||||
</div>
|
||||
<div>
|
||||
<v-checkbox dense :value="this.$store.getters.getPreferenceBoolean('autoplay')" @change="onAutoplayChg" label="Autoplay next video" />
|
||||
<v-checkbox dense v-model="selectedAutoLoop" label="Loop this video" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col md="8" offset-md="1" v-if="comments && comments.comments">
|
||||
<h5 class="display-1 text-center my-4">Comments</h5>
|
||||
<VideoComment v-for="comment in comments.comments" :key="comment.commentId" :comment="comment" :video="video" class="my-4" />
|
||||
<v-progress-linear indeterminate v-intersect="onCommentsProgressIntersect" v-if="comments.comments.length !== 0" />
|
||||
</v-col>
|
||||
<v-col md="2" v-if="video && video.relatedStreams">
|
||||
<h5 class="display-1 text-center my-4">Related Videos</h5>
|
||||
<VideoItem class="my-4" v-for="related in video.relatedStreams" :video="related" :key="related.url" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { LibPiped } from '@/tools/libpiped'
|
||||
|
||||
import Player from '@/components/Player.vue'
|
||||
import VideoItem from '@/components/VideoItem.vue'
|
||||
import ErrorHandler from '@/components/ErrorHandler.vue'
|
||||
import VideoComment from '@/components/VideoComment'
|
||||
|
||||
export default {
|
||||
name: 'WatchVideo',
|
||||
data () {
|
||||
return {
|
||||
video: {
|
||||
title: 'Loading...'
|
||||
},
|
||||
sponsors: null,
|
||||
selectedAutoLoop: false,
|
||||
showDesc: true,
|
||||
comments: null,
|
||||
subscribed: false,
|
||||
channelId: null
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initialize()
|
||||
},
|
||||
watch: {
|
||||
'$route.query.v': function (v) {
|
||||
if (v) {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initialize () {
|
||||
this.getVideoData().then(() => {
|
||||
this.$refs.videoPlayer.loadVideo()
|
||||
})
|
||||
this.getSponsors()
|
||||
if (this.$store.getters.getPreferenceBoolean('comments', true)) this.getComments()
|
||||
},
|
||||
|
||||
numberFormat (...args) {
|
||||
return LibPiped.numberFormat(...args)
|
||||
},
|
||||
|
||||
addCommas (...args) {
|
||||
return LibPiped.addCommas(...args)
|
||||
},
|
||||
|
||||
fetchVideo () {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: '/streams/' + this.getVideoId()
|
||||
})
|
||||
},
|
||||
|
||||
async fetchSponsors () {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: '/sponsors/' + this.getVideoId(),
|
||||
params: {
|
||||
category:
|
||||
'["' +
|
||||
this.$store.getters.getPreferenceString('selectedSkip', 'sponsor,interaction,selfpromo,music_offtopic').replaceAll(
|
||||
',',
|
||||
'","'
|
||||
) +
|
||||
'"]'
|
||||
}
|
||||
})
|
||||
},
|
||||
fetchComments () {
|
||||
return this.$store.dispatch('fetchJson', {
|
||||
path: '/comments/' + this.getVideoId()
|
||||
})
|
||||
},
|
||||
|
||||
onCommentsProgressIntersect (entries) {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.fetchMoreComments()
|
||||
}
|
||||
},
|
||||
|
||||
fetchMoreComments () {
|
||||
this.$store.dispatch('fetchJson', {
|
||||
path: '/nextpage/comments/' + this.getVideoId(),
|
||||
params: {
|
||||
url: this.comments.nextpage
|
||||
}
|
||||
}).then(json => {
|
||||
this.comments.nextpage = json.nextpage
|
||||
this.comments.comments = this.comments.comments.concat(json.comments)
|
||||
})
|
||||
},
|
||||
|
||||
onAutoplayChg (ev) {
|
||||
this.$store.commit('setPrefs', {
|
||||
id: 'autoplay',
|
||||
value: ev
|
||||
})
|
||||
},
|
||||
|
||||
async getVideoData () {
|
||||
await this.fetchVideo()
|
||||
.then(data => {
|
||||
this.video = data
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.video.error) {
|
||||
document.title = this.video.title + ' - Piped'
|
||||
this.channelId = this.video.uploaderUrl.split('/')[2]
|
||||
this.fetchSubscribedStatus()
|
||||
|
||||
this.video.description = LibPiped.purifyHTML(
|
||||
this.video.description
|
||||
.replaceAll('http://www.youtube.com', '')
|
||||
.replaceAll('https://www.youtube.com', '')
|
||||
.replaceAll('\n', '<br>')
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
async getSponsors () {
|
||||
if (this.$store.getters.getPreferenceString('sponsorblock', true)) { this.fetchSponsors().then(data => (this.sponsors = data)) }
|
||||
},
|
||||
async getComments () {
|
||||
this.fetchComments().then(data => (this.comments = data))
|
||||
},
|
||||
async fetchSubscribedStatus () {
|
||||
if (!this.channelId) return
|
||||
|
||||
this.$store.dispatch('fetchJson', {
|
||||
path: '/subscribed',
|
||||
params: {
|
||||
channelId: this.channelId
|
||||
},
|
||||
options: {
|
||||
headers: {
|
||||
Authorization: this.$store.getters.getAuthToken
|
||||
}
|
||||
}
|
||||
}).then(json => {
|
||||
this.subscribed = json.subscribed
|
||||
})
|
||||
},
|
||||
subscribeHandler () {
|
||||
this.$store.dispatch('fetchJson', {
|
||||
path: (this.subscribed ? '/unsubscribe' : '/subscribe'),
|
||||
params: null,
|
||||
options: {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelId: this.channelId
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.$store.getters.getAuthToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
})
|
||||
this.subscribed = !this.subscribed
|
||||
},
|
||||
|
||||
getVideoId () {
|
||||
return this.$route.query.v || this.$route.params.v
|
||||
}
|
||||
},
|
||||
components: {
|
||||
VideoComment,
|
||||
Player,
|
||||
VideoItem,
|
||||
ErrorHandler
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
import { get as _get, keys as _keys, set as _set } from 'lodash-es'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
const store = new Vuex.Store({
|
||||
state: {
|
||||
isAuthenticated: false,
|
||||
prefs: {}
|
||||
},
|
||||
mutations: {
|
||||
setAuthenticated (state, authStatus) {
|
||||
state.isAuthenticated = authStatus
|
||||
},
|
||||
setPrefs (state, {
|
||||
id,
|
||||
value
|
||||
}) {
|
||||
_set(state.prefs, id, value)
|
||||
},
|
||||
|
||||
replacePrefs (state, nextPrefs) {
|
||||
state.prefs = nextPrefs
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
loadState ({ commit }) {
|
||||
try {
|
||||
const p = JSON.parse(window.localStorage.getItem('PREFERENCES'))
|
||||
commit('replacePrefs', p)
|
||||
} catch (e) {
|
||||
console.log('Error:', e)
|
||||
}
|
||||
},
|
||||
|
||||
fetchJson ({ getters }, {
|
||||
path,
|
||||
params = {},
|
||||
options = {}
|
||||
}) {
|
||||
const url = new URL(getters.apiUrl)
|
||||
|
||||
url.pathname = path
|
||||
if (params) {
|
||||
for (const param of _keys(params)) {
|
||||
url.searchParams.set(param, params[param])
|
||||
}
|
||||
|
||||
const at = getters.getAuthToken
|
||||
if (at) {
|
||||
url.searchParams.set('authToken', at)
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(url.href, options).then(response => {
|
||||
return response.json()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
getPreferenceString: state => (id, default_) => {
|
||||
return _get(state.prefs, id, default_)
|
||||
},
|
||||
|
||||
getPreferenceBoolean: (_, getters) => (...args) => {
|
||||
const v = getters.getPreferenceString(...args)
|
||||
|
||||
switch (v) {
|
||||
case 'true':
|
||||
case true:
|
||||
case '1':
|
||||
case 'on':
|
||||
case 'yes':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
getPreferenceNumber: (_, getters) => (id, default_) => {
|
||||
const v = getters.getPreferenceString(id, default_)
|
||||
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) {
|
||||
return default_
|
||||
} else {
|
||||
return n
|
||||
}
|
||||
},
|
||||
|
||||
getEffectiveTheme (state, getters) {
|
||||
let theme = getters.getPreferenceString('theme', 'light')
|
||||
if (theme === 'auto') {
|
||||
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
},
|
||||
|
||||
backgroundColor (state, getters) {
|
||||
return getters.getEffectiveTheme === 'light' ? '#fff' : '#0b0e0f'
|
||||
},
|
||||
secondaryBackgroundColor (state, getters) {
|
||||
return getters.getEffectiveTheme === 'light' ? '#e5e5e5' : '#242727'
|
||||
},
|
||||
foregroundColor (state, getters) {
|
||||
return getters.getEffectiveTheme === 'light' ? '#15191a' : '#0b0e0f'
|
||||
},
|
||||
secondaryForegroundColor (state, getters) {
|
||||
return getters.getEffectiveTheme === 'light' ? '#666' : '#393d3d'
|
||||
},
|
||||
|
||||
getAuthToken (state, getters) {
|
||||
return getters.getPreferenceString(['authToken', getters])
|
||||
},
|
||||
|
||||
apiUrl (state, getters) {
|
||||
return getters.getPreferenceString('instance', 'https://pipedapi.kavin.rocks')
|
||||
},
|
||||
|
||||
authenticated (state, getters) {
|
||||
return getters.getAuthToken !== undefined
|
||||
}
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
|
||||
store.watch(state => state.prefs, (nextPrefs) => {
|
||||
window.localStorage.setItem('PREFERENCES', JSON.stringify(nextPrefs))
|
||||
})
|
||||
|
||||
export default store
|
||||
@@ -1,6 +1,6 @@
|
||||
// Based of https://github.com/GilgusMaximus/yt-dash-manifest-generator/blob/master/src/DashGenerator.js
|
||||
|
||||
const xml = require('xml-js')
|
||||
import xml from 'xml-js'
|
||||
|
||||
const DashUtils = {
|
||||
generate_dash_file_from_formats (VideoFormats, VideoLength) {
|
||||
@@ -45,7 +45,7 @@ const DashUtils = {
|
||||
// sort the formats by mime types
|
||||
VideoFormatArray.forEach(videoFormat => {
|
||||
// the dual formats should not be used
|
||||
if (videoFormat.mimeType.indexOf('video') != -1 && !videoFormat.videoOnly) {
|
||||
if (videoFormat.mimeType.indexOf('video') !== -1 && !videoFormat.videoOnly) {
|
||||
return
|
||||
}
|
||||
// if these properties are not available, then we skip it because we cannot set these properties
|
||||
@@ -185,4 +185,6 @@ const DashUtils = {
|
||||
}
|
||||
}
|
||||
|
||||
export default DashUtils
|
||||
export {
|
||||
DashUtils
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import TimeAgo from 'javascript-time-ago'
|
||||
import en from 'javascript-time-ago/locale/en'
|
||||
|
||||
TimeAgo.addDefaultLocale(en)
|
||||
const timeAgo = new TimeAgo('en-US')
|
||||
|
||||
class LibPiped {
|
||||
timeFormat (duration) {
|
||||
const pad = function (num, size) {
|
||||
return ('000' + num).slice(size * -1)
|
||||
}
|
||||
|
||||
const time = parseFloat(duration).toFixed(3)
|
||||
const hours = Math.floor(time / 60 / 60)
|
||||
const minutes = Math.floor(time / 60) % 60
|
||||
const seconds = Math.floor(time - minutes * 60)
|
||||
|
||||
let str = ''
|
||||
|
||||
if (hours > 0) str += hours + ':'
|
||||
|
||||
str += pad(minutes, 2) + ':' + pad(seconds, 2)
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
numberFormat (num) {
|
||||
const digits = 2
|
||||
const si = [
|
||||
{ value: 1, symbol: '' },
|
||||
{ value: 1e3, symbol: 'K' },
|
||||
{ value: 1e6, symbol: 'M' },
|
||||
{ value: 1e9, symbol: 'B' }
|
||||
]
|
||||
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/
|
||||
let i
|
||||
for (i = si.length - 1; i > 0; i--) {
|
||||
if (num >= si[i].value) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return (num / si[i].value).toFixed(digits).replace(rx, '$1') + si[i].symbol
|
||||
}
|
||||
|
||||
addCommas (num) {
|
||||
num = parseInt(num)
|
||||
return num.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
purifyHTML (original) {
|
||||
return DOMPurify.sanitize(original)
|
||||
}
|
||||
|
||||
timeAgo (time) {
|
||||
return timeAgo.format(time)
|
||||
}
|
||||
}
|
||||
|
||||
const lp = new LibPiped()
|
||||
export { lp as LibPiped }
|
||||
@@ -1,19 +1,7 @@
|
||||
module.exports = {
|
||||
pwa: {
|
||||
name: 'Piped',
|
||||
themeColor: '#fa4b4b',
|
||||
msTileColor: '#000000',
|
||||
appleMobileWebAppCapable: 'yes',
|
||||
appleMobileWebAppStatusBarStyle: 'black',
|
||||
workboxPluginMode: 'GenerateSW',
|
||||
workboxOptions: {
|
||||
navigateFallback: 'index.html',
|
||||
skipWaiting: true,
|
||||
importWorkboxFrom: 'local',
|
||||
runtimeCaching: [{
|
||||
urlPattern: /\.(?:png|svg|ico)$/,
|
||||
handler: 'CacheFirst'
|
||||
}]
|
||||
}
|
||||
}
|
||||
transpileDependencies: [
|
||||
'vuetify'
|
||||
],
|
||||
|
||||
lintOnSave: false
|
||||
}
|
||||
|
||||