Merge branch 'Master' into 'efy', Update efy submodule

This commit is contained in:
dragos-efy
2023-09-06 00:13:33 +03:00
111 changed files with 11620 additions and 6205 deletions

View File

@@ -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>

View File

@@ -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;
});
},

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -6,7 +6,10 @@
import { defineAsyncComponent } from "vue";
const props = defineProps({
item: Object,
item: {
type: Object,
required: true,
},
});
const VideoItem = defineAsyncComponent(() => import("./VideoItem.vue"));

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
},
},
};

View File

@@ -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 ? '&#9989;' : '&#10060;'}`" />
<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
View 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>

View 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>

View File

@@ -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({

View File

@@ -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);

View File

@@ -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>

View File

@@ -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"]);

View File

@@ -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>

View 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>

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>