mirror of
https://github.com/TeamPiped/Piped.git
synced 2026-09-01 10:23:44 +00:00
Merge branch 'Master' into 'efy', Update efy submodule
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col flex-justify-between">
|
||||
<router-link :to="props.item.url">
|
||||
<div class="relative">
|
||||
<img class="w-full" :src="props.item.thumbnail" loading="lazy" />
|
||||
<div class="my-4 flex justify-center">
|
||||
<img class="aspect-square w-[50%] rounded-full" :src="props.item.thumbnail" loading="lazy" />
|
||||
</div>
|
||||
<p>
|
||||
<span v-text="props.item.name" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="props.item.verified" icon="check" />
|
||||
<font-awesome-icon v-if="props.item.verified" class="ml-1.5" icon="check" />
|
||||
</p>
|
||||
</router-link>
|
||||
<p v-if="props.item.description" v-text="props.item.description" />
|
||||
<router-link v-if="props.item.uploaderUrl" class="link" :to="props.item.uploaderUrl">
|
||||
<p>
|
||||
<span v-text="props.item.uploader" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="props.item.uploaderVerified" icon="check" />
|
||||
<font-awesome-icon v-if="props.item.uploaderVerified" class="ml-1.5" icon="check" />
|
||||
</p>
|
||||
</router-link>
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
item: Object,
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,88 +1,93 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="channel && channel.error" :message="channel.message" :error="channel.error" />
|
||||
|
||||
<div v-if="channel" v-show="!channel.error">
|
||||
<img v-if="channel.bannerUrl" :src="channel.bannerUrl" class="w-full pb-1.5" loading="lazy" />
|
||||
<div class="pp-channel-page-author flex place-items-center">
|
||||
<img height="48" width="48" class="m-1" :src="channel.avatarUrl" />
|
||||
<h5 v-text="channel.name" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="channel.verified" icon="check" />
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p class="whitespace-pre-wrap mt-2">
|
||||
<span v-html="purifyHTML(urlify(channel.description))" />
|
||||
</p>
|
||||
<LoadingIndicatorPage :show-content="channel != null && !channel.error">
|
||||
<img v-if="channel.bannerUrl" :src="channel.bannerUrl" class="w-full pb-1.5" loading="lazy" />
|
||||
<div class="pp-channel-page-author flex">
|
||||
<img height="48" width="48" class="m-1" :src="channel.avatarUrl" />
|
||||
<h5 v-text="channel.name" />
|
||||
<font-awesome-icon v-if="channel.verified" class="ml-1.5" icon="check" />
|
||||
</div>
|
||||
<p v-text="channel.description" style="margin: 10rem 0 0 0" />
|
||||
|
||||
<div class="flex mt-4 mb-2 pp-channel-tabs">
|
||||
<button
|
||||
class="btn pp-subscribe"
|
||||
@click="subscribeHandler"
|
||||
v-t="{
|
||||
path: `actions.${subscribed ? 'unsubscribe' : 'subscribe'}`,
|
||||
args: { count: numberFormat(channel.subscriberCount) },
|
||||
}"
|
||||
></button>
|
||||
<div class="pp-channel-tabs">
|
||||
<button
|
||||
v-t="{
|
||||
path: `actions.${subscribed ? 'unsubscribe' : 'subscribe'}`,
|
||||
args: { count: numberFormat(channel.subscriberCount) },
|
||||
}"
|
||||
class="pp-subscribe"
|
||||
@click="subscribeHandler"
|
||||
></button>
|
||||
|
||||
<!-- RSS Feed button -->
|
||||
<a
|
||||
aria-label="RSS feed"
|
||||
title="RSS feed"
|
||||
role="button"
|
||||
v-if="channel.id"
|
||||
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${channel.id}`"
|
||||
target="_blank"
|
||||
class="btn"
|
||||
style="display: inline; float: unset; margin-left: var(--efy_gap0)"
|
||||
>
|
||||
<font-awesome-icon icon="rss" />
|
||||
</a>
|
||||
<WatchOnYouTubeButton :link="`https://youtube.com/channel/${this.channel.id}`" />
|
||||
<p>|</p>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.name"
|
||||
style="margin-right: var(--efy_gap0)"
|
||||
@click="loadTab(index)"
|
||||
:class="{ active: selectedTab == index }"
|
||||
>
|
||||
<span v-text="tab.translatedName"></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- RSS Feed button -->
|
||||
<a
|
||||
v-if="channel.id"
|
||||
aria-label="RSS feed"
|
||||
title="RSS feed"
|
||||
role="button"
|
||||
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${channel.id}`"
|
||||
target="_blank"
|
||||
class="pp-square"
|
||||
style="display: inline; float: unset"
|
||||
>
|
||||
<font-awesome-icon icon="rss" />
|
||||
</a>
|
||||
<WatchOnButton :link="`https://youtube.com/channel/${channel.id}`" />
|
||||
<p style="place-self: center">|</p>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.name"
|
||||
:class="{ active: selectedTab == index }"
|
||||
@click="loadTab(index)"
|
||||
>
|
||||
<span v-text="tab.translatedName"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<div class="video-grid">
|
||||
<ContentItem
|
||||
v-for="item in contentItems"
|
||||
:key="item.url"
|
||||
:item="item"
|
||||
height="94"
|
||||
width="168"
|
||||
hide-channel
|
||||
class="efy_trans_filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="video-grid">
|
||||
<ContentItem
|
||||
v-for="item in contentItems"
|
||||
:key="item.url"
|
||||
:item="item"
|
||||
height="94"
|
||||
width="168"
|
||||
hide-channel
|
||||
class="efy_trans_filter"
|
||||
/>
|
||||
</div>
|
||||
</LoadingIndicatorPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.pp-channel-tabs > p {
|
||||
place-self: center;
|
||||
padding: 0 10rem;
|
||||
-webkit-text-fill-color: var(--efy_text_trans2);
|
||||
.pp-channel-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 15rem 0;
|
||||
gap: var(--efy_gap0);
|
||||
}
|
||||
.pp-channel-tabs :is(button, [role="button"]) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import ErrorHandler from "./ErrorHandler.vue";
|
||||
import ContentItem from "./ContentItem.vue";
|
||||
import WatchOnYouTubeButton from "./WatchOnYouTubeButton.vue";
|
||||
import WatchOnButton from "./WatchOnButton.vue";
|
||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||
// import CollapsableText from "./CollapsableText.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ErrorHandler,
|
||||
ContentItem,
|
||||
WatchOnYouTubeButton,
|
||||
WatchOnButton,
|
||||
LoadingIndicatorPage,
|
||||
// CollapsableText,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -130,7 +135,9 @@ export default {
|
||||
});
|
||||
},
|
||||
async fetchChannel() {
|
||||
const url = this.apiUrl() + "/" + this.$route.params.path + "/" + this.$route.params.channelId;
|
||||
const url = this.$route.path.includes("@")
|
||||
? this.apiUrl() + "/@/" + this.$route.params.channelId
|
||||
: this.apiUrl() + "/" + this.$route.params.path + "/" + this.$route.params.channelId;
|
||||
return await this.fetchJson(url);
|
||||
},
|
||||
async getChannelData() {
|
||||
@@ -142,13 +149,16 @@ export default {
|
||||
this.contentItems = this.channel.relatedStreams;
|
||||
this.fetchSubscribedStatus();
|
||||
this.updateWatched(this.channel.relatedStreams);
|
||||
this.fetchDeArrowContent(this.channel.relatedStreams);
|
||||
this.tabs.push({
|
||||
translatedName: this.$t("video.videos"),
|
||||
});
|
||||
const tabQuery = this.$route.query.tab;
|
||||
for (let i = 0; i < this.channel.tabs.length; i++) {
|
||||
let tab = this.channel.tabs[i];
|
||||
tab.translatedName = this.getTranslatedTabName(tab.name);
|
||||
this.tabs.push(tab);
|
||||
if (tab.name === tabQuery) this.loadTab(i + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -178,6 +188,7 @@ export default {
|
||||
this.loading = false;
|
||||
this.updateWatched(json.relatedStreams);
|
||||
json.relatedStreams.map(stream => this.contentItems.push(stream));
|
||||
this.fetchDeArrowContent(this.contentItems);
|
||||
});
|
||||
},
|
||||
fetchChannelTabNextPage() {
|
||||
@@ -188,6 +199,7 @@ export default {
|
||||
this.tabs[this.selectedTab].tabNextPage = json.nextpage;
|
||||
this.loading = false;
|
||||
json.content.map(item => this.contentItems.push(item));
|
||||
this.fetchDeArrowContent(this.contentItems);
|
||||
this.tabs[this.selectedTab].content = this.contentItems;
|
||||
});
|
||||
},
|
||||
@@ -231,10 +243,17 @@ export default {
|
||||
},
|
||||
loadTab(index) {
|
||||
this.selectedTab = index;
|
||||
|
||||
// update the tab query in the url path
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set("tab", this.tabs[index].name ?? "videos");
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
|
||||
if (index == 0) {
|
||||
this.contentItems = this.channel.relatedStreams;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tabs[index].content) {
|
||||
this.contentItems = this.tabs[index].content;
|
||||
return;
|
||||
@@ -243,6 +262,7 @@ export default {
|
||||
data: this.tabs[index].data,
|
||||
}).then(tab => {
|
||||
this.contentItems = this.tabs[index].content = tab.content;
|
||||
this.fetchDeArrowContent(this.contentItems);
|
||||
this.tabs[this.selectedTab].tabNextPage = tab.nextpage;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
{{ $t("video.chapters") }} - {{ chapters.length }}
|
||||
</h6>
|
||||
<div
|
||||
:key="chapter.start"
|
||||
v-for="(chapter, index) in chapters"
|
||||
@click="$emit('seek', chapter.start)"
|
||||
:key="chapter.start"
|
||||
class="chapter efy_anim_pulse efy_trans_filter"
|
||||
:class="{ 'pp-chapter-active': isCurrentChapter(index) }"
|
||||
@click="$emit('seek', chapter.start)"
|
||||
>
|
||||
<div class="flex">
|
||||
<img :src="chapter.image" :alt="chapter.title" />
|
||||
@@ -23,11 +23,11 @@
|
||||
<!-- mobile view -->
|
||||
<div v-else class="pp-chapters pp-mobile flex overflow-x-auto">
|
||||
<div
|
||||
:key="chapter.start"
|
||||
v-for="(chapter, index) in chapters"
|
||||
@click="$emit('seek', chapter.start)"
|
||||
:key="chapter.start"
|
||||
class="chapter efy_anim_pulse efy_trans_filter"
|
||||
:class="{ 'pp-chapter-active': isCurrentChapter(index) }"
|
||||
@click="$emit('seek', chapter.start)"
|
||||
>
|
||||
<img :src="chapter.image" :alt="chapter.title" />
|
||||
<div class="m-1 flex">
|
||||
@@ -38,24 +38,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.chapter {
|
||||
@apply cursor-pointer self-center p-2.5;
|
||||
}
|
||||
.pp-mobile .chapter img {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.chapter img {
|
||||
@apply w-3/10 h-3/10;
|
||||
}
|
||||
.text-truncate {
|
||||
@apply truncate overflow-hidden inline-block w-10em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
chapters: Object,
|
||||
chapters: {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
mobileLayout: {
|
||||
type: Boolean,
|
||||
default: () => true,
|
||||
@@ -75,3 +63,24 @@ const isCurrentChapter = index => {
|
||||
|
||||
defineEmits(["seek"]);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.chapter {
|
||||
@apply cursor-pointer self-center p-2.5;
|
||||
}
|
||||
.pp-mobile .chapter img {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.chapter img {
|
||||
@apply w-3/10 h-3/10;
|
||||
}
|
||||
.text-truncate {
|
||||
@apply truncate overflow-hidden inline-block w-10em;
|
||||
}
|
||||
.pp-chapter-active,
|
||||
.pp-chapters .chapter:hover {
|
||||
background: var(--efy_color);
|
||||
background-clip: padding-box;
|
||||
color: var(--efy_text2);
|
||||
}
|
||||
</style>
|
||||
|
||||
42
src/components/CollapsableText.vue
Normal file
42
src/components/CollapsableText.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template v-if="text">
|
||||
<div class="mx-1 whitespace-pre-wrap py-2">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-if="showFullText" v-html="fullText()" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-else v-html="colapsedText()" />
|
||||
<span v-if="text.length > 100 && !showFullText">...</span>
|
||||
<button
|
||||
v-if="text.length > 100"
|
||||
class="block whitespace-normal font-semibold text-neutral-500 hover:underline"
|
||||
@click="showFullText = !showFullText"
|
||||
>
|
||||
[{{ showFullText ? $t("actions.show_less") : $t("actions.show_more") }}]
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFullText: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
fullText() {
|
||||
return purifyHTML(rewriteDescription(this.text));
|
||||
},
|
||||
colapsedText() {
|
||||
return purifyHTML(rewriteDescription(this.text.slice(0, 100)));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,38 +1,43 @@
|
||||
<template>
|
||||
<div class="comment flex mt-1.5">
|
||||
<img :src="comment.thumbnail" class="comment-avatar" height="48" width="48" loading="lazy" alt="Avatar" />
|
||||
<router-link style="height: fit-content" :to="comment.commentorUrl">
|
||||
<img :src="comment.thumbnail" class="comment-avatar" height="48" width="48" loading="lazy" alt="Avatar" />
|
||||
</router-link>
|
||||
|
||||
<div class="comment-content pl-2">
|
||||
<div class="comment-header">
|
||||
<div v-if="comment.pinned" class="comment-pinned">
|
||||
<font-awesome-icon icon="thumbtack" />
|
||||
<span
|
||||
class="ml-1.5"
|
||||
v-t="{
|
||||
path: 'comment.pinned_by',
|
||||
args: { author: uploader },
|
||||
}"
|
||||
class="ml-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="comment-author mt-1 flex">
|
||||
<router-link class="font-bold link" :to="comment.commentorUrl">{{ comment.author }}</router-link>
|
||||
<font-awesome-icon class="ml-1.5" v-if="comment.verified" icon="check" />
|
||||
<div class="comment-author flex align-center">
|
||||
<router-link class="link font-bold" :to="comment.commentorUrl">{{ comment.author }}</router-link>
|
||||
<font-awesome-icon v-if="comment.verified" class="ml-1.5" icon="check" />
|
||||
<div class="comment-meta mb-1.5" v-text="' • ' + comment.commentedTime + ' •'" />
|
||||
<div class="i-fa-solid:thumbs-up" />
|
||||
<span class="ml-1" v-text="numberFormat(comment.likeCount)" />
|
||||
<font-awesome-icon class="ml-1" v-if="comment.hearted" icon="heart" />
|
||||
<div class="comment-footer mt-1 flex items-center">
|
||||
<div class="i-fa6-solid:thumbs-up" />
|
||||
<span class="ml-1" v-text="numberFormat(comment.likeCount)" />
|
||||
<font-awesome-icon v-if="comment.hearted" class="ml-1" icon="heart" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap" v-html="purifyHTML(comment.commentText)" />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="whitespace-pre-wrap" v-html="purifiedText" />
|
||||
<template v-if="comment.repliesPage && (!loadingReplies || !showingReplies)">
|
||||
<div @click="loadReplies" class="cursor-pointer">
|
||||
<div class="cursor-pointer" @click="loadReplies">
|
||||
<a v-text="`${$t('actions.reply_count', comment.replyCount)}`" />
|
||||
<font-awesome-icon class="ml-1.5" icon="level-down-alt" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showingReplies">
|
||||
<div @click="hideReplies" class="cursor-pointer">
|
||||
<div class="cursor-pointer" @click="hideReplies">
|
||||
<a v-t="'actions.hide_replies'" />
|
||||
<font-awesome-icon class="ml-1.5" icon="level-up-alt" />
|
||||
</div>
|
||||
@@ -41,7 +46,7 @@
|
||||
<div v-for="reply in replies" :key="reply.commentId" class="w-full">
|
||||
<CommentItem :comment="reply" :uploader="uploader" :video-id="videoId" />
|
||||
</div>
|
||||
<div v-if="nextpage" @click="loadReplies" class="cursor-pointer">
|
||||
<div v-if="nextpage" class="cursor-pointer" @click="loadReplies">
|
||||
<a v-t="'actions.load_more_replies'" />
|
||||
<font-awesome-icon class="ml-1.5" icon="level-down-alt" />
|
||||
</div>
|
||||
@@ -51,6 +56,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { purifyHTML } from "@/utils/HtmlUtils";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
comment: {
|
||||
@@ -70,6 +77,11 @@ export default {
|
||||
nextpage: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
purifiedText() {
|
||||
return purifyHTML(this.comment.commentText);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadReplies() {
|
||||
if (!this.showingReplies && this.loadingReplies) {
|
||||
@@ -97,4 +109,7 @@ export default {
|
||||
.comment-content {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.comment-avatar {
|
||||
max-width: unset;
|
||||
}
|
||||
</style>
|
||||
|
||||
28
src/components/ConfirmModal.vue
Normal file
28
src/components/ConfirmModal.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<ModalComponent @close="$emit('close')">
|
||||
<div>
|
||||
<h3 class="text-xl" v-text="message" />
|
||||
<div class="ml-auto mt-8 w-min flex gap-2">
|
||||
<button v-t="'actions.cancel'" class="btn" @click="$emit('close')" />
|
||||
<button v-t="'actions.okay'" class="btn" @click="$emit('confirm')" />
|
||||
</div>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ModalComponent from "./ModalComponent.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalComponent,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["close", "confirm"],
|
||||
};
|
||||
</script>
|
||||
@@ -6,7 +6,10 @@
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
item: Object,
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const VideoItem = defineAsyncComponent(() => import("./VideoItem.vue"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<p v-text="message" />
|
||||
<button @click="toggleTrace" class="btn" v-t="'actions.show_more'" />
|
||||
<button v-t="'actions.show_more'" class="btn" @click="toggleTrace" />
|
||||
<p ref="stacktrace" class="whitespace-pre-wrap" hidden v-text="error" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,40 +1,97 @@
|
||||
<template>
|
||||
<button class="btn mr-2" @click="exportHandler">
|
||||
<router-link to="/subscriptions">Subscriptions</router-link>
|
||||
</button>
|
||||
|
||||
<span>
|
||||
<a :href="getRssUrl" class="btn">
|
||||
<font-awesome-icon icon="rss" />
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span class="md:float-right flex pp-sortby-feed">
|
||||
<SortingSelector by-key="uploaded" @apply="order => videos.sort(order)" />
|
||||
</span>
|
||||
|
||||
<hr />
|
||||
<div class="flex flex-wrap align-center" style="place-content: space-between">
|
||||
<span class="buttons flex" style="gap: var(--efy_gap0)">
|
||||
<router-link role="button" to="/subscriptions">Subscriptions</router-link>
|
||||
<a :href="getRssUrl" role="button" class="pp-square">
|
||||
<font-awesome-icon icon="rss" />
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div class="video-grid">
|
||||
<VideoItem :is-feed="true" v-for="video in videos" :key="video.url" :item="video" />
|
||||
<div class="filters flex align-center">
|
||||
<span class="flex">
|
||||
<label for="filters" v-text="`${$t('actions.filter')}:`" />
|
||||
<select
|
||||
id="filters"
|
||||
v-model="selectedFilter"
|
||||
default="all"
|
||||
class="select flex-grow"
|
||||
@change="onFilterChange()"
|
||||
>
|
||||
<option v-for="filter in availableFilters" :key="filter" v-t="`video.${filter}`" :value="filter" />
|
||||
</select>
|
||||
</span>
|
||||
|
||||
<span class="flex">
|
||||
<label for="group-selector" v-text="`${$t('titles.channel_groups')}:`" />
|
||||
<select id="group-selector" v-model="selectedGroupName" default="" class="select flex-grow">
|
||||
<option v-t="`video.all`" value="" />
|
||||
<option
|
||||
v-for="group in channelGroups"
|
||||
:key="group.groupName"
|
||||
:value="group.groupName"
|
||||
v-text="group.groupName"
|
||||
/>
|
||||
</select>
|
||||
</span>
|
||||
|
||||
<span class="pp-sortby-feed flex">
|
||||
<SortingSelector by-key="uploaded" @apply="order => videos.sort(order)" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<LoadingIndicatorPage :show-content="videosStore != null" class="video-grid">
|
||||
<template v-for="video in filteredVideos" :key="video.url">
|
||||
<VideoItem v-if="shouldShowVideo(video)" :is-feed="true" :item="video" />
|
||||
</template>
|
||||
</LoadingIndicatorPage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters,
|
||||
.filters span {
|
||||
gap: var(--efy_gap0);
|
||||
}
|
||||
.filters :is(select, label),
|
||||
.buttons a[role="button"] {
|
||||
margin: 0 !important;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
place-content: center;
|
||||
}
|
||||
.filters span {
|
||||
align-items: center;
|
||||
}
|
||||
.buttons a[role="button"] {
|
||||
height: var(--efy_ratio_width);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import VideoItem from "./VideoItem.vue";
|
||||
import SortingSelector from "./SortingSelector.vue";
|
||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
VideoItem,
|
||||
SortingSelector,
|
||||
LoadingIndicatorPage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentVideoCount: 0,
|
||||
videoStep: 100,
|
||||
videosStore: [],
|
||||
videosStore: null,
|
||||
videos: [],
|
||||
availableFilters: ["all", "shorts", "videos"],
|
||||
selectedFilter: "all",
|
||||
selectedGroupName: "",
|
||||
channelGroups: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -42,6 +99,12 @@ export default {
|
||||
if (_this.authenticated) return _this.authApiUrl() + "/feed/rss?authToken=" + _this.getAuthToken();
|
||||
else return _this.authApiUrl() + "/feed/unauthenticated/rss?channels=" + _this.getUnauthenticatedChannels();
|
||||
},
|
||||
filteredVideos(_this) {
|
||||
const selectedGroup = _this.channelGroups.filter(group => group.groupName == _this.selectedGroupName);
|
||||
return _this.selectedGroupName == ""
|
||||
? _this.videos
|
||||
: _this.videos.filter(video => selectedGroup[0].channels.includes(video.uploaderUrl.substr(-11)));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchFeed().then(videos => {
|
||||
@@ -49,6 +112,23 @@ export default {
|
||||
this.loadMoreVideos();
|
||||
this.updateWatched(this.videos);
|
||||
});
|
||||
|
||||
this.selectedFilter = this.getPreferenceString("feedFilter") ?? "all";
|
||||
|
||||
if (!window.db) return;
|
||||
|
||||
const cursor = this.getChannelGroupsCursor();
|
||||
cursor.onsuccess = e => {
|
||||
const cursor = e.target.result;
|
||||
if (cursor) {
|
||||
const group = cursor.value;
|
||||
this.channelGroups.push({
|
||||
groupName: group.groupName,
|
||||
channels: JSON.parse(group.channels),
|
||||
});
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
},
|
||||
activated() {
|
||||
document.title = this.$t("titles.feed") + " - Piped";
|
||||
@@ -74,15 +154,31 @@ export default {
|
||||
}
|
||||
},
|
||||
loadMoreVideos() {
|
||||
if (!this.videosStore) return;
|
||||
this.currentVideoCount = Math.min(this.currentVideoCount + this.videoStep, this.videosStore.length);
|
||||
if (this.videos.length != this.videosStore.length)
|
||||
if (this.videos.length != this.videosStore.length) {
|
||||
this.videos = this.videosStore.slice(0, this.currentVideoCount);
|
||||
this.fetchDeArrowContent(this.videos);
|
||||
}
|
||||
},
|
||||
handleScroll() {
|
||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||
this.loadMoreVideos();
|
||||
}
|
||||
},
|
||||
shouldShowVideo(video) {
|
||||
switch (this.selectedFilter.toLowerCase()) {
|
||||
case "shorts":
|
||||
return video.isShort;
|
||||
case "videos":
|
||||
return !video.isShort;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onFilterChange() {
|
||||
this.setPreference("feedFilter", this.selectedFilter);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
<template>
|
||||
<h1 class="font-bold text-center" v-t="'titles.history'" />
|
||||
|
||||
<div class="flex place-items-center">
|
||||
<div class="mr-2">
|
||||
<button class="btn" v-t="'actions.clear_history'" @click="clearHistory" />
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<button v-t="'actions.clear_history'" class="btn" @click="clearHistory" />
|
||||
|
||||
<button v-t="'actions.export_to_json'" class="btn" @click="exportHistory" />
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<SortingSelector by-key="watchedAt" @apply="order => videos.sort(order)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mr-2">
|
||||
<SortingSelector by-key="watchedAt" @apply="order => videos.sort(order)" />
|
||||
<div class="ml-4 flex items-center">
|
||||
<input id="autoDelete" v-model="autoDeleteHistory" type="checkbox" @change="onChange" />
|
||||
<label v-t="'actions.delete_automatically'" class="ml-2" for="autoDelete" />
|
||||
<select v-model="autoDeleteDelayHours" class="select ml-3 pl-3" @change="onChange">
|
||||
<option v-t="{ path: 'info.hours', args: { amount: '1' } }" value="1" />
|
||||
<option v-t="{ path: 'info.hours', args: { amount: '3' } }" value="3" />
|
||||
<option v-t="{ path: 'info.hours', args: { amount: '6' } }" value="6" />
|
||||
<option v-t="{ path: 'info.hours', args: { amount: '12' } }" value="12" />
|
||||
<option v-t="{ path: 'info.days', args: { amount: '1' } }" value="24" />
|
||||
<option v-t="{ path: 'info.days', args: { amount: '3' } }" value="72" />
|
||||
<option v-t="{ path: 'info.weeks', args: { amount: '1' } }" value="168" />
|
||||
<option v-t="{ path: 'info.weeks', args: { amount: '3' } }" value="336" />
|
||||
<option v-t="{ path: 'info.months', args: { amount: '1' } }" value="672" />
|
||||
<option v-t="{ path: 'info.months', args: { amount: '2' } }" value="1344" />
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,28 +49,39 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
videos: [],
|
||||
autoDeleteHistory: false,
|
||||
autoDeleteDelayHours: "24",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.autoDeleteHistory = this.getPreferenceBoolean("autoDeleteWatchHistory", false);
|
||||
this.autoDeleteDelayHours = this.getPreferenceString("autoDeleteWatchHistoryDelayHours", "24");
|
||||
|
||||
(async () => {
|
||||
if (window.db) {
|
||||
var tx = window.db.transaction("watch_history", "readonly");
|
||||
if (window.db && this.getPreferenceBoolean("watchHistory", false)) {
|
||||
var tx = window.db.transaction("watch_history", "readwrite");
|
||||
var store = tx.objectStore("watch_history");
|
||||
const cursorRequest = store.index("watchedAt").openCursor(null, "prev");
|
||||
cursorRequest.onsuccess = e => {
|
||||
const cursor = e.target.result;
|
||||
if (cursor) {
|
||||
const video = cursor.value;
|
||||
this.videos.push({
|
||||
url: "/watch?v=" + video.videoId,
|
||||
title: video.title,
|
||||
uploaderName: video.uploaderName,
|
||||
uploaderUrl: video.uploaderUrl,
|
||||
duration: video.duration,
|
||||
thumbnail: video.thumbnail,
|
||||
watchedAt: video.watchedAt,
|
||||
});
|
||||
if (this.videos.length < 1000) cursor.continue();
|
||||
if (!this.shouldRemoveVideo(video)) {
|
||||
this.videos.push({
|
||||
url: "/watch?v=" + video.videoId,
|
||||
title: video.title,
|
||||
uploaderName: video.uploaderName,
|
||||
uploaderUrl: video.uploaderUrl,
|
||||
duration: video.duration,
|
||||
thumbnail: video.thumbnail,
|
||||
watchedAt: video.watchedAt,
|
||||
watched: true,
|
||||
currentTime: video.currentTime,
|
||||
});
|
||||
} else {
|
||||
store.delete(video.videoId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -71,6 +99,32 @@ export default {
|
||||
}
|
||||
this.videos = [];
|
||||
},
|
||||
exportHistory() {
|
||||
const dateStr = new Date().toISOString().split(".")[0];
|
||||
let json = {
|
||||
format: "Piped",
|
||||
version: 1,
|
||||
playlists: [
|
||||
{
|
||||
name: `Piped History ${dateStr}`,
|
||||
type: "history",
|
||||
visibility: "private",
|
||||
videos: this.videos.map(video => "https://youtube.com" + video.url),
|
||||
},
|
||||
],
|
||||
};
|
||||
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
|
||||
},
|
||||
onChange() {
|
||||
this.setPreference("autoDeleteWatchHistory", this.autoDeleteHistory);
|
||||
this.setPreference("autoDeleteWatchHistoryDelayHours", this.autoDeleteDelayHours);
|
||||
},
|
||||
shouldRemoveVideo(video) {
|
||||
if (!this.autoDeleteHistory) return false;
|
||||
// convert from hours to milliseconds
|
||||
let maximumTimeDiff = Number(this.autoDeleteDelayHours) * 60 * 60 * 1000;
|
||||
return Date.now() - video.watchedAt > maximumTimeDiff;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -106,10 +106,14 @@ export default {
|
||||
}
|
||||
// FreeTube DB
|
||||
else if (text.indexOf("allChannels") != -1) {
|
||||
const json = JSON.parse(text);
|
||||
json.subscriptions.forEach(item => {
|
||||
this.subscriptions.push(item.id);
|
||||
});
|
||||
const lines = text.split("\n");
|
||||
for (let line of lines) {
|
||||
if (line === "") continue;
|
||||
const json = JSON.parse(line);
|
||||
json.subscriptions.forEach(item => {
|
||||
this.subscriptions.push(item.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Google Takeout JSON
|
||||
else if (text.indexOf("contentDetails") != -1) {
|
||||
|
||||
47
src/components/LoadingIndicatorPage.vue
Normal file
47
src/components/LoadingIndicatorPage.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div v-if="!showContent" class="min-h-[75vh] w-full flex items-center justify-center">
|
||||
<span id="spinner" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
showContent: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#spinner {
|
||||
display: inline-block;
|
||||
width: 70rem;
|
||||
height: 70rem;
|
||||
}
|
||||
#spinner:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 54rem;
|
||||
height: 54rem;
|
||||
margin: 8rem;
|
||||
border-radius: 50%;
|
||||
border: 4rem solid var(--efy_text);
|
||||
border-color: var(--efy_text) transparent var(--efy_text) transparent;
|
||||
animation: spinner 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
70
src/components/LoginPage.vue
Normal file
70
src/components/LoginPage.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<h1 v-t="'titles.login'" class="my-4 text-center font-bold" />
|
||||
<hr />
|
||||
<div class="text-center">
|
||||
<form class="children:pb-3">
|
||||
<div>
|
||||
<input
|
||||
v-model="username"
|
||||
class="input"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:placeholder="$t('login.username')"
|
||||
:aria-label="$t('login.username')"
|
||||
@keyup.enter="login"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
v-model="password"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="password"
|
||||
:placeholder="$t('login.password')"
|
||||
:aria-label="$t('login.password')"
|
||||
@keyup.enter="login"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<a v-t="'titles.login'" class="btn w-auto" @click="login" />
|
||||
</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("/");
|
||||
}
|
||||
},
|
||||
activated() {
|
||||
document.title = this.$t("titles.login") + " - Piped";
|
||||
},
|
||||
methods: {
|
||||
login() {
|
||||
if (!this.username || !this.password) return;
|
||||
this.fetchJson(this.authApiUrl() + "/login", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
}),
|
||||
}).then(resp => {
|
||||
if (resp.token) {
|
||||
this.setPreference("authToken" + this.hashCode(this.authApiUrl()), resp.token);
|
||||
window.location = "/"; // done to bypass cache
|
||||
} else alert(resp.error);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ["close"],
|
||||
mounted() {
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
},
|
||||
@@ -34,7 +35,10 @@ export default {
|
||||
|
||||
<style>
|
||||
.modal {
|
||||
@apply fixed z-50 top-0 left-0 w-full h-full bg-dark-900 bg-opacity-80 transition-opacity table;
|
||||
@apply fixed z-50 top-0 left-0 w-full h-full bg-gray bg-opacity-80 transition-opacity table;
|
||||
}
|
||||
.dark .modal {
|
||||
@apply bg-dark-900 bg-opacity-80;
|
||||
}
|
||||
|
||||
.modal > div {
|
||||
@@ -42,7 +46,7 @@ export default {
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
@apply w-300rem m-auto max-w-[100vw] relative;
|
||||
@apply w-100% m-auto max-w-[410rem] relative;
|
||||
}
|
||||
|
||||
.modal-container > button {
|
||||
|
||||
@@ -56,10 +56,10 @@
|
||||
</div>
|
||||
<div class="lt-md:hidden flex flex-1 justify-start" style="position: relative">
|
||||
<input
|
||||
ref="videoSearch"
|
||||
v-model="searchText"
|
||||
type="text"
|
||||
role="search"
|
||||
ref="videoSearch"
|
||||
:title="$t('actions.search')"
|
||||
:placeholder="$t('actions.search')"
|
||||
@keyup="onKeyUp"
|
||||
@@ -78,8 +78,8 @@
|
||||
<router-link v-t="'titles.preferences'" to="/preferences" />
|
||||
<p
|
||||
v-if="shouldShowLogin"
|
||||
class="cursor-pointer font-bold"
|
||||
v-t="'titles.account'"
|
||||
class="cursor-pointer font-bold"
|
||||
@click="showLoginModal = !showLoginModal"
|
||||
/>
|
||||
<router-link v-if="shouldShowHistory" v-t="'titles.history'" to="/history" />
|
||||
@@ -87,15 +87,8 @@
|
||||
<router-link v-if="!shouldShowTrending" v-t="'titles.feed'" to="/feed" />
|
||||
<button
|
||||
efy_sidebar_btn="relative, pp-desktop"
|
||||
style="
|
||||
background: transparent;
|
||||
-webkit-text-fill-color: var(--efy_text);
|
||||
padding: 0;
|
||||
margin: -5rem 0 0 0;
|
||||
border: 0;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
"
|
||||
style="background: transparent; padding: 0; margin: -5rem 0 0 0; border: 0"
|
||||
class="efy_trans_filter_off efy_shadow_button_off"
|
||||
>
|
||||
<i efy_icon="menu" style="margin: 0" />
|
||||
</button>
|
||||
@@ -115,7 +108,7 @@
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
/>
|
||||
<span v-if="searchText" class="delete-search" @click="searchText = ''">x</span>
|
||||
<span v-if="searchText" class="delete-search" @click="searchText = ''">⨉</span>
|
||||
</div>
|
||||
<SearchSuggestions
|
||||
v-show="(searchText || showSearchHistory) && suggestionsVisible"
|
||||
@@ -204,17 +197,17 @@ export default {
|
||||
suggestionsVisible: false,
|
||||
showLoginModal: false,
|
||||
showTopNav: false,
|
||||
homePagePath: "/",
|
||||
registrationDisabled: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const query = new URLSearchParams(window.location.search).get("search_query");
|
||||
if (query) this.onSearchTextChange(query);
|
||||
this.focusOnSearchBar();
|
||||
},
|
||||
computed: {
|
||||
shouldShowLogin(_this) {
|
||||
return _this.getAuthToken() == null;
|
||||
},
|
||||
shouldShowRegister(_this) {
|
||||
return _this.registrationDisabled == false ? _this.shouldShowLogin : false;
|
||||
},
|
||||
shouldShowHistory(_this) {
|
||||
return _this.getPreferenceBoolean("watchHistory", false);
|
||||
},
|
||||
@@ -225,6 +218,13 @@ export default {
|
||||
return _this.getPreferenceBoolean("searchHistory", false) && localStorage.getItem("search_history");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchAuthConfig();
|
||||
const query = new URLSearchParams(window.location.search).get("search_query");
|
||||
if (query) this.onSearchTextChange(query);
|
||||
this.focusOnSearchBar();
|
||||
this.homePagePath = this.getHomePage(this);
|
||||
},
|
||||
methods: {
|
||||
// focus on search bar when Ctrl+k is pressed
|
||||
focusOnSearchBar() {
|
||||
@@ -241,12 +241,7 @@ export default {
|
||||
},
|
||||
onKeyPress(e) {
|
||||
if (e.key === "Enter") {
|
||||
e.target.blur();
|
||||
this.$router.push({
|
||||
name: "SearchResults",
|
||||
query: { search_query: this.searchText },
|
||||
});
|
||||
return;
|
||||
this.submitSearch(e);
|
||||
}
|
||||
},
|
||||
onInputFocus() {
|
||||
@@ -259,6 +254,22 @@ export default {
|
||||
onSearchTextChange(searchText) {
|
||||
this.searchText = searchText;
|
||||
},
|
||||
async fetchAuthConfig() {
|
||||
this.fetchJson(this.authApiUrl() + "/config").then(config => {
|
||||
this.registrationDisabled = config?.registrationDisabled === true;
|
||||
});
|
||||
},
|
||||
onSearchClick(e) {
|
||||
this.submitSearch(e);
|
||||
},
|
||||
submitSearch(e) {
|
||||
e.target.blur();
|
||||
this.$router.push({
|
||||
name: "SearchResults",
|
||||
query: { search_query: this.searchText },
|
||||
});
|
||||
return;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-center items-center min-h-[88vh]">
|
||||
<h1 class="font-bold !text-[170rem] mb-[-6vh]">404</h1>
|
||||
<h2 class="!text-[40rem]" v-t="'info.page_not_found'" />
|
||||
<a class="btn mt-128" href="/" v-t="'actions.back_to_home'" />
|
||||
<h2 v-t="'info.page_not_found'" class="!text-[40rem]" />
|
||||
<a v-t="'actions.back_to_home'" role="button" href="/" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
<option v-for="playlist in playlists" :value="playlist.id" :key="playlist.id" v-text="playlist.name" />
|
||||
</select>
|
||||
<div class="flex justify-end">
|
||||
<button ref="addButton" v-t="'actions.create_playlist'" class="btn" @click="onCreatePlaylist" />
|
||||
<button
|
||||
class="btn"
|
||||
@click="handleClick(selectedPlaylist)"
|
||||
ref="addButton"
|
||||
v-t="'actions.add_to_playlist'"
|
||||
class="btn"
|
||||
@click="handleClick(selectedPlaylist)"
|
||||
/>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
@@ -23,11 +24,16 @@ export default {
|
||||
ModalComponent,
|
||||
},
|
||||
props: {
|
||||
videoInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
videoId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["close"],
|
||||
data() {
|
||||
return {
|
||||
playlists: [],
|
||||
@@ -62,31 +68,25 @@ export default {
|
||||
this.$refs.addButton.disabled = true;
|
||||
this.processing = true;
|
||||
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists/add", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playlistId: playlistId,
|
||||
videoId: this.videoId,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(json => {
|
||||
this.addVideosToPlaylist(playlistId, [this.videoId], [this.videoInfo]).then(json => {
|
||||
this.setPreference("selectedPlaylist" + this.hashCode(this.authApiUrl()), playlistId);
|
||||
this.$emit("close");
|
||||
if (json.error) alert(json.error);
|
||||
});
|
||||
},
|
||||
async fetchPlaylists() {
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists", null, {
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
},
|
||||
}).then(json => {
|
||||
this.getPlaylists().then(json => {
|
||||
this.playlists = json;
|
||||
});
|
||||
},
|
||||
onCreatePlaylist() {
|
||||
const name = prompt(this.$t("actions.create_playlist"));
|
||||
if (!name) return;
|
||||
this.createPlaylist(name).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else this.fetchPlaylists();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col flex-justify-between">
|
||||
<router-link :to="props.item.url">
|
||||
<div class="relative">
|
||||
<img class="w-full" :src="props.item.thumbnail" loading="lazy" />
|
||||
</div>
|
||||
<p>
|
||||
<span v-text="props.item.name" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="props.item.verified" icon="check" />
|
||||
<font-awesome-icon v-if="props.item.verified" class="ml-1.5" icon="check" />
|
||||
</p>
|
||||
</router-link>
|
||||
<p v-if="props.item.description" v-text="props.item.description" />
|
||||
|
||||
<router-link v-if="props.item.uploaderUrl" class="link" :to="props.item.uploaderUrl">
|
||||
<p>
|
||||
<span v-text="props.item.uploader" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="props.item.uploaderVerified" icon="check" />
|
||||
<span v-text="props.item.uploaderName" />
|
||||
<font-awesome-icon v-if="props.item.uploaderVerified" class="ml-1.5" icon="check" />
|
||||
</p>
|
||||
</router-link>
|
||||
<a v-else-if="props.item.uploaderName" class="link" v-text="props.item.uploaderName" />
|
||||
|
||||
<a v-if="props.item.uploaderName" class="link" v-text="props.item.uploaderName" />
|
||||
<template v-if="props.item.videos >= 0">
|
||||
<br v-if="props.item.uploaderName" />
|
||||
<strong v-text="`${props.item.videos} ${$t('video.videos')}`" />
|
||||
@@ -29,6 +30,9 @@
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
item: Object,
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<ErrorHandler v-if="playlist && playlist.error" :message="playlist.message" :error="playlist.error" />
|
||||
|
||||
<div v-if="playlist" v-show="!playlist.error">
|
||||
<h1 class="text-center my-4" v-text="playlist.name" />
|
||||
<LoadingIndicatorPage v-show="!playlist?.error" :show-content="playlist">
|
||||
<h5 class="mb-1 ml-1" v-text="playlist.name" />
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<CollapsableText v-if="playlist?.description" :text="playlist.description" />
|
||||
|
||||
<div class="mt-1 flex items-center justify-between">
|
||||
<div>
|
||||
<router-link class="link" :to="playlist.uploaderUrl || '/'">
|
||||
<img :src="playlist.uploaderAvatar" loading="lazy" />
|
||||
@@ -14,7 +16,11 @@
|
||||
<div>
|
||||
<strong v-text="`${playlist.videos} ${$t('video.videos')}`" />
|
||||
<br />
|
||||
<button class="btn mr-1 ml-2" v-if="authenticated && !isPipedPlaylist" @click="clonePlaylist">
|
||||
<button v-if="!isPipedPlaylist" class="btn mx-1" @click="bookmarkPlaylist">
|
||||
{{ $t(`actions.${isBookmarked ? "playlist_bookmarked" : "bookmark_playlist"}`)
|
||||
}}<font-awesome-icon class="ml-3" icon="bookmark" />
|
||||
</button>
|
||||
<button v-if="authenticated && !isPipedPlaylist" class="btn mr-1 ml-2" @click="clonePlaylist">
|
||||
{{ $t("actions.clone_playlist") }}<font-awesome-icon class="ml-3" icon="clone" />
|
||||
</button>
|
||||
<button class="btn mr-1" @click="downloadPlaylistAsTxt">
|
||||
@@ -23,7 +29,7 @@
|
||||
<a class="btn" :href="getRssUrl">
|
||||
<font-awesome-icon icon="rss" />
|
||||
</a>
|
||||
<WatchOnYouTubeButton :link="`https://www.youtube.com/playlist?list=${this.$route.query.list}`" />
|
||||
<WatchOnButton :link="`https://www.youtube.com/playlist?list=${$route.query.list}`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,29 +43,34 @@
|
||||
:index="index"
|
||||
:playlist-id="$route.query.list"
|
||||
:admin="admin"
|
||||
@remove="removeVideo(index)"
|
||||
height="94"
|
||||
width="168"
|
||||
@remove="removeVideo(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</LoadingIndicatorPage>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ErrorHandler from "./ErrorHandler.vue";
|
||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||
import CollapsableText from "./CollapsableText.vue";
|
||||
import VideoItem from "./VideoItem.vue";
|
||||
import WatchOnYouTubeButton from "./WatchOnYouTubeButton.vue";
|
||||
import WatchOnButton from "./WatchOnButton.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ErrorHandler,
|
||||
VideoItem,
|
||||
WatchOnYouTubeButton,
|
||||
WatchOnButton,
|
||||
LoadingIndicatorPage,
|
||||
CollapsableText,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
playlist: null,
|
||||
admin: false,
|
||||
isBookmarked: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -74,19 +85,17 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getPlaylistData();
|
||||
const playlistId = this.$route.query.list;
|
||||
if (this.authenticated && playlistId?.length == 36)
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists", null, {
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
},
|
||||
}).then(json => {
|
||||
this.getPlaylists().then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else if (json.filter(playlist => playlist.id === playlistId).length > 0) this.admin = true;
|
||||
else if (json.some(playlist => playlist.id === playlistId)) this.admin = true;
|
||||
});
|
||||
else if (playlistId.startsWith("local")) this.admin = true;
|
||||
this.isPlaylistBookmarked();
|
||||
},
|
||||
activated() {
|
||||
this.getPlaylistData();
|
||||
window.addEventListener("scroll", this.handleScroll);
|
||||
if (this.playlist) this.updateTitle();
|
||||
},
|
||||
@@ -95,12 +104,21 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async fetchPlaylist() {
|
||||
const playlistId = this.$route.query.list;
|
||||
if (playlistId.startsWith("local")) {
|
||||
return this.getPlaylist(playlistId);
|
||||
}
|
||||
|
||||
return await await this.fetchJson(this.authApiUrl() + "/playlists/" + this.$route.query.list);
|
||||
},
|
||||
async getPlaylistData() {
|
||||
this.fetchPlaylist()
|
||||
.then(data => (this.playlist = data))
|
||||
.then(() => this.updateTitle());
|
||||
.then(() => {
|
||||
this.updateTitle();
|
||||
this.updateWatched(this.playlist.relatedStreams);
|
||||
this.fetchDeArrowContent(this.playlist.relatedStreams);
|
||||
});
|
||||
},
|
||||
async updateTitle() {
|
||||
document.title = this.playlist.name + " - Piped";
|
||||
@@ -116,6 +134,7 @@ export default {
|
||||
this.playlist.nextpage = json.nextpage;
|
||||
this.loading = false;
|
||||
json.relatedStreams.map(stream => this.playlist.relatedStreams.push(stream));
|
||||
this.fetchDeArrowContent(this.playlist.relatedStreams);
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -144,6 +163,48 @@ export default {
|
||||
});
|
||||
this.download(data, this.playlist.name + ".txt", "text/plain");
|
||||
},
|
||||
async bookmarkPlaylist() {
|
||||
if (!this.playlist) return;
|
||||
|
||||
if (this.isBookmarked) {
|
||||
this.removePlaylistBookmark();
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.db) {
|
||||
const playlistId = this.$route.query.list;
|
||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||
var store = tx.objectStore("playlist_bookmarks");
|
||||
store.put({
|
||||
playlistId: playlistId,
|
||||
name: this.playlist.name,
|
||||
uploader: this.playlist.uploader,
|
||||
uploaderUrl: this.playlist.uploaderUrl,
|
||||
thumbnail: this.playlist.thumbnailUrl,
|
||||
uploaderAvatar: this.playlist.uploaderAvatar,
|
||||
videos: this.playlist.videos,
|
||||
});
|
||||
this.isBookmarked = true;
|
||||
}
|
||||
},
|
||||
async removePlaylistBookmark() {
|
||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||
var store = tx.objectStore("playlist_bookmarks");
|
||||
store.delete(this.$route.query.list);
|
||||
this.isBookmarked = false;
|
||||
},
|
||||
async isPlaylistBookmarked() {
|
||||
// needed in order to change the is bookmarked var later
|
||||
const App = this;
|
||||
const playlistId = this.$route.query.list;
|
||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||
var store = tx.objectStore("playlist_bookmarks");
|
||||
var req = store.openCursor(playlistId);
|
||||
req.onsuccess = function (e) {
|
||||
var cursor = e.target.result;
|
||||
App.isBookmarked = cursor ? true : false;
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -29,18 +29,7 @@ export default {
|
||||
},
|
||||
selectedIndex: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateScroll();
|
||||
},
|
||||
methods: {
|
||||
updateScroll() {
|
||||
const elems = Array.from(this.$refs.scrollable.children).filter(elm => elm.matches("div"));
|
||||
const index = this.selectedIndex - 1;
|
||||
if (index < elems.length)
|
||||
this.$refs.scrollable.scrollTop =
|
||||
elems[this.selectedIndex - 1].offsetTop - this.$refs.scrollable.offsetTop;
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -54,5 +43,18 @@ export default {
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateScroll();
|
||||
this.updateWatched(this.playlist.relatedStreams);
|
||||
},
|
||||
methods: {
|
||||
updateScroll() {
|
||||
const elems = Array.from(this.$refs.scrollable.children).filter(elm => elm.matches("div"));
|
||||
const index = this.selectedIndex - 1;
|
||||
if (index < elems.length)
|
||||
this.$refs.scrollable.scrollTop =
|
||||
elems[this.selectedIndex - 1].offsetTop - this.$refs.scrollable.offsetTop;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,125 +1,153 @@
|
||||
<template>
|
||||
<h1 class="font-bold text-center my-4" v-t="'titles.playlists'" />
|
||||
<hr />
|
||||
<div class="flex justify-between">
|
||||
<button v-t="'actions.create_playlist'" class="btn mr-2" @click="onCreatePlaylist" />
|
||||
<div class="flex">
|
||||
<button v-if="playlists.length > 0" v-t="'actions.export_to_json'" @click="exportPlaylists" />
|
||||
<input id="fileSelector" ref="fileSelector" type="file" class="display-none" @change="importPlaylists" />
|
||||
<label for="fileSelector" v-t="'actions.import_from_json'" class="btn ml-2" role="button" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-grid">
|
||||
<div v-for="playlist in playlists" :key="playlist.id" class="efy_trans_filter">
|
||||
<router-link :to="`/playlist?list=${playlist.id}`">
|
||||
<img class="w-full" :src="playlist.thumbnail" alt="thumbnail" />
|
||||
<p
|
||||
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin: 0 15rem"
|
||||
class="flex link"
|
||||
:title="playlist.name"
|
||||
v-text="playlist.name"
|
||||
/>
|
||||
</router-link>
|
||||
<div class="pp-video-card-buttons flex gap-15rem children:m-0" style="flex-wrap: wrap">
|
||||
<button v-text="`${playlist.videos} ${$t('video.videos')}`" class="thumbnail-overlay thumbnail-right" />
|
||||
<button
|
||||
v-t="'actions.edit_playlist'"
|
||||
class="pp-color h-auto"
|
||||
@click="showPlaylistEditModal(playlist)"
|
||||
/>
|
||||
<button
|
||||
v-t="'actions.delete_playlist'"
|
||||
class="pp-color h-auto"
|
||||
@click="playlistToDelete = playlist.id"
|
||||
/>
|
||||
</div>
|
||||
<ModalComponent v-if="playlist.id == playlistToEdit" @close="playlistToEdit = null">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 v-t="'actions.edit_playlist'" />
|
||||
<input
|
||||
v-model="newPlaylistName"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('actions.playlist_name')"
|
||||
/>
|
||||
<input
|
||||
v-model="newPlaylistDescription"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('actions.playlist_description')"
|
||||
/>
|
||||
<button v-t="'actions.okay'" class="btn ml-auto" @click="editPlaylist(playlist)" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
<ConfirmModal
|
||||
v-if="playlistToDelete == playlist.id"
|
||||
:message="$t('actions.delete_playlist_confirm')"
|
||||
@close="playlistToDelete = null"
|
||||
@confirm="onDeletePlaylist(playlist.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between">
|
||||
<button v-t="'actions.create_playlist'" class="btn mr-2" @click="onCreatePlaylist" />
|
||||
<div class="flex">
|
||||
<button
|
||||
v-if="this.playlists.length > 0"
|
||||
v-t="'actions.export_to_json'"
|
||||
class="btn"
|
||||
@click="exportPlaylists"
|
||||
/>
|
||||
<input
|
||||
id="fileSelector"
|
||||
ref="fileSelector"
|
||||
type="file"
|
||||
class="display-none"
|
||||
@change="importPlaylists"
|
||||
/>
|
||||
<label for="fileSelector" v-t="'actions.import_from_json'" class="btn ml-2" role="button" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 v-t="'titles.bookmarks'" class="my-4 font-bold" />
|
||||
|
||||
<div class="video-grid">
|
||||
<div v-for="playlist in playlists" :key="playlist.id" class="efy_trans_filter">
|
||||
<router-link :to="`/playlist?list=${playlist.id}`">
|
||||
<img class="w-full" :src="playlist.thumbnail" alt="thumbnail" />
|
||||
<p
|
||||
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
|
||||
class="flex link"
|
||||
:title="playlist.name"
|
||||
v-text="playlist.name"
|
||||
/>
|
||||
</router-link>
|
||||
<div class="pp-video-card-buttons flex gap-15rem children:m-0" style="flex-wrap: wrap">
|
||||
<button
|
||||
class="thumbnail-overlay thumbnail-right"
|
||||
v-text="`${playlist.videos} ${$t('video.videos')}`"
|
||||
/>
|
||||
<button
|
||||
class="pp-color h-auto"
|
||||
@click="renamePlaylist(playlist.id)"
|
||||
v-t="'actions.rename_playlist'"
|
||||
/>
|
||||
<button
|
||||
class="pp-color h-auto"
|
||||
@click="deletePlaylist(playlist.id)"
|
||||
v-t="'actions.delete_playlist'"
|
||||
/>
|
||||
<div v-if="bookmarks" class="video-grid">
|
||||
<router-link
|
||||
v-for="(playlist, index) in bookmarks"
|
||||
:key="playlist.playlistId"
|
||||
:to="`/playlist?list=${playlist.playlistId}`"
|
||||
>
|
||||
<img class="w-full" :src="playlist.thumbnail" alt="thumbnail" />
|
||||
<div class="relative text-sm">
|
||||
<span class="thumbnail-overlay thumbnail-right" v-text="`${playlist.videos} ${$t('video.videos')}`" />
|
||||
<div class="absolute bottom-100px right-5px z-100 px-5px" @click.prevent="removeBookmark(index)">
|
||||
<font-awesome-icon class="ml-3" icon="bookmark" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin: 0 0 0 10rem"
|
||||
class="link my-2 flex overflow-hidden"
|
||||
:title="playlist.name"
|
||||
v-text="playlist.name"
|
||||
/>
|
||||
<a :href="playlist.uploaderUrl" class="flex items-center">
|
||||
<img class="h-32px w-32px rounded-full" :src="playlist.uploaderAvatar" />
|
||||
<span class="ml-3 hover:underline" v-text="playlist.uploader" />
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ConfirmModal from "./ConfirmModal.vue";
|
||||
import ModalComponent from "./ModalComponent.vue";
|
||||
|
||||
export default {
|
||||
components: { ConfirmModal, ModalComponent },
|
||||
data() {
|
||||
return {
|
||||
playlists: [],
|
||||
bookmarks: [],
|
||||
playlistToDelete: null,
|
||||
playlistToEdit: null,
|
||||
newPlaylistName: "",
|
||||
newPlaylistDescription: "",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (this.authenticated) this.fetchPlaylists();
|
||||
else this.$router.push("/login");
|
||||
this.fetchPlaylists();
|
||||
this.loadPlaylistBookmarks();
|
||||
},
|
||||
activated() {
|
||||
document.title = this.$t("titles.playlists") + " - Piped";
|
||||
},
|
||||
methods: {
|
||||
fetchPlaylists() {
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists", null, {
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
},
|
||||
}).then(json => {
|
||||
this.getPlaylists().then(json => {
|
||||
this.playlists = json;
|
||||
});
|
||||
},
|
||||
renamePlaylist(id) {
|
||||
const newName = prompt(this.$t("actions.new_playlist_name"));
|
||||
if (!newName) return;
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists/rename", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playlistId: id,
|
||||
newName: newName,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else {
|
||||
this.playlists.forEach((playlist, index) => {
|
||||
if (playlist.id == id) {
|
||||
this.playlists[index].name = newName;
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
showPlaylistEditModal(playlist) {
|
||||
this.newPlaylistName = playlist.name;
|
||||
this.newPlaylistDescription = playlist.description;
|
||||
this.playlistToEdit = playlist.id;
|
||||
},
|
||||
deletePlaylist(id) {
|
||||
if (confirm(this.$t("actions.delete_playlist_confirm")))
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists/delete", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playlistId: id,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(json => {
|
||||
editPlaylist(selectedPlaylist) {
|
||||
// save the new name and description since they could be overwritten during the http request
|
||||
const newName = this.newPlaylistName;
|
||||
const newDescription = this.newPlaylistDescription;
|
||||
if (newName != selectedPlaylist.name) {
|
||||
this.renamePlaylist(selectedPlaylist.id, newName).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else this.playlists = this.playlists.filter(playlist => playlist.id !== id);
|
||||
else selectedPlaylist.name = newName;
|
||||
});
|
||||
}
|
||||
if (newDescription != selectedPlaylist.description) {
|
||||
this.changePlaylistDescription(selectedPlaylist.id, newDescription).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else selectedPlaylist.description = newDescription;
|
||||
});
|
||||
}
|
||||
this.playlistToEdit = null;
|
||||
},
|
||||
onDeletePlaylist(id) {
|
||||
this.deletePlaylist(id).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else this.playlists = this.playlists.filter(playlist => playlist.id !== id);
|
||||
});
|
||||
this.playlistToDelete = null;
|
||||
},
|
||||
onCreatePlaylist() {
|
||||
const name = prompt(this.$t("actions.create_playlist"));
|
||||
@@ -129,19 +157,6 @@ export default {
|
||||
else this.fetchPlaylists();
|
||||
});
|
||||
},
|
||||
async createPlaylist(name) {
|
||||
let json = await this.fetchJson(this.authApiUrl() + "/user/playlists/create", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
return json;
|
||||
},
|
||||
async exportPlaylists() {
|
||||
if (!this.playlists) return;
|
||||
let json = {
|
||||
@@ -154,8 +169,8 @@ export default {
|
||||
this.download(JSON.stringify(json), "playlists.json", "application/json");
|
||||
},
|
||||
async fetchPlaylistJson(playlistId) {
|
||||
let playlist = await this.fetchJson(this.authApiUrl() + "/playlists/" + playlistId);
|
||||
let playlistJson = {
|
||||
let playlist = await this.getPlaylist(playlistId);
|
||||
return {
|
||||
name: playlist.name,
|
||||
// possible other types: history, watch later, ...
|
||||
type: "playlist",
|
||||
@@ -164,57 +179,69 @@ export default {
|
||||
// list of the videos, starting with "https://youtube.com" to clarify that those are YT videos
|
||||
videos: playlist.relatedStreams.map(stream => "https://youtube.com" + stream.url),
|
||||
};
|
||||
return playlistJson;
|
||||
},
|
||||
async importPlaylists() {
|
||||
const file = this.$refs.fileSelector.files[0];
|
||||
const files = this.$refs.fileSelector.files;
|
||||
for (let file of files) {
|
||||
await this.importPlaylistFile(file);
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
async importPlaylistFile(file) {
|
||||
let text = await file.text();
|
||||
let tasks = [];
|
||||
// list of playlists exported from Piped
|
||||
if (text.includes("playlists")) {
|
||||
if (file.name.slice(-4).toLowerCase() == ".csv") {
|
||||
const lines = text.split("\n");
|
||||
const playlistName = lines[1].split(",")[4];
|
||||
const playlist = {
|
||||
name: playlistName != "" ? playlistName : new Date().toJSON(),
|
||||
videos: lines
|
||||
.slice(4, lines.length)
|
||||
.filter(line => line != "")
|
||||
.slice(1)
|
||||
.map(line => `https://youtube.com/watch?v=${line.split(",")[0]}`),
|
||||
};
|
||||
tasks.push(this.createPlaylistWithVideos(playlist));
|
||||
} else if (text.includes('"Piped"')) {
|
||||
// CSV from Google Takeout
|
||||
let playlists = JSON.parse(text).playlists;
|
||||
if (!playlists.length) {
|
||||
alert(this.$t("actions.no_valid_playlists"));
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < playlists.length; i++) {
|
||||
tasks.push(this.createPlaylistWithVideos(playlists[i]));
|
||||
for (let playlist of playlists) {
|
||||
tasks.push(this.createPlaylistWithVideos(playlist));
|
||||
}
|
||||
// CSV from Google Takeout
|
||||
} else if (file.name.slice(-4).toLowerCase() == ".csv") {
|
||||
const lines = text.split("\n");
|
||||
const playlist = {
|
||||
name: lines[1].split(",")[4],
|
||||
videos: lines
|
||||
.slice(4, lines.length)
|
||||
.filter(line => line != "")
|
||||
.map(line => `https://youtube.com/watch?v=${line.split(",")[0]}`),
|
||||
};
|
||||
tasks.push(this.createPlaylistWithVideos(playlist));
|
||||
} else {
|
||||
alert(this.$t("actions.no_valid_playlists"));
|
||||
return;
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
window.location.reload();
|
||||
},
|
||||
async createPlaylistWithVideos(playlist) {
|
||||
let newPlaylist = await this.createPlaylist(playlist.name);
|
||||
let videoIds = playlist.videos.map(url => url.substr(-11));
|
||||
await this.addVideosToPlaylist(newPlaylist.playlistId, videoIds);
|
||||
},
|
||||
async addVideosToPlaylist(playlistId, videoIds) {
|
||||
await this.fetchJson(this.authApiUrl() + "/user/playlists/add", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playlistId: playlistId,
|
||||
videoIds: videoIds,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
async loadPlaylistBookmarks() {
|
||||
if (!window.db) return;
|
||||
var tx = window.db.transaction("playlist_bookmarks", "readonly");
|
||||
var store = tx.objectStore("playlist_bookmarks");
|
||||
const cursorRequest = store.openCursor();
|
||||
cursorRequest.onsuccess = e => {
|
||||
const cursor = e.target.result;
|
||||
if (cursor) {
|
||||
this.bookmarks.push(cursor.value);
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
},
|
||||
async removeBookmark(index) {
|
||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||
var store = tx.objectStore("playlist_bookmarks");
|
||||
store.delete(this.bookmarks[index].playlistId);
|
||||
this.bookmarks.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<!--efy-->
|
||||
<div class="pp-pref-cards">
|
||||
<div efy_card="grid">
|
||||
<h2>Quick</h2>
|
||||
@@ -96,6 +97,342 @@
|
||||
v-t="'actions.restore_preferences'"
|
||||
@click="restorePreferences()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!--master-->
|
||||
<div class="flex">
|
||||
<button @click="$router.go(-1) || $router.push('/')">
|
||||
<font-awesome-icon icon="chevron-left" /><span v-t="'actions.back'" class="ml-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
<h1 v-t="'titles.preferences'" class="text-center font-bold" />
|
||||
<hr />
|
||||
<label for="ddlTheme" class="pref">
|
||||
<strong v-t="'actions.theme'" />
|
||||
<select id="ddlTheme" v-model="selectedTheme" class="select w-auto" @change="onChange($event)">
|
||||
<option v-t="'actions.auto'" value="auto" />
|
||||
<option v-t="'actions.dark'" value="dark" />
|
||||
<option v-t="'actions.light'" value="light" />
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="ddlLanguageSelection">
|
||||
<strong v-t="'actions.language_selection'" />
|
||||
<select id="ddlLanguageSelection" v-model="selectedLanguage" class="select w-auto" @change="onChange($event)">
|
||||
<option v-for="language in languages" :key="language.code" :value="language.code" v-text="language.name" />
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="ddlCountrySelection">
|
||||
<strong v-t="'actions.country_selection'" />
|
||||
<select id="ddlCountrySelection" v-model="countrySelected" class="select w-50" @change="onChange($event)">
|
||||
<option v-for="country in countryMap" :key="country.code" :value="country.code" v-text="country.name" />
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="ddlDefaultHomepage">
|
||||
<strong v-t="'actions.default_homepage'" />
|
||||
<select id="ddlDefaultHomepage" v-model="defaultHomepage" class="select w-auto" @change="onChange($event)">
|
||||
<option v-t="'titles.trending'" value="trending" />
|
||||
<option v-t="'titles.feed'" value="feed" />
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<h2 v-t="'titles.player'" class="text-center" />
|
||||
<label class="pref" for="chkAutoPlayVideo">
|
||||
<strong v-t="'actions.autoplay_video'" />
|
||||
<input
|
||||
id="chkAutoPlayVideo"
|
||||
v-model="autoPlayVideo"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkAutoDisplayCaptions">
|
||||
<strong v-t="'actions.auto_display_captions'" />
|
||||
<input
|
||||
id="chkAutoDisplayCaptions"
|
||||
v-model="autoDisplayCaptions"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkAutoPlayNextCountdown">
|
||||
<strong v-t="'actions.autoplay_next_countdown'" />
|
||||
<input
|
||||
id="chkAutoPlayNextCountdown"
|
||||
v-model="autoPlayNextCountdown"
|
||||
class="input w-24"
|
||||
type="number"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkAudioOnly">
|
||||
<strong v-t="'actions.audio_only'" />
|
||||
<input id="chkAudioOnly" v-model="listen" class="checkbox" type="checkbox" @change="onChange($event)" />
|
||||
</label>
|
||||
<label class="pref" for="ddlDefaultQuality">
|
||||
<strong v-t="'actions.default_quality'" />
|
||||
<select id="ddlDefaultQuality" v-model="defaultQuality" class="select w-auto" @change="onChange($event)">
|
||||
<option v-t="'actions.auto'" value="0" />
|
||||
<option v-for="resolution in resolutions" :key="resolution" :value="resolution" v-text="`${resolution}p`" />
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="txtBufferingGoal">
|
||||
<strong v-t="'actions.buffering_goal'" />
|
||||
<input
|
||||
id="txtBufferingGoal"
|
||||
v-model="bufferingGoal"
|
||||
class="input w-24"
|
||||
type="text"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkMinimizeComments">
|
||||
<strong v-t="'actions.minimize_comments_default'" />
|
||||
<input
|
||||
id="chkMinimizeComments"
|
||||
v-model="minimizeComments"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkMinimizeDescription">
|
||||
<strong v-t="'actions.minimize_description_default'" />
|
||||
<input
|
||||
id="chkMinimizeDescription"
|
||||
v-model="minimizeDescription"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkMinimizeRecommendations">
|
||||
<strong v-t="'actions.minimize_recommendations_default'" />
|
||||
<input
|
||||
id="chkMinimizeRecommendations"
|
||||
v-model="minimizeRecommendations"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkMinimizeChapters">
|
||||
<strong v-t="'actions.minimize_chapters_default'" />
|
||||
<input
|
||||
id="chkMinimizeChapters"
|
||||
v-model="minimizeChapters"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<!-- chapters layout on mobile -->
|
||||
<label class="pref lg:invisible" for="chkMinimizeChapters">
|
||||
<strong v-t="'actions.chapters_layout_mobile'" />
|
||||
|
||||
<select id="ddlDefaultHomepage" v-model="mobileChapterLayout" class="select w-auto" @change="onChange($event)">
|
||||
<option v-t="'video.chapters_horizontal'" value="Horizontal" />
|
||||
<option v-t="'video.chapters_vertical'" value="Vertical" />
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="chkShowWatchOnYouTube">
|
||||
<strong v-t="'actions.show_watch_on_youtube'" />
|
||||
<input
|
||||
id="chkShowWatchOnYouTube"
|
||||
v-model="showWatchOnYouTube"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkShowSearchSuggestions">
|
||||
<strong v-t="'actions.show_search_suggestions'" />
|
||||
<input
|
||||
id="chkShowSearchSuggestions"
|
||||
v-model="searchSuggestions"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkStoreSearchHistory">
|
||||
<strong v-t="'actions.store_search_history'" />
|
||||
<input
|
||||
id="chkStoreSearchHistory"
|
||||
v-model="searchHistory"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="chkStoreWatchHistory">
|
||||
<strong v-t="'actions.store_watch_history'" />
|
||||
<input
|
||||
id="chkStoreWatchHistory"
|
||||
v-model="watchHistory"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="watchHistory" class="pref" for="chkHideWatched">
|
||||
<strong v-t="'actions.hide_watched'" />
|
||||
<input id="chkHideWatched" v-model="hideWatched" class="checkbox" type="checkbox" @change="onChange($event)" />
|
||||
</label>
|
||||
<label class="pref" for="ddlEnabledCodecs">
|
||||
<strong v-t="'actions.enabled_codecs'" />
|
||||
<select
|
||||
id="ddlEnabledCodecs"
|
||||
v-model="enabledCodecs"
|
||||
class="select h-auto w-auto"
|
||||
multiple
|
||||
@change="onChange($event)"
|
||||
>
|
||||
<option value="av1">AV1</option>
|
||||
<option value="vp9">VP9</option>
|
||||
<option value="avc">AVC (h.264)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="chkDisableLBRY">
|
||||
<strong v-t="'actions.disable_lbry'" />
|
||||
<input id="chkDisableLBRY" v-model="disableLBRY" class="checkbox" type="checkbox" @change="onChange($event)" />
|
||||
</label>
|
||||
<label class="pref" for="chkEnableLBRYProxy">
|
||||
<strong v-t="'actions.enable_lbry_proxy'" />
|
||||
<input
|
||||
id="chkEnableLBRYProxy"
|
||||
v-model="proxyLBRY"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h2 class="text-center">SponsorBlock</h2>
|
||||
<p class="text-center">
|
||||
<span v-t="'actions.uses_api_from'" /><a class="link" href="https://sponsor.ajay.app/">sponsor.ajay.app</a>
|
||||
</p>
|
||||
<label class="pref" for="chkEnableSponsorblock">
|
||||
<strong v-t="'actions.enable_sponsorblock'" />
|
||||
<input
|
||||
id="chkEnableSponsorblock"
|
||||
v-model="sponsorBlock"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="sponsorBlock">
|
||||
<label v-for="[name, item] in skipOptions" :key="name" class="pref" :for="'ddlSkip_' + name">
|
||||
<strong v-t="item.label" />
|
||||
<select :id="'ddlSkip_' + name" v-model="item.value" class="select w-auto" @change="onChange($event)">
|
||||
<option v-t="'actions.no'" value="no" />
|
||||
<option v-t="'actions.skip_button_only'" value="button" />
|
||||
<option v-t="'actions.skip_automatically'" value="auto" />
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="chkShowMarkers">
|
||||
<strong v-t="'actions.show_markers'" />
|
||||
<input
|
||||
id="chkShowMarkers"
|
||||
v-model="showMarkers"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<label class="pref" for="txtMinSegmentLength">
|
||||
<strong v-t="'actions.min_segment_length'" />
|
||||
<input
|
||||
id="txtMinSegmentLength"
|
||||
v-model="minSegmentLength"
|
||||
class="input w-24"
|
||||
type="text"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2 v-t="'titles.dearrow'" class="text-center" />
|
||||
<p class="text-center">
|
||||
<span v-t="'actions.uses_api_from'" /><a class="link" href="https://sponsor.ajay.app/">sponsor.ajay.app</a>
|
||||
</p>
|
||||
<label class="pref" for="chkDeArrow">
|
||||
<strong v-t="'actions.enable_dearrow'" />
|
||||
<input id="chkDeArrow" v-model="dearrow" class="checkbox" type="checkbox" @change="onChange($event)" />
|
||||
</label>
|
||||
|
||||
<h2 v-t="'titles.instance'" class="text-center" />
|
||||
<label class="pref" for="ddlInstanceSelection">
|
||||
<strong v-text="`${$t('actions.instance_selection')}:`" />
|
||||
<select id="ddlInstanceSelection" v-model="selectedInstance" class="select w-auto" @change="onChange($event)">
|
||||
<option
|
||||
v-for="instance in instances"
|
||||
:key="instance.name"
|
||||
:value="instance.api_url"
|
||||
v-text="instance.name"
|
||||
/>
|
||||
</select>
|
||||
</label>
|
||||
<label class="pref" for="chkAuthInstance">
|
||||
<strong v-text="`${$t('actions.different_auth_instance')}:`" />
|
||||
<input
|
||||
id="chkAuthInstance"
|
||||
v-model="authInstance"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
@change="onChange($event)"
|
||||
/>
|
||||
</label>
|
||||
<template v-if="authInstance">
|
||||
<label class="pref" for="ddlAuthInstanceSelection">
|
||||
<strong v-text="`${$t('actions.instance_auth_selection')}:`" />
|
||||
<select
|
||||
id="ddlAuthInstanceSelection"
|
||||
v-model="selectedAuthInstance"
|
||||
class="select w-auto"
|
||||
@change="onChange($event)"
|
||||
>
|
||||
<option
|
||||
v-for="instance in instances"
|
||||
:key="instance.name"
|
||||
:value="instance.api_url"
|
||||
v-text="instance.name"
|
||||
/>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<br />
|
||||
|
||||
<!-- options that are visible only when logged in -->
|
||||
<div v-if="authenticated">
|
||||
<h2 v-t="'titles.account'" class="text-center"></h2>
|
||||
<label class="pref" for="txtDeleteAccountPassword">
|
||||
<strong v-t="'actions.delete_account'" />
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="txtDeleteAccountPassword"
|
||||
ref="txtDeleteAccountPassword"
|
||||
v-model="password"
|
||||
:placeholder="$t('login.password')"
|
||||
:aria-label="$t('login.password')"
|
||||
class="input mr-2 w-auto"
|
||||
type="password"
|
||||
@keyup.enter="deleteAccount"
|
||||
/>
|
||||
<a v-t="'actions.delete_account'" class="btn w-auto" @click="deleteAccount" />
|
||||
</div>
|
||||
</label>
|
||||
<div class="pref">
|
||||
<a v-t="'actions.logout'" class="btn w-auto" @click="logout" />
|
||||
<a
|
||||
v-t="'actions.invalidate_session'"
|
||||
class="btn w-auto"
|
||||
style="margin-left: 0.5em"
|
||||
@click="invalidateSession"
|
||||
/>
|
||||
<!--masterend-->
|
||||
<input class="hidden" id="fileSelector" ref="fileSelector" type="file" @change="restorePreferences()" />
|
||||
|
||||
<!-- options that are visible only when logged in -->
|
||||
@@ -406,7 +743,7 @@
|
||||
<th v-t="'preferences.instance_locations'" />
|
||||
<th v-t="'preferences.has_cdn'" />
|
||||
<th v-t="'preferences.registered_users'" />
|
||||
<th class="lt-md:hidden" v-t="'preferences.version'" />
|
||||
<th v-t="'preferences.version'" class="lt-md:hidden" />
|
||||
<th v-t="'preferences.up_to_date'" />
|
||||
<th v-t="'preferences.ssl_score'" />
|
||||
</tr>
|
||||
@@ -420,35 +757,61 @@
|
||||
<td class="lt-md:hidden" v-text="instance.version" />
|
||||
<td v-text="`${instance.up_to_date ? '✅' : '❌'}`" />
|
||||
<td>
|
||||
<a :href="sslScore(instance.api_url)" target="_blank" v-t="'actions.view_ssl_score'" />
|
||||
<a v-t="'actions.view_ssl_score'" :href="sslScore(instance.api_url)" target="_blank" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!--master-->
|
||||
<br />
|
||||
<p v-t="'info.preferences_note'" />
|
||||
<br />
|
||||
<button v-t="'actions.reset_preferences'" class="btn" @click="showConfirmResetPrefsDialog = true" />
|
||||
<button v-t="'actions.backup_preferences'" class="btn mx-4" @click="backupPreferences()" />
|
||||
<label v-t="'actions.restore_preferences'" for="fileSelector" class="btn" @click="restorePreferences()" />
|
||||
<input id="fileSelector" ref="fileSelector" class="hidden" type="file" @change="restorePreferences()" />
|
||||
<ConfirmModal
|
||||
v-if="showConfirmResetPrefsDialog"
|
||||
:message="$t('actions.confirm_reset_preferences')"
|
||||
@close="showConfirmResetPrefsDialog = false"
|
||||
@confirm="resetPreferences()"
|
||||
/>
|
||||
<!--masterend-->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CountryMap from "@/utils/CountryMaps/en.json";
|
||||
import ConfirmModal from "./ConfirmModal.vue";
|
||||
export default {
|
||||
components: {
|
||||
ConfirmModal,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mobileChapterLayout: "Vertical",
|
||||
selectedInstance: null,
|
||||
authInstance: false,
|
||||
selectedAuthInstance: null,
|
||||
instances: [],
|
||||
sponsorBlock: true,
|
||||
skipSponsor: true,
|
||||
skipIntro: false,
|
||||
skipOutro: false,
|
||||
skipPreview: false,
|
||||
skipInteraction: true,
|
||||
skipSelfPromo: true,
|
||||
skipMusicOffTopic: true,
|
||||
skipHighlight: false,
|
||||
skipFiller: false,
|
||||
skipOptions: new Map([
|
||||
["sponsor", { value: "auto", label: "actions.skip_sponsors" }],
|
||||
["intro", { value: "no", label: "actions.skip_intro" }],
|
||||
["outro", { value: "no", label: "actions.skip_outro" }],
|
||||
["preview", { value: "no", label: "actions.skip_preview" }],
|
||||
["interaction", { value: "auto", label: "actions.skip_interaction" }],
|
||||
["selfpromo", { value: "auto", label: "actions.skip_self_promo" }],
|
||||
["music_offtopic", { value: "auto", label: "actions.skip_non_music" }],
|
||||
["poi_highlight", { value: "no", label: "actions.skip_highlight" }],
|
||||
["filler", { value: "no", label: "actions.skip_filler_tangent" }],
|
||||
]),
|
||||
showMarkers: true,
|
||||
minSegmentLength: 0,
|
||||
dearrow: false,
|
||||
selectedTheme: "dark",
|
||||
autoPlayVideo: true,
|
||||
autoDisplayCaptions: false,
|
||||
autoPlayNextCountdown: 5,
|
||||
listen: false,
|
||||
resolutions: [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320],
|
||||
defaultQuality: 0,
|
||||
@@ -461,6 +824,7 @@ export default {
|
||||
minimizeRecommendations: false,
|
||||
minimizeChapters: false,
|
||||
showWatchOnYouTube: false,
|
||||
searchSuggestions: true,
|
||||
watchHistory: false,
|
||||
searchHistory: false,
|
||||
hideWatched: false,
|
||||
@@ -468,6 +832,7 @@ export default {
|
||||
languages: [
|
||||
{ code: "ar", name: "Arabic" },
|
||||
{ code: "az", name: "Azərbaycan" },
|
||||
{ code: "bg", name: "Български" },
|
||||
{ code: "bn", name: "বাংলা" },
|
||||
{ code: "bs", name: "Bosanski" },
|
||||
{ code: "ca", name: "Català" },
|
||||
@@ -495,6 +860,7 @@ export default {
|
||||
{ code: "ml", name: "മലയാളം" },
|
||||
{ code: "nb_NO", name: "Norwegian Bokmål" },
|
||||
{ code: "nl", name: "Nederlands" },
|
||||
{ code: "oc", name: "Occitan" },
|
||||
{ code: "or", name: "ଓଡ଼ିଆ" },
|
||||
{ code: "pl", name: "Polski" },
|
||||
{ code: "pt", name: "Português" },
|
||||
@@ -502,6 +868,7 @@ export default {
|
||||
{ code: "pt_BR", name: "Português (Brasil)" },
|
||||
{ code: "ro", name: "Română" },
|
||||
{ code: "ru", name: "Русский" },
|
||||
{ code: "si", name: "සිංහල" },
|
||||
{ code: "sr", name: "Српски" },
|
||||
{ code: "sv", name: "Svenska" },
|
||||
{ code: "ta", name: "தமிழ்" },
|
||||
@@ -516,6 +883,7 @@ export default {
|
||||
disableLBRY: false,
|
||||
proxyLBRY: false,
|
||||
password: null,
|
||||
showConfirmResetPrefsDialog: false,
|
||||
};
|
||||
},
|
||||
activated() {
|
||||
@@ -526,7 +894,7 @@ export default {
|
||||
|
||||
this.fetchJson("https://piped-instances.kavin.rocks/").then(resp => {
|
||||
this.instances = resp;
|
||||
if (this.instances.filter(instance => instance.api_url == this.apiUrl()).length == 0)
|
||||
if (!this.instances.some(instance => instance.api_url == this.apiUrl()))
|
||||
this.instances.push({
|
||||
name: "Custom Instance",
|
||||
api_url: this.apiUrl(),
|
||||
@@ -541,57 +909,30 @@ export default {
|
||||
this.selectedAuthInstance = this.getPreferenceString("auth_instance_url", this.selectedInstance);
|
||||
|
||||
this.sponsorBlock = this.getPreferenceBoolean("sponsorblock", true);
|
||||
if (localStorage.getItem("selectedSkip") !== null) {
|
||||
var skipList = localStorage.getItem("selectedSkip").split(",");
|
||||
this.skipSponsor =
|
||||
this.skipIntro =
|
||||
this.skipOutro =
|
||||
this.skipPreview =
|
||||
this.skipInteraction =
|
||||
this.skipSelfPromo =
|
||||
this.skipMusicOffTopic =
|
||||
this.skipHighlight =
|
||||
this.skipFiller =
|
||||
false;
|
||||
var skipOptions, skipList;
|
||||
if ((skipOptions = this.getPreferenceJSON("skipOptions")) !== undefined) {
|
||||
Object.entries(skipOptions).forEach(([key, value]) => {
|
||||
var opt = this.skipOptions.get(key);
|
||||
if (opt !== undefined) opt.value = value;
|
||||
else console.log("Unknown sponsor type: " + key);
|
||||
});
|
||||
} else if ((skipList = this.getPreferenceString("selectedSkip")) !== undefined) {
|
||||
skipList = skipList.split(",");
|
||||
this.skipOptions.forEach(opt => (opt.value = "no"));
|
||||
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;
|
||||
case "poi_highlight":
|
||||
this.skipHighlight = true;
|
||||
break;
|
||||
case "filler":
|
||||
this.skipFiller = true;
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown sponsor type: " + skip);
|
||||
break;
|
||||
}
|
||||
var opt = this.skipOptions.get(skip);
|
||||
if (opt !== undefined) opt.value = "auto";
|
||||
else console.log("Unknown sponsor type: " + skip);
|
||||
});
|
||||
}
|
||||
|
||||
this.showMarkers = this.getPreferenceBoolean("showMarkers", true);
|
||||
this.minSegmentLength = Math.max(this.getPreferenceNumber("minSegmentLength", 0), 0);
|
||||
this.dearrow = this.getPreferenceBoolean("dearrow", false);
|
||||
this.selectedTheme = this.getPreferenceString("theme", "dark");
|
||||
this.autoPlayVideo = this.getPreferenceBoolean("playerAutoPlay", true);
|
||||
this.autoDisplayCaptions = this.getPreferenceBoolean("autoDisplayCaptions", false);
|
||||
this.autoPlayNextCountdown = this.getPreferenceNumber("autoPlayNextCountdown", 5);
|
||||
this.listen = this.getPreferenceBoolean("listen", false);
|
||||
this.defaultQuality = Number(localStorage.getItem("quality"));
|
||||
this.bufferingGoal = Math.max(Number(localStorage.getItem("bufferGoal")), 10);
|
||||
@@ -602,6 +943,7 @@ export default {
|
||||
this.minimizeRecommendations = this.getPreferenceBoolean("minimizeRecommendations", false);
|
||||
this.minimizeChapters = this.getPreferenceBoolean("minimizeChapters", false);
|
||||
this.showWatchOnYouTube = this.getPreferenceBoolean("showWatchOnYouTube", false);
|
||||
this.searchSuggestions = this.getPreferenceBoolean("searchSuggestions", true);
|
||||
this.watchHistory = this.getPreferenceBoolean("watchHistory", false);
|
||||
this.searchHistory = this.getPreferenceBoolean("searchHistory", false);
|
||||
this.selectedLanguage = this.getPreferenceString("hl", await this.defaultLanguage);
|
||||
@@ -609,6 +951,7 @@ export default {
|
||||
this.disableLBRY = this.getPreferenceBoolean("disableLBRY", false);
|
||||
this.proxyLBRY = this.getPreferenceBoolean("proxyLBRY", false);
|
||||
this.hideWatched = this.getPreferenceBoolean("hideWatched", false);
|
||||
this.mobileChapterLayout = this.getPreferenceString("mobileChapterLayout", "Vertical");
|
||||
if (this.selectedLanguage != "en") {
|
||||
try {
|
||||
this.CountryMap = await import(`../utils/CountryMaps/${this.selectedLanguage}.json`).then(
|
||||
@@ -638,21 +981,19 @@ export default {
|
||||
localStorage.setItem("auth_instance_url", this.selectedAuthInstance);
|
||||
localStorage.setItem("sponsorblock", this.sponsorBlock);
|
||||
|
||||
var 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");
|
||||
if (this.skipHighlight) sponsorSelected.push("poi_highlight");
|
||||
if (this.skipFiller) sponsorSelected.push("filler");
|
||||
localStorage.setItem("selectedSkip", sponsorSelected);
|
||||
var skipOptions = {};
|
||||
this.skipOptions.forEach((v, k) => (skipOptions[k] = v.value));
|
||||
localStorage.setItem("skipOptions", JSON.stringify(skipOptions));
|
||||
|
||||
localStorage.setItem("showMarkers", this.showMarkers);
|
||||
localStorage.setItem("minSegmentLength", this.minSegmentLength);
|
||||
|
||||
localStorage.setItem("dearrow", this.dearrow);
|
||||
|
||||
localStorage.setItem("theme", this.selectedTheme);
|
||||
localStorage.setItem("playerAutoPlay", this.autoPlayVideo);
|
||||
localStorage.setItem("autoDisplayCaptions", this.autoDisplayCaptions);
|
||||
localStorage.setItem("autoPlayNextCountdown", this.autoPlayNextCountdown);
|
||||
localStorage.setItem("listen", this.listen);
|
||||
localStorage.setItem("quality", this.defaultQuality);
|
||||
localStorage.setItem("bufferGoal", this.bufferingGoal);
|
||||
@@ -663,6 +1004,7 @@ export default {
|
||||
localStorage.setItem("minimizeRecommendations", this.minimizeRecommendations);
|
||||
localStorage.setItem("minimizeChapters", this.minimizeChapters);
|
||||
localStorage.setItem("showWatchOnYouTube", this.showWatchOnYouTube);
|
||||
localStorage.setItem("searchSuggestions", this.searchSuggestions);
|
||||
localStorage.setItem("watchHistory", this.watchHistory);
|
||||
localStorage.setItem("searchHistory", this.searchHistory);
|
||||
if (!this.searchHistory) localStorage.removeItem("search_history");
|
||||
@@ -671,6 +1013,7 @@ export default {
|
||||
localStorage.setItem("disableLBRY", this.disableLBRY);
|
||||
localStorage.setItem("proxyLBRY", this.proxyLBRY);
|
||||
localStorage.setItem("hideWatched", this.hideWatched);
|
||||
localStorage.setItem("mobileChapterLayout", this.mobileChapterLayout);
|
||||
|
||||
if (shouldReload) window.location.reload();
|
||||
}
|
||||
@@ -700,7 +1043,7 @@ export default {
|
||||
window.location = "/";
|
||||
},
|
||||
resetPreferences() {
|
||||
if (!confirm(this.$t("actions.confirm_reset_preferences"))) return;
|
||||
this.showConfirmResetPrefsDialog = false;
|
||||
// clear the local storage
|
||||
localStorage.clear();
|
||||
// redirect to the home page
|
||||
@@ -740,4 +1083,10 @@ export default {
|
||||
.pref {
|
||||
@apply flex justify-between items-center;
|
||||
}
|
||||
.pref:nth-child(odd) {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
.dark .pref:nth-child(odd) {
|
||||
@apply bg-dark-800;
|
||||
}
|
||||
</style>
|
||||
|
||||
30
src/components/QrCode.vue
Normal file
30
src/components/QrCode.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<canvas ref="qrCodeCanvas" style="border-radius: var(--efy_radius)" />
|
||||
</template>
|
||||
<script>
|
||||
import QRCode from "qrcode";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
text() {
|
||||
this.generateQrCode();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.generateQrCode();
|
||||
},
|
||||
methods: {
|
||||
generateQrCode() {
|
||||
QRCode.toCanvas(this.$refs.qrCodeCanvas, this.text, error => {
|
||||
if (error) console.error(error);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
114
src/components/RegisterPage.vue
Normal file
114
src/components/RegisterPage.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<h1 v-t="'titles.register'" class="my-4 text-center font-bold" />
|
||||
<hr />
|
||||
<div class="flex justify-center text-center">
|
||||
<form class="items-center px-3 children:pb-3">
|
||||
<div>
|
||||
<input
|
||||
v-model="username"
|
||||
class="input w-full"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:placeholder="$t('login.username')"
|
||||
:aria-label="$t('login.username')"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<input
|
||||
v-model="password"
|
||||
class="input w-full"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="password"
|
||||
:placeholder="$t('login.password')"
|
||||
:aria-label="$t('login.password')"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
<button type="button" class="btn ml-2" @click="showPassword = !showPassword">
|
||||
<div class="i-fa6-solid:eye" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
class="input w-full"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="password"
|
||||
:placeholder="$t('login.password_confirm')"
|
||||
:aria-label="$t('login.password_confirm')"
|
||||
@keyup.enter="register"
|
||||
/>
|
||||
<button type="button" class="btn ml-2" @click="showConfirmPassword = !showConfirmPassword">
|
||||
<div class="i-fa6-solid:eye" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<a v-t="'titles.register'" class="btn w-auto" @click="register" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<ConfirmModal
|
||||
v-if="showUnsecureRegisterDialog"
|
||||
:message="$t('info.register_no_email_note')"
|
||||
@close="showUnsecureRegisterDialog = false"
|
||||
@confirm="
|
||||
forceUnsecureRegister = true;
|
||||
showUnsecureRegisterDialog = false;
|
||||
register();
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isEmail } from "../utils/Misc.js";
|
||||
import ConfirmModal from "./ConfirmModal.vue";
|
||||
|
||||
export default {
|
||||
components: { ConfirmModal },
|
||||
data() {
|
||||
return {
|
||||
username: null,
|
||||
password: null,
|
||||
passwordConfirm: null,
|
||||
showPassword: false,
|
||||
showConfirmPassword: false,
|
||||
showUnsecureRegisterDialog: false,
|
||||
forceUnsecureRegister: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
//TODO: Add Server Side check
|
||||
if (this.getAuthToken()) {
|
||||
this.$router.push("/");
|
||||
}
|
||||
},
|
||||
activated() {
|
||||
document.title = "Register - Piped";
|
||||
},
|
||||
methods: {
|
||||
register() {
|
||||
if (!this.username || !this.password) return;
|
||||
if (this.password != this.passwordConfirm) {
|
||||
alert(this.$t("login.passwords_incorrect"));
|
||||
return;
|
||||
}
|
||||
if (isEmail(this.username) && !this.forceUnsecureRegister) {
|
||||
this.showUnsecureRegisterDialog = true;
|
||||
return;
|
||||
}
|
||||
this.fetchJson(this.authApiUrl() + "/register", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
}),
|
||||
}).then(resp => {
|
||||
if (resp.token) {
|
||||
this.setPreference("authToken" + this.hashCode(this.authApiUrl()), resp.token);
|
||||
window.location = "/"; // done to bypass cache
|
||||
} else alert(resp.error);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<h1 class="text-center my-2" v-text="$route.query.search_query" />
|
||||
<h1 class="my-2 text-center" v-text="$route.query.search_query" />
|
||||
|
||||
<label for="ddlSearchFilters" class="mr-2">
|
||||
<strong v-text="`${$t('actions.filter')}:`" />
|
||||
</label>
|
||||
<select id="ddlSearchFilters" v-model="selectedFilter" default="all" class="select w-auto" @change="updateFilter()">
|
||||
<option v-for="filter in availableFilters" :key="filter" :value="filter" v-t="`search.${filter}`" />
|
||||
<option v-for="filter in availableFilters" :key="filter" v-t="`search.${filter}`" :value="filter" />
|
||||
</select>
|
||||
|
||||
<hr />
|
||||
@@ -18,19 +18,21 @@
|
||||
</i18n-t>
|
||||
</div>
|
||||
|
||||
<div v-if="results" class="video-grid">
|
||||
<LoadingIndicatorPage :show-content="results != null && results.items?.length" class="video-grid">
|
||||
<template v-for="result in results.items" :key="result.url">
|
||||
<ContentItem :item="result" height="94" width="168" />
|
||||
</template>
|
||||
</div>
|
||||
</LoadingIndicatorPage>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ContentItem from "./ContentItem.vue";
|
||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContentItem,
|
||||
LoadingIndicatorPage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -44,6 +46,7 @@ export default {
|
||||
"music_videos",
|
||||
"music_albums",
|
||||
"music_playlists",
|
||||
"music_artists",
|
||||
],
|
||||
selectedFilter: this.$route.query.filter ?? "all",
|
||||
};
|
||||
@@ -72,7 +75,10 @@ export default {
|
||||
},
|
||||
async updateResults() {
|
||||
document.title = this.$route.query.search_query + " - Piped";
|
||||
this.results = this.fetchResults().then(json => (this.results = json));
|
||||
this.results = this.fetchResults().then(json => {
|
||||
this.results = json;
|
||||
this.updateWatched(this.results.items);
|
||||
});
|
||||
},
|
||||
updateFilter() {
|
||||
this.$router.replace({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute suggestions-container">
|
||||
<div class="suggestions-container absolute">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(suggestion, i) in searchSuggestions"
|
||||
@@ -50,12 +50,16 @@ export default {
|
||||
if (!this.searchText) {
|
||||
if (this.getPreferenceBoolean("searchHistory", false))
|
||||
this.searchSuggestions = JSON.parse(localStorage.getItem("search_history")) ?? [];
|
||||
} else if (this.getPreferenceBoolean("searchSuggestions", true)) {
|
||||
this.searchSuggestions =
|
||||
(
|
||||
await this.fetchJson(this.apiUrl() + "/opensearch/suggestions", {
|
||||
query: this.searchText,
|
||||
})
|
||||
)?.[1] ?? [];
|
||||
} else {
|
||||
this.searchSuggestions = (
|
||||
await this.fetchJson(this.apiUrl() + "/opensearch/suggestions", {
|
||||
query: this.searchText,
|
||||
})
|
||||
)?.[1];
|
||||
this.searchSuggestions = [];
|
||||
return;
|
||||
}
|
||||
this.searchSuggestions.unshift(this.searchText);
|
||||
this.setSelected(0);
|
||||
|
||||
@@ -3,34 +3,45 @@
|
||||
<h4 v-t="'actions.share'" />
|
||||
<div class="flex justify-between mt-2 mb-2">
|
||||
<label v-t="'actions.piped_link'" />
|
||||
<input type="checkbox" v-model="pipedLink" @change="onChange" />
|
||||
<input v-model="pipedLink" type="checkbox" @change="onChange" />
|
||||
</div>
|
||||
<div v-if="this.hasPlaylist" class="flex justify-between">
|
||||
<div v-if="hasPlaylist" class="flex justify-between">
|
||||
<label v-t="'actions.with_playlist'" />
|
||||
<input type="checkbox" v-model="withPlaylist" @change="onChange" />
|
||||
<input v-model="withPlaylist" type="checkbox" @change="onChange" />
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<label v-t="'actions.with_timecode'" for="withTimeCode" />
|
||||
<input id="withTimeCode" type="checkbox" v-model="withTimeCode" @change="onChange" />
|
||||
<input id="withTimeCode" v-model="withTimeCode" type="checkbox" @change="onChange" />
|
||||
</div>
|
||||
<div v-if="this.withTimeCode" class="flex justify-between mt-2" style="align-items: center">
|
||||
<div v-if="withTimeCode" class="flex justify-between mt-2" style="align-items: center">
|
||||
<label v-t="'actions.time_code'" />
|
||||
<input class="input w-300 mb-0rem" type="text" v-model="timeStamp" />
|
||||
<input v-model="timeStamp" class="input w-12" type="text" @change="onChange" />
|
||||
</div>
|
||||
<a :href="generatedLink" target="_blank">
|
||||
<h6 class="mb-2 mt-2" v-text="generatedLink" />
|
||||
</a>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button class="btn" style="margin-right: 15rem" v-t="'actions.follow_link'" @click="followLink()" />
|
||||
<button class="btn" v-t="'actions.copy_link'" @click="copyLink()" />
|
||||
<QrCode v-if="showQrCode" :text="generatedLink" />
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button v-t="'actions.generate_qrcode'" class="btn" @click="showQrCode = !showQrCode" />
|
||||
<button v-t="'actions.follow_link'" class="btn ml-3" @click="followLink()" />
|
||||
<button v-t="'actions.copy_link'" class="btn ml-3" @click="copyLink()" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
const QrCode = defineAsyncComponent(() => import("./QrCode.vue"));
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import ModalComponent from "./ModalComponent.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ModalComponent,
|
||||
},
|
||||
props: {
|
||||
videoId: {
|
||||
type: String,
|
||||
@@ -42,14 +53,13 @@ export default {
|
||||
},
|
||||
playlistId: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
playlistIndex: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ModalComponent,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
withTimeCode: true,
|
||||
@@ -57,8 +67,23 @@ export default {
|
||||
withPlaylist: true,
|
||||
timeStamp: null,
|
||||
hasPlaylist: false,
|
||||
showQrCode: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
generatedLink() {
|
||||
var baseUrl = this.pipedLink
|
||||
? window.location.origin + "/watch?v=" + this.videoId
|
||||
: "https://youtu.be/" + this.videoId;
|
||||
var url = new URL(baseUrl);
|
||||
if (this.withTimeCode && this.timeStamp > 0) url.searchParams.append("t", this.timeStamp);
|
||||
if (this.hasPlaylist && this.withPlaylist) {
|
||||
url.searchParams.append("list", this.playlistId);
|
||||
url.searchParams.append("index", this.playlistIndex);
|
||||
}
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.timeStamp = parseInt(this.currentTime);
|
||||
this.withTimeCode = this.getPreferenceBoolean("shareWithTimeCode", true);
|
||||
@@ -87,19 +112,5 @@ export default {
|
||||
this.setPreference("shareWithPlaylist", this.withPlaylist, true);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
generatedLink() {
|
||||
var baseUrl = this.pipedLink
|
||||
? window.location.origin + "/watch?v=" + this.videoId
|
||||
: "https://youtu.be/" + this.videoId;
|
||||
var url = new URL(baseUrl);
|
||||
if (this.withTimeCode && this.timeStamp > 0) url.searchParams.append("t", this.timeStamp);
|
||||
if (this.hasPlaylist && this.withPlaylist) {
|
||||
url.searchParams.append("list", this.playlistId);
|
||||
url.searchParams.append("index", this.playlistIndex);
|
||||
}
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<label for="ddlSortBy" v-t="'actions.sort_by'" class="mr-2" />
|
||||
<label v-t="'actions.sort_by'" for="ddlSortBy" class="mr-2" />
|
||||
<select id="ddlSortBy" v-model="selectedSort" class="select w-auto m-0">
|
||||
<option v-for="(value, key) in options" v-t="`actions.${key}`" :key="key" :value="value" />
|
||||
<option v-for="(value, key) in options" :key="key" v-t="`actions.${key}`" :value="value" />
|
||||
</select>
|
||||
</template>
|
||||
|
||||
@@ -18,7 +18,10 @@ const options = {
|
||||
const selectedSort = ref("descending");
|
||||
|
||||
const props = defineProps({
|
||||
byKey: String,
|
||||
byKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["apply"]);
|
||||
|
||||
@@ -4,26 +4,77 @@
|
||||
<button class="btn mr-2">
|
||||
<router-link to="/import" v-t="'actions.import_from_json'" />
|
||||
</button>
|
||||
<button class="btn" @click="exportHandler" v-t="'actions.export_to_json'" />
|
||||
<button v-t="'actions.export_to_json'" class="btn" @click="exportHandler" />
|
||||
</div>
|
||||
<i18n-t keypath="subscriptions.subscribed_channels_count">{{ subscriptions.length }}</i18n-t>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="w-full flex flex-wrap">
|
||||
<button
|
||||
v-for="group in channelGroups"
|
||||
:key="group.groupName"
|
||||
class="btn mx-1 w-max"
|
||||
:class="{ selected: selectedGroup === group }"
|
||||
@click="selectGroup(group)"
|
||||
>
|
||||
<span v-text="group.groupName !== '' ? group.groupName : $t('video.all')" />
|
||||
<div v-if="group.groupName != '' && selectedGroup == group">
|
||||
<font-awesome-icon class="mx-2" icon="edit" @click="showEditGroupModal = true" />
|
||||
<font-awesome-icon class="mx-2" icon="circle-minus" @click="deleteGroup(group)" />
|
||||
</div>
|
||||
</button>
|
||||
<button class="btn mx-1">
|
||||
<font-awesome-icon icon="circle-plus" @click="showCreateGroupModal = true" />
|
||||
</button>
|
||||
</div>
|
||||
<br />
|
||||
<hr />
|
||||
<!-- Subscriptions card list -->
|
||||
<div class="pp-subs-cards">
|
||||
<!-- channel info card -->
|
||||
<div efy_card class="pp-subs-card" v-for="subscription in subscriptions" :key="subscription.url">
|
||||
<div v-for="subscription in filteredSubscriptions" :key="subscription.url" efy_card class="pp-subs-card">
|
||||
<router-link :to="subscription.url" class="pp-import-channel flex font-bold">
|
||||
<img :src="subscription.avatar" width="48" height="48" />
|
||||
<span class="mx-2" v-text="subscription.name" />
|
||||
</router-link>
|
||||
<!-- (un)subscribe btn -->
|
||||
<button
|
||||
@click="handleButton(subscription)"
|
||||
v-t="`actions.${subscription.subscribed ? 'unsubscribe' : 'subscribe'}`"
|
||||
class="btn mt-2 w-full"
|
||||
@click="handleButton(subscription)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<ModalComponent v-if="showCreateGroupModal" @close="showCreateGroupModal = !showCreateGroupModal">
|
||||
<h2 v-t="'actions.create_group'" />
|
||||
<div class="flex flex-col">
|
||||
<input v-model="newGroupName" class="input my-4" type="text" :placeholder="$t('actions.group_name')" />
|
||||
<button v-t="'actions.create_group'" class="btn ml-auto w-max" @click="createGroup()" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
|
||||
<ModalComponent v-if="showEditGroupModal" @close="showEditGroupModal = false">
|
||||
<div class="mb-5 mt-3 flex justify-between">
|
||||
<input v-model="editedGroupName" type="text" class="input" />
|
||||
<button v-t="'actions.okay'" class="btn" :placeholder="$t('actions.group_name')" @click="editGroupName()" />
|
||||
</div>
|
||||
<div class="mb-2 mt-3 flex flex-col overflow-y-scroll" style="height: 300rem">
|
||||
<div v-for="subscription in subscriptions" :key="subscription.name">
|
||||
<div class="mr-3 flex justify-between">
|
||||
<span>{{ subscription.name }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="selectedGroup.channels.includes(subscription.url.substr(-11))"
|
||||
@change="checkedChange(subscription)"
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -39,20 +90,58 @@
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.selected {
|
||||
border: 0.1rem outset red;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import ModalComponent from "./ModalComponent.vue";
|
||||
|
||||
export default {
|
||||
components: { ModalComponent },
|
||||
data() {
|
||||
return {
|
||||
subscriptions: [],
|
||||
selectedGroup: {
|
||||
groupName: "",
|
||||
channels: [],
|
||||
},
|
||||
channelGroups: [],
|
||||
showCreateGroupModal: false,
|
||||
showEditGroupModal: false,
|
||||
newGroupName: "",
|
||||
editedGroupName: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredSubscriptions(_this) {
|
||||
return _this.selectedGroup.groupName == ""
|
||||
? _this.subscriptions
|
||||
: _this.subscriptions.filter(channel => _this.selectedGroup.channels.includes(channel.url.substr(-11)));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchSubscriptions().then(json => {
|
||||
this.subscriptions = json;
|
||||
this.subscriptions.forEach(subscription => (subscription.subscribed = true));
|
||||
});
|
||||
|
||||
this.channelGroups.push(this.selectedGroup);
|
||||
|
||||
if (!window.db) return;
|
||||
const cursor = this.getChannelGroupsCursor();
|
||||
cursor.onsuccess = e => {
|
||||
const cursor = e.target.result;
|
||||
if (cursor) {
|
||||
const group = cursor.value;
|
||||
this.channelGroups.push({
|
||||
groupName: group.groupName,
|
||||
channels: JSON.parse(group.channels),
|
||||
});
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
},
|
||||
activated() {
|
||||
document.title = "Subscriptions - Piped";
|
||||
@@ -91,7 +180,6 @@ export default {
|
||||
},
|
||||
exportHandler() {
|
||||
const subscriptions = [];
|
||||
|
||||
this.subscriptions.forEach(subscription => {
|
||||
subscriptions.push({
|
||||
url: "https://www.youtube.com" + subscription.url,
|
||||
@@ -99,15 +187,58 @@ export default {
|
||||
service_id: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const json = JSON.stringify({
|
||||
app_version: "",
|
||||
app_version_int: 0,
|
||||
subscriptions: subscriptions,
|
||||
});
|
||||
|
||||
this.download(json, "subscriptions.json", "application/json");
|
||||
},
|
||||
selectGroup(group) {
|
||||
this.selectedGroup = group;
|
||||
this.editedGroupName = group.groupName;
|
||||
},
|
||||
createGroup() {
|
||||
if (!this.newGroupName || this.channelGroups.some(group => group.groupName == this.newGroupName)) return;
|
||||
|
||||
const newGroup = {
|
||||
groupName: this.newGroupName,
|
||||
channels: [],
|
||||
};
|
||||
this.channelGroups.push(newGroup);
|
||||
this.createOrUpdateChannelGroup(newGroup);
|
||||
|
||||
this.newGroupName = "";
|
||||
|
||||
this.showCreateGroupModal = false;
|
||||
},
|
||||
editGroupName() {
|
||||
const oldGroupName = this.selectedGroup.groupName;
|
||||
const newGroupName = this.editedGroupName;
|
||||
|
||||
// the group mustn't yet exist and the name can't be empty
|
||||
if (!newGroupName || newGroupName == oldGroupName) return;
|
||||
if (this.channelGroups.some(group => group.groupName == newGroupName)) return;
|
||||
|
||||
// create a new group with the same info and delete the old one
|
||||
this.selectedGroup.groupName = newGroupName;
|
||||
this.createOrUpdateChannelGroup(this.selectedGroup);
|
||||
this.deleteChannelGroup(oldGroupName);
|
||||
|
||||
this.showEditGroupModal = false;
|
||||
},
|
||||
deleteGroup(group) {
|
||||
this.deleteChannelGroup(group.groupName);
|
||||
this.channelGroups = this.channelGroups.filter(g => g != group);
|
||||
this.selectedGroup = this.channelGroups[0];
|
||||
},
|
||||
checkedChange(subscription) {
|
||||
const channelId = subscription.url.substr(-11);
|
||||
this.selectedGroup.channels = this.selectedGroup.channels.includes(channelId)
|
||||
? this.selectedGroup.channels.filter(channel => channel != channelId)
|
||||
: this.selectedGroup.channels.concat(channelId);
|
||||
this.createOrUpdateChannelGroup(this.selectedGroup);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
29
src/components/ToastComponent.vue
Normal file
29
src/components/ToastComponent.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="toast">
|
||||
<slot />
|
||||
<button v-t="'actions.dismiss'" @click="dismiss" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ["dismissed"],
|
||||
methods: {
|
||||
dismiss() {
|
||||
this.$emit("dismissed");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.toast {
|
||||
@apply bg-white/80 text-black flex flex-col justify-center fixed top-12 right-12 p-4 min-w-max shadow rounded duration-200 z-9999;
|
||||
}
|
||||
.dark .toast {
|
||||
@apply bg-dark-900/80 text-white;
|
||||
}
|
||||
.toast button {
|
||||
@apply underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div class="video-grid">
|
||||
<LoadingIndicatorPage :show-content="videos.length != 0" class="video-grid">
|
||||
<VideoItem v-for="video in videos" :key="video.url" :item="video" height="118" width="210" />
|
||||
</div>
|
||||
</LoadingIndicatorPage>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||
import VideoItem from "./VideoItem.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
VideoItem,
|
||||
LoadingIndicatorPage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -23,21 +25,15 @@ export default {
|
||||
this.fetchTrending(region).then(videos => {
|
||||
this.videos = videos;
|
||||
this.updateWatched(this.videos);
|
||||
this.fetchDeArrowContent(this.videos);
|
||||
});
|
||||
},
|
||||
activated() {
|
||||
document.title = this.$t("titles.trending") + " - Piped";
|
||||
if (this.videos.length > 0) this.updateWatched(this.videos);
|
||||
if (this.$route.path == "/") {
|
||||
switch (this.getPreferenceString("homepage", "trending")) {
|
||||
case "trending":
|
||||
break;
|
||||
case "feed":
|
||||
this.$router.push("/feed");
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
let homepage = this.getHomePage(this);
|
||||
if (homepage !== undefined) this.$router.push(homepage);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-if="showVideo" class="efy_trans_filter">
|
||||
<div v-if="showVideo" class="efy_trans_filter efy_shadow_trans">
|
||||
<router-link
|
||||
class="video_item_link inline-block w-full focus:underline hover:underline"
|
||||
:to="{
|
||||
path: '/watch',
|
||||
query: {
|
||||
@@ -9,18 +10,8 @@
|
||||
...(index >= 0 && { index: index + 1 }),
|
||||
},
|
||||
}"
|
||||
class="video_item_link"
|
||||
>
|
||||
<img
|
||||
class="w-full"
|
||||
:src="item.thumbnail"
|
||||
:alt="item.title"
|
||||
:class="{ 'shorts-img': item.isShort }"
|
||||
loading="lazy"
|
||||
/>
|
||||
<p class="pp-video-card-title my-2 overflow-hidden flex link" :title="item.title" v-text="item.title" />
|
||||
</router-link>
|
||||
|
||||
<!-- EFY
|
||||
<div class="pp-video-card-buttons">
|
||||
<button v-if="item.duration > 0" v-text="timeFormat(item.duration)" tabindex="-1" />
|
||||
<button v-if="item.views >= 0" tabindex="-1">
|
||||
@@ -77,9 +68,129 @@
|
||||
|
||||
<div class="pp-text" title="item.uploaderName">
|
||||
<span v-text="item.uploaderName" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="item.uploaderVerified" icon="check" />
|
||||
<font-awesome-icon class="ml-1.5" v-if="item.uploaderVerified" icon="check" />-->
|
||||
|
||||
<div class="w-full">
|
||||
<img
|
||||
class="w-full"
|
||||
:src="thumbnail"
|
||||
:alt="title"
|
||||
:class="{ 'shorts-img': item.isShort, 'opacity-75': item.watched }"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- progress bar -->
|
||||
<div class="relative h-1 w-full">
|
||||
<div
|
||||
v-if="item.watched && item.duration > 0"
|
||||
class="absolute bottom-0 left-0 h-1 bg-red-600"
|
||||
:style="{ width: `clamp(0%, ${(item.currentTime / item.duration) * 100}%, 100%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative text-sm">
|
||||
<span
|
||||
v-if="item.duration > 0"
|
||||
class="thumbnail-overlay thumbnail-right"
|
||||
v-text="timeFormat(item.duration)"
|
||||
/>
|
||||
<!-- shorts thumbnail -->
|
||||
<span v-if="item.isShort" v-t="'video.shorts'" class="thumbnail-overlay thumbnail-left" />
|
||||
<span
|
||||
v-else-if="item.duration >= 0"
|
||||
class="thumbnail-overlay thumbnail-right"
|
||||
v-text="timeFormat(item.duration)"
|
||||
/>
|
||||
<i18n-t v-else keypath="video.live" class="thumbnail-overlay thumbnail-right !bg-red-600" tag="div">
|
||||
<font-awesome-icon class="w-3" :icon="['fas', 'broadcast-tower']" />
|
||||
</i18n-t>
|
||||
<span v-if="item.watched" v-t="'video.watched'" class="thumbnail-overlay bottom-5px left-5px" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical"
|
||||
class="pp-video-card-title my-2 overflow-hidden flex link"
|
||||
:title="title"
|
||||
v-text="title"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<div class="flex">
|
||||
<router-link :to="item.uploaderUrl">
|
||||
<img
|
||||
v-if="item.uploaderAvatar"
|
||||
:src="item.uploaderAvatar"
|
||||
loading="lazy"
|
||||
class="mr-0.5 mt-0.5 h-32px w-32px rounded-full"
|
||||
width="68"
|
||||
height="68"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<div class="flex-1 px-2">
|
||||
<router-link
|
||||
v-if="item.uploaderUrl && item.uploaderName && !hideChannel"
|
||||
class="link-secondary block overflow-hidden text-sm"
|
||||
:to="item.uploaderUrl"
|
||||
:title="item.uploaderName"
|
||||
>
|
||||
<span v-text="item.uploaderName" />
|
||||
<font-awesome-icon v-if="item.uploaderVerified" class="ml-1.5" icon="check" />
|
||||
</router-link>
|
||||
|
||||
<div v-if="item.views >= 0 || item.uploadedDate" class="mt-1 text-xs font-normal text-gray-300">
|
||||
<span v-if="item.views >= 0">
|
||||
<font-awesome-icon icon="eye" />
|
||||
<span class="pl-1" v-text="`${numberFormat(item.views)} •`" />
|
||||
</span>
|
||||
<span v-if="item.uploaded > 0" class="pl-0.5" v-text="timeAgo(item.uploaded)" />
|
||||
<span v-else-if="item.uploadedDate" class="pl-0.5" v-text="item.uploadedDate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/watch',
|
||||
query: {
|
||||
v: item.url.substr(-11),
|
||||
...(playlistId && { list: playlistId }),
|
||||
...(index >= 0 && { index: index + 1 }),
|
||||
listen: '1',
|
||||
},
|
||||
}"
|
||||
:aria-label="'Listen to ' + title"
|
||||
:title="'Listen to ' + title"
|
||||
>
|
||||
<font-awesome-icon icon="headphones" />
|
||||
</router-link>
|
||||
<button :title="$t('actions.add_to_playlist')" @click="showModal = !showModal">
|
||||
<font-awesome-icon icon="circle-plus" />
|
||||
</button>
|
||||
<button
|
||||
v-if="admin"
|
||||
ref="removeButton"
|
||||
:title="$t('actions.remove_from_playlist')"
|
||||
@click="showConfirmRemove = true"
|
||||
>
|
||||
<font-awesome-icon icon="circle-minus" />
|
||||
</button>
|
||||
<ConfirmModal
|
||||
v-if="showConfirmRemove"
|
||||
:message="$t('actions.delete_playlist_video_confirm')"
|
||||
@close="showConfirmRemove = false"
|
||||
@confirm="removeVideo(item.url.substr(-11))"
|
||||
/>
|
||||
<PlaylistAddModal
|
||||
v-if="showModal"
|
||||
:video-id="item.url.substr(-11)"
|
||||
:video-info="item"
|
||||
@close="showModal = !showModal"
|
||||
/>
|
||||
<!--Master-->
|
||||
</div>
|
||||
<!-- </router-link> -->
|
||||
</div>
|
||||
</div>
|
||||
<PlaylistAddModal v-if="showModal" :video-id="video.url.substr(-11)" @close="showModal = !showModal" />
|
||||
</template>
|
||||
@@ -95,8 +206,10 @@
|
||||
|
||||
<script>
|
||||
import PlaylistAddModal from "./PlaylistAddModal.vue";
|
||||
import ConfirmModal from "./ConfirmModal.vue";
|
||||
|
||||
export default {
|
||||
components: { PlaylistAddModal, ConfirmModal },
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
@@ -115,34 +228,32 @@ export default {
|
||||
playlistId: { type: String, default: null },
|
||||
admin: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["remove"],
|
||||
data() {
|
||||
return {
|
||||
showModal: false,
|
||||
showVideo: true,
|
||||
showConfirmRemove: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.item.dearrow?.titles[0]?.title ?? this.item.title;
|
||||
},
|
||||
thumbnail() {
|
||||
return this.item.dearrow?.thumbnails[0]?.thumbnail ?? this.item.thumbnail;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.shouldShowVideo();
|
||||
},
|
||||
methods: {
|
||||
removeVideo() {
|
||||
if (confirm(this.$t("actions.delete_playlist_video_confirm"))) {
|
||||
this.$refs.removeButton.disabled = true;
|
||||
this.fetchJson(this.authApiUrl() + "/user/playlists/remove", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
playlistId: this.playlistId,
|
||||
index: this.index,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: this.getAuthToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else this.$emit("remove");
|
||||
});
|
||||
}
|
||||
this.$refs.removeButton.disabled = true;
|
||||
this.removeVideoFromPlaylist(this.playlistId, this.index).then(json => {
|
||||
if (json.error) alert(json.error);
|
||||
else this.$emit("remove");
|
||||
});
|
||||
},
|
||||
shouldShowVideo() {
|
||||
if (!this.isFeed || !this.getPreferenceBoolean("hideWatched", false)) return;
|
||||
@@ -158,6 +269,5 @@ export default {
|
||||
};
|
||||
},
|
||||
},
|
||||
components: { PlaylistAddModal },
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -2,15 +2,35 @@
|
||||
<div
|
||||
ref="container"
|
||||
data-shaka-player-container
|
||||
class="w-full max-h-screen flex justify-center efy_trans_filter_off"
|
||||
class="relative max-h-screen w-full flex justify-center efy_trans_filter_off"
|
||||
:class="{ 'player-container': !isEmbed }"
|
||||
>
|
||||
<video ref="videoEl" class="w-full" data-shaka-player :autoplay="shouldAutoPlay" :loop="selectedAutoLoop" />
|
||||
<span
|
||||
id="preview-container"
|
||||
ref="previewContainer"
|
||||
class="absolute bottom-0 z-[2000] mb-[3.5%] hidden flex-col items-center"
|
||||
>
|
||||
<canvas id="preview" ref="preview" class="rounded-sm" />
|
||||
<span class="mt-2 w-min rounded-xl bg-dark-700 px-2 pb-1 pt-1.5 text-sm" v-text="timeFormat(currentTime)" />
|
||||
</span>
|
||||
<button
|
||||
v-if="inSegment"
|
||||
class="skip-segment-button"
|
||||
type="button"
|
||||
:aria-label="$t('actions.skip_segment')"
|
||||
aria-pressed="false"
|
||||
@click="onClickSkipSegment"
|
||||
>
|
||||
<span v-t="'actions.skip_segment'" />
|
||||
<i class="material-icons-round">skip_next</i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import("shaka-player/dist/controls.css");
|
||||
import "shaka-player/dist/controls.css";
|
||||
import { parseTimeParam } from "@/utils/Misc";
|
||||
const shaka = import("shaka-player/dist/shaka-player.ui.js");
|
||||
if (!window.muxjs) {
|
||||
import("mux.js").then(muxjs => {
|
||||
@@ -27,14 +47,6 @@ export default {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
playlist: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
sponsors: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
@@ -45,12 +57,16 @@ export default {
|
||||
selectedAutoLoop: Boolean,
|
||||
isEmbed: Boolean,
|
||||
},
|
||||
emits: ["timeupdate"],
|
||||
emits: ["timeupdate", "ended", "navigateNext"],
|
||||
data() {
|
||||
return {
|
||||
lastUpdate: new Date().getTime(),
|
||||
initialSeekComplete: false,
|
||||
destroying: false,
|
||||
inSegment: false,
|
||||
isHoveringTimebar: false,
|
||||
currentTime: 0,
|
||||
seekbarPadding: 2,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -88,7 +104,7 @@ export default {
|
||||
this.hotkeysPromise.then(() => {
|
||||
var self = this;
|
||||
this.$hotkeys(
|
||||
"f,m,j,k,l,c,space,up,down,left,right,0,1,2,3,4,5,6,7,8,9,shift+n,shift+,,shift+.",
|
||||
"f,m,j,k,l,c,space,up,down,left,right,0,1,2,3,4,5,6,7,8,9,shift+n,shift+,,shift+.,alt+p,return,.,,",
|
||||
function (e, handler) {
|
||||
const videoEl = self.$refs.videoEl;
|
||||
switch (handler.key) {
|
||||
@@ -175,7 +191,7 @@ export default {
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "shift+n":
|
||||
self.navigateNext();
|
||||
self.$emit("navigateNext");
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "shift+,":
|
||||
@@ -184,6 +200,22 @@ export default {
|
||||
case "shift+.":
|
||||
self.$player.trickPlay(Math.min(videoEl.playbackRate + 0.25, 2));
|
||||
break;
|
||||
case "alt+p":
|
||||
document.pictureInPictureElement
|
||||
? document.exitPictureInPicture()
|
||||
: videoEl.requestPictureInPicture();
|
||||
break;
|
||||
case "return":
|
||||
self.skipSegment(videoEl);
|
||||
break;
|
||||
case ".":
|
||||
videoEl.currentTime += 0.04;
|
||||
e.preventDefault();
|
||||
break;
|
||||
case ",":
|
||||
videoEl.currentTime -= 0.04;
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -199,6 +231,8 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async loadVideo() {
|
||||
this.updateSponsors();
|
||||
|
||||
const component = this;
|
||||
const videoEl = this.$refs.videoEl;
|
||||
|
||||
@@ -207,26 +241,9 @@ export default {
|
||||
const time = this.$route.query.t ?? this.$route.query.start;
|
||||
|
||||
if (time) {
|
||||
let start = 0;
|
||||
if (/^[\d]*$/g.test(time)) {
|
||||
start = time;
|
||||
} else {
|
||||
const hours = /([\d]*)h/gi.exec(time)?.[1];
|
||||
const minutes = /([\d]*)m/gi.exec(time)?.[1];
|
||||
const seconds = /([\d]*)s/gi.exec(time)?.[1];
|
||||
if (hours) {
|
||||
start += parseInt(hours) * 60 * 60;
|
||||
}
|
||||
if (minutes) {
|
||||
start += parseInt(minutes) * 60;
|
||||
}
|
||||
if (seconds) {
|
||||
start += parseInt(seconds);
|
||||
}
|
||||
}
|
||||
videoEl.currentTime = start;
|
||||
videoEl.currentTime = parseTimeParam(time);
|
||||
this.initialSeekComplete = true;
|
||||
} else if (window.db) {
|
||||
} else if (window.db && this.getPreferenceBoolean("watchHistory", false)) {
|
||||
var tx = window.db.transaction("watch_history", "readonly");
|
||||
var store = tx.objectStore("watch_history");
|
||||
var request = store.get(this.video.id);
|
||||
@@ -256,9 +273,7 @@ export default {
|
||||
|
||||
const MseSupport = window.MediaSource !== undefined;
|
||||
|
||||
const lbry = this.getPreferenceBoolean("disableLBRY", false)
|
||||
? null
|
||||
: this.video.videoStreams.filter(stream => stream.quality === "LBRY")[0];
|
||||
const lbry = null;
|
||||
|
||||
var uri;
|
||||
var mime;
|
||||
@@ -268,9 +283,10 @@ export default {
|
||||
mime = "application/x-mpegURL";
|
||||
} else if (this.video.audioStreams.length > 0 && !lbry && MseSupport) {
|
||||
if (!this.video.dash) {
|
||||
const dash = (
|
||||
await import("@/utils/DashUtils.js").then(mod => mod.default)
|
||||
).generate_dash_file_from_formats(streams, this.video.duration);
|
||||
const dash = (await import("../utils/DashUtils.js")).generate_dash_file_from_formats(
|
||||
streams,
|
||||
this.video.duration,
|
||||
);
|
||||
|
||||
uri = "data:application/dash+xml;charset=utf-8;base64," + btoa(dash);
|
||||
} else {
|
||||
@@ -306,7 +322,7 @@ export default {
|
||||
uri = this.video.hls;
|
||||
mime = "application/x-mpegURL";
|
||||
} else {
|
||||
uri = this.video.videoStreams.filter(stream => stream.codec == null).slice(-1)[0].url;
|
||||
uri = this.video.videoStreams.findLast(stream => stream.codec == null).url;
|
||||
mime = "video/mp4";
|
||||
}
|
||||
|
||||
@@ -356,22 +372,19 @@ export default {
|
||||
else this.setPlayerAttrs(this.$player, videoEl, uri, mime, this.$shaka);
|
||||
|
||||
if (noPrevPlayer) {
|
||||
videoEl.addEventListener("loadeddata", () => {
|
||||
if (document.pictureInPictureElement) videoEl.requestPictureInPicture();
|
||||
});
|
||||
videoEl.addEventListener("timeupdate", () => {
|
||||
const time = videoEl.currentTime;
|
||||
this.$emit("timeupdate", time);
|
||||
this.updateProgressDatabase(time);
|
||||
if (this.sponsors && this.sponsors.segments) {
|
||||
this.sponsors.segments.map(segment => {
|
||||
if (!segment.skipped || this.selectedAutoLoop) {
|
||||
const end = segment.segment[1];
|
||||
if (time >= segment.segment[0] && time < end) {
|
||||
console.log("Skipped segment at " + time);
|
||||
videoEl.currentTime = end;
|
||||
segment.skipped = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
const segment = this.findCurrentSegment(time);
|
||||
this.inSegment = !!segment;
|
||||
if (segment?.autoskip && (!segment.skipped || this.selectedAutoLoop)) {
|
||||
this.skipSegment(videoEl, segment);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -386,18 +399,27 @@ export default {
|
||||
});
|
||||
|
||||
videoEl.addEventListener("ended", () => {
|
||||
if (
|
||||
!this.selectedAutoLoop &&
|
||||
this.selectedAutoPlay &&
|
||||
(this.playlist?.relatedStreams?.length > 0 || this.video.relatedStreams.length > 0)
|
||||
) {
|
||||
this.navigateNext();
|
||||
}
|
||||
this.$emit("ended");
|
||||
});
|
||||
}
|
||||
|
||||
//TODO: Add sponsors on seekbar: https://github.com/ajayyy/SponsorBlock/blob/e39de9fd852adb9196e0358ed827ad38d9933e29/src/js-components/previewBar.ts#L12
|
||||
},
|
||||
findCurrentSegment(time) {
|
||||
return this.sponsors?.segments?.find(s => time >= s.segment[0] && time < s.segment[1]);
|
||||
},
|
||||
onClickSkipSegment() {
|
||||
const videoEl = this.$refs.videoEl;
|
||||
this.skipSegment(videoEl);
|
||||
},
|
||||
skipSegment(videoEl, segment) {
|
||||
const time = videoEl.currentTime;
|
||||
if (!segment) segment = this.findCurrentSegment(time);
|
||||
if (!segment) return;
|
||||
console.log("Skipped segment at " + time);
|
||||
videoEl.currentTime = segment.segment[1];
|
||||
segment.skipped = true;
|
||||
},
|
||||
setPlayerAttrs(localPlayer, videoEl, uri, mime, shaka) {
|
||||
const url = "/watch?v=" + this.video.id;
|
||||
|
||||
@@ -471,8 +493,13 @@ export default {
|
||||
|
||||
this.updateMarkers();
|
||||
|
||||
const event = new Event("playerInit");
|
||||
window.dispatchEvent(event);
|
||||
|
||||
const player = this.$ui.getControls().getPlayer();
|
||||
|
||||
this.setupSeekbarPreview();
|
||||
|
||||
this.$player = player;
|
||||
|
||||
const disableVideo = this.getPreferenceBoolean("listen", false) && !this.video.livestream;
|
||||
@@ -543,7 +570,7 @@ export default {
|
||||
player.addTextTrackAsync(
|
||||
subtitle.url,
|
||||
subtitle.code,
|
||||
"SUBTITLE",
|
||||
"subtitles",
|
||||
subtitle.mimeType,
|
||||
null,
|
||||
subtitle.name,
|
||||
@@ -553,7 +580,14 @@ export default {
|
||||
const rate = this.getPreferenceNumber("rate", 1);
|
||||
videoEl.playbackRate = rate;
|
||||
videoEl.defaultPlaybackRate = rate;
|
||||
|
||||
const autoDisplayCaptions = this.getPreferenceBoolean("autoDisplayCaptions", false);
|
||||
this.$player.setTextTrackVisibility(autoDisplayCaptions);
|
||||
});
|
||||
|
||||
// expand the player to fullscreen when the fullscreen query equals true
|
||||
if (this.$route.query.fullscreen === "true" && !this.$ui.getControls().isFullScreenEnabled())
|
||||
this.$ui.getControls().toggleFullScreen();
|
||||
},
|
||||
async updateProgressDatabase(time) {
|
||||
// debounce
|
||||
@@ -578,29 +612,7 @@ export default {
|
||||
this.$refs.videoEl.currentTime = time;
|
||||
}
|
||||
},
|
||||
navigateNext() {
|
||||
const params = this.$route.query;
|
||||
let url = this.playlist?.relatedStreams?.[this.index]?.url ?? this.video.relatedStreams[0].url;
|
||||
const searchParams = new URLSearchParams();
|
||||
for (var param in params)
|
||||
switch (param) {
|
||||
case "v":
|
||||
case "t":
|
||||
break;
|
||||
case "index":
|
||||
if (this.index < this.playlist.relatedStreams.length) searchParams.set("index", this.index + 1);
|
||||
break;
|
||||
case "list":
|
||||
if (this.index < this.playlist.relatedStreams.length) searchParams.set("list", params.list);
|
||||
break;
|
||||
default:
|
||||
searchParams.set(param, params[param]);
|
||||
break;
|
||||
}
|
||||
const paramStr = searchParams.toString();
|
||||
if (paramStr.length > 0) url += "&" + paramStr;
|
||||
this.$router.push(url);
|
||||
},
|
||||
|
||||
updateMarkers() {
|
||||
const markers = this.$refs.container.querySelector(".shaka-ad-markers");
|
||||
const array = ["to right"];
|
||||
@@ -608,38 +620,19 @@ export default {
|
||||
const start = (segment.segment[0] / this.video.duration) * 100;
|
||||
const end = (segment.segment[1] / this.video.duration) * 100;
|
||||
|
||||
var color;
|
||||
switch (segment.category) {
|
||||
case "sponsor":
|
||||
color = "#00d400";
|
||||
break;
|
||||
case "selfpromo":
|
||||
color = "#ffff00";
|
||||
break;
|
||||
case "interaction":
|
||||
color = "#cc00ff";
|
||||
break;
|
||||
case "poi_highlight":
|
||||
color = "#ff1684";
|
||||
break;
|
||||
case "intro":
|
||||
color = "#00ffff";
|
||||
break;
|
||||
case "outro":
|
||||
color = "#0202ed";
|
||||
break;
|
||||
case "preview":
|
||||
color = "#008fd6";
|
||||
break;
|
||||
case "filler":
|
||||
color = "#7300FF";
|
||||
break;
|
||||
case "music_offtopic":
|
||||
color = "#ff9900";
|
||||
break;
|
||||
default:
|
||||
color = "white";
|
||||
}
|
||||
var color = [
|
||||
"sponsor",
|
||||
"selfpromo",
|
||||
"interaction",
|
||||
"poi_highlight",
|
||||
"intro",
|
||||
"outro",
|
||||
"preview",
|
||||
"filler",
|
||||
"music_offtopic",
|
||||
].includes(segment.category)
|
||||
? `var(--spon-seg-${segment.category})`
|
||||
: "var(--spon-seg-default)";
|
||||
|
||||
array.push(`transparent ${start}%`);
|
||||
array.push(`${color} ${start}%`);
|
||||
@@ -653,33 +646,133 @@ export default {
|
||||
|
||||
if (markers) markers.style.background = `linear-gradient(${array.join(",")})`;
|
||||
},
|
||||
destroy(hotkeys) {
|
||||
if (this.$ui) {
|
||||
this.$ui.destroy();
|
||||
this.$ui = undefined;
|
||||
this.$player = undefined;
|
||||
}
|
||||
if (this.$player) {
|
||||
this.$player.destroy();
|
||||
this.$player = undefined;
|
||||
}
|
||||
if (hotkeys) this.$hotkeys?.unbind();
|
||||
this.$refs.container?.querySelectorAll("div").forEach(node => node.remove());
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
sponsors() {
|
||||
updateSponsors() {
|
||||
if (this.getPreferenceBoolean("showMarkers", true)) {
|
||||
this.shakaPromise.then(() => {
|
||||
this.updateMarkers();
|
||||
});
|
||||
}
|
||||
},
|
||||
setupSeekbarPreview() {
|
||||
if (!this.video.previewFrames) return;
|
||||
let seekBar = document.querySelector(".shaka-seek-bar");
|
||||
// load the thumbnail preview when the user moves over the seekbar
|
||||
seekBar.addEventListener("mousemove", e => {
|
||||
this.isHoveringTimebar = true;
|
||||
const position = (e.offsetX / e.target.offsetWidth) * this.video.duration;
|
||||
this.showSeekbarPreview(position * 1000);
|
||||
});
|
||||
// hide the preview when the user stops hovering the seekbar
|
||||
seekBar.addEventListener("mouseout", () => {
|
||||
this.isHoveringTimebar = false;
|
||||
this.$refs.previewContainer.style.display = "none";
|
||||
});
|
||||
},
|
||||
async showSeekbarPreview(position) {
|
||||
const frame = this.getFrame(position);
|
||||
const originalImage = await this.loadImage(frame.url);
|
||||
if (!this.isHoveringTimebar) return;
|
||||
|
||||
const seekBar = document.querySelector(".shaka-seek-bar");
|
||||
const container = this.$refs.previewContainer;
|
||||
const canvas = this.$refs.preview;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const offsetX = frame.positionX * frame.frameWidth;
|
||||
const offsetY = frame.positionY * frame.frameHeight;
|
||||
|
||||
canvas.width = frame.frameWidth > 100 ? frame.frameWidth : frame.frameWidth * 2;
|
||||
canvas.height = frame.frameWidth > 100 ? frame.frameHeight : frame.frameHeight * 2;
|
||||
// draw the thumbnail preview into the canvas by cropping only the relevant part
|
||||
ctx.drawImage(
|
||||
originalImage,
|
||||
offsetX,
|
||||
offsetY,
|
||||
frame.frameWidth,
|
||||
frame.frameHeight,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
// calculate the thumbnail preview offset and display it
|
||||
const centerOffset = position / this.video.duration / 10;
|
||||
const left = centerOffset - ((0.5 * canvas.width) / seekBar.clientWidth) * 100;
|
||||
const maxLeft =
|
||||
((seekBar.clientWidth - canvas.clientWidth) / seekBar.clientWidth) * 100 - this.seekbarPadding;
|
||||
|
||||
this.currentTime = position / 1000;
|
||||
|
||||
container.style.left = `max(${this.seekbarPadding}%, min(${left}%, ${maxLeft}%))`;
|
||||
container.style.display = "flex";
|
||||
},
|
||||
// ineffective algorithm to find the thumbnail corresponding to the currently hovered position in the video
|
||||
getFrame(position) {
|
||||
let startPosition = 0;
|
||||
const framePage = this.video.previewFrames.at(-1);
|
||||
for (let i = 0; i < framePage.urls.length; i++) {
|
||||
for (let positionY = 0; positionY < framePage.framesPerPageY; positionY++) {
|
||||
for (let positionX = 0; positionX < framePage.framesPerPageX; positionX++) {
|
||||
const endPosition = startPosition + framePage.durationPerFrame;
|
||||
if (position >= startPosition && position <= endPosition) {
|
||||
return {
|
||||
url: framePage.urls[i],
|
||||
positionX: positionX,
|
||||
positionY: positionY,
|
||||
frameWidth: framePage.frameWidth,
|
||||
frameHeight: framePage.frameHeight,
|
||||
};
|
||||
}
|
||||
startPosition = endPosition;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// creates a new image from an URL
|
||||
loadImage(url) {
|
||||
return new Promise(r => {
|
||||
const i = new Image();
|
||||
i.onload = () => r(i);
|
||||
i.src = url;
|
||||
});
|
||||
},
|
||||
destroy(hotkeys) {
|
||||
if (this.$ui && !document.pictureInPictureElement) {
|
||||
this.$ui.destroy();
|
||||
this.$ui = undefined;
|
||||
this.$player = undefined;
|
||||
}
|
||||
if (this.$player) {
|
||||
this.$player.destroy();
|
||||
if (!document.pictureInPictureElement) this.$player = undefined;
|
||||
}
|
||||
if (hotkeys) this.$hotkeys?.unbind();
|
||||
this.$refs.container?.querySelectorAll("div").forEach(node => node.remove());
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--player-base: rgba(255, 255, 255, 0.3);
|
||||
--player-buffered: rgba(255, 255, 255, 0.54);
|
||||
--player-played: rgba(255, 0, 0);
|
||||
|
||||
--spon-seg-sponsor: #00d400;
|
||||
--spon-seg-selfpromo: #ffff00;
|
||||
--spon-seg-interaction: #cc00ff;
|
||||
--spon-seg-poi_highlight: #ff1684;
|
||||
--spon-seg-intro: #00ffff;
|
||||
--spon-seg-outro: #0202ed;
|
||||
--spon-seg-preview: #008fd6;
|
||||
--spon-seg-filler: #7300ff;
|
||||
--spon-seg-music_offtopic: #ff9900;
|
||||
--spon-seg-default: white;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
@apply max-h-75vh min-h-64;
|
||||
background: #000;
|
||||
@@ -732,11 +825,15 @@ html .shaka-range-element:focus {
|
||||
.material-icons-round {
|
||||
font-family: "Material Icons Round" !important;
|
||||
}
|
||||
.material-icons-round,
|
||||
.shaka-bottom-controls .material-icons-round,
|
||||
.shaka-current-time {
|
||||
-webkit-text-fill-color: #fff !important;
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.shaka-bottom-controls .material-icons-round {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.shaka-overflow-menu,
|
||||
.shaka-settings-menu {
|
||||
@@ -767,4 +864,17 @@ html .shaka-range-element:focus {
|
||||
.shaka-controls-container {
|
||||
border-radius: var(--efy_radius) !important;
|
||||
}
|
||||
|
||||
.skip-segment-button {
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
transform: translate(0, -50%);
|
||||
top: 50%;
|
||||
right: 0;
|
||||
border-right: 0;
|
||||
border-radius: var(--efy_radius) 0 0 var(--efy_radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
29
src/components/WatchOnButton.vue
Normal file
29
src/components/WatchOnButton.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
link: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "YouTube",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="getPreferenceBoolean('showWatchOnYouTube', false)">
|
||||
<a
|
||||
:href="link"
|
||||
role="button"
|
||||
class="pp-square flex items-center justify-center"
|
||||
:aria-label="'Watch on Odysee'"
|
||||
:title="`${$t('player.watch_on')}${platform}`"
|
||||
>
|
||||
<font-awesome-icon class="mx-1.5" :icon="['fab', platform.toLowerCase()]" />
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
link: String,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="this.getPreferenceBoolean('showWatchOnYouTube', false)">
|
||||
<a :href="link" class="pp-watch-onyt-btn btn" role="button" :title="$t('player.watch_on') + 'YouTube'">
|
||||
<font-awesome-icon class="mx-1.5" :icon="['fab', 'youtube']" />
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.pp-watch-onyt-btn {
|
||||
display: flex;
|
||||
margin-left: var(--efy_gap0);
|
||||
align-items: center;
|
||||
gap: 5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +1,48 @@
|
||||
<template>
|
||||
<div v-if="video && isEmbed" class="absolute top-0 left-0 h-full w-full bg-black z-50">
|
||||
<div v-if="video && isEmbed" class="absolute left-0 top-0 z-50 h-full w-full bg-black">
|
||||
<VideoPlayer
|
||||
ref="videoPlayer"
|
||||
:video="video"
|
||||
:sponsors="sponsors"
|
||||
:playlist="playlist"
|
||||
:index="index"
|
||||
:selected-auto-play="false"
|
||||
:selected-auto-loop="selectedAutoLoop"
|
||||
:is-embed="isEmbed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="video && !isEmbed" class="w-full">
|
||||
<LoadingIndicatorPage :show-content="video && !isEmbed" class="w-full">
|
||||
<ErrorHandler v-if="video && video.error" :message="video.message" :error="video.error" />
|
||||
<Transition>
|
||||
<ToastComponent v-if="shouldShowToast" @dismissed="dismiss">
|
||||
<i18n-t keypath="info.next_video_countdown">{{ counter }}</i18n-t>
|
||||
</ToastComponent>
|
||||
</Transition>
|
||||
|
||||
<div v-show="!video.error">
|
||||
<div :class="isMobile ? 'flex-col' : 'flex'">
|
||||
<VideoPlayer
|
||||
ref="videoPlayer"
|
||||
:video="video"
|
||||
:sponsors="sponsors"
|
||||
:playlist="playlist"
|
||||
:index="index"
|
||||
:selected-auto-play="selectedAutoPlay"
|
||||
:selected-auto-loop="selectedAutoLoop"
|
||||
@timeupdate="onTimeUpdate"
|
||||
/>
|
||||
<keep-alive>
|
||||
<VideoPlayer
|
||||
ref="videoPlayer"
|
||||
:video="video"
|
||||
:sponsors="sponsors"
|
||||
:selected-auto-play="selectedAutoPlay"
|
||||
:selected-auto-loop="selectedAutoLoop"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onVideoEnded"
|
||||
@navigate-next="navigateNext"
|
||||
/>
|
||||
</keep-alive>
|
||||
<ChaptersBar
|
||||
:mobileLayout="isMobile"
|
||||
v-if="video?.chapters?.length > 0 && showChapters"
|
||||
:mobile-layout="isMobile"
|
||||
:chapters="video.chapters"
|
||||
:player-position="currentTime"
|
||||
@seek="navigate"
|
||||
/>
|
||||
</div>
|
||||
<!-- video title -->
|
||||
<div class="pp-video-title font-bold mt-2 text-2xl break-words" v-text="video.title" />
|
||||
<div class="pp-bellow-video flex flex-wrap mt-3 mb-3">
|
||||
<div class="pp-video-title mt-2 break-words text-2xl font-bold" v-text="video.title" />
|
||||
<div class="pp-bellow-video mb-3 mt-3 flex flex-wrap">
|
||||
<!-- views / date -->
|
||||
<div class="flex flex-auto">
|
||||
<span v-t="{ path: 'video.views', args: { views: addCommas(video.views) } }" />
|
||||
@@ -48,11 +53,11 @@
|
||||
<div class="pp-likes flex children:mr-2">
|
||||
<template v-if="video.likes >= 0">
|
||||
<div class="flex items-center">
|
||||
<div class="i-fa-solid:thumbs-up" />
|
||||
<div class="i-fa6-solid:thumbs-up" />
|
||||
<strong class="ml-1" v-text="addCommas(video.likes)" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="i-fa-solid:thumbs-down" />
|
||||
<div class="i-fa6-solid:thumbs-down" />
|
||||
<strong class="ml-1" v-text="video.dislikes >= 0 ? addCommas(video.dislikes) : '?'" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,54 +77,24 @@
|
||||
video.uploader
|
||||
}}</router-link>
|
||||
<!-- Verified Badge -->
|
||||
<font-awesome-icon class="ml-1" v-if="video.uploaderVerified" icon="check" />
|
||||
<font-awesome-icon v-if="video.uploaderVerified" class="ml-1" icon="check" />
|
||||
</div>
|
||||
<div class="pp-watch-buttons">
|
||||
<!-- Subscribe button -->
|
||||
<button
|
||||
class="btn"
|
||||
@click="subscribeHandler"
|
||||
v-t="{
|
||||
path: `actions.${subscribed ? 'unsubscribe' : 'subscribe'}`,
|
||||
args: { count: numberFormat(video.uploaderSubscriberCount) },
|
||||
}"
|
||||
class="btn"
|
||||
@click="subscribeHandler"
|
||||
/>
|
||||
<!-- RSS Feed button -->
|
||||
<a
|
||||
aria-label="RSS feed"
|
||||
title="RSS feed"
|
||||
role="button"
|
||||
v-if="video.uploaderUrl"
|
||||
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${video.uploaderUrl.split('/')[2]}`"
|
||||
target="_blank"
|
||||
class="btn flex-col"
|
||||
>
|
||||
<font-awesome-icon icon="rss" />
|
||||
</a>
|
||||
<WatchOnYouTubeButton :link="`https://youtu.be/${getVideoId()}`" />
|
||||
<!-- Share Dialog -->
|
||||
<button class="btn" @click="showShareModal = !showShareModal">
|
||||
<i18n-t class="lt-lg:hidden" keypath="actions.share" tag="strong"></i18n-t>
|
||||
<font-awesome-icon class="mx-1.5 ml-1" icon="fa-share" />
|
||||
</button>
|
||||
<!-- LBRY -->
|
||||
<a v-if="video.lbryId" :href="'https://odysee.com/' + video.lbryId" class="btn">
|
||||
<i18n-t keypath="player.watch_on" tag="strong">LBRY</i18n-t>
|
||||
</a>
|
||||
<!-- listen / watch toggle -->
|
||||
<router-link
|
||||
:to="toggleListenUrl"
|
||||
:aria-label="(isListening ? 'Watch ' : 'Listen to ') + video.title"
|
||||
:title="(isListening ? 'Watch ' : 'Listen to ') + video.title"
|
||||
class="btn flex-col"
|
||||
>
|
||||
<font-awesome-icon :icon="isListening ? 'tv' : 'headphones'" />
|
||||
</router-link>
|
||||
<!-- Playlist Add button -->
|
||||
<button class="btn" v-if="authenticated" @click="showModal = !showModal">
|
||||
<button v-if="authenticated" class="btn" @click="showModal = !showModal">
|
||||
{{ $t("actions.add_to_playlist") }}<font-awesome-icon class="ml-1" icon="circle-plus" />
|
||||
</button>
|
||||
<PlaylistAddModal v-if="showModal" :video-id="getVideoId()" @close="showModal = !showModal" />
|
||||
<!-- Share Dialog -->
|
||||
<ShareModal
|
||||
v-if="showShareModal"
|
||||
:video-id="getVideoId()"
|
||||
@@ -128,59 +103,116 @@
|
||||
:playlist-index="index"
|
||||
@close="showShareModal = !showShareModal"
|
||||
/>
|
||||
<button class="btn flex items-center" @click="showShareModal = !showShareModal">
|
||||
<font-awesome-icon class="mx-1.5 mr-1" icon="fa-share" />
|
||||
<i18n-t class="lt-lg:hidden" keypath="actions.share" tag="strong"></i18n-t>
|
||||
</button>
|
||||
<!-- YouTube -->
|
||||
<WatchOnButton :link="`https://youtu.be/${getVideoId()}`" />
|
||||
<!-- Odysee -->
|
||||
<WatchOnButton v-if="video.lbryId" platform="Odysee" :link="`https://odysee.com/${video.lbryId}`" />
|
||||
<!-- listen / watch toggle -->
|
||||
<router-link
|
||||
:to="toggleListenUrl"
|
||||
role="button"
|
||||
:aria-label="(isListening ? 'Watch ' : 'Listen to ') + video.title"
|
||||
:title="(isListening ? 'Watch ' : 'Listen to ') + video.title"
|
||||
class="pp-square btn flex items-center"
|
||||
>
|
||||
<font-awesome-icon class="mx-1.5" :icon="isListening ? 'tv' : 'headphones'" />
|
||||
</router-link>
|
||||
<!-- RSS Feed button -->
|
||||
<a
|
||||
v-if="video.uploaderUrl"
|
||||
aria-label="RSS feed"
|
||||
title="RSS feed"
|
||||
role="button"
|
||||
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${video.uploaderUrl.split('/')[2]}`"
|
||||
target="_blank"
|
||||
class="pp-square btn flex items-center"
|
||||
>
|
||||
<font-awesome-icon class="mx-1.5" icon="rss" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<hr class="mb-2" />
|
||||
|
||||
<div
|
||||
v-for="metaInfo in video?.metaInfo ?? []"
|
||||
:key="metaInfo.title"
|
||||
class="btn my-3 flex flex-wrap cursor-default gap-2 px-4 py-2"
|
||||
>
|
||||
<span>{{ metaInfo.description ?? metaInfo.title }}</span>
|
||||
<a v-for="(link, linkIndex) in metaInfo.urls" :key="linkIndex" :href="link" class="underline">{{
|
||||
metaInfo.urlTexts[linkIndex]
|
||||
}}</a>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div efy_select>
|
||||
<input id="showDesc" type="checkbox" v-model="showDesc" />
|
||||
<label for="showDesc" v-t="'actions.show_description'" />
|
||||
<input id="showComments" type="checkbox" v-model="showComments" @click="toggleComments" />
|
||||
<label for="showComments" v-t="'actions.show_comments'" />
|
||||
<input id="showRecs" type="checkbox" v-model="showRecs" />
|
||||
<label for="showRecs" v-t="'actions.show_recommendations'" />
|
||||
<input id="showDesc" v-model="showDesc" type="checkbox" />
|
||||
<label v-t="'actions.show_description'" for="showDesc" />
|
||||
<input id="showComments" v-model="showComments" type="checkbox" @click="toggleComments" />
|
||||
<label
|
||||
v-text="`${$t('actions.show_comments')} - ${numberFormat(comments?.commentCount)}`"
|
||||
for="showComments"
|
||||
/>
|
||||
<input id="showRecs" v-model="showRecs" type="checkbox" />
|
||||
<label v-t="'actions.show_recommendations'" for="showRecs" />
|
||||
<span v-show="video?.chapters?.length > 0">
|
||||
<input id="showChapters" v-model="showChapters" type="checkbox" />
|
||||
<label v-t="'actions.show_chapters'" class="ml-2" for="showChapters" />
|
||||
</span>
|
||||
<input id="chkAutoLoop" v-model="selectedAutoLoop" type="checkbox" @change="onChange($event)" />
|
||||
<label for="chkAutoLoop" v-text="`${$t('actions.loop_this_video')}`" />
|
||||
<input id="chkAutoPlay" v-model="selectedAutoPlay" type="checkbox" @change="onChange($event)" />
|
||||
<label for="chkAutoPlay" v-text="`${$t('actions.auto_play_next_video')}`" />
|
||||
<span v-show="video?.chapters?.length > 0">
|
||||
<input id="showChapters" type="checkbox" v-model="showChapters" />
|
||||
<label class="ml-2" for="showChapters" v-t="'actions.show_chapters'" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div
|
||||
v-show="showDesc"
|
||||
class="break-words mb-2"
|
||||
v-html="purifyHTML(video.description)"
|
||||
style="border-top: var(--efy_border); margin: 15rem 0; padding: 15rem 0"
|
||||
/>
|
||||
<div
|
||||
v-if="showDesc && sponsors && sponsors.segments"
|
||||
v-text="`${$t('video.sponsor_segments')}: ${sponsors.segments.length}`"
|
||||
/>
|
||||
<template v-if="showDesc">
|
||||
<hr />
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="description break-words" v-html="purifiedDescription" />
|
||||
<hr />
|
||||
<div
|
||||
v-if="sponsors && sponsors.segments"
|
||||
v-text="`${$t('video.sponsor_segments')}: ${sponsors.segments.length}`"
|
||||
/>
|
||||
<div v-if="video.category" v-text="`${$t('video.category')}: ${video.category}`" />
|
||||
<div v-text="`${$t('video.license')}: ${video.license}`" />
|
||||
<div class="capitalize" v-text="`${$t('video.visibility')}: ${video.visibility}`" />
|
||||
<hr />
|
||||
<div v-if="video.tags" class="video-tags">
|
||||
<router-link
|
||||
v-for="tag in video.tags"
|
||||
:key="tag"
|
||||
class="line-clamp-1 efy_trans_filter efy_shadow_trans"
|
||||
:to="`/results?search_query=${encodeURIComponent(tag)}`"
|
||||
>{{ tag }}</router-link
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="grid pp-rec-vids">
|
||||
<div class="pp-rec-vids grid">
|
||||
<div v-if="!showComments" class="w-full"></div>
|
||||
<div v-if="!comments" class="">
|
||||
<p class="text-center mt-8" v-t="'comment.loading'"></p>
|
||||
<div v-else-if="!comments">
|
||||
<p v-t="'comment.loading'" class="mt-8 text-center"></p>
|
||||
</div>
|
||||
<div v-else-if="comments.disabled" class="">
|
||||
<p class="text-center mt-8" v-t="'comment.disabled'"></p>
|
||||
<div v-else-if="comments.disabled">
|
||||
<p v-t="'comment.disabled'" class="mt-8 text-center"></p>
|
||||
</div>
|
||||
<div v-else ref="comments" v-show="showComments" class="pp-comments">
|
||||
<div v-else ref="comments" class="pp-comments">
|
||||
<CommentItem
|
||||
v-for="comment in comments.comments"
|
||||
:key="comment.commentId"
|
||||
:comment="comment"
|
||||
:uploader="video.uploader"
|
||||
:video-id="getVideoId()"
|
||||
class="efy_trans_filter"
|
||||
class="efy_trans_filter efy_shadow_trans"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -203,7 +235,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LoadingIndicatorPage>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -215,7 +247,11 @@ import ChaptersBar from "./ChaptersBar.vue";
|
||||
import PlaylistAddModal from "./PlaylistAddModal.vue";
|
||||
import ShareModal from "./ShareModal.vue";
|
||||
import PlaylistVideos from "./PlaylistVideos.vue";
|
||||
import WatchOnYouTubeButton from "./WatchOnYouTubeButton.vue";
|
||||
import WatchOnButton from "./WatchOnButton.vue";
|
||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||
import ToastComponent from "./ToastComponent.vue";
|
||||
import { parseTimeParam } from "@/utils/Misc";
|
||||
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
@@ -228,14 +264,14 @@ export default {
|
||||
PlaylistAddModal,
|
||||
ShareModal,
|
||||
PlaylistVideos,
|
||||
WatchOnYouTubeButton,
|
||||
WatchOnButton,
|
||||
LoadingIndicatorPage,
|
||||
ToastComponent,
|
||||
},
|
||||
data() {
|
||||
const smallViewQuery = window.matchMedia("(max-width: 640px)");
|
||||
return {
|
||||
video: {
|
||||
title: "Loading...",
|
||||
},
|
||||
video: null,
|
||||
playlistId: null,
|
||||
playlist: null,
|
||||
index: null,
|
||||
@@ -256,6 +292,9 @@ export default {
|
||||
showShareModal: false,
|
||||
isMobile: true,
|
||||
currentTime: 0,
|
||||
shouldShowToast: false,
|
||||
timeoutCounter: null,
|
||||
counter: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -277,6 +316,12 @@ export default {
|
||||
year: "numeric",
|
||||
});
|
||||
},
|
||||
defaultCounter(_this) {
|
||||
return _this.getPreferenceNumber("autoPlayNextCountdown", 5);
|
||||
},
|
||||
purifiedDescription() {
|
||||
return purifyHTML(this.video.description);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// check screen size
|
||||
@@ -295,7 +340,7 @@ export default {
|
||||
(async () => {
|
||||
const videoId = this.getVideoId();
|
||||
const instance = this;
|
||||
if (window.db && !this.video.error) {
|
||||
if (window.db && this.getPreferenceBoolean("watchHistory", false) && !this.video.error) {
|
||||
var tx = window.db.transaction("watch_history", "readwrite");
|
||||
var store = tx.objectStore("watch_history");
|
||||
var request = store.get(videoId);
|
||||
@@ -325,6 +370,8 @@ export default {
|
||||
this.getPlaylistData();
|
||||
this.getSponsors();
|
||||
if (!this.isEmbed && this.showComments) this.getComments();
|
||||
if (this.isEmbed) document.querySelector("html").style.overflow = "hidden";
|
||||
window.addEventListener("click", this.handleClick);
|
||||
window.addEventListener("resize", () => {
|
||||
this.smallView = this.smallViewQuery.matches;
|
||||
});
|
||||
@@ -336,7 +383,7 @@ export default {
|
||||
this.showDesc = !this.getPreferenceBoolean("minimizeDescription", false);
|
||||
this.showRecs = !this.getPreferenceBoolean("minimizeRecommendations", false);
|
||||
this.showChapters = !this.getPreferenceBoolean("minimizeChapters", false);
|
||||
if (this.video.duration) {
|
||||
if (this.video?.duration) {
|
||||
document.title = this.video.title + " - Piped";
|
||||
this.$refs.videoPlayer.loadVideo();
|
||||
}
|
||||
@@ -345,24 +392,45 @@ export default {
|
||||
deactivated() {
|
||||
this.active = false;
|
||||
window.removeEventListener("scroll", this.handleScroll);
|
||||
this.dismiss();
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener("scroll", this.handleScroll);
|
||||
window.removeEventListener("click", this.handleClick);
|
||||
this.dismiss();
|
||||
},
|
||||
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(
|
||||
",",
|
||||
'","',
|
||||
) +
|
||||
'"]',
|
||||
var selectedSkip = this.getPreferenceString(
|
||||
"selectedSkip",
|
||||
"sponsor,interaction,selfpromo,music_offtopic",
|
||||
).split(",");
|
||||
const skipOptions = this.getPreferenceJSON("skipOptions");
|
||||
if (skipOptions !== undefined) {
|
||||
selectedSkip = Object.keys(skipOptions).filter(
|
||||
k => skipOptions[k] !== undefined && skipOptions[k] !== "no",
|
||||
);
|
||||
}
|
||||
|
||||
const sponsors = await this.fetchJson(this.apiUrl() + "/sponsors/" + this.getVideoId(), {
|
||||
category: JSON.stringify(selectedSkip),
|
||||
});
|
||||
|
||||
sponsors?.segments?.forEach(segment => {
|
||||
const option = skipOptions[segment.category];
|
||||
segment.autoskip = option === undefined || option === "auto";
|
||||
});
|
||||
|
||||
const minSegmentLength = Math.max(this.getPreferenceNumber("minSegmentLength", 0), 0);
|
||||
sponsors.segments = sponsors.segments?.filter(segment => {
|
||||
const length = segment.segment[1] - segment.segment[0];
|
||||
return length >= minSegmentLength;
|
||||
});
|
||||
|
||||
return sponsors;
|
||||
},
|
||||
toggleComments() {
|
||||
this.showComments = !this.showComments;
|
||||
@@ -395,13 +463,10 @@ export default {
|
||||
elem.outerHTML = elem.getAttribute("href");
|
||||
});
|
||||
xmlDoc.querySelectorAll("br").forEach(elem => (elem.outerHTML = "\n"));
|
||||
this.video.description = this.urlify(xmlDoc.querySelector("body").innerHTML)
|
||||
.replaceAll(/(?:http(?:s)?:\/\/)?(?:www\.)?youtube\.com(\/[/a-zA-Z0-9_?=&-]*)/gm, "$1")
|
||||
.replaceAll(
|
||||
/(?:http(?:s)?:\/\/)?(?:www\.)?youtu\.be\/(?:watch\?v=)?([/a-zA-Z0-9_?=&-]*)/gm,
|
||||
"/watch?v=$1",
|
||||
)
|
||||
.replaceAll("\n", "<br>");
|
||||
this.video.description = rewriteDescription(xmlDoc.querySelector("body").innerHTML);
|
||||
this.updateWatched(this.video.relatedStreams);
|
||||
|
||||
this.fetchDeArrowContent(this.video.relatedStreams);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -422,6 +487,9 @@ export default {
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.fetchPlaylistPages().then(() => {
|
||||
this.fetchDeArrowContent(this.playlist.relatedStreams);
|
||||
});
|
||||
}
|
||||
},
|
||||
async fetchPlaylistPages() {
|
||||
@@ -500,6 +568,35 @@ export default {
|
||||
}
|
||||
this.subscribed = !this.subscribed;
|
||||
},
|
||||
handleClick(event) {
|
||||
if (!event || !event.target) return;
|
||||
if (!event.target.matches("a[href]")) return;
|
||||
const target = event.target;
|
||||
if (!target.getAttribute("href")) return;
|
||||
if (this.handleTimestampLinks(target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
handleTimestampLinks(target) {
|
||||
try {
|
||||
const url = new URL(target.getAttribute("href"), document.baseURI);
|
||||
if (
|
||||
url.searchParams.size > 2 ||
|
||||
url.searchParams.get("v") !== this.getVideoId() ||
|
||||
!url.searchParams.has("t")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const time = parseTimeParam(url.searchParams.get("t"));
|
||||
if (time) {
|
||||
this.navigate(time);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleScroll() {
|
||||
if (this.loading || !this.comments || !this.comments.nextpage) return;
|
||||
if (window.innerHeight + window.scrollY >= this.$refs.comments?.offsetHeight - window.innerHeight) {
|
||||
@@ -523,6 +620,73 @@ export default {
|
||||
onTimeUpdate(time) {
|
||||
this.currentTime = time;
|
||||
},
|
||||
onVideoEnded() {
|
||||
if (
|
||||
!this.selectedAutoLoop &&
|
||||
this.selectedAutoPlay &&
|
||||
(this.playlist?.relatedStreams?.length > 0 || this.video.relatedStreams.length > 0)
|
||||
) {
|
||||
this.showToast();
|
||||
}
|
||||
},
|
||||
showToast() {
|
||||
this.counter = this.defaultCounter;
|
||||
if (this.counter < 1) {
|
||||
this.navigateNext();
|
||||
return;
|
||||
}
|
||||
if (this.timeoutCounter) clearInterval(this.timeoutCounter);
|
||||
this.timeoutCounter = setInterval(() => {
|
||||
this.counter--;
|
||||
if (this.counter === 0) {
|
||||
this.dismiss();
|
||||
this.navigateNext();
|
||||
}
|
||||
}, 1000);
|
||||
this.shouldShowToast = true;
|
||||
},
|
||||
dismiss() {
|
||||
clearInterval(this.timeoutCounter);
|
||||
this.shouldShowToast = false;
|
||||
},
|
||||
navigateNext() {
|
||||
const params = this.$route.query;
|
||||
let url = this.playlist?.relatedStreams?.[this.index]?.url ?? this.video.relatedStreams[0].url;
|
||||
const searchParams = new URLSearchParams();
|
||||
for (var param in params)
|
||||
switch (param) {
|
||||
case "v":
|
||||
case "t":
|
||||
break;
|
||||
case "index":
|
||||
if (this.index < this.playlist.relatedStreams.length) searchParams.set("index", this.index + 1);
|
||||
break;
|
||||
case "list":
|
||||
if (this.index < this.playlist.relatedStreams.length) searchParams.set("list", params.list);
|
||||
break;
|
||||
default:
|
||||
searchParams.set(param, params[param]);
|
||||
break;
|
||||
}
|
||||
// save the fullscreen state
|
||||
searchParams.set("fullscreen", this.$refs.videoPlayer.$ui.getControls().isFullScreenEnabled());
|
||||
const paramStr = searchParams.toString();
|
||||
if (paramStr.length > 0) url += "&" + paramStr;
|
||||
this.$router.push(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.5);
|
||||
}
|
||||
|
||||
.description a {
|
||||
text-decoration: underline;
|
||||
filter: brightness(0.75);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user