Migrate code to composition api.

This commit is contained in:
Kavin
2026-03-27 00:41:48 +05:30
parent 2448b8aa1d
commit fa5bbbd267
50 changed files with 4506 additions and 4418 deletions

View File

@@ -13,26 +13,45 @@
</div>
</template>
<script>
<script setup>
import { ref, onMounted } from "vue";
import NavBar from "./components/NavBar.vue";
import FooterComponent from "./components/FooterComponent.vue";
import { getPreferenceString } from "@/composables/usePreferences.js";
import { getDefaultLanguage, TimeAgo, TimeAgoConfig } from "@/composables/useFormatting.js";
import { fetchSubscriptions } from "@/composables/useSubscriptions.js";
import { getChannelGroups, createOrUpdateChannelGroup } from "@/composables/useChannelGroups.js";
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
export default {
components: {
NavBar,
FooterComponent,
},
data() {
return {
theme: "dark",
const theme = ref("dark");
function setTheme() {
let themePref = getPreferenceString("theme", "dark");
const themes = {
dark: "dark",
light: "light",
auto: darkModePreference.matches ? "dark" : "light",
};
},
mounted() {
this.setTheme();
theme.value = themes[themePref];
changeTitleBarColor();
const root = document.querySelector(":root");
theme.value === "dark" ? root.classList.add("dark") : root.classList.remove("dark");
}
function changeTitleBarColor() {
const currentColor = { dark: "#0F0F0F", light: "#FFF" };
const themeColor = document.querySelector("meta[name='theme-color']");
themeColor.setAttribute("content", currentColor[theme.value]);
}
onMounted(() => {
setTheme();
darkModePreference.addEventListener("change", () => {
this.setTheme();
setTheme();
});
if ("indexedDB" in window) {
@@ -67,8 +86,8 @@ export default {
// migration to fix an invalid previous length of channel ids: 11 -> 24
(async () => {
if (ev.oldVersion < 6) {
const subscriptions = await this.fetchSubscriptions();
const channelGroups = await this.getChannelGroups();
const subscriptions = await fetchSubscriptions();
const channelGroups = await getChannelGroups();
for (let group of channelGroups) {
for (let i = 0; i < group.channels.length; i++) {
const tooShortChannelId = group.channels[i];
@@ -77,7 +96,7 @@ export default {
);
if (foundChannel) group.channels[i] = foundChannel.url.substr(-24);
}
this.createOrUpdateChannelGroup(group);
createOrUpdateChannelGroup(group);
}
}
})();
@@ -87,18 +106,16 @@ export default {
};
} else console.log("This browser doesn't support IndexedDB");
const App = this;
(async function () {
const defaultLang = await App.defaultLanguage;
const locale = App.getPreferenceString("hl", defaultLang);
if (locale !== App.TimeAgoConfig.locale) {
const defaultLang = await getDefaultLanguage();
const locale = getPreferenceString("hl", defaultLang);
if (locale !== TimeAgoConfig.locale) {
const localeTime = await import(`../node_modules/javascript-time-ago/locale/${locale}.json`)
.catch(() => null)
.then(module => module?.default);
if (localeTime) {
App.TimeAgo.addLocale(localeTime);
App.TimeAgoConfig.locale = locale;
TimeAgo.addLocale(localeTime);
TimeAgoConfig.locale = locale;
}
}
if (window.i18n.global.locale.value !== locale) {
@@ -109,31 +126,7 @@ export default {
window.i18n.global.locale.value = locale;
}
})();
},
methods: {
setTheme() {
let themePref = this.getPreferenceString("theme", "dark"); // dark, light or auto
const themes = {
dark: "dark",
light: "light",
auto: darkModePreference.matches ? "dark" : "light",
};
this.theme = themes[themePref];
this.changeTitleBarColor();
// Used for the scrollbar
const root = document.querySelector(":root");
this.theme === "dark" ? root.classList.add("dark") : root.classList.remove("dark");
},
changeTitleBarColor() {
const currentColor = { dark: "#0F0F0F", light: "#FFF" };
const themeColor = document.querySelector("meta[name='theme-color']");
themeColor.setAttribute("content", currentColor[this.theme]);
},
},
};
});
</script>
<style>

View File

@@ -25,56 +25,52 @@
@close="showCreateGroupModal = false"
/>
</template>
<script>
<script setup>
import { ref, onMounted } from "vue";
import ModalComponent from "./ModalComponent.vue";
import CreateGroupModal from "./CreateGroupModal.vue";
import { getChannelGroups, createOrUpdateChannelGroup } from "@/composables/useChannelGroups.js";
export default {
components: {
ModalComponent,
CreateGroupModal,
},
props: {
const props = defineProps({
channelId: {
type: String,
required: true,
},
},
emits: ["close"],
data() {
return {
showCreateGroupModal: false,
channelGroups: [],
};
},
mounted() {
this.loadChannelGroups();
},
methods: {
async loadChannelGroups() {
const groups = await this.getChannelGroups();
this.channelGroups.push(...groups);
},
onCheckedChange(index, group) {
if (group.channels.includes(this.channelId)) {
});
defineEmits(["close"]);
const showCreateGroupModal = ref(false);
const channelGroups = ref([]);
async function loadChannelGroups() {
const groups = await getChannelGroups();
channelGroups.value.push(...groups);
}
onMounted(() => {
loadChannelGroups();
});
function onCheckedChange(index, group) {
if (group.channels.includes(props.channelId)) {
group.channels.splice(index, 1);
} else {
group.channels.push(this.channelId);
group.channels.push(props.channelId);
}
this.createOrUpdateChannelGroup(group);
},
onCreateGroup(newGroupName) {
if (!newGroupName || this.channelGroups.some(group => group.groupName == newGroupName)) return;
createOrUpdateChannelGroup(group);
}
function onCreateGroup(newGroupName) {
if (!newGroupName || channelGroups.value.some(group => group.groupName == newGroupName)) return;
const newGroup = {
groupName: newGroupName,
channels: [],
};
this.channelGroups.push(newGroup);
this.createOrUpdateChannelGroup(newGroup);
channelGroups.value.push(newGroup);
createOrUpdateChannelGroup(newGroup);
this.showCreateGroupModal = false;
},
},
};
showCreateGroupModal.value = false;
}
</script>

View File

@@ -36,37 +36,34 @@
</div>
</template>
<script>
export default {
props: {
<script setup>
import { ref, computed, onMounted } from "vue";
import { fetchSubscriptionStatus, toggleSubscriptionState } from "@/composables/useSubscriptions.js";
import { numberFormat } from "@/composables/useFormatting.js";
const props = defineProps({
item: {
type: Object,
required: true,
},
},
data() {
return {
subscribed: null,
};
},
computed: {
channelId(_this) {
return _this.item.url.substr(-24);
},
},
mounted() {
this.updateSubscribedStatus();
},
methods: {
async updateSubscribedStatus() {
this.subscribed = await this.fetchSubscriptionStatus(this.channelId);
console.log(this.subscribed);
},
subscribeHandler() {
this.toggleSubscriptionState(this.channelId, this.subscribed).then(success => {
if (success) this.subscribed = !this.subscribed;
});
const subscribed = ref(null);
const channelId = computed(() => props.item.url.substr(-24));
async function updateSubscribedStatus() {
subscribed.value = await fetchSubscriptionStatus(channelId.value);
console.log(subscribed.value);
}
function subscribeHandler() {
toggleSubscriptionState(channelId.value, subscribed.value).then(success => {
if (success) subscribed.value = !subscribed.value;
});
},
},
};
}
onMounted(() => {
updateSubscribedStatus();
});
</script>

View File

@@ -86,175 +86,186 @@
</LoadingIndicatorPage>
</template>
<script>
<script setup>
import { ref, onMounted, onActivated, onDeactivated, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import ErrorHandler from "./ErrorHandler.vue";
import ContentItem from "./ContentItem.vue";
import WatchOnButton from "./WatchOnButton.vue";
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
import CollapsableText from "./CollapsableText.vue";
import AddToGroupModal from "./AddToGroupModal.vue";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
import { numberFormat } from "@/composables/useFormatting.js";
import {
fetchSubscriptionStatus,
toggleSubscriptionState,
fetchDeArrowContent,
} from "@/composables/useSubscriptions.js";
import { updateWatched } from "@/composables/useMisc.js";
export default {
components: {
ErrorHandler,
ContentItem,
WatchOnButton,
LoadingIndicatorPage,
CollapsableText,
AddToGroupModal,
},
data() {
return {
channel: null,
subscribed: false,
tabs: [],
selectedTab: 0,
contentItems: [],
showGroupModal: false,
};
},
mounted() {
this.getChannelData();
},
activated() {
if (this.channel && !this.channel.error) document.title = this.channel.name + " - Piped";
window.addEventListener("scroll", this.handleScroll);
if (this.channel && !this.channel.error) this.updateWatched(this.channel.relatedStreams);
},
deactivated() {
window.removeEventListener("scroll", this.handleScroll);
},
unmounted() {
window.removeEventListener("scroll", this.handleScroll);
},
methods: {
async fetchSubscribedStatus() {
if (!this.channel.id) return;
const route = useRoute();
const { t } = useI18n();
this.subscribed = await this.fetchSubscriptionStatus(this.channel.id);
},
async fetchChannel() {
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() {
this.fetchChannel()
.then(data => (this.channel = data))
const channel = ref(null);
const subscribed = ref(false);
const tabs = ref([]);
const selectedTab = ref(0);
const contentItems = ref([]);
const showGroupModal = ref(false);
let loading = false;
async function fetchSubscribedStatus() {
if (!channel.value.id) return;
subscribed.value = await fetchSubscriptionStatus(channel.value.id);
}
async function fetchChannel() {
const url = route.path.includes("@")
? apiUrl() + "/@/" + route.params.channelId
: apiUrl() + "/" + route.params.path + "/" + route.params.channelId;
return await fetchJson(url);
}
async function getChannelData() {
fetchChannel()
.then(data => (channel.value = data))
.then(() => {
if (!this.channel.error) {
document.title = this.channel.name + " - Piped";
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"),
if (!channel.value.error) {
document.title = channel.value.name + " - Piped";
contentItems.value = channel.value.relatedStreams;
fetchSubscribedStatus();
updateWatched(channel.value.relatedStreams);
fetchDeArrowContent(channel.value.relatedStreams);
tabs.value.push({
translatedName: 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);
const tabQuery = route.query.tab;
for (let i = 0; i < channel.value.tabs.length; i++) {
let tab = channel.value.tabs[i];
tab.translatedName = getTranslatedTabName(tab.name);
tabs.value.push(tab);
if (tab.name === tabQuery) loadTab(i + 1);
}
}
});
},
handleScroll() {
}
function handleScroll() {
if (
this.loading ||
!this.channel ||
!this.channel.nextpage ||
(this.selectedTab != 0 && !this.tabs[this.selectedTab].tabNextPage)
loading ||
!channel.value ||
!channel.value.nextpage ||
(selectedTab.value != 0 && !tabs.value[selectedTab.value].tabNextPage)
)
return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
this.loading = true;
if (this.selectedTab == 0) {
this.fetchChannelNextPage();
loading = true;
if (selectedTab.value == 0) {
fetchChannelNextPage();
} else {
this.fetchChannelTabNextPage();
fetchChannelTabNextPage();
}
}
},
fetchChannelNextPage() {
this.fetchJson(this.apiUrl() + "/nextpage/channel/" + this.channel.id, {
nextpage: this.channel.nextpage,
}
function fetchChannelNextPage() {
fetchJson(apiUrl() + "/nextpage/channel/" + channel.value.id, {
nextpage: channel.value.nextpage,
}).then(json => {
this.channel.nextpage = json.nextpage;
this.loading = false;
this.updateWatched(json.relatedStreams);
this.contentItems.push(...json.relatedStreams);
this.fetchDeArrowContent(json.relatedStreams);
channel.value.nextpage = json.nextpage;
loading = false;
updateWatched(json.relatedStreams);
contentItems.value.push(...json.relatedStreams);
fetchDeArrowContent(json.relatedStreams);
});
},
fetchChannelTabNextPage() {
this.fetchJson(this.apiUrl() + "/channels/tabs", {
data: this.tabs[this.selectedTab].data,
nextpage: this.tabs[this.selectedTab].tabNextPage,
}
function fetchChannelTabNextPage() {
fetchJson(apiUrl() + "/channels/tabs", {
data: tabs.value[selectedTab.value].data,
nextpage: tabs.value[selectedTab.value].tabNextPage,
}).then(json => {
this.tabs[this.selectedTab].tabNextPage = json.nextpage;
this.loading = false;
this.contentItems.push(...json.content);
this.fetchDeArrowContent(json.content);
this.tabs[this.selectedTab].content = this.contentItems;
tabs.value[selectedTab.value].tabNextPage = json.nextpage;
loading = false;
contentItems.value.push(...json.content);
fetchDeArrowContent(json.content);
tabs.value[selectedTab.value].content = contentItems.value;
});
},
subscribeHandler() {
this.toggleSubscriptionState(this.channel.id, this.subscribed).then(success => {
if (success) this.subscribed = !this.subscribed;
}
function subscribeHandler() {
toggleSubscriptionState(channel.value.id, subscribed.value).then(success => {
if (success) subscribed.value = !subscribed.value;
});
},
getTranslatedTabName(tabName) {
}
function getTranslatedTabName(tabName) {
let translatedTabName = tabName;
switch (tabName) {
case "livestreams":
translatedTabName = this.$t("titles.livestreams");
translatedTabName = t("titles.livestreams");
break;
case "playlists":
translatedTabName = this.$t("titles.playlists");
translatedTabName = t("titles.playlists");
break;
case "albums":
translatedTabName = this.$t("titles.albums");
translatedTabName = t("titles.albums");
break;
case "shorts":
translatedTabName = this.$t("video.shorts");
translatedTabName = t("video.shorts");
break;
default:
console.error(`Tab name "${tabName}" is not translated yet!`);
break;
}
return translatedTabName;
},
loadTab(index) {
this.selectedTab = index;
}
function loadTab(index) {
selectedTab.value = index;
// update the tab query in the url path
const url = new URL(window.location);
url.searchParams.set("tab", this.tabs[index].name ?? "videos");
url.searchParams.set("tab", tabs.value[index].name ?? "videos");
window.history.replaceState(window.history.state, "", url);
if (index == 0) {
this.contentItems = this.channel.relatedStreams;
contentItems.value = channel.value.relatedStreams;
return;
}
if (this.tabs[index].content) {
this.contentItems = this.tabs[index].content;
if (tabs.value[index].content) {
contentItems.value = tabs.value[index].content;
return;
}
this.fetchJson(this.apiUrl() + "/channels/tabs", {
data: this.tabs[index].data,
fetchJson(apiUrl() + "/channels/tabs", {
data: tabs.value[index].data,
}).then(tab => {
this.contentItems = this.tabs[index].content = tab.content;
this.fetchDeArrowContent(tab.content);
this.tabs[this.selectedTab].tabNextPage = tab.nextpage;
contentItems.value = tabs.value[index].content = tab.content;
fetchDeArrowContent(tab.content);
tabs.value[selectedTab.value].tabNextPage = tab.nextpage;
});
},
},
};
}
onMounted(() => {
getChannelData();
});
onActivated(() => {
if (channel.value && !channel.value.error) document.title = channel.value.name + " - Piped";
window.addEventListener("scroll", handleScroll);
if (channel.value && !channel.value.error) updateWatched(channel.value.relatedStreams);
});
onDeactivated(() => {
window.removeEventListener("scroll", handleScroll);
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>
<style>

View File

@@ -2,15 +2,22 @@
<ErrorHandler v-if="response && response.error" :message="response.message" :error="response.error" />
</template>
<script>
export default {
activated() {
this.fetchJson(this.apiUrl() + "/clips/" + this.$route.params.clipId).then(response => {
this.response = response;
if (this.response.videoId) {
this.$router.push(`/watch?v=${this.response.videoId}`);
<script setup>
import { ref, onActivated } from "vue";
import { useRouter, useRoute } from "vue-router";
import { fetchJson } from "@/composables/useApi.js";
import { apiUrl } from "@/composables/useApi.js";
const router = useRouter();
const route = useRoute();
const response = ref(null);
onActivated(() => {
fetchJson(apiUrl() + "/clips/" + route.params.clipId).then(res => {
response.value = res;
if (response.value.videoId) {
router.push(`/watch?v=${response.value.videoId}`);
}
});
},
};
});
</script>

View File

@@ -15,11 +15,11 @@
</div>
</template>
<script>
<script setup>
import { ref } from "vue";
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
export default {
props: {
const props = defineProps({
text: {
type: String,
default: null,
@@ -28,21 +28,17 @@ export default {
type: Number,
default: 100,
},
},
data() {
return {
showFullText: false,
};
},
methods: {
fullText() {
return purifyHTML(rewriteDescription(this.text));
},
collapsedText() {
return purifyHTML(rewriteDescription(this.text.slice(0, this.visibleLimit)));
},
},
};
});
const showFullText = ref(false);
function fullText() {
return purifyHTML(rewriteDescription(props.text));
}
function collapsedText() {
return purifyHTML(rewriteDescription(props.text.slice(0, props.visibleLimit)));
}
</script>
<style>

View File

@@ -67,12 +67,13 @@
</div>
</template>
<script>
<script setup>
import { ref } from "vue";
import CollapsableText from "./CollapsableText.vue";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
import { numberFormat } from "@/composables/useFormatting.js";
export default {
components: { CollapsableText },
props: {
const props = defineProps({
comment: {
type: Object,
default: () => {
@@ -82,33 +83,29 @@ export default {
uploader: { type: String, default: null },
uploaderAvatarUrl: { type: String, default: null },
videoId: { type: String, default: null },
},
data() {
return {
loadingReplies: false,
showingReplies: false,
replies: [],
nextpage: null,
};
},
methods: {
async loadReplies() {
if (!this.showingReplies && this.loadingReplies) {
this.showingReplies = true;
});
const loadingReplies = ref(false);
const showingReplies = ref(false);
const replies = ref([]);
const nextpage = ref(null);
async function loadReplies() {
if (!showingReplies.value && loadingReplies.value) {
showingReplies.value = true;
return;
}
this.loadingReplies = true;
this.showingReplies = true;
this.fetchJson(this.apiUrl() + "/nextpage/comments/" + this.videoId, {
nextpage: this.nextpage || this.comment.repliesPage,
loadingReplies.value = true;
showingReplies.value = true;
fetchJson(apiUrl() + "/nextpage/comments/" + props.videoId, {
nextpage: nextpage.value || props.comment.repliesPage,
}).then(json => {
this.replies = this.replies.concat(json.comments);
this.nextpage = json.nextpage;
replies.value = replies.value.concat(json.comments);
nextpage.value = json.nextpage;
});
},
async hideReplies() {
this.showingReplies = false;
},
},
};
}
async function hideReplies() {
showingReplies.value = false;
}
</script>

View File

@@ -10,33 +10,31 @@
</ModalComponent>
</template>
<script>
<script setup>
import { onMounted, onUnmounted } from "vue";
import ModalComponent from "./ModalComponent.vue";
export default {
components: {
ModalComponent,
},
props: {
defineProps({
message: {
type: String,
required: true,
},
},
emits: ["close", "confirm"],
mounted() {
window.addEventListener("keydown", this.handleKeyDown);
},
unmounted() {
window.removeEventListener("keydown", this.handleKeyDown);
},
methods: {
handleKeyDown(event) {
});
const emit = defineEmits(["close", "confirm"]);
function handleKeyDown(event) {
if (event.code === "Enter") {
this.$emit("confirm");
emit("confirm");
event.preventDefault();
}
},
},
};
}
onMounted(() => {
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown);
});
</script>

View File

@@ -8,28 +8,23 @@
</ModalComponent>
</template>
<script>
<script setup>
import ModalComponent from "./ModalComponent.vue";
import { ref } from "vue";
export default {
components: { ModalComponent },
props: {
const props = defineProps({
onCreateGroup: {
required: true,
type: Function,
},
},
emits: ["close"],
data() {
return {
groupName: "",
};
},
methods: {
createGroup() {
this.onCreateGroup(this.groupName);
this.$emit("close");
},
},
};
});
const emit = defineEmits(["close"]);
const groupName = ref("");
function createGroup() {
props.onCreateGroup(groupName.value);
emit("close");
}
</script>

View File

@@ -11,44 +11,41 @@
</ModalComponent>
</template>
<script>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import ModalComponent from "./ModalComponent.vue";
import { createPlaylist } from "@/composables/usePlaylists.js";
export default {
components: {
ModalComponent,
},
emits: ["created", "close"],
data() {
return {
playlistName: "",
};
},
mounted() {
this.$refs.input.focus();
window.addEventListener("keydown", this.handleKeyDown);
},
unmounted() {
window.removeEventListener("keydown", this.handleKeyDown);
},
methods: {
handleKeyDown(event) {
const emit = defineEmits(["created", "close"]);
const playlistName = ref("");
const input = ref(null);
function handleKeyDown(event) {
if (event.code === "Enter") {
this.onCreatePlaylist();
onCreatePlaylist();
event.preventDefault();
}
},
onCreatePlaylist() {
if (!this.playlistName) return;
}
this.createPlaylist(this.playlistName).then(response => {
function onCreatePlaylist() {
if (!playlistName.value) return;
createPlaylist(playlistName.value).then(response => {
if (response.error) alert(response.error);
else {
this.$emit("created", response.playlistId, this.playlistName);
this.$emit("close");
emit("created", response.playlistId, playlistName.value);
emit("close");
}
});
},
},
};
}
onMounted(() => {
input.value.focus();
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown);
});
</script>

View File

@@ -30,50 +30,51 @@
</ModalComponent>
</template>
<script>
<script setup>
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import ModalComponent from "./ModalComponent.vue";
export default {
components: { ModalComponent },
emits: ["close"],
data() {
return {
customInstances: [],
name: "",
url: "",
};
},
mounted() {
this.customInstances = this.getCustomInstances();
},
methods: {
async addInstance() {
import { getCustomInstances, addCustomInstance, removeCustomInstance } from "@/composables/useCustomInstances.js";
const { t } = useI18n();
defineEmits(["close"]);
const customInstances = ref([]);
const name = ref("");
const url = ref("");
onMounted(() => {
customInstances.value = getCustomInstances();
});
function isValidInstanceUrl(str) {
var a = document.createElement("a");
a.href = str;
return a.host && a.host != window.location.host;
}
async function addInstance() {
const newInstance = {
name: this.name,
api_url: this.url,
name: name.value,
api_url: url.value,
};
if (!newInstance.name || !newInstance.api_url) {
return;
}
if (!this.isValidInstanceUrl(newInstance.api_url)) {
alert(this.$t("actions.invalid_url"));
if (!isValidInstanceUrl(newInstance.api_url)) {
alert(t("actions.invalid_url"));
return;
}
this.addCustomInstance(newInstance);
this.name = "";
this.url = "";
},
removeInstance(instance, index) {
this.customInstances.splice(index, 1);
addCustomInstance(newInstance);
name.value = "";
url.value = "";
}
this.removeCustomInstance(instance);
},
isValidInstanceUrl(str) {
var a = document.createElement("a");
a.href = str;
return a.host && a.host != window.location.host;
},
},
};
function removeInstance(instance, index) {
customInstances.value.splice(index, 1);
removeCustomInstance(instance);
}
</script>

View File

@@ -4,16 +4,17 @@
<p ref="stacktrace" class="whitespace-pre-wrap" hidden v-text="error" />
</template>
<script>
export default {
props: {
<script setup>
import { ref } from "vue";
defineProps({
error: { type: String, default: null },
message: { type: String, default: null },
},
methods: {
toggleTrace() {
this.$refs.stacktrace.hidden = !this.$refs.stacktrace.hidden;
},
},
};
});
const stacktrace = ref(null);
function toggleTrace() {
stacktrace.value.hidden = !stacktrace.value.hidden;
}
</script>

View File

@@ -32,18 +32,15 @@
</ModalComponent>
</template>
<script>
<script setup>
import { ref } from "vue";
import ModalComponent from "./ModalComponent.vue";
import { download } from "@/composables/useMisc.js";
export default {
components: {
ModalComponent,
},
data() {
return {
exportOptions: ["playlist", "history"],
exportAs: "playlist",
fields: [
const exportOptions = ["playlist", "history"];
const exportAs = ref("playlist");
const fields = ["videoId", "title", "uploaderName", "uploaderUrl", "duration", "thumbnail", "watchedAt", "currentTime"];
const selectedFields = ref([
"videoId",
"title",
"uploaderName",
@@ -52,21 +49,11 @@ export default {
"thumbnail",
"watchedAt",
"currentTime",
],
selectedFields: [
"videoId",
"title",
"uploaderName",
"uploaderUrl",
"duration",
"thumbnail",
"watchedAt",
"currentTime",
],
};
},
methods: {
async fetchAllVideos() {
]);
let exportVideos = [];
async function fetchAllVideos() {
if (window.db) {
var tx = window.db.transaction("watch_history", "readonly");
var store = tx.objectStore("watch_history");
@@ -74,7 +61,7 @@ export default {
return new Promise((resolve, reject) => {
(request.onsuccess = e => {
const videos = e.target.result;
this.exportVideos = videos;
exportVideos = videos;
resolve();
}),
(request.onerror = e => {
@@ -82,27 +69,29 @@ export default {
});
});
}
},
handleExport() {
if (this.exportAs === "playlist") {
this.fetchAllVideos()
}
function handleExport() {
if (exportAs.value === "playlist") {
fetchAllVideos()
.then(() => {
this.exportAsPlaylist();
exportAsPlaylist();
})
.catch(e => {
console.error(e);
});
} else if (this.exportAs === "history") {
this.fetchAllVideos()
} else if (exportAs.value === "history") {
fetchAllVideos()
.then(() => {
this.exportAsHistory();
exportAsHistory();
})
.catch(e => {
console.error(e);
});
}
},
exportAsPlaylist() {
}
function exportAsPlaylist() {
const dateStr = new Date().toISOString().split(".")[0];
let json = {
format: "Piped",
@@ -112,31 +101,31 @@ export default {
name: `Piped History ${dateStr}`,
type: "history",
visibility: "private",
videos: this.exportVideos.map(video => "https://youtube.com" + video.url),
videos: exportVideos.map(video => "https://youtube.com" + video.url),
},
],
};
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
},
exportAsHistory() {
download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
}
function exportAsHistory() {
const dateStr = new Date().toISOString().split(".")[0];
let json = {
format: "Piped",
version: 1,
watchHistory: this.exportVideos.map(video => {
watchHistory: exportVideos.map(video => {
let obj = {};
this.selectedFields.forEach(field => {
selectedFields.value.forEach(field => {
obj[field] = video[field];
});
return obj;
}),
};
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
},
formatField(field) {
download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
}
function formatField(field) {
// camelCase to Title Case
return field.replace(/([A-Z])/g, " $1").replace(/^./, str => str.toUpperCase());
},
},
};
}
</script>

View File

@@ -53,104 +53,73 @@
</LoadingIndicatorPage>
</template>
<script>
<script setup>
import { ref, computed, onMounted, onActivated, onDeactivated, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import VideoItem from "./VideoItem.vue";
import SortingSelector from "./SortingSelector.vue";
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
import { authApiUrl, getAuthToken, isAuthenticated } from "@/composables/useApi.js";
import { getPreferenceBoolean, getPreferenceString, setPreference } from "@/composables/usePreferences.js";
import { fetchFeed, getUnauthenticatedChannels, fetchDeArrowContent } from "@/composables/useSubscriptions.js";
import { getChannelGroups } from "@/composables/useChannelGroups.js";
import { updateWatched } from "@/composables/useMisc.js";
export default {
components: {
VideoItem,
SortingSelector,
LoadingIndicatorPage,
},
data() {
return {
currentVideoCount: 0,
videoStep: 100,
videosStore: null,
videos: [],
availableFilters: ["all", "shorts", "videos"],
selectedFilter: "all",
selectedGroupName: "",
channelGroups: [],
};
},
computed: {
getRssUrl(_this) {
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);
const { t } = useI18n();
const videos = this.getPreferenceBoolean("hideWatched", false)
? this.videos.filter(video => !video.watched)
: this.videos;
let currentVideoCount = 0;
const videoStep = 100;
let videosStore = null;
const videos = ref([]);
const availableFilters = ["all", "shorts", "videos"];
const selectedFilter = ref("all");
const selectedGroupName = ref("");
const channelGroups = ref([]);
return _this.selectedGroupName == ""
? videos
: videos.filter(video => selectedGroup[0].channels.includes(video.uploaderUrl.substr(-24)));
},
},
mounted() {
this.fetchFeed().then(resp => {
if (resp.error) {
alert(resp.error);
return;
const getRssUrl = computed(() => {
if (isAuthenticated()) return authApiUrl() + "/feed/rss?authToken=" + getAuthToken();
else return authApiUrl() + "/feed/unauthenticated/rss?channels=" + getUnauthenticatedChannels();
});
const filteredVideos = computed(() => {
const selectedGroup = channelGroups.value.filter(group => group.groupName == selectedGroupName.value);
const vids = getPreferenceBoolean("hideWatched", false)
? videos.value.filter(video => !video.watched)
: videos.value;
return selectedGroupName.value == ""
? vids
: vids.filter(video => selectedGroup[0].channels.includes(video.uploaderUrl.substr(-24)));
});
function loadMoreVideos() {
if (!videosStore) return;
currentVideoCount = Math.min(currentVideoCount + videoStep, videosStore.length);
if (videos.value.length != videosStore.length) {
fetchDeArrowContent(videosStore.slice(videos.value.length, currentVideoCount));
videos.value = videosStore.slice(0, currentVideoCount);
}
}
this.videosStore = resp;
this.loadMoreVideos();
this.updateWatched(this.videos);
});
this.selectedFilter = this.getPreferenceString("feedFilter") ?? "all";
if (!window.db) return;
this.loadChannelGroups();
},
activated() {
document.title = this.$t("titles.feed") + " - Piped";
if (this.videos.length > 0) this.updateWatched(this.videos);
window.addEventListener("scroll", this.handleScroll);
},
deactivated() {
window.removeEventListener("scroll", this.handleScroll);
},
unmounted() {
window.removeEventListener("scroll", this.handleScroll);
},
methods: {
async loadChannelGroups() {
const groups = await this.getChannelGroups();
this.channelGroups.push(...groups);
},
loadMoreVideos() {
if (!this.videosStore) return;
this.currentVideoCount = Math.min(this.currentVideoCount + this.videoStep, this.videosStore.length);
if (this.videos.length != this.videosStore.length) {
this.fetchDeArrowContent(this.videosStore.slice(this.videos.length, this.currentVideoCount));
this.videos = this.videosStore.slice(0, this.currentVideoCount);
}
},
handleScroll() {
function handleScroll() {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
this.loadMoreVideos();
loadMoreVideos();
}
},
onUpdateWatched(urls = null) {
}
function onUpdateWatched(urls = null) {
if (urls === null) {
if (this.videos.length > 0) this.updateWatched(this.videos);
if (videos.value.length > 0) updateWatched(videos.value);
return;
}
const subset = this.videos.filter(({ url }) => urls.includes(url));
if (subset.length > 0) this.updateWatched(subset);
},
shouldShowVideo(video) {
switch (this.selectedFilter.toLowerCase()) {
const subset = videos.value.filter(({ url }) => urls.includes(url));
if (subset.length > 0) updateWatched(subset);
}
function shouldShowVideo(video) {
switch (selectedFilter.value.toLowerCase()) {
case "shorts":
return video.isShort;
case "videos":
@@ -158,10 +127,45 @@ export default {
default:
return true;
}
},
onFilterChange() {
this.setPreference("feedFilter", this.selectedFilter);
},
},
};
}
function onFilterChange() {
setPreference("feedFilter", selectedFilter.value);
}
onMounted(() => {
fetchFeed().then(resp => {
if (resp.error) {
alert(resp.error);
return;
}
videosStore = resp;
loadMoreVideos();
updateWatched(videos.value);
});
selectedFilter.value = getPreferenceString("feedFilter") ?? "all";
if (!window.db) return;
(async () => {
const groups = await getChannelGroups();
channelGroups.value.push(...groups);
})();
});
onActivated(() => {
document.title = t("titles.feed") + " - Piped";
if (videos.value.length > 0) updateWatched(videos.value);
window.addEventListener("scroll", handleScroll);
});
onDeactivated(() => {
window.removeEventListener("scroll", handleScroll);
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@@ -27,28 +27,21 @@
</footer>
</template>
<script>
export default {
data() {
return {
donationHref: null,
statusPageHref: null,
privacyPolicyHref: null,
};
},
mounted() {
this.fetchConfig();
},
methods: {
async fetchConfig() {
this.fetchJson(this.apiUrl() + "/config").then(config => {
this.donationHref = config?.donationUrl;
this.statusPageHref = config?.statusPageUrl;
this.privacyPolicyHref = config?.privacyPolicyUrl;
<script setup>
import { ref, onMounted } from "vue";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
const donationHref = ref(null);
const statusPageHref = ref(null);
const privacyPolicyHref = ref(null);
onMounted(() => {
fetchJson(apiUrl() + "/config").then(config => {
donationHref.value = config?.donationUrl;
statusPageHref.value = config?.statusPageUrl;
privacyPolicyHref.value = config?.privacyPolicyUrl;
});
},
},
};
});
</script>
<style>

View File

@@ -42,37 +42,60 @@
<ImportHistoryModal v-if="showImportModal" @close="showImportModal = false" />
</template>
<script>
<script setup>
import { ref, onMounted, onActivated, onDeactivated } from "vue";
import VideoItem from "./VideoItem.vue";
import SortingSelector from "./SortingSelector.vue";
import ExportHistoryModal from "./ExportHistoryModal.vue";
import ImportHistoryModal from "./ImportHistoryModal.vue";
import { getPreferenceBoolean, getPreferenceString, setPreference } from "@/composables/usePreferences.js";
export default {
components: {
VideoItem,
SortingSelector,
ExportHistoryModal,
ImportHistoryModal,
},
data() {
return {
currentVideoCount: 0,
videoStep: 100,
videosStore: [],
videos: [],
autoDeleteHistory: false,
autoDeleteDelayHours: "24",
showExportModal: false,
showImportModal: false,
};
},
mounted() {
this.autoDeleteHistory = this.getPreferenceBoolean("autoDeleteWatchHistory", false);
this.autoDeleteDelayHours = this.getPreferenceString("autoDeleteWatchHistoryDelayHours", "24");
let currentVideoCount = 0;
const videoStep = 100;
const videosStore = [];
const videos = ref([]);
const autoDeleteHistory = ref(false);
const autoDeleteDelayHours = ref("24");
const showExportModal = ref(false);
const showImportModal = ref(false);
function shouldRemoveVideo(video) {
if (!autoDeleteHistory.value) return false;
let maximumTimeDiff = Number(autoDeleteDelayHours.value) * 60 * 60 * 1000;
return Date.now() - video.watchedAt > maximumTimeDiff;
}
function loadMoreVideos() {
currentVideoCount = Math.min(currentVideoCount + videoStep, videosStore.length);
if (videos.value.length != videosStore.length) videos.value = videosStore.slice(0, currentVideoCount);
}
function handleScroll() {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
loadMoreVideos();
}
}
function clearHistory() {
if (window.db) {
var tx = window.db.transaction("watch_history", "readwrite");
var store = tx.objectStore("watch_history");
store.clear();
}
videos.value = [];
}
function onChange() {
setPreference("autoDeleteWatchHistory", autoDeleteHistory.value);
setPreference("autoDeleteWatchHistoryDelayHours", autoDeleteDelayHours.value);
}
onMounted(() => {
autoDeleteHistory.value = getPreferenceBoolean("autoDeleteWatchHistory", false);
autoDeleteDelayHours.value = getPreferenceString("autoDeleteWatchHistoryDelayHours", "24");
(async () => {
if (window.db && this.getPreferenceBoolean("watchHistory", false)) {
if (window.db && 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");
@@ -81,13 +104,13 @@ export default {
const cursor = e.target.result;
if (cursor) {
const video = cursor.value;
if (!this.shouldRemoveVideo(video)) {
this.videosStore.push({
if (!shouldRemoveVideo(video)) {
videosStore.push({
url: "/watch?v=" + video.videoId,
title: video.title,
uploaderName: video.uploaderName,
uploaderUrl: video.uploaderUrl ?? "", // Router doesn't like undefined
duration: video.duration ?? 0, // Undefined duration shows "Live"
uploaderUrl: video.uploaderUrl ?? "",
duration: video.duration ?? 0,
thumbnail: video.thumbnail,
watchedAt: video.watchedAt,
watched: true,
@@ -96,7 +119,7 @@ export default {
} else {
store.delete(video.videoId);
}
if (this.videosStore.length < 1000) cursor.continue();
if (videosStore.length < 1000) cursor.continue();
else resolve();
} else resolve();
};
@@ -104,45 +127,16 @@ export default {
await cursorPromise;
}
})().then(() => {
this.loadMoreVideos();
loadMoreVideos();
});
},
activated() {
});
onActivated(() => {
document.title = "Watch History - Piped";
window.addEventListener("scroll", this.handleScroll);
},
deactivated() {
window.removeEventListener("scroll", this.handleScroll);
},
methods: {
clearHistory() {
if (window.db) {
var tx = window.db.transaction("watch_history", "readwrite");
var store = tx.objectStore("watch_history");
store.clear();
}
this.videos = [];
},
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;
},
loadMoreVideos() {
this.currentVideoCount = Math.min(this.currentVideoCount + this.videoStep, this.videosStore.length);
if (this.videos.length != this.videosStore.length)
this.videos = this.videosStore.slice(0, this.currentVideoCount);
},
handleScroll() {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
this.loadMoreVideos();
}
},
},
};
window.addEventListener("scroll", handleScroll);
});
onDeactivated(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@@ -34,75 +34,67 @@
</div>
</ModalComponent>
</template>
<script>
<script setup>
import { ref, computed } from "vue";
import ModalComponent from "./ModalComponent.vue";
export default {
components: { ModalComponent },
data() {
return {
items: [],
override: false,
index: 0,
success: 0,
error: 0,
skipped: 0,
};
},
computed: {
itemsLength() {
return this.items.length;
},
},
methods: {
fileChange() {
const file = this.$refs.fileSelector.files[0];
const fileSelector = ref(null);
const items = ref([]);
const override = ref(false);
const index = ref(0);
const success = ref(0);
const error = ref(0);
const skipped = ref(0);
const itemsLength = computed(() => items.value.length);
function fileChange() {
const file = fileSelector.value.files[0];
file.text().then(text => {
this.items = [];
items.value = [];
const json = JSON.parse(text);
const items = json.watchHistory.map(video => {
const parsed = json.watchHistory.map(video => {
return {
...video,
watchedAt: video.watchedAt ?? 0,
currentTime: video.currentTime ?? 0,
};
});
this.items = items.sort((a, b) => b.watchedAt - a.watchedAt);
items.value = parsed.sort((a, b) => b.watchedAt - a.watchedAt);
});
},
handleImport() {
}
function handleImport() {
if (window.db) {
var tx = window.db.transaction("watch_history", "readwrite");
var store = tx.objectStore("watch_history");
this.items.forEach(item => {
items.value.forEach(item => {
const dbItem = store.get(item.videoId);
dbItem.onsuccess = () => {
if (dbItem.result && dbItem.result.videoId === item.videoId) {
if (!this.override) {
this.index++;
this.skipped++;
if (!override.value) {
index.value++;
skipped.value++;
return;
}
}
try {
const request = store.put(JSON.parse(JSON.stringify(item))); // prevent "Symbol could not be cloned." error
request.onsuccess = () => {
this.index++;
this.success++;
index.value++;
success.value++;
};
request.onerror = () => {
this.index++;
this.error++;
index.value++;
error.value++;
};
} catch (error) {
console.error(error);
this.index++;
this.error++;
} catch (err) {
console.error(err);
index.value++;
error.value++;
}
};
});
}
},
},
};
}
</script>

View File

@@ -57,27 +57,28 @@
</div>
</template>
<script>
export default {
data() {
return {
subscriptions: [],
override: false,
};
},
computed: {
selectedSubscriptions() {
return this.subscriptions.length;
},
},
activated() {
<script setup>
import { ref, computed, onActivated } from "vue";
import { useI18n } from "vue-i18n";
import { fetchJson, authApiUrl, getAuthToken, isAuthenticated } from "@/composables/useApi.js";
import { getLocalSubscriptions } from "@/composables/useSubscriptions.js";
const { t } = useI18n();
const fileSelector = ref(null);
const subscriptions = ref([]);
const override = ref(false);
const selectedSubscriptions = computed(() => subscriptions.value.length);
onActivated(() => {
document.title = "Import - Piped";
},
methods: {
fileChange() {
const file = this.$refs.fileSelector.files[0];
});
function fileChange() {
const file = fileSelector.value.files[0];
file.text().then(text => {
this.subscriptions = [];
subscriptions.value = [];
// Invidious
if (text.indexOf("opml") != -1) {
@@ -86,7 +87,7 @@ export default {
xmlDoc.querySelectorAll("outline[xmlUrl]").forEach(item => {
const url = item.getAttribute("xmlUrl");
const id = url.slice(-24);
this.subscriptions.push(id);
subscriptions.value.push(id);
});
}
// NewPipe
@@ -98,13 +99,13 @@ export default {
.forEach(item => {
const url = item.url;
const id = url.slice(-24);
this.subscriptions.push(id);
subscriptions.value.push(id);
});
}
// Invidious JSON
else if (text.indexOf("thin_mode") != -1) {
const json = JSON.parse(text);
this.subscriptions = json.subscriptions;
subscriptions.value = json.subscriptions;
}
// FreeTube DB
else if (text.indexOf("allChannels") != -1) {
@@ -113,7 +114,7 @@ export default {
if (line === "") continue;
const json = JSON.parse(line);
json.subscriptions.forEach(item => {
this.subscriptions.push(item.id);
subscriptions.value.push(item.id);
});
}
}
@@ -122,7 +123,7 @@ export default {
const json = JSON.parse(text);
json.forEach(item => {
const id = item.snippet.resourceId.channelId;
this.subscriptions.push(id);
subscriptions.value.push(id);
});
}
@@ -132,45 +133,45 @@ export default {
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const id = line.slice(0, line.indexOf(","));
if (id.length === 24) this.subscriptions.push(id);
if (id.length === 24) subscriptions.value.push(id);
}
}
});
},
handleImport() {
if (this.authenticated) {
this.fetchJson(
this.authApiUrl() + "/import",
}
function handleImport() {
if (isAuthenticated()) {
fetchJson(
authApiUrl() + "/import",
{
override: this.override,
override: override.value,
},
{
method: "POST",
headers: {
Authorization: this.getAuthToken(),
Authorization: getAuthToken(),
},
body: JSON.stringify(this.subscriptions),
body: JSON.stringify(subscriptions.value),
},
).then(json => {
if (json.message === "ok") window.location = "/feed";
});
} else {
this.importSubscriptionsLocally(this.subscriptions);
importSubscriptionsLocally(subscriptions.value);
window.location = "/feed";
}
},
importSubscriptionsLocally(newChannels) {
const subscriptions = this.override
}
function importSubscriptionsLocally(newChannels) {
const subs = override.value
? [...new Set(newChannels)]
: [...new Set((this.getLocalSubscriptions() ?? []).concat(newChannels))];
: [...new Set((getLocalSubscriptions() ?? []).concat(newChannels))];
// Sort for better cache hits
subscriptions.sort();
subs.sort();
try {
localStorage.setItem("localSubscriptions", JSON.stringify(subscriptions));
localStorage.setItem("localSubscriptions", JSON.stringify(subs));
} catch (e) {
alert(this.$t("info.local_storage"));
alert(t("info.local_storage"));
}
},
},
};
}
</script>

View File

@@ -7,15 +7,13 @@
</div>
</template>
<script>
export default {
props: {
<script setup>
defineProps({
showContent: {
type: Boolean,
required: true,
},
},
};
});
</script>
<style>

View File

@@ -35,39 +35,43 @@
</div>
</template>
<script>
export default {
data() {
return {
username: null,
password: null,
};
},
mounted() {
<script setup>
import { ref, onMounted, onActivated } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { fetchJson, authApiUrl, getAuthToken, hashCode } from "@/composables/useApi.js";
import { setPreference } from "@/composables/usePreferences.js";
const router = useRouter();
const { t } = useI18n();
const username = ref(null);
const password = ref(null);
onMounted(() => {
//TODO: Add Server Side check
if (this.getAuthToken()) {
this.$router.push(import.meta.env.BASE_URL);
if (getAuthToken()) {
router.push(import.meta.env.BASE_URL);
}
},
activated() {
document.title = this.$t("titles.login") + " - Piped";
},
methods: {
login() {
if (!this.username || !this.password) return;
this.fetchJson(this.authApiUrl() + "/login", null, {
});
onActivated(() => {
document.title = t("titles.login") + " - Piped";
});
function login() {
if (!username.value || !password.value) return;
fetchJson(authApiUrl() + "/login", null, {
method: "POST",
body: JSON.stringify({
username: this.username,
password: this.password,
username: username.value,
password: password.value,
}),
}).then(resp => {
if (resp.token) {
this.setPreference("authToken" + this.hashCode(this.authApiUrl()), resp.token);
setPreference("authToken" + hashCode(authApiUrl()), resp.token);
window.location = import.meta.env.BASE_URL; // done to bypass cache
} else alert(resp.error);
});
},
},
};
}
</script>

View File

@@ -9,28 +9,30 @@
</div>
</template>
<script>
export default {
emits: ["close"],
mounted() {
window.addEventListener("keydown", this.handleKeyDown);
},
unmounted() {
window.removeEventListener("keydown", this.handleKeyDown);
},
methods: {
handleKeyDown(event) {
<script setup>
import { onMounted, onUnmounted } from "vue";
const emit = defineEmits(["close"]);
function handleKeyDown(event) {
if (event.code === "Escape") {
this.$emit("close");
emit("close");
} else return;
event.preventDefault();
},
handleClick(event) {
}
function handleClick(event) {
if (event.target !== event.currentTarget) return;
this.$emit("close");
},
},
};
emit("close");
}
onMounted(() => {
window.addEventListener("keydown", handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown);
});
</script>
<style scoped>

View File

@@ -119,107 +119,121 @@
/>
</template>
<script>
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import SearchSuggestions from "./SearchSuggestions.vue";
import hotkeys from "hotkeys-js";
export default {
components: {
SearchSuggestions,
import { fetchJson, authApiUrl, getAuthToken } from "@/composables/useApi.js";
import { getPreferenceBoolean, getPreferenceString } from "@/composables/usePreferences.js";
import { getHomePage } from "@/composables/useMisc.js";
const router = useRouter();
const route = useRoute();
const videoSearch = ref(null);
const searchSuggestions = ref(null);
const searchText = ref("");
const suggestionsVisible = ref(false);
const showTopNav = ref(false);
const homePagePath = ref(import.meta.env.BASE_URL);
const registrationDisabled = ref(false);
const shouldShowLogin = computed(() => {
return getAuthToken() == null;
});
const shouldShowRegister = computed(() => {
return registrationDisabled.value == false ? shouldShowLogin.value : false;
});
const shouldShowHistory = computed(() => {
return getPreferenceBoolean("watchHistory", false);
});
const shouldShowTrending = computed(() => {
return getPreferenceString("homepage", "trending") != "trending";
});
const showSearchHistory = computed(() => {
return getPreferenceBoolean("searchHistory", false) && localStorage.getItem("search_history");
});
watch(
() => route.fullPath,
() => {
updateSearchTextFromURLSearchParams();
},
data() {
return {
searchText: "",
suggestionsVisible: false,
showTopNav: false,
homePagePath: import.meta.env.BASE_URL,
registrationDisabled: false,
};
},
computed: {
shouldShowLogin(_this) {
return _this.getAuthToken() == null;
},
shouldShowRegister(_this) {
return _this.registrationDisabled == false ? _this.shouldShowLogin : false;
},
shouldShowHistory(_this) {
return _this.getPreferenceBoolean("watchHistory", false);
},
shouldShowTrending(_this) {
return _this.getPreferenceString("homepage", "trending") != "trending";
},
showSearchHistory(_this) {
return _this.getPreferenceBoolean("searchHistory", false) && localStorage.getItem("search_history");
},
},
watch: {
$route() {
this.updateSearchTextFromURLSearchParams();
},
},
mounted() {
this.fetchAuthConfig();
this.updateSearchTextFromURLSearchParams();
this.focusOnSearchBar();
this.homePagePath = this.getHomePage(this);
},
methods: {
updateSearchTextFromURLSearchParams() {
);
function updateSearchTextFromURLSearchParams() {
const query = new URLSearchParams(window.location.search).get("search_query");
if (query) this.onSearchTextChange(query);
},
// focus on search bar when Ctrl+k is pressed
focusOnSearchBar() {
if (query) onSearchTextChange(query);
}
function focusOnSearchBar() {
hotkeys("ctrl+k", event => {
event.preventDefault();
this.$refs.videoSearch.focus();
videoSearch.value.focus();
});
},
onKeyUp(e) {
}
function onKeyUp(e) {
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
}
this.$refs.searchSuggestions.onKeyUp(e);
},
onKeyPress(e) {
searchSuggestions.value.onKeyUp(e);
}
function onKeyPress(e) {
if (e.key === "Enter") {
this.submitSearch(e);
submitSearch(e);
}
},
onInputFocus() {
if (this.showSearchHistory) this.$refs.searchSuggestions.refreshSuggestions();
this.suggestionsVisible = true;
},
onInputBlur() {
// the search suggestions will be hidden after some seconds
// otherwise anchor links won't work!
setTimeout(() => (this.suggestionsVisible = false), 200);
},
onSearchTextChange(searchText) {
this.searchText = searchText;
},
async fetchAuthConfig() {
this.fetchJson(this.authApiUrl() + "/config").then(config => {
this.registrationDisabled = config?.registrationDisabled === true;
}
function onInputFocus() {
if (showSearchHistory.value) searchSuggestions.value.refreshSuggestions();
suggestionsVisible.value = true;
}
function onInputBlur() {
setTimeout(() => (suggestionsVisible.value = false), 200);
}
function onSearchTextChange(text) {
searchText.value = text;
}
async function fetchAuthConfig() {
fetchJson(authApiUrl() + "/config").then(config => {
registrationDisabled.value = config?.registrationDisabled === true;
});
},
onSearchClick(e) {
this.submitSearch(e);
},
submitSearch(e) {
}
function onSearchClick(e) {
submitSearch(e);
}
function submitSearch(e) {
e.target.blur();
if (this.searchText) {
this.$router.push({
if (searchText.value) {
router.push({
name: "SearchResults",
query: { search_query: this.searchText },
query: { search_query: searchText.value },
});
} else {
this.$router.push("/");
router.push("/");
}
return;
},
},
};
}
onMounted(() => {
fetchAuthConfig();
updateSearchTextFromURLSearchParams();
focusOnSearchBar();
homePagePath.value = getHomePage();
});
</script>
<style>

View File

@@ -26,16 +26,18 @@
/>
</template>
<script>
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import ModalComponent from "./ModalComponent.vue";
import CreatePlaylistModal from "./CreatePlaylistModal.vue";
import { getPlaylists, addVideosToPlaylist } from "@/composables/usePlaylists.js";
import { getPreferenceString, setPreference } from "@/composables/usePreferences.js";
import { authApiUrl, hashCode } from "@/composables/useApi.js";
export default {
components: {
ModalComponent,
CreatePlaylistModal,
},
props: {
const { t } = useI18n();
const props = defineProps({
videoInfo: {
type: Object,
required: true,
@@ -44,55 +46,56 @@ export default {
type: String,
required: true,
},
},
emits: ["close"],
data() {
return {
playlists: [],
selectedPlaylist: null,
processing: false,
showCreatePlaylistModal: false,
};
},
mounted() {
this.getPlaylists().then(json => {
this.playlists = json;
});
this.selectedPlaylist = this.getPreferenceString("selectedPlaylist" + this.hashCode(this.authApiUrl()));
window.addEventListener("keydown", this.handleKeyDown);
window.blur();
},
unmounted() {
window.removeEventListener("keydown", this.handleKeyDown);
},
methods: {
handleKeyDown(event) {
if (event.code === "Enter" && !this.showCreatePlaylistModal) {
this.handleClick(this.selectedPlaylist);
});
const emit = defineEmits(["close"]);
const addButton = ref(null);
const playlists = ref([]);
const selectedPlaylist = ref(null);
const processing = ref(false);
const showCreatePlaylistModal = ref(false);
function handleKeyDown(event) {
if (event.code === "Enter" && !showCreatePlaylistModal.value) {
handleClick(selectedPlaylist.value);
event.preventDefault();
}
},
handleClick(playlistId) {
}
function handleClick(playlistId) {
if (!playlistId) {
alert(this.$t("actions.please_select_playlist"));
alert(t("actions.please_select_playlist"));
return;
}
if (this.processing) return;
if (processing.value) return;
this.$refs.addButton.disabled = true;
this.processing = true;
addButton.value.disabled = true;
processing.value = true;
this.addVideosToPlaylist(playlistId, [this.videoId], [this.videoInfo]).then(json => {
this.setPreference("selectedPlaylist" + this.hashCode(this.authApiUrl()), playlistId);
this.$emit("close");
addVideosToPlaylist(playlistId, [props.videoId], [props.videoInfo]).then(json => {
setPreference("selectedPlaylist" + hashCode(authApiUrl()), playlistId);
emit("close");
if (json.error) alert(json.error);
});
},
addCreatedPlaylist(playlistId, playlistName) {
this.playlists.push({ id: playlistId, name: playlistName });
this.selectedPlaylist = playlistId;
},
},
};
}
function addCreatedPlaylist(playlistId, playlistName) {
playlists.value.push({ id: playlistId, name: playlistName });
selectedPlaylist.value = playlistId;
}
onMounted(() => {
getPlaylists().then(json => {
playlists.value = json;
});
selectedPlaylist.value = getPreferenceString("selectedPlaylist" + hashCode(authApiUrl()));
window.addEventListener("keydown", handleKeyDown);
window.blur();
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyDown);
});
</script>

View File

@@ -54,158 +54,164 @@
</LoadingIndicatorPage>
</template>
<script>
<script setup>
import { ref, computed, onMounted, onActivated, onDeactivated } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import ErrorHandler from "./ErrorHandler.vue";
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
import CollapsableText from "./CollapsableText.vue";
import VideoItem from "./VideoItem.vue";
import WatchOnButton from "./WatchOnButton.vue";
import { fetchJson, authApiUrl, getAuthToken, isAuthenticated } from "@/composables/useApi.js";
import { getPlaylists } from "@/composables/usePlaylists.js";
import { getPlaylist } from "@/composables/usePlaylists.js";
import { updateWatched, download } from "@/composables/useMisc.js";
import { fetchDeArrowContent } from "@/composables/useSubscriptions.js";
import { timeFormat } from "@/composables/useFormatting.js";
export default {
components: {
ErrorHandler,
VideoItem,
WatchOnButton,
LoadingIndicatorPage,
CollapsableText,
},
data() {
return {
playlist: null,
totalDuration: 0,
admin: false,
isBookmarked: false,
};
},
computed: {
getRssUrl: _this => {
return _this.authApiUrl() + "/rss/playlists/" + _this.$route.query.list;
},
isPipedPlaylist: _this => {
// regex to determine whether it's a Piped plalylist
return /[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}/.test(
_this.$route.query.list,
);
},
},
mounted() {
const playlistId = this.$route.query.list;
if (this.authenticated && playlistId?.length == 36)
this.getPlaylists().then(json => {
if (json.error) alert(json.error);
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();
},
deactivated() {
window.removeEventListener("scroll", this.handleScroll);
},
methods: {
async getPlaylistData() {
this.getPlaylist(this.$route.query.list)
.then(data => (this.playlist = data))
const route = useRoute();
const { t } = useI18n();
const playlist = ref(null);
const totalDuration = ref(0);
const admin = ref(false);
const isBookmarked = ref(false);
let loading = false;
const getRssUrl = computed(() => {
return authApiUrl() + "/rss/playlists/" + route.query.list;
});
const isPipedPlaylist = computed(() => {
return /[\da-fA-F]{8}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{4}-[\da-fA-F]{12}/.test(route.query.list);
});
function updateTitle() {
document.title = playlist.value.name + " - Piped";
}
function updateTotalDuration() {
totalDuration.value = playlist.value.relatedStreams.map(video => video.duration).reduce((a, b) => a + b);
}
async function getPlaylistData() {
getPlaylist(route.query.list)
.then(data => (playlist.value = data))
.then(() => {
this.updateTitle();
this.updateTotalDuration();
this.updateWatched(this.playlist.relatedStreams);
this.fetchDeArrowContent(this.playlist.relatedStreams);
updateTitle();
updateTotalDuration();
updateWatched(playlist.value.relatedStreams);
fetchDeArrowContent(playlist.value.relatedStreams);
});
},
async updateTitle() {
document.title = this.playlist.name + " - Piped";
},
handleScroll() {
if (this.loading || !this.playlist || !this.playlist.nextpage) return;
}
function handleScroll() {
if (loading || !playlist.value || !playlist.value.nextpage) return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
this.loading = true;
this.fetchJson(this.authApiUrl() + "/nextpage/playlists/" + this.$route.query.list, {
nextpage: this.playlist.nextpage,
loading = true;
fetchJson(authApiUrl() + "/nextpage/playlists/" + route.query.list, {
nextpage: playlist.value.nextpage,
}).then(json => {
this.playlist.nextpage = json.nextpage;
this.loading = false;
this.playlist.relatedStreams.push(...json.relatedStreams);
this.updateTotalDuration();
this.fetchDeArrowContent(json.relatedStreams);
playlist.value.nextpage = json.nextpage;
loading = false;
playlist.value.relatedStreams.push(...json.relatedStreams);
updateTotalDuration();
fetchDeArrowContent(json.relatedStreams);
});
}
},
removeVideo(index) {
this.playlist.relatedStreams.splice(index, 1);
},
updateTotalDuration() {
this.totalDuration = this.playlist.relatedStreams.map(video => video.duration).reduce((a, b) => a + b);
},
async clonePlaylist() {
this.fetchJson(this.authApiUrl() + "/import/playlist", null, {
}
function removeVideo(index) {
playlist.value.relatedStreams.splice(index, 1);
}
async function clonePlaylist() {
fetchJson(authApiUrl() + "/import/playlist", null, {
method: "POST",
headers: {
Authorization: this.getAuthToken(),
Authorization: getAuthToken(),
},
body: JSON.stringify({
playlistId: this.$route.query.list,
playlistId: route.query.list,
}),
}).then(resp => {
if (!resp.error) {
alert(this.$t("actions.clone_playlist_success"));
alert(t("actions.clone_playlist_success"));
} else alert(resp.error);
});
},
downloadPlaylistAsTxt() {
const data = this.playlist.relatedStreams
}
function downloadPlaylistAsTxt() {
const data = playlist.value.relatedStreams
.map(video => {
return "https://piped.video" + video.url;
})
.join("\n");
this.download(data, this.playlist.name + ".txt", "text/plain");
},
async bookmarkPlaylist() {
if (!this.playlist) return;
download(data, playlist.value.name + ".txt", "text/plain");
}
if (this.isBookmarked) {
this.removePlaylistBookmark();
async function bookmarkPlaylist() {
if (!playlist.value) return;
if (isBookmarked.value) {
removePlaylistBookmark();
return;
}
if (window.db) {
const playlistId = this.$route.query.list;
const playlistId = 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,
name: playlist.value.name,
uploader: playlist.value.uploader,
uploaderUrl: playlist.value.uploaderUrl,
thumbnail: playlist.value.thumbnailUrl,
uploaderAvatar: playlist.value.uploaderAvatar,
videos: playlist.value.videos,
});
this.isBookmarked = true;
isBookmarked.value = true;
}
},
async removePlaylistBookmark() {
}
async function 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;
store.delete(route.query.list);
isBookmarked.value = false;
}
async function checkPlaylistBookmarked() {
const playlistId = 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;
isBookmarked.value = cursor ? true : false;
};
},
},
};
}
onMounted(() => {
const playlistId = route.query.list;
if (isAuthenticated() && playlistId?.length == 36)
getPlaylists().then(json => {
if (json.error) alert(json.error);
else if (json.some(pl => pl.id === playlistId)) admin.value = true;
});
else if (playlistId.startsWith("local")) admin.value = true;
checkPlaylistBookmarked();
});
onActivated(() => {
getPlaylistData();
window.addEventListener("scroll", handleScroll);
if (playlist.value) updateTitle();
});
onDeactivated(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@@ -58,12 +58,12 @@
</div>
</template>
<script>
import { nextTick } from "vue";
<script setup>
import { ref, watch, onMounted, nextTick } from "vue";
import VideoThumbnail from "./VideoThumbnail.vue";
export default {
components: { VideoThumbnail },
props: {
import { updateWatched } from "@/composables/useMisc.js";
const props = defineProps({
playlist: {
type: Object,
required: true,
@@ -80,30 +80,30 @@ export default {
type: Boolean,
default: false,
},
},
watch: {
playlist: {
handler() {
if (this.selectedIndex - 1 < this.playlist.relatedStreams.length)
});
const scrollable = ref(null);
watch(
() => props.playlist,
() => {
if (props.selectedIndex - 1 < props.playlist.relatedStreams.length)
nextTick(() => {
this.updateScroll();
updateScroll();
});
},
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;
{ deep: true },
);
function updateScroll() {
const elems = Array.from(scrollable.value.children).filter(elm => elm.matches("div"));
const index = props.selectedIndex - 1;
if (index < elems.length)
this.$refs.scrollable.scrollTop =
elems[this.selectedIndex - 1].offsetTop - this.$refs.scrollable.offsetTop;
},
},
};
scrollable.value.scrollTop = elems[props.selectedIndex - 1].offsetTop - scrollable.value.offsetTop;
}
onMounted(() => {
updateScroll();
updateWatched(props.playlist.relatedStreams);
});
</script>

View File

@@ -99,107 +99,108 @@
/>
</template>
<script>
<script setup>
import { ref, onMounted, onActivated } from "vue";
import { useI18n } from "vue-i18n";
import ConfirmModal from "./ConfirmModal.vue";
import ModalComponent from "./ModalComponent.vue";
import CreatePlaylistModal from "./CreatePlaylistModal.vue";
import {
getPlaylists,
getPlaylist,
createPlaylist,
deletePlaylist,
renamePlaylist,
changePlaylistDescription,
addVideosToPlaylist,
} from "@/composables/usePlaylists.js";
import { download } from "@/composables/useMisc.js";
export default {
components: { ConfirmModal, ModalComponent, CreatePlaylistModal },
data() {
return {
playlists: [],
bookmarks: [],
playlistToDelete: null,
playlistToEdit: null,
newPlaylistName: "",
newPlaylistDescription: "",
showCreatePlaylistModal: false,
};
},
mounted() {
this.fetchPlaylists();
this.loadPlaylistBookmarks();
},
activated() {
document.title = this.$t("titles.playlists") + " - Piped";
},
methods: {
fetchPlaylists() {
this.getPlaylists().then(json => {
this.playlists = json;
const { t } = useI18n();
const fileSelector = ref(null);
const playlists = ref([]);
const bookmarks = ref([]);
const playlistToDelete = ref(null);
const playlistToEdit = ref(null);
const newPlaylistName = ref("");
const newPlaylistDescription = ref("");
const showCreatePlaylistModal = ref(false);
function fetchPlaylistsList() {
getPlaylists().then(json => {
playlists.value = json;
});
},
showPlaylistEditModal(playlist) {
this.newPlaylistName = playlist.name;
this.newPlaylistDescription = playlist.description;
this.playlistToEdit = playlist.id;
},
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;
}
function showPlaylistEditModal(playlist) {
newPlaylistName.value = playlist.name;
newPlaylistDescription.value = playlist.description;
playlistToEdit.value = playlist.id;
}
function editPlaylist(selectedPlaylist) {
const newName = newPlaylistName.value;
const newDescription = newPlaylistDescription.value;
if (newName != selectedPlaylist.name) {
this.renamePlaylist(selectedPlaylist.id, newName).then(json => {
renamePlaylist(selectedPlaylist.id, newName).then(json => {
if (json.error) alert(json.error);
else selectedPlaylist.name = newName;
});
}
if (newDescription != selectedPlaylist.description) {
this.changePlaylistDescription(selectedPlaylist.id, newDescription).then(json => {
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 => {
playlistToEdit.value = null;
}
function onDeletePlaylist(id) {
deletePlaylist(id).then(json => {
if (json.error) alert(json.error);
else this.playlists = this.playlists.filter(playlist => playlist.id !== id);
else playlists.value = playlists.value.filter(playlist => playlist.id !== id);
});
this.playlistToDelete = null;
},
async exportPlaylists() {
if (!this.playlists) return;
playlistToDelete.value = null;
}
async function exportPlaylists() {
if (!playlists.value) return;
let json = {
format: "Piped",
version: 1,
playlists: [],
};
let tasks = this.playlists.map(playlist => this.fetchPlaylistJson(playlist.id));
let tasks = playlists.value.map(playlist => fetchPlaylistJson(playlist.id));
json.playlists = await Promise.all(tasks);
this.download(JSON.stringify(json), "playlists.json", "application/json");
},
async fetchPlaylistJson(playlistId) {
let playlist = await this.getPlaylist(playlistId);
download(JSON.stringify(json), "playlists.json", "application/json");
}
async function fetchPlaylistJson(playlistId) {
let playlist = await getPlaylist(playlistId);
return {
name: playlist.name,
// possible other types: history, watch later, ...
type: "playlist",
// as Invidious supports public and private playlists
visibility: "private",
// 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),
};
},
async importPlaylists() {
const files = this.$refs.fileSelector.files;
}
async function importPlaylists() {
const files = fileSelector.value.files;
for (let file of files) {
await this.importPlaylistFile(file);
await importPlaylistFile(file);
}
window.location.reload();
},
async importPlaylistFile(file) {
}
async function importPlaylistFile(file) {
let text = (await file.text()).trim();
let tasks = [];
// list of playlists exported from Piped
if (file.name.slice(-4).toLowerCase() == ".csv") {
const lines = text.split("\n");
// old format: first two lines contain playlist info (e.g. name) in CSV format
// new format: no information about playlist like name, ...
// video list has two columns: videoId and date of addition
const playlistInfo = lines[1].split(",");
let videoListStartIndex = 0;
let playlistName = null;
@@ -216,29 +217,30 @@ export default {
.slice(1)
.map(line => `https://youtube.com/watch?v=${line.split(",")[0]}`),
};
tasks.push(this.createPlaylistWithVideos(playlist));
tasks.push(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"));
let parsedPlaylists = JSON.parse(text).playlists;
if (!parsedPlaylists.length) {
alert(t("actions.no_valid_playlists"));
return;
}
for (let playlist of playlists) {
tasks.push(this.createPlaylistWithVideos(playlist));
for (let playlist of parsedPlaylists) {
tasks.push(createPlaylistWithVideos(playlist));
}
} else {
alert(this.$t("actions.no_valid_playlists"));
alert(t("actions.no_valid_playlists"));
return;
}
await Promise.all(tasks);
},
async createPlaylistWithVideos(playlist) {
let newPlaylist = await this.createPlaylist(playlist.name);
}
async function createPlaylistWithVideos(playlist) {
let newPlaylist = await createPlaylist(playlist.name);
let videoIds = playlist.videos.map(url => url.substr(-11));
await this.addVideosToPlaylist(newPlaylist.playlistId, videoIds);
},
async loadPlaylistBookmarks() {
await addVideosToPlaylist(newPlaylist.playlistId, videoIds);
}
async function loadPlaylistBookmarks() {
if (!window.db) return;
var tx = window.db.transaction("playlist_bookmarks", "readonly");
var store = tx.objectStore("playlist_bookmarks");
@@ -246,17 +248,25 @@ export default {
cursorRequest.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
this.bookmarks.push(cursor.value);
bookmarks.value.push(cursor.value);
cursor.continue();
}
};
},
async removeBookmark(index) {
}
async function 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);
},
},
};
store.delete(bookmarks.value[index].playlistId);
bookmarks.value.splice(index, 1);
}
onMounted(() => {
fetchPlaylistsList();
loadPlaylistBookmarks();
});
onActivated(() => {
document.title = t("titles.playlists") + " - Piped";
});
</script>

View File

@@ -388,7 +388,7 @@
<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()" />
<input id="fileSelector" ref="fileSelectorEl" class="hidden" type="file" @change="restorePreferences()" />
<ConfirmModal
v-if="showConfirmResetPrefsDialog"
:message="$t('actions.confirm_reset_preferences')"
@@ -404,25 +404,40 @@
/>
</template>
<script>
import CountryMap from "@/utils/CountryMaps/en.json";
<script setup>
import { ref, computed, onMounted, onActivated } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import CountryMapDefault from "@/utils/CountryMaps/en.json";
import ConfirmModal from "./ConfirmModal.vue";
import CustomInstanceModal from "./CustomInstanceModal.vue";
export default {
components: {
ConfirmModal,
CustomInstanceModal,
},
data() {
return {
mobileChapterLayout: "Vertical",
selectedInstance: null,
authInstance: false,
selectedAuthInstance: null,
customInstances: [],
publicInstances: [],
sponsorBlock: true,
skipOptions: new Map([
import {
testLocalStorage,
getPreferenceString,
getPreferenceBoolean,
getPreferenceNumber,
getPreferenceJSON,
} from "@/composables/usePreferences";
import { fetchJson, apiUrl, authApiUrl, getAuthToken, hashCode, isAuthenticated } from "@/composables/useApi";
import { getCustomInstances } from "@/composables/useCustomInstances";
import { download } from "@/composables/useMisc";
import { getDefaultLanguage } from "@/composables/useFormatting";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const fileSelectorEl = ref(null);
const mobileChapterLayout = ref("Vertical");
const selectedInstance = ref(null);
const authInstance = ref(false);
const selectedAuthInstance = ref(null);
const customInstances = ref([]);
const publicInstances = ref([]);
const sponsorBlock = ref(true);
const skipOptions = ref(
new Map([
["sponsor", { value: "auto", label: "actions.skip_sponsors" }],
["intro", { value: "no", label: "actions.skip_intro" }],
["outro", { value: "no", label: "actions.skip_outro" }],
@@ -433,32 +448,33 @@ export default {
["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],
preferHls: false,
defaultQuality: 0,
bufferingGoal: 10,
countryMap: CountryMap,
countrySelected: "US",
defaultHomepage: "trending",
minimizeComments: false,
minimizeDescription: true,
minimizeRecommendations: false,
minimizeChapters: false,
showWatchOnYouTube: false,
searchSuggestions: true,
watchHistory: false,
searchHistory: false,
hideWatched: false,
selectedLanguage: "en",
languages: [
);
const showMarkers = ref(true);
const minSegmentLength = ref(0);
const dearrow = ref(false);
const selectedTheme = ref("dark");
const autoPlayVideo = ref(true);
const autoDisplayCaptions = ref(false);
const autoPlayNextCountdown = ref(5);
const listen = ref(false);
const resolutions = [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320];
const preferHls = ref(false);
const defaultQuality = ref(0);
const bufferingGoal = ref(10);
const countryMap = ref(CountryMapDefault);
const countrySelected = ref("US");
const defaultHomepage = ref("trending");
const minimizeComments = ref(false);
const minimizeDescription = ref(true);
const minimizeRecommendations = ref(false);
const minimizeChapters = ref(false);
const showWatchOnYouTube = ref(false);
const searchSuggestions = ref(true);
const watchHistory = ref(false);
const searchHistory = ref(false);
const hideWatched = ref(false);
const selectedLanguage = ref("en");
const languages = [
{ code: "ar", name: "Arabic" },
{ code: "az", name: "Azərbaycan" },
{ code: "bg", name: "Български" },
@@ -508,211 +524,213 @@ export default {
{ code: "vi", name: "Tiếng Việt" },
{ code: "zh_Hant", name: "繁體中文" },
{ code: "zh_Hans", name: "简体中文" },
],
enabledCodecs: ["vp9", "avc"],
disableLBRY: false,
proxyLBRY: false,
prefetchLimit: 2,
password: null,
showConfirmResetPrefsDialog: false,
showCustomInstancesModal: false,
};
},
computed: {
instances() {
return [...this.publicInstances, ...this.customInstances];
},
},
activated() {
document.title = this.$t("titles.preferences") + " - Piped";
},
async mounted() {
if (Object.keys(this.$route.query).length > 0) this.$router.replace({ query: {} });
];
const enabledCodecs = ref(["vp9", "avc"]);
const disableLBRY = ref(false);
const proxyLBRY = ref(false);
const prefetchLimit = ref(2);
const password = ref(null);
const showConfirmResetPrefsDialog = ref(false);
const showCustomInstancesModal = ref(false);
this.fetchInstances();
const authenticated = computed(() => isAuthenticated());
const instances = computed(() => [...publicInstances.value, ...customInstances.value]);
if (this.testLocalStorage) {
this.selectedInstance = this.getPreferenceString("instance", import.meta.env.VITE_PIPED_API);
this.authInstance = this.getPreferenceBoolean("authInstance", false);
this.selectedAuthInstance = this.getPreferenceString("auth_instance_url", this.selectedInstance);
onActivated(() => {
document.title = t("titles.preferences") + " - Piped";
});
this.sponsorBlock = this.getPreferenceBoolean("sponsorblock", true);
var skipOptions, skipList;
if ((skipOptions = this.getPreferenceJSON("skipOptions")) !== undefined) {
Object.entries(skipOptions).forEach(([key, value]) => {
var opt = this.skipOptions.get(key);
onMounted(async () => {
if (Object.keys(route.query).length > 0) router.replace({ query: {} });
fetchInstances();
if (testLocalStorage()) {
selectedInstance.value = getPreferenceString("instance", import.meta.env.VITE_PIPED_API);
authInstance.value = getPreferenceBoolean("authInstance", false);
selectedAuthInstance.value = getPreferenceString("auth_instance_url", selectedInstance.value);
sponsorBlock.value = getPreferenceBoolean("sponsorblock", true);
var savedSkipOptions, skipList;
if ((savedSkipOptions = getPreferenceJSON("skipOptions")) !== undefined) {
Object.entries(savedSkipOptions).forEach(([key, value]) => {
var opt = skipOptions.value.get(key);
if (opt !== undefined) opt.value = value;
else console.log("Unknown sponsor type: " + key);
});
} else if ((skipList = this.getPreferenceString("selectedSkip")) !== undefined) {
} else if ((skipList = getPreferenceString("selectedSkip")) !== undefined) {
skipList = skipList.split(",");
this.skipOptions.forEach(opt => (opt.value = "no"));
skipOptions.value.forEach(opt => (opt.value = "no"));
skipList.forEach(skip => {
var opt = this.skipOptions.get(skip);
var opt = skipOptions.value.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);
this.countrySelected = this.getPreferenceString("region", "US");
this.defaultHomepage = this.getPreferenceString("homepage", "trending");
this.minimizeComments = this.getPreferenceBoolean("minimizeComments", false);
this.minimizeDescription = this.getPreferenceBoolean("minimizeDescription", true);
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);
this.enabledCodecs = this.getPreferenceString("enabledCodecs", "vp9,avc").split(",");
this.disableLBRY = this.getPreferenceBoolean("disableLBRY", false);
this.proxyLBRY = this.getPreferenceBoolean("proxyLBRY", false);
this.prefetchLimit = this.getPreferenceNumber("prefetchLimit", 2);
this.hideWatched = this.getPreferenceBoolean("hideWatched", false);
this.mobileChapterLayout = this.getPreferenceString("mobileChapterLayout", "Vertical");
if (this.selectedLanguage != "en") {
showMarkers.value = getPreferenceBoolean("showMarkers", true);
minSegmentLength.value = Math.max(getPreferenceNumber("minSegmentLength", 0), 0);
dearrow.value = getPreferenceBoolean("dearrow", false);
selectedTheme.value = getPreferenceString("theme", "dark");
autoPlayVideo.value = getPreferenceBoolean("playerAutoPlay", true);
autoDisplayCaptions.value = getPreferenceBoolean("autoDisplayCaptions", false);
autoPlayNextCountdown.value = getPreferenceNumber("autoPlayNextCountdown", 5);
listen.value = getPreferenceBoolean("listen", false);
defaultQuality.value = Number(localStorage.getItem("quality"));
bufferingGoal.value = Math.max(Number(localStorage.getItem("bufferGoal")), 10);
countrySelected.value = getPreferenceString("region", "US");
defaultHomepage.value = getPreferenceString("homepage", "trending");
minimizeComments.value = getPreferenceBoolean("minimizeComments", false);
minimizeDescription.value = getPreferenceBoolean("minimizeDescription", true);
minimizeRecommendations.value = getPreferenceBoolean("minimizeRecommendations", false);
minimizeChapters.value = getPreferenceBoolean("minimizeChapters", false);
showWatchOnYouTube.value = getPreferenceBoolean("showWatchOnYouTube", false);
searchSuggestions.value = getPreferenceBoolean("searchSuggestions", true);
watchHistory.value = getPreferenceBoolean("watchHistory", false);
searchHistory.value = getPreferenceBoolean("searchHistory", false);
selectedLanguage.value = getPreferenceString("hl", await getDefaultLanguage());
enabledCodecs.value = getPreferenceString("enabledCodecs", "vp9,avc").split(",");
disableLBRY.value = getPreferenceBoolean("disableLBRY", false);
proxyLBRY.value = getPreferenceBoolean("proxyLBRY", false);
prefetchLimit.value = getPreferenceNumber("prefetchLimit", 2);
hideWatched.value = getPreferenceBoolean("hideWatched", false);
mobileChapterLayout.value = getPreferenceString("mobileChapterLayout", "Vertical");
if (selectedLanguage.value != "en") {
try {
this.CountryMap = await import(`../utils/CountryMaps/${this.selectedLanguage}.json`).then(
countryMap.value = await import(`../utils/CountryMaps/${selectedLanguage.value}.json`).then(
val => val.default,
);
} catch (e) {
console.error("Countries not translated into " + this.selectedLanguage);
console.error("Countries not translated into " + selectedLanguage.value);
}
}
}
},
methods: {
async onChange() {
if (this.testLocalStorage) {
});
async function onChange() {
if (testLocalStorage()) {
var shouldReload = false;
if (
this.getPreferenceString("theme", "dark") !== this.selectedTheme ||
this.getPreferenceBoolean("watchHistory", false) != this.watchHistory ||
this.getPreferenceString("hl", await this.defaultLanguage) !== this.selectedLanguage ||
this.getPreferenceString("enabledCodecs", "vp9,avc") !== this.enabledCodecs.join(",")
getPreferenceString("theme", "dark") !== selectedTheme.value ||
getPreferenceBoolean("watchHistory", false) != watchHistory.value ||
getPreferenceString("hl", await getDefaultLanguage()) !== selectedLanguage.value ||
getPreferenceString("enabledCodecs", "vp9,avc") !== enabledCodecs.value.join(",")
)
shouldReload = true;
localStorage.setItem("instance", this.selectedInstance);
localStorage.setItem("authInstance", this.authInstance);
localStorage.setItem("auth_instance_url", this.selectedAuthInstance);
localStorage.setItem("sponsorblock", this.sponsorBlock);
localStorage.setItem("instance", selectedInstance.value);
localStorage.setItem("authInstance", authInstance.value);
localStorage.setItem("auth_instance_url", selectedAuthInstance.value);
localStorage.setItem("sponsorblock", sponsorBlock.value);
var skipOptions = {};
this.skipOptions.forEach((v, k) => (skipOptions[k] = v.value));
localStorage.setItem("skipOptions", JSON.stringify(skipOptions));
var savedSkipObj = {};
skipOptions.value.forEach((v, k) => (savedSkipObj[k] = v.value));
localStorage.setItem("skipOptions", JSON.stringify(savedSkipObj));
localStorage.setItem("showMarkers", this.showMarkers);
localStorage.setItem("minSegmentLength", this.minSegmentLength);
localStorage.setItem("showMarkers", showMarkers.value);
localStorage.setItem("minSegmentLength", minSegmentLength.value);
localStorage.setItem("dearrow", this.dearrow);
localStorage.setItem("dearrow", dearrow.value);
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("preferHls", this.preferHls);
localStorage.setItem("quality", this.defaultQuality);
localStorage.setItem("bufferGoal", this.bufferingGoal);
localStorage.setItem("region", this.countrySelected);
localStorage.setItem("homepage", this.defaultHomepage);
localStorage.setItem("minimizeComments", this.minimizeComments);
localStorage.setItem("minimizeDescription", this.minimizeDescription);
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");
localStorage.setItem("hl", this.selectedLanguage);
localStorage.setItem("enabledCodecs", this.enabledCodecs.join(","));
localStorage.setItem("disableLBRY", this.disableLBRY);
localStorage.setItem("proxyLBRY", this.proxyLBRY);
localStorage.setItem("prefetchLimit", this.prefetchLimit);
localStorage.setItem("hideWatched", this.hideWatched);
localStorage.setItem("mobileChapterLayout", this.mobileChapterLayout);
localStorage.setItem("theme", selectedTheme.value);
localStorage.setItem("playerAutoPlay", autoPlayVideo.value);
localStorage.setItem("autoDisplayCaptions", autoDisplayCaptions.value);
localStorage.setItem("autoPlayNextCountdown", autoPlayNextCountdown.value);
localStorage.setItem("listen", listen.value);
localStorage.setItem("preferHls", preferHls.value);
localStorage.setItem("quality", defaultQuality.value);
localStorage.setItem("bufferGoal", bufferingGoal.value);
localStorage.setItem("region", countrySelected.value);
localStorage.setItem("homepage", defaultHomepage.value);
localStorage.setItem("minimizeComments", minimizeComments.value);
localStorage.setItem("minimizeDescription", minimizeDescription.value);
localStorage.setItem("minimizeRecommendations", minimizeRecommendations.value);
localStorage.setItem("minimizeChapters", minimizeChapters.value);
localStorage.setItem("showWatchOnYouTube", showWatchOnYouTube.value);
localStorage.setItem("searchSuggestions", searchSuggestions.value);
localStorage.setItem("watchHistory", watchHistory.value);
localStorage.setItem("searchHistory", searchHistory.value);
if (!searchHistory.value) localStorage.removeItem("search_history");
localStorage.setItem("hl", selectedLanguage.value);
localStorage.setItem("enabledCodecs", enabledCodecs.value.join(","));
localStorage.setItem("disableLBRY", disableLBRY.value);
localStorage.setItem("proxyLBRY", proxyLBRY.value);
localStorage.setItem("prefetchLimit", prefetchLimit.value);
localStorage.setItem("hideWatched", hideWatched.value);
localStorage.setItem("mobileChapterLayout", mobileChapterLayout.value);
if (shouldReload) window.location.reload();
}
},
async fetchInstances() {
this.customInstances = this.getCustomInstances();
}
this.fetchJson(import.meta.env.VITE_PIPED_INSTANCES).then(resp => {
this.publicInstances = resp;
if (!this.publicInstances.some(instance => instance.api_url == this.apiUrl()))
this.publicInstances.push({
async function fetchInstances() {
customInstances.value = getCustomInstances();
fetchJson(import.meta.env.VITE_PIPED_INSTANCES).then(resp => {
publicInstances.value = resp;
if (!publicInstances.value.some(instance => instance.api_url == apiUrl()))
publicInstances.value.push({
name: "Selected Instance",
api_url: this.apiUrl(),
api_url: apiUrl(),
locations: "Unknown",
cdn: false,
uptime_30d: 100,
});
});
},
sslScore(url) {
}
function sslScore(url) {
return "https://www.ssllabs.com/ssltest/analyze.html?d=" + new URL(url).host + "&latest";
},
async deleteAccount() {
this.fetchJson(this.authApiUrl() + "/user/delete", null, {
}
async function deleteAccount() {
fetchJson(authApiUrl() + "/user/delete", null, {
method: "POST",
headers: {
Authorization: this.getAuthToken(),
Authorization: getAuthToken(),
},
body: JSON.stringify({
password: this.password,
password: password.value,
}),
}).then(resp => {
if (!resp.error) {
this.logout();
logout();
} else alert(resp.error);
});
},
logout() {
// reset the auth token
localStorage.removeItem("authToken" + this.hashCode(this.authApiUrl()));
// redirect to trending page
}
function logout() {
localStorage.removeItem("authToken" + hashCode(authApiUrl()));
window.location = import.meta.env.BASE_URL;
},
resetPreferences() {
this.showConfirmResetPrefsDialog = false;
// clear the local storage
}
function resetPreferences() {
showConfirmResetPrefsDialog.value = false;
localStorage.clear();
// redirect to the home page
window.location = import.meta.env.BASE_URL;
},
async invalidateSession() {
this.fetchJson(this.authApiUrl() + "/logout", null, {
}
async function invalidateSession() {
fetchJson(authApiUrl() + "/logout", null, {
method: "POST",
headers: {
Authorization: this.getAuthToken(),
Authorization: getAuthToken(),
},
}).then(resp => {
if (!resp.error) {
this.logout();
logout();
} else alert(resp.error);
});
},
backupPreferences() {
}
function backupPreferences() {
const data = JSON.stringify(localStorage);
this.download(data, "preferences.json", "application/json");
},
restorePreferences() {
var file = this.$refs.fileSelector.files[0];
download(data, "preferences.json", "application/json");
}
function restorePreferences() {
var file = fileSelectorEl.value.files[0];
file.text().then(text => {
const data = JSON.parse(text);
Object.keys(data).forEach(function (key) {
@@ -720,9 +738,7 @@ export default {
});
window.location.reload();
});
},
},
};
}
</script>
<style>

View File

@@ -1,30 +1,28 @@
<template>
<canvas ref="qrCodeCanvas" class="mx-auto my-2" />
</template>
<script>
<script setup>
import { ref, watch, onMounted } from "vue";
import QRCode from "qrcode";
export default {
props: {
const props = defineProps({
text: {
type: String,
required: true,
},
},
watch: {
text() {
this.generateQrCode();
},
},
mounted() {
this.generateQrCode();
},
methods: {
generateQrCode() {
QRCode.toCanvas(this.$refs.qrCodeCanvas, this.text, error => {
});
const qrCodeCanvas = ref(null);
function generateQrCode() {
QRCode.toCanvas(qrCodeCanvas.value, props.text, error => {
if (error) console.error(error);
});
},
},
};
}
watch(() => props.text, generateQrCode);
onMounted(() => {
generateQrCode();
});
</script>

View File

@@ -62,56 +62,58 @@
/>
</template>
<script>
<script setup>
import { ref, onMounted, onActivated } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { isEmail } from "../utils/Misc.js";
import ConfirmModal from "./ConfirmModal.vue";
import { fetchJson, authApiUrl, getAuthToken, hashCode } from "@/composables/useApi.js";
import { setPreference } from "@/composables/usePreferences.js";
export default {
components: { ConfirmModal },
data() {
return {
username: null,
password: null,
passwordConfirm: null,
showPassword: false,
showConfirmPassword: false,
showUnsecureRegisterDialog: false,
forceUnsecureRegister: false,
};
},
mounted() {
const router = useRouter();
const { t } = useI18n();
const username = ref(null);
const password = ref(null);
const passwordConfirm = ref(null);
const showPassword = ref(false);
const showConfirmPassword = ref(false);
const showUnsecureRegisterDialog = ref(false);
const forceUnsecureRegister = ref(false);
onMounted(() => {
//TODO: Add Server Side check
if (this.getAuthToken()) {
this.$router.push(import.meta.env.BASE_URL);
if (getAuthToken()) {
router.push(import.meta.env.BASE_URL);
}
},
activated() {
});
onActivated(() => {
document.title = "Register - Piped";
},
methods: {
register() {
if (!this.username || !this.password) return;
if (this.password != this.passwordConfirm) {
alert(this.$t("login.passwords_incorrect"));
});
function register() {
if (!username.value || !password.value) return;
if (password.value != passwordConfirm.value) {
alert(t("login.passwords_incorrect"));
return;
}
if (isEmail(this.username) && !this.forceUnsecureRegister) {
this.showUnsecureRegisterDialog = true;
if (isEmail(username.value) && !forceUnsecureRegister.value) {
showUnsecureRegisterDialog.value = true;
return;
}
this.fetchJson(this.authApiUrl() + "/register", null, {
fetchJson(authApiUrl() + "/register", null, {
method: "POST",
body: JSON.stringify({
username: this.username,
password: this.password,
username: username.value,
password: password.value,
}),
}).then(resp => {
if (resp.token) {
this.setPreference("authToken" + this.hashCode(this.authApiUrl()), resp.token);
setPreference("authToken" + hashCode(authApiUrl()), resp.token);
window.location = import.meta.env.BASE_URL; // done to bypass cache
} else alert(resp.error);
});
},
},
};
}
</script>

View File

@@ -25,19 +25,20 @@
</LoadingIndicatorPage>
</template>
<script>
<script setup>
import { ref, onMounted, onUpdated, onActivated, onDeactivated, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import ContentItem from "./ContentItem.vue";
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
import { getPreferenceBoolean } from "@/composables/usePreferences.js";
import { updateWatched } from "@/composables/useMisc.js";
export default {
components: {
ContentItem,
LoadingIndicatorPage,
},
data() {
return {
results: null,
availableFilters: [
const route = useRoute();
const router = useRouter();
const results = ref(null);
const availableFilters = [
"all",
"videos",
"channels",
@@ -47,83 +48,67 @@ export default {
"music_albums",
"music_playlists",
"music_artists",
],
selectedFilter: this.$route.query.filter ?? "all",
};
},
mounted() {
if (this.handleRedirect()) return;
this.updateResults();
this.saveQueryToHistory();
},
updated() {
if (this.$route.query.search_query !== undefined) {
document.title = this.$route.query.search_query + " - Piped";
}
},
activated() {
this.handleRedirect();
window.addEventListener("scroll", this.handleScroll);
},
deactivated() {
window.removeEventListener("scroll", this.handleScroll);
},
unmounted() {
window.removeEventListener("scroll", this.handleScroll);
},
methods: {
async fetchResults() {
return await await this.fetchJson(this.apiUrl() + "/search", {
q: this.$route.query.search_query,
filter: this.$route.query.filter ?? "all",
];
const selectedFilter = ref(route.query.filter ?? "all");
let loading = false;
async function fetchResultsData() {
return await fetchJson(apiUrl() + "/search", {
q: route.query.search_query,
filter: route.query.filter ?? "all",
});
},
async updateResults() {
document.title = this.$route.query.search_query + " - Piped";
this.results = this.fetchResults().then(json => {
this.results = json;
this.updateWatched(this.results.items);
}
async function updateResults() {
document.title = route.query.search_query + " - Piped";
fetchResultsData().then(json => {
results.value = json;
updateWatched(results.value.items);
});
},
updateFilter() {
this.$router.replace({
}
function updateFilter() {
router.replace({
query: {
search_query: this.$route.query.search_query,
filter: this.selectedFilter,
search_query: route.query.search_query,
filter: selectedFilter.value,
},
});
},
handleScroll() {
if (this.loading || !this.results || !this.results.nextpage) return;
}
function handleScroll() {
if (loading || !results.value || !results.value.nextpage) return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
this.loading = true;
this.fetchJson(this.apiUrl() + "/nextpage/search", {
nextpage: this.results.nextpage,
q: this.$route.query.search_query,
filter: this.$route.query.filter ?? "all",
loading = true;
fetchJson(apiUrl() + "/nextpage/search", {
nextpage: results.value.nextpage,
q: route.query.search_query,
filter: route.query.filter ?? "all",
}).then(json => {
this.results.nextpage = json.nextpage;
this.results.id = json.id;
this.loading = false;
json.items.map(stream => this.results.items.push(stream));
results.value.nextpage = json.nextpage;
results.value.id = json.id;
loading = false;
json.items.map(stream => results.value.items.push(stream));
});
}
},
handleRedirect() {
const query = this.$route.query.search_query;
}
function handleRedirect() {
const query = route.query.search_query;
const url =
/(?:http(?:s)?:\/\/)?(?:www\.)?youtube\.com(\/[/a-zA-Z0-9_?=&-]*)/gm.exec(query)?.[1] ??
/(?:http(?:s)?:\/\/)?(?:www\.)?youtu\.be\/(?:watch\?v=)?([/a-zA-Z0-9_?=&-]*)/gm
.exec(query)?.[1]
.replace(/^/, "/watch?v=");
if (url) {
this.$router.push(url);
router.push(url);
return true;
}
},
saveQueryToHistory() {
if (!this.getPreferenceBoolean("searchHistory", false)) return;
const query = this.$route.query.search_query;
}
function saveQueryToHistory() {
if (!getPreferenceBoolean("searchHistory", false)) return;
const query = route.query.search_query;
if (!query) return;
const searchHistory = JSON.parse(localStorage.getItem("search_history")) ?? [];
if (searchHistory.includes(query)) {
@@ -133,7 +118,30 @@ export default {
searchHistory.unshift(query);
if (searchHistory.length > 10) searchHistory.shift();
localStorage.setItem("search_history", JSON.stringify(searchHistory));
},
},
};
}
onMounted(() => {
if (handleRedirect()) return;
updateResults();
saveQueryToHistory();
});
onUpdated(() => {
if (route.query.search_query !== undefined) {
document.title = route.query.search_query + " - Piped";
}
});
onActivated(() => {
handleRedirect();
window.addEventListener("scroll", handleScroll);
});
onDeactivated(() => {
window.removeEventListener("scroll", handleScroll);
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@@ -18,67 +18,71 @@
</div>
</template>
<script>
export default {
props: {
<script setup>
import { ref } from "vue";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
import { getPreferenceBoolean } from "@/composables/usePreferences.js";
const props = defineProps({
searchText: { type: String, default: "" },
},
emits: ["searchchange"],
data() {
return {
selected: 0,
searchSuggestions: [],
};
},
methods: {
onKeyUp(e) {
});
const emit = defineEmits(["searchchange"]);
const selected = ref(0);
const searchSuggestions = ref([]);
function onKeyUp(e) {
if (e.key === "ArrowUp") {
if (this.selected <= 0) {
this.setSelected(this.searchSuggestions.length - 1);
if (selected.value <= 0) {
setSelected(searchSuggestions.value.length - 1);
} else {
this.setSelected(this.selected - 1);
setSelected(selected.value - 1);
}
e.preventDefault();
} else if (e.key === "ArrowDown") {
if (this.selected >= this.searchSuggestions.length - 1) {
this.setSelected(0);
if (selected.value >= searchSuggestions.value.length - 1) {
setSelected(0);
} else {
this.setSelected(this.selected + 1);
setSelected(selected.value + 1);
}
e.preventDefault();
} else {
this.refreshSuggestions();
refreshSuggestions();
}
},
async refreshSuggestions() {
if (!this.searchText) {
if (this.getPreferenceBoolean("searchHistory", false))
this.searchSuggestions = JSON.parse(localStorage.getItem("search_history")) ?? [];
} else if (this.getPreferenceBoolean("searchSuggestions", true)) {
this.searchSuggestions =
}
async function refreshSuggestions() {
if (!props.searchText) {
if (getPreferenceBoolean("searchHistory", false))
searchSuggestions.value = JSON.parse(localStorage.getItem("search_history")) ?? [];
} else if (getPreferenceBoolean("searchSuggestions", true)) {
searchSuggestions.value =
(
await this.fetchJson(this.apiUrl() + "/opensearch/suggestions", {
query: this.searchText,
await fetchJson(apiUrl() + "/opensearch/suggestions", {
query: props.searchText,
})
)?.[1] ?? [];
} else {
this.searchSuggestions = [];
searchSuggestions.value = [];
return;
}
this.searchSuggestions.unshift(this.searchText);
this.setSelected(0);
},
onMouseOver(i) {
if (i !== this.selected) {
this.selected = i;
searchSuggestions.value.unshift(props.searchText);
setSelected(0);
}
function onMouseOver(i) {
if (i !== selected.value) {
selected.value = i;
}
},
setSelected(val) {
this.selected = val;
this.$emit("searchchange", this.searchSuggestions[this.selected]);
},
},
};
}
function setSelected(val) {
selected.value = val;
emit("searchchange", searchSuggestions.value[selected.value]);
}
defineExpose({ onKeyUp, refreshSuggestions });
</script>
<style>

View File

@@ -32,19 +32,16 @@
</template>
<script setup>
import { defineAsyncComponent } from "vue";
import { ref, computed, onMounted, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import ModalComponent from "./ModalComponent.vue";
import { getPreferenceBoolean, setPreference } from "@/composables/usePreferences.js";
const QrCode = defineAsyncComponent(() => import("./QrCode.vue"));
</script>
<script>
import ModalComponent from "./ModalComponent.vue";
const { t } = useI18n();
export default {
components: {
ModalComponent,
},
props: {
const props = defineProps({
videoId: {
type: String,
required: true,
@@ -61,69 +58,70 @@ export default {
type: Number,
default: undefined,
},
},
data() {
return {
withTimeCode: true,
pipedLink: true,
withPlaylist: true,
timeStamp: null,
hasPlaylist: false,
showQrCode: false,
durations: [1, 60, 60 * 60, 60 * 60 * 24],
};
},
computed: {
generatedLink() {
const baseUrl = this.pipedLink
? window.location.origin + "/watch?v=" + this.videoId
: "https://youtu.be/" + this.videoId;
});
const durations = [1, 60, 60 * 60, 60 * 60 * 24];
const withTimeCode = ref(true);
const pipedLink = ref(true);
const withPlaylist = ref(true);
const timeStamp = ref(null);
const hasPlaylist = ref(false);
const showQrCode = ref(false);
const generatedLink = computed(() => {
const baseUrl = pipedLink.value
? window.location.origin + "/watch?v=" + props.videoId
: "https://youtu.be/" + props.videoId;
const url = new URL(baseUrl);
if (this.withTimeCode && this.timeStamp)
url.searchParams.append("t", this.parseTimeStampToSeconds(this.timeStamp));
if (withTimeCode.value && timeStamp.value) url.searchParams.append("t", parseTimeStampToSeconds(timeStamp.value));
if (this.hasPlaylist && this.withPlaylist) {
url.searchParams.append("list", this.playlistId);
url.searchParams.append("index", this.playlistIndex);
if (hasPlaylist.value && withPlaylist.value) {
url.searchParams.append("list", props.playlistId);
url.searchParams.append("index", props.playlistIndex);
}
return url.href;
},
},
mounted() {
this.timeStamp = this.parseSecondsToTimeStamp(this.currentTime ?? 0);
this.withTimeCode = this.getPreferenceBoolean("shareWithTimeCode", true);
this.pipedLink = this.getPreferenceBoolean("shareAsPipedLink", true);
this.withPlaylist = this.getPreferenceBoolean("shareWithPlaylist", true);
this.hasPlaylist = this.playlistId != undefined && !isNaN(this.playlistIndex);
},
methods: {
followLink() {
window.open(this.generatedLink, "_blank").focus();
},
async copyLink() {
await this.copyURL(this.generatedLink);
},
async copyURL(mytext) {
});
onMounted(() => {
timeStamp.value = parseSecondsToTimeStamp(props.currentTime ?? 0);
withTimeCode.value = getPreferenceBoolean("shareWithTimeCode", true);
pipedLink.value = getPreferenceBoolean("shareAsPipedLink", true);
withPlaylist.value = getPreferenceBoolean("shareWithPlaylist", true);
hasPlaylist.value = props.playlistId != undefined && !isNaN(props.playlistIndex);
});
function followLink() {
window.open(generatedLink.value, "_blank").focus();
}
async function copyLink() {
await copyURL(generatedLink.value);
}
async function copyURL(mytext) {
try {
await navigator.clipboard.writeText(mytext);
alert(this.$t("info.copied"));
alert(t("info.copied"));
} catch ($e) {
alert(this.$t("info.cannot_copy"));
alert(t("info.cannot_copy"));
}
},
parseTimeStampToSeconds(timestamp) {
}
function parseTimeStampToSeconds(timestamp) {
const timeArray = timestamp.split(":").reverse();
let seconds = 0;
for (let i = 0; i < timeArray.length; i++) {
seconds += timeArray[i] * this.durations[i];
seconds += timeArray[i] * durations[i];
}
return seconds;
},
parseSecondsToTimeStamp(seconds) {
}
function parseSecondsToTimeStamp(seconds) {
const timeArray = [];
const durationsReversed = this.durations.toReversed();
const durationsReversed = durations.toReversed();
for (let i in durationsReversed) {
const currentValue = Math.floor(seconds / durationsReversed[i]);
if (currentValue > 0) {
@@ -132,12 +130,11 @@ export default {
}
}
return timeArray.join(":");
},
onChange() {
this.setPreference("shareWithTimeCode", this.withTimeCode, true);
this.setPreference("shareAsPipedLink", this.pipedLink, true);
this.setPreference("shareWithPlaylist", this.withPlaylist, true);
},
},
};
}
function onChange() {
setPreference("shareWithTimeCode", withTimeCode.value, true);
setPreference("shareAsPipedLink", pipedLink.value, true);
setPreference("shareWithPlaylist", withPlaylist.value, true);
}
</script>

View File

@@ -121,80 +121,57 @@
</ModalComponent>
</template>
<script>
<script setup>
import { ref, computed, onMounted, onActivated } from "vue";
import ModalComponent from "./ModalComponent.vue";
import CreateGroupModal from "./CreateGroupModal.vue";
import ConfirmModal from "./ConfirmModal.vue";
import { fetchJson, authApiUrl, getAuthToken, isAuthenticated } from "@/composables/useApi.js";
import { fetchSubscriptions, handleLocalSubscriptions } from "@/composables/useSubscriptions.js";
import { getChannelGroups, createOrUpdateChannelGroup, deleteChannelGroup } from "@/composables/useChannelGroups.js";
import { download } from "@/composables/useMisc.js";
export default {
components: { ModalComponent, CreateGroupModal, ConfirmModal },
data() {
return {
subscriptions: [],
selectedGroup: {
const fileSelector = ref(null);
const subscriptions = ref([]);
const selectedGroup = ref({
groupName: "",
channels: [],
},
channelGroups: [],
showCreateGroupModal: false,
showEditGroupModal: false,
editedGroupName: "",
groupToDelete: null,
};
},
computed: {
filteredSubscriptions(_this) {
return _this.selectedGroup.groupName == ""
? _this.subscriptions
: _this.subscriptions.filter(channel => _this.selectedGroup.channels.includes(channel.url.substr(-24)));
},
},
mounted() {
this.fetchSubscriptions().then(json => {
if (json.error) {
alert(json.error);
return;
}
});
const channelGroups = ref([]);
const showCreateGroupModal = ref(false);
const showEditGroupModal = ref(false);
const editedGroupName = ref("");
const groupToDelete = ref(null);
this.subscriptions = json;
this.subscriptions.forEach(subscription => (subscription.subscribed = true));
});
const filteredSubscriptions = computed(() => {
return selectedGroup.value.groupName == ""
? subscriptions.value
: subscriptions.value.filter(channel => selectedGroup.value.channels.includes(channel.url.substr(-24)));
});
this.channelGroups.push(this.selectedGroup);
if (!window.db) return;
this.loadChannelGroups();
},
activated() {
document.title = "Subscriptions - Piped";
},
methods: {
async loadChannelGroups() {
const groups = await this.getChannelGroups();
this.channelGroups.push(...groups);
},
handleButton(subscription) {
function handleButton(subscription) {
const channelId = subscription.url.split("/")[2];
if (this.authenticated) {
this.fetchJson(this.authApiUrl() + (subscription.subscribed ? "/unsubscribe" : "/subscribe"), null, {
if (isAuthenticated()) {
fetchJson(authApiUrl() + (subscription.subscribed ? "/unsubscribe" : "/subscribe"), null, {
method: "POST",
body: JSON.stringify({
channelId: channelId,
}),
headers: {
Authorization: this.getAuthToken(),
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
} else {
this.handleLocalSubscriptions(channelId);
handleLocalSubscriptions(channelId);
}
subscription.subscribed = !subscription.subscribed;
},
exportHandler() {
const subscriptions = [];
this.subscriptions.forEach(subscription => {
subscriptions.push({
}
function exportHandler() {
const subs = [];
subscriptions.value.forEach(subscription => {
subs.push({
url: "https://www.youtube.com" + subscription.url,
name: subscription.name,
service_id: 0,
@@ -203,74 +180,101 @@ export default {
const json = JSON.stringify({
app_version: "",
app_version_int: 0,
subscriptions: subscriptions,
subscriptions: subs,
});
this.download(json, "subscriptions.json", "application/json");
},
selectGroup(group) {
this.selectedGroup = group;
this.editedGroupName = group.groupName;
},
createGroup(newGroupName) {
if (!newGroupName || this.channelGroups.some(group => group.groupName == newGroupName)) return;
download(json, "subscriptions.json", "application/json");
}
function selectGroup(group) {
selectedGroup.value = group;
editedGroupName.value = group.groupName;
}
function createGroup(newGroupName) {
if (!newGroupName || channelGroups.value.some(group => group.groupName == newGroupName)) return;
const newGroup = {
groupName: newGroupName,
channels: [],
};
this.channelGroups.push(newGroup);
this.createOrUpdateChannelGroup(newGroup);
channelGroups.value.push(newGroup);
createOrUpdateChannelGroup(newGroup);
this.showCreateGroupModal = false;
},
editGroupName() {
const oldGroupName = this.selectedGroup.groupName;
const newGroupName = this.editedGroupName;
showCreateGroupModal.value = false;
}
function editGroupName() {
const oldGroupName = selectedGroup.value.groupName;
const newGroupName = editedGroupName.value;
// 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;
if (channelGroups.value.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);
selectedGroup.value.groupName = newGroupName;
createOrUpdateChannelGroup(selectedGroup.value);
deleteChannelGroup(oldGroupName);
this.showEditGroupModal = false;
},
deleteGroup(group) {
this.deleteChannelGroup(group);
this.channelGroups = this.channelGroups.filter(g => g.groupName != group);
this.selectedGroup = this.channelGroups[0] || {};
this.groupToDelete = null;
},
checkedChange(subscription) {
showEditGroupModal.value = false;
}
function deleteGroup(group) {
deleteChannelGroup(group);
channelGroups.value = channelGroups.value.filter(g => g.groupName != group);
selectedGroup.value = channelGroups.value[0] || {};
groupToDelete.value = null;
}
function checkedChange(subscription) {
const channelId = subscription.url.substr(-24);
this.selectedGroup.channels = this.selectedGroup.channels.includes(channelId)
? this.selectedGroup.channels.filter(channel => channel != channelId)
: this.selectedGroup.channels.concat(channelId);
this.createOrUpdateChannelGroup(this.selectedGroup);
},
async importGroupsHandler() {
const files = this.$refs.fileSelector.files;
selectedGroup.value.channels = selectedGroup.value.channels.includes(channelId)
? selectedGroup.value.channels.filter(channel => channel != channelId)
: selectedGroup.value.channels.concat(channelId);
createOrUpdateChannelGroup(selectedGroup.value);
}
async function importGroupsHandler() {
const files = fileSelector.value.files;
for (let file of files) {
const groups = JSON.parse(await file.text()).groups;
for (let group of groups) {
this.createOrUpdateChannelGroup(group);
this.channelGroups.push(group);
createOrUpdateChannelGroup(group);
channelGroups.value.push(group);
}
}
},
exportGroupsHandler() {
}
function exportGroupsHandler() {
const json = {
format: "Piped",
version: 1,
groups: this.channelGroups.slice(1),
groups: channelGroups.value.slice(1),
};
this.download(JSON.stringify(json), "channel_groups.json", "application/json");
},
},
};
download(JSON.stringify(json), "channel_groups.json", "application/json");
}
onMounted(() => {
fetchSubscriptions().then(json => {
if (json.error) {
alert(json.error);
return;
}
subscriptions.value = json;
subscriptions.value.forEach(subscription => (subscription.subscribed = true));
});
channelGroups.value.push(selectedGroup.value);
if (!window.db) return;
(async () => {
const groups = await getChannelGroups();
channelGroups.value.push(...groups);
})();
});
onActivated(() => {
document.title = "Subscriptions - Piped";
});
</script>
<style>

View File

@@ -5,15 +5,12 @@
</div>
</template>
<script>
export default {
emits: ["dismissed"],
methods: {
dismiss() {
this.$emit("dismissed");
},
},
};
<script setup>
const emit = defineEmits(["dismissed"]);
function dismiss() {
emit("dismissed");
}
</script>
<style>

View File

@@ -8,49 +8,49 @@
</LoadingIndicatorPage>
</template>
<script>
<script setup>
import { ref, onMounted, onActivated } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
import VideoItem from "./VideoItem.vue";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
import { getPreferenceString } from "@/composables/usePreferences.js";
import { updateWatched } from "@/composables/useMisc.js";
import { fetchDeArrowContent } from "@/composables/useSubscriptions.js";
import { getHomePage } from "@/composables/useMisc.js";
export default {
components: {
VideoItem,
LoadingIndicatorPage,
},
data() {
return {
videos: [],
};
},
mounted() {
if (
this.$route.path == import.meta.env.BASE_URL &&
this.getPreferenceString("homepage", "trending") == "feed"
) {
return;
}
let region = this.getPreferenceString("region", "US");
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
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 == import.meta.env.BASE_URL) {
let homepage = this.getHomePage(this);
if (homepage !== undefined) this.$router.push(homepage);
}
},
methods: {
async fetchTrending(region) {
return await this.fetchJson(this.apiUrl() + "/trending", {
const videos = ref([]);
async function fetchTrending(region) {
return await fetchJson(apiUrl() + "/trending", {
region: region || "US",
});
},
},
};
}
onMounted(() => {
if (route.path == import.meta.env.BASE_URL && getPreferenceString("homepage", "trending") == "feed") {
return;
}
let region = getPreferenceString("region", "US");
fetchTrending(region).then(vids => {
videos.value = vids;
updateWatched(videos.value);
fetchDeArrowContent(videos.value);
});
});
onActivated(() => {
document.title = t("titles.trending") + " - Piped";
if (videos.value.length > 0) updateWatched(videos.value);
if (route.path == import.meta.env.BASE_URL) {
let homepage = getHomePage();
if (homepage !== undefined) router.push(homepage);
}
});
</script>

View File

@@ -127,15 +127,17 @@
</div>
</template>
<script>
<script setup>
import { ref, computed, onMounted } from "vue";
import PlaylistAddModal from "./PlaylistAddModal.vue";
import ShareModal from "./ShareModal.vue";
import ConfirmModal from "./ConfirmModal.vue";
import VideoThumbnail from "./VideoThumbnail.vue";
import { numberFormat, timeAgo } from "@/composables/useFormatting.js";
import { getPreferenceBoolean } from "@/composables/usePreferences.js";
import { removeVideoFromPlaylist } from "@/composables/usePlaylists.js";
export default {
components: { PlaylistAddModal, ConfirmModal, ShareModal, VideoThumbnail },
props: {
const props = defineProps({
item: {
type: Object,
default: () => {
@@ -153,58 +155,51 @@ export default {
playlistId: { type: String, default: null },
preferListen: { type: Boolean, default: false },
admin: { type: Boolean, default: false },
},
emits: ["update:watched", "remove"],
data() {
return {
showPlaylistModal: false,
showShareModal: false,
showVideo: true,
showConfirmRemove: false,
showMarkOnWatched: 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();
this.shouldShowMarkOnWatched();
},
methods: {
removeVideo() {
this.$refs.removeButton.disabled = true;
this.removeVideoFromPlaylist(this.playlistId, this.index).then(json => {
});
const emit = defineEmits(["update:watched", "remove"]);
const removeButton = ref(null);
const showPlaylistModal = ref(false);
const showShareModal = ref(false);
const showVideo = ref(true);
const showConfirmRemove = ref(false);
const showMarkOnWatched = ref(false);
const title = computed(() => {
return props.item.dearrow?.titles[0]?.title ?? props.item.title;
});
function removeVideo() {
removeButton.value.disabled = true;
removeVideoFromPlaylist(props.playlistId, props.index).then(json => {
if (json.error) alert(json.error);
else this.$emit("remove");
else emit("remove");
});
},
shouldShowVideo() {
if (!this.isFeed || !this.getPreferenceBoolean("hideWatched", false)) return;
}
function shouldShowVideo() {
if (!props.isFeed || !getPreferenceBoolean("hideWatched", false)) return;
const objectStore = window.db.transaction("watch_history", "readonly").objectStore("watch_history");
const request = objectStore.get(this.item.url.substr(-11));
const request = objectStore.get(props.item.url.substr(-11));
request.onsuccess = event => {
const video = event.target.result;
if (video && (video.currentTime ?? 0) > video.duration * 0.9) {
this.showVideo = false;
showVideo.value = false;
return;
}
};
},
shouldShowMarkOnWatched() {
this.showMarkOnWatched = this.getPreferenceBoolean("watchHistory", false);
},
toggleWatched(videoId) {
}
function shouldShowMarkOnWatched() {
showMarkOnWatched.value = getPreferenceBoolean("watchHistory", false);
}
function toggleWatched(videoId) {
if (window.db) {
var tx = window.db.transaction("watch_history", "readwrite");
var store = tx.objectStore("watch_history");
var instance = this;
var request = store.get(videoId);
request.onsuccess = function (event) {
var video = event.target.result;
@@ -213,24 +208,26 @@ export default {
} else {
video = {
videoId: videoId,
title: instance.item.title,
duration: instance.item.duration,
thumbnail: instance.item.thumbnail,
uploaderUrl: instance.item.uploaderUrl,
uploaderName: instance.item.uploaderName,
title: props.item.title,
duration: props.item.duration,
thumbnail: props.item.thumbnail,
uploaderUrl: props.item.uploaderUrl,
uploaderName: props.item.uploaderName,
watchedAt: Date.now(),
};
}
video.currentTime =
instance.item.currentTime < instance.item.duration * 0.9 ? instance.item.duration : 0;
video.currentTime = props.item.currentTime < props.item.duration * 0.9 ? props.item.duration : 0;
store.put(video);
instance.$emit("update:watched", [instance.item.url]);
instance.shouldShowVideo();
emit("update:watched", [props.item.url]);
shouldShowVideo();
};
}
},
},
};
}
onMounted(() => {
shouldShowVideo();
shouldShowMarkOnWatched();
});
</script>
<style>

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,19 @@
<div v-t="'actions.loading'" />
</template>
<script>
export default {
activated() {
const videoId = this.$route.params.videoId;
<script setup>
import { onActivated } from "vue";
import { useRouter, useRoute } from "vue-router";
const router = useRouter();
const route = useRoute();
onActivated(() => {
const videoId = route.params.videoId;
if (videoId)
this.$router.replace({
router.replace({
path: "/watch",
query: { v: videoId, t: this.$route.query.t },
query: { v: videoId, t: route.query.t },
});
},
};
});
</script>

View File

@@ -31,9 +31,10 @@
<span v-if="item.watched" v-t="'video.watched'" class="thumbnail-overlay bottom-5px left-5px" />
</div>
</template>
<script>
export default {
props: {
<script setup>
import { timeFormat } from "@/composables/useFormatting.js";
defineProps({
item: {
type: Object,
default: () => {
@@ -46,16 +47,7 @@ export default {
return false;
},
},
},
computed: {
title() {
return this.item.dearrow?.titles[0]?.title ?? this.item.title;
},
thumbnail() {
return this.item.dearrow?.thumbnails[0]?.thumbnail ?? this.item.thumbnail;
},
},
};
});
</script>
<style>

View File

@@ -1,6 +1,7 @@
<script>
export default {
props: {
<script setup>
import { getPreferenceBoolean } from "@/composables/usePreferences.js";
defineProps({
link: {
type: String,
required: true,
@@ -10,8 +11,7 @@ export default {
required: false,
default: "YouTube",
},
},
};
});
</script>
<template>

View File

@@ -152,7 +152,9 @@
aria-label="RSS feed"
title="RSS feed"
role="button"
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${video.uploaderUrl.split('/')[2]}`"
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${
video.uploaderUrl.split('/')[2]
}`"
target="_blank"
class="btn flex items-center"
>
@@ -282,11 +284,11 @@
<button
v-if="!comments?.disabled"
class="btn mb-2"
@click="toggleComments"
@click="toggleCommentsVisibility"
v-text="
`${$t(showComments ? 'actions.minimize_comments' : 'actions.show_comments')} (${numberFormat(
comments?.commentCount,
)})`
`${$t(
showComments ? 'actions.minimize_comments' : 'actions.show_comments',
)} (${numberFormat(comments?.commentCount)})`
"
/>
</div>
@@ -297,7 +299,7 @@
<div v-else-if="comments.disabled" class="">
<p v-t="'comment.disabled'" class="mt-8 text-center"></p>
</div>
<div v-else ref="comments" class="">
<div v-else ref="commentsEl" class="">
<CommentItem
v-for="comment in comments.comments"
:key="comment.commentId"
@@ -346,7 +348,9 @@
</LoadingIndicatorPage>
</template>
<script>
<script setup>
import { ref, computed, onMounted, onActivated, onDeactivated, onUnmounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import VideoPlayer from "./VideoPlayer.vue";
import ContentItem from "./ContentItem.vue";
import ErrorHandler from "./ErrorHandler.vue";
@@ -360,372 +364,317 @@ import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
import ToastComponent from "./ToastComponent.vue";
import { parseTimeParam } from "@/utils/Misc";
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
import { fetchJson, apiUrl } from "@/composables/useApi.js";
import {
getPreferenceBoolean,
getPreferenceNumber,
getPreferenceString,
getPreferenceJSON,
setPreference,
} from "@/composables/usePreferences.js";
import { numberFormat, addCommas } from "@/composables/useFormatting.js";
import {
fetchSubscriptionStatus,
toggleSubscriptionState,
fetchDeArrowContent,
} from "@/composables/useSubscriptions.js";
import { updateWatched } from "@/composables/useMisc.js";
import { getPlaylist } from "@/composables/usePlaylists.js";
export default {
name: "App",
components: {
VideoPlayer,
ContentItem,
ErrorHandler,
CommentItem,
ChaptersBar,
PlaylistAddModal,
ShareModal,
PlaylistVideos,
WatchOnButton,
LoadingIndicatorPage,
ToastComponent,
},
data() {
const smallViewQuery = window.matchMedia("(max-width: 640px)");
return {
video: null,
playlistId: null,
playlist: null,
index: null,
sponsors: null,
selectedAutoLoop: false,
selectedAutoPlay: null,
showComments: true,
showDesc: false,
showRecs: true,
showChapters: true,
comments: null,
subscribed: false,
channelId: null,
active: true,
smallViewQuery: smallViewQuery,
smallView: smallViewQuery.matches,
showModal: false,
showShareModal: false,
isMobile: true,
currentTime: 0,
shouldShowToast: false,
timeoutCounter: null,
counter: 0,
theaterMode: false,
};
},
computed: {
isListening(_this) {
return _this.getPreferenceBoolean("listen", false);
},
toggleListenUrl(_this) {
const route = useRoute();
const router = useRouter();
const smallViewQuery = window.matchMedia("(max-width: 640px)");
const videoPlayer = ref(null);
const commentsEl = ref(null);
const video = ref(null);
const playlistId = ref(null);
const playlist = ref(null);
const index = ref(null);
const sponsors = ref(null);
const selectedAutoLoop = ref(false);
const selectedAutoPlay = ref(null);
const showComments = ref(true);
const showDesc = ref(false);
const showRecs = ref(true);
const showChapters = ref(true);
const comments = ref(null);
const subscribed = ref(false);
const channelId = ref(null);
const active = ref(true);
const smallView = ref(smallViewQuery.matches);
const showModal = ref(false);
const showShareModal = ref(false);
const isMobile = ref(true);
const currentTime = ref(0);
const shouldShowToast = ref(false);
let timeoutCounter = null;
const counter = ref(0);
const theaterMode = ref(false);
let loading = false;
const isListening = computed(() => {
return getPreferenceBoolean("listen", false);
});
const toggleListenUrl = computed(() => {
const url = new URL(window.location.href);
url.searchParams.set("listen", _this.isListening ? "0" : "1");
url.searchParams.set("t", Math.floor(this.currentTime));
url.searchParams.set("listen", isListening.value ? "0" : "1");
url.searchParams.set("t", Math.floor(currentTime.value));
return url.pathname + url.search;
},
isEmbed(_this) {
return String(_this.$route.path).indexOf("/embed/") == 0;
},
uploadDate(_this) {
return new Date(_this.video.uploadDate).toLocaleString(undefined, {
});
const isEmbed = computed(() => {
return String(route.path).indexOf("/embed/") == 0;
});
const uploadDate = computed(() => {
return new Date(video.value.uploadDate).toLocaleString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
},
defaultCounter(_this) {
return _this.getPreferenceNumber("autoPlayNextCountdown", 5);
},
purifiedDescription() {
return purifyHTML(this.video.description);
},
youtubeVideoHref() {
let link = `https://youtu.be/${this.getVideoId()}?t=${Math.round(this.currentTime)}`;
if (this.playlistId) link += `&list=${this.playlistId}`;
});
const defaultCounter = computed(() => {
return getPreferenceNumber("autoPlayNextCountdown", 5);
});
const purifiedDescription = computed(() => {
return purifyHTML(video.value.description);
});
const youtubeVideoHref = computed(() => {
let link = `https://youtu.be/${getVideoId()}?t=${Math.round(currentTime.value)}`;
if (playlistId.value) link += `&list=${playlistId.value}`;
return link;
},
},
mounted() {
// check screen size
this.isMobile = window.innerWidth < 1024;
// add an event listener to watch for screen size changes
window.addEventListener("resize", () => {
this.isMobile = window.innerWidth < 1024;
});
this.getVideoData().then(() => {
(async () => {
const videoId = this.getVideoId();
const instance = this;
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);
request.onsuccess = function (event) {
var video = event.target.result;
if (video) {
video.watchedAt = Date.now();
} else {
video = {
videoId: videoId,
title: instance.video.title,
duration: instance.video.duration,
thumbnail: instance.video.thumbnailUrl,
uploaderUrl: instance.video.uploaderUrl,
uploaderName: instance.video.uploader,
watchedAt: Date.now(),
};
}
store.put(video);
};
}
})();
if (this.active) this.$refs.videoPlayer.loadVideo();
});
this.playlistId = this.$route.query.list;
this.index = Number(this.$route.query.index);
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;
});
},
activated() {
this.active = true;
this.theaterMode = this.getPreferenceBoolean(
"theaterMode",
window.innerWidth < (window.innerHeight * 4) / 3 + 467, //if the video player is limited by width rather than height, then clear up some horizontal room
);
this.selectedAutoPlay = this.getPreferenceNumber("autoplay", 1);
this.showComments = !this.getPreferenceBoolean("minimizeComments", false);
this.showDesc = !this.getPreferenceBoolean("minimizeDescription", true);
this.showRecs = !this.getPreferenceBoolean("minimizeRecommendations", false);
this.showChapters = !this.getPreferenceBoolean("minimizeChapters", false);
if (this.video?.duration) {
document.title = this.video.title + " - Piped";
this.$refs.videoPlayer.loadVideo();
}
window.addEventListener("scroll", this.handleScroll);
},
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() {
var selectedSkip = this.getPreferenceString(
"selectedSkip",
"sponsor,interaction,selfpromo,music_offtopic",
).split(",");
const skipOptions = this.getPreferenceJSON("skipOptions");
});
function fetchVideo() {
return fetchJson(apiUrl() + "/streams/" + getVideoId());
}
async function fetchSponsors() {
var selectedSkip = getPreferenceString("selectedSkip", "sponsor,interaction,selfpromo,music_offtopic").split(",");
const skipOptions = getPreferenceJSON("skipOptions");
if (skipOptions !== undefined) {
selectedSkip = Object.keys(skipOptions).filter(
k => skipOptions[k] !== undefined && skipOptions[k] !== "no",
);
selectedSkip = Object.keys(skipOptions).filter(k => skipOptions[k] !== undefined && skipOptions[k] !== "no");
}
const sponsors = await this.fetchJson(this.apiUrl() + "/sponsors/" + this.getVideoId(), {
const sponsorsData = await fetchJson(apiUrl() + "/sponsors/" + getVideoId(), {
category: JSON.stringify(selectedSkip),
});
sponsors?.segments?.forEach(segment => {
sponsorsData?.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 minSegmentLength = Math.max(getPreferenceNumber("minSegmentLength", 0), 0);
sponsorsData.segments = sponsorsData.segments?.filter(segment => {
const length = segment.segment[1] - segment.segment[0];
return length >= minSegmentLength;
});
return sponsors;
},
toggleComments() {
this.showComments = !this.showComments;
if (this.showComments && this.comments === null) {
this.fetchComments();
return sponsorsData;
}
function toggleCommentsVisibility() {
showComments.value = !showComments.value;
if (showComments.value && comments.value === null) {
fetchCommentsData();
}
},
fetchComments() {
return this.fetchJson(this.apiUrl() + "/comments/" + this.getVideoId());
},
onChange() {
this.setPreference("autoplay", this.selectedAutoPlay, true);
},
async getVideoData() {
await this.fetchVideo()
}
function fetchCommentsData() {
return fetchJson(apiUrl() + "/comments/" + getVideoId());
}
function onChange() {
setPreference("autoplay", selectedAutoPlay.value, true);
}
async function getVideoData() {
await fetchVideo()
.then(data => {
this.video = data;
this.video.id = this.getVideoId();
video.value = data;
video.value.id = getVideoId();
})
.then(() => {
if (!this.video.error) {
document.title = this.video.title + " - Piped";
this.channelId = this.video.uploaderUrl.split("/")[2];
if (!this.isEmbed) this.fetchSubscribedStatus();
if (!video.value.error) {
document.title = video.value.title + " - Piped";
channelId.value = video.value.uploaderUrl.split("/")[2];
if (!isEmbed.value) fetchSubscribedStatus();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(this.video.description, "text/html");
const xmlDoc = parser.parseFromString(video.value.description, "text/html");
xmlDoc.querySelectorAll("a").forEach(elem => {
if (!elem.innerText.match(/(?:[\d]{1,2}:)?(?:[\d]{1,2}):(?:[\d]{1,2})/))
elem.outerHTML = elem.getAttribute("href");
});
xmlDoc.querySelectorAll("br").forEach(elem => (elem.outerHTML = "\n"));
this.video.description = rewriteDescription(xmlDoc.querySelector("body").innerHTML);
this.updateWatched(this.video.relatedStreams);
video.value.description = rewriteDescription(xmlDoc.querySelector("body").innerHTML);
updateWatched(video.value.relatedStreams);
this.fetchDeArrowContent(this.video.relatedStreams);
fetchDeArrowContent(video.value.relatedStreams);
}
});
},
async getPlaylistData() {
if (this.playlistId) {
this.playlist = await this.getPlaylist(this.playlistId);
await this.fetchPlaylistPages().then(() => {
if (!(this.index >= 0)) {
for (let i = 0; i < this.playlist.relatedStreams.length; i++)
if (this.playlist.relatedStreams[i].url.substr(-11) == this.getVideoId()) {
this.index = i + 1;
this.$router.replace({
query: { ...this.$route.query, index: this.index },
}
async function getPlaylistData() {
if (playlistId.value) {
playlist.value = await getPlaylist(playlistId.value);
await fetchPlaylistPages().then(() => {
if (!(index.value >= 0)) {
for (let i = 0; i < playlist.value.relatedStreams.length; i++)
if (playlist.value.relatedStreams[i].url.substr(-11) == getVideoId()) {
index.value = i + 1;
router.replace({
query: { ...route.query, index: index.value },
});
break;
}
}
});
await this.fetchPlaylistPages();
await fetchPlaylistPages();
}
},
async fetchPlaylistPages() {
if (this.playlist.nextpage) {
await this.fetchJson(this.apiUrl() + "/nextpage/playlists/" + this.playlistId, {
nextpage: this.playlist.nextpage,
}).then(json => {
this.playlist.relatedStreams.push(...json.relatedStreams);
this.playlist.nextpage = json.nextpage;
this.fetchDeArrowContent(json.relatedStreams);
});
await this.fetchPlaylistPages();
}
},
async getSponsors() {
if (this.getPreferenceBoolean("sponsorblock", true))
this.fetchSponsors().then(data => (this.sponsors = data));
},
async getComments() {
this.comments = await this.fetchComments();
},
async fetchSubscribedStatus() {
if (!this.channelId) return;
}
this.subscribed = await this.fetchSubscriptionStatus(this.channelId);
},
subscribeHandler() {
this.toggleSubscriptionState(this.channelId, this.subscribed).then(success => {
if (success) this.subscribed = !this.subscribed;
async function fetchPlaylistPages() {
if (playlist.value.nextpage) {
await fetchJson(apiUrl() + "/nextpage/playlists/" + playlistId.value, {
nextpage: playlist.value.nextpage,
}).then(json => {
playlist.value.relatedStreams.push(...json.relatedStreams);
playlist.value.nextpage = json.nextpage;
fetchDeArrowContent(json.relatedStreams);
});
},
handleClick(event) {
await fetchPlaylistPages();
}
}
async function getSponsors() {
if (getPreferenceBoolean("sponsorblock", true)) fetchSponsors().then(data => (sponsors.value = data));
}
async function getComments() {
comments.value = await fetchCommentsData();
}
async function fetchSubscribedStatus() {
if (!channelId.value) return;
subscribed.value = await fetchSubscriptionStatus(channelId.value);
}
function subscribeHandler() {
toggleSubscriptionState(channelId.value, subscribed.value).then(success => {
if (success) subscribed.value = !subscribed.value;
});
}
function 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)) {
if (handleTimestampLinks(target)) {
event.preventDefault();
}
},
handleTimestampLinks(target) {
}
function 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")
) {
if (url.searchParams.size > 2 || url.searchParams.get("v") !== getVideoId() || !url.searchParams.has("t")) {
return false;
}
const time = parseTimeParam(url.searchParams.get("t"));
if (time) {
this.navigate(time);
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) {
this.loading = true;
this.fetchJson(this.apiUrl() + "/nextpage/comments/" + this.getVideoId(), {
nextpage: this.comments.nextpage,
}
function handleScroll() {
if (loading || !comments.value || !comments.value.nextpage) return;
if (window.innerHeight + window.scrollY >= commentsEl.value?.offsetHeight - window.innerHeight) {
loading = true;
fetchJson(apiUrl() + "/nextpage/comments/" + getVideoId(), {
nextpage: comments.value.nextpage,
}).then(json => {
this.comments.nextpage = json.nextpage;
this.loading = false;
this.comments.comments = this.comments.comments.concat(json.comments);
comments.value.nextpage = json.nextpage;
loading = false;
comments.value.comments = comments.value.comments.concat(json.comments);
});
}
},
getVideoId() {
if (this.$route.query.video_ids) {
const videos_list = this.$route.query.video_ids.split(",");
this.index = Number(this.$route.query.index ?? 0);
return videos_list[this.index];
}
function getVideoId() {
if (route.query.video_ids) {
const videos_list = route.query.video_ids.split(",");
index.value = Number(route.query.index ?? 0);
return videos_list[index.value];
}
return this.$route.query.v || this.$route.params.v;
},
navigate(time) {
this.$refs.videoPlayer.seek(time);
},
onTimeUpdate(time) {
this.currentTime = time;
},
onVideoEnded() {
return route.query.v || route.params.v;
}
function navigate(time) {
videoPlayer.value.seek(time);
}
function onTimeUpdate(time) {
currentTime.value = time;
}
function onVideoEnded() {
if (
!this.selectedAutoLoop &&
((this.selectedAutoPlay >= 1 && this.playlist?.relatedStreams?.length > this.index) ||
(this.selectedAutoPlay >= 2 && this.video.relatedStreams.length > 0))
!selectedAutoLoop.value &&
((selectedAutoPlay.value >= 1 && playlist.value?.relatedStreams?.length > index.value) ||
(selectedAutoPlay.value >= 2 && video.value.relatedStreams.length > 0))
) {
this.showToast();
showToast();
}
},
showToast() {
this.counter = this.defaultCounter;
if (this.counter < 1) {
this.navigateNext();
}
function showToast() {
counter.value = defaultCounter.value;
if (counter.value < 1) {
navigateNext();
return;
}
if (this.timeoutCounter) clearInterval(this.timeoutCounter);
this.timeoutCounter = setInterval(() => {
this.counter--;
if (this.counter === 0) {
this.dismiss();
this.navigateNext();
if (timeoutCounter) clearInterval(timeoutCounter);
timeoutCounter = setInterval(() => {
counter.value--;
if (counter.value === 0) {
dismiss();
navigateNext();
}
}, 1000);
this.shouldShowToast = true;
},
dismiss() {
clearInterval(this.timeoutCounter);
this.shouldShowToast = false;
},
navigateNext() {
const params = this.$route.query;
const video_ids = this.$route.query.video_ids?.split(",") ?? [];
shouldShowToast.value = true;
}
function dismiss() {
clearInterval(timeoutCounter);
shouldShowToast.value = false;
}
function navigateNext() {
const params = route.query;
const video_ids = route.query.video_ids?.split(",") ?? [];
let url;
if (this.playlist) {
url = this.playlist?.relatedStreams?.[this.index]?.url ?? this.video.relatedStreams[0].url;
} else if (video_ids.length > this.index + 1) {
url = `${this.$route.path}?index=${this.index + 1}`;
if (playlist.value) {
url = playlist.value?.relatedStreams?.[index.value]?.url ?? video.value.relatedStreams[0].url;
} else if (video_ids.length > index.value + 1) {
url = `${route.path}?index=${index.value + 1}`;
} else {
url = this.video.relatedStreams[0].url;
url = video.value.relatedStreams[0].url;
}
const searchParams = new URLSearchParams();
for (var param in params)
@@ -734,39 +683,109 @@ export default {
case "t":
break;
case "index":
if (this.playlist && this.index < this.playlist.relatedStreams.length)
searchParams.set("index", this.index + 1);
if (playlist.value && index.value < playlist.value.relatedStreams.length)
searchParams.set("index", index.value + 1);
break;
case "list":
if (this.index < this.playlist.relatedStreams.length) searchParams.set("list", params.list);
if (index.value < playlist.value.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());
searchParams.set("fullscreen", videoPlayer.value.isFullScreenEnabled());
const paramStr = searchParams.toString();
if (paramStr.length > 0) url += "&" + paramStr;
this.$router.push(url);
},
downloadCurrentFrame() {
const video = document.querySelector("video");
router.push(url);
}
function downloadCurrentFrame() {
const videoEl = document.querySelector("video");
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const context = canvas.getContext("2d");
context.drawImage(video, 0, 0, canvas.width, canvas.height);
context.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
let link = document.createElement("a");
const currentTime = Math.round(video.currentTime * 1000) / 1000;
link.download = `${this.video.title}_${currentTime}s.png`;
const ct = Math.round(videoEl.currentTime * 1000) / 1000;
link.download = `${video.value.title}_${ct}s.png`;
link.href = canvas.toDataURL();
link.click();
},
},
};
}
onMounted(() => {
isMobile.value = window.innerWidth < 1024;
window.addEventListener("resize", () => {
isMobile.value = window.innerWidth < 1024;
});
getVideoData().then(() => {
(async () => {
const videoId = getVideoId();
if (window.db && getPreferenceBoolean("watchHistory", false) && !video.value.error) {
var tx = window.db.transaction("watch_history", "readwrite");
var store = tx.objectStore("watch_history");
var request = store.get(videoId);
request.onsuccess = function (event) {
var vid = event.target.result;
if (vid) {
vid.watchedAt = Date.now();
} else {
vid = {
videoId: videoId,
title: video.value.title,
duration: video.value.duration,
thumbnail: video.value.thumbnailUrl,
uploaderUrl: video.value.uploaderUrl,
uploaderName: video.value.uploader,
watchedAt: Date.now(),
};
}
store.put(vid);
};
}
})();
if (active.value) videoPlayer.value.loadVideo();
});
playlistId.value = route.query.list;
index.value = Number(route.query.index);
getPlaylistData();
getSponsors();
if (!isEmbed.value && showComments.value) getComments();
if (isEmbed.value) document.querySelector("html").style.overflow = "hidden";
window.addEventListener("click", handleClick);
window.addEventListener("resize", () => {
smallView.value = smallViewQuery.matches;
});
});
onActivated(() => {
active.value = true;
theaterMode.value = getPreferenceBoolean("theaterMode", window.innerWidth < (window.innerHeight * 4) / 3 + 467);
selectedAutoPlay.value = getPreferenceNumber("autoplay", 1);
showComments.value = !getPreferenceBoolean("minimizeComments", false);
showDesc.value = !getPreferenceBoolean("minimizeDescription", true);
showRecs.value = !getPreferenceBoolean("minimizeRecommendations", false);
showChapters.value = !getPreferenceBoolean("minimizeChapters", false);
if (video.value?.duration) {
document.title = video.value.title + " - Piped";
videoPlayer.value.loadVideo();
}
window.addEventListener("scroll", handleScroll);
});
onDeactivated(() => {
active.value = false;
window.removeEventListener("scroll", handleScroll);
dismiss();
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("click", handleClick);
dismiss();
});
</script>
<style>

36
src/composables/useApi.js Normal file
View File

@@ -0,0 +1,36 @@
import { getPreferenceBoolean, getPreferenceString } from "./usePreferences.js";
export function fetchJson(url, params, options) {
if (params) {
url = new URL(url);
for (var param in params) url.searchParams.set(param, params[param]);
}
return fetch(url, options).then(response => {
return response.json();
});
}
export function hashCode(s) {
return s.split("").reduce(function (a, b) {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0);
}
export function apiUrl() {
return getPreferenceString("instance", import.meta.env.VITE_PIPED_API);
}
export function authApiUrl() {
if (getPreferenceBoolean("authInstance", false)) {
return getPreferenceString("auth_instance_url", apiUrl());
} else return apiUrl();
}
export function getAuthToken() {
return getPreferenceString("authToken" + hashCode(authApiUrl()));
}
export function isAuthenticated() {
return getAuthToken() !== undefined;
}

View File

@@ -0,0 +1,36 @@
export async function getChannelGroups() {
return new Promise(resolve => {
let channelGroups = [];
var tx = window.db.transaction("channel_groups", "readonly");
var store = tx.objectStore("channel_groups");
const cursor = store.index("groupName").openCursor();
cursor.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
const group = cursor.value;
channelGroups.push({
groupName: group.groupName,
channels: JSON.parse(group.channels),
});
cursor.continue();
} else {
resolve(channelGroups);
}
};
});
}
export function createOrUpdateChannelGroup(group) {
var tx = window.db.transaction("channel_groups", "readwrite");
var store = tx.objectStore("channel_groups");
store.put({
groupName: group.groupName,
channels: JSON.stringify(group.channels),
});
}
export function deleteChannelGroup(groupName) {
var tx = window.db.transaction("channel_groups", "readwrite");
var store = tx.objectStore("channel_groups");
store.delete(groupName);
}

View File

@@ -0,0 +1,14 @@
export function getCustomInstances() {
return JSON.parse(window.localStorage.getItem("customInstances")) ?? [];
}
export function addCustomInstance(instance) {
let customInstances = getCustomInstances();
customInstances.push(instance);
window.localStorage.setItem("customInstances", JSON.stringify(customInstances));
}
export function removeCustomInstance(instanceToDelete) {
let customInstances = getCustomInstances().filter(instance => instance.api_url != instanceToDelete.api_url);
window.localStorage.setItem("customInstances", JSON.stringify(customInstances));
}

View File

@@ -0,0 +1,68 @@
import TimeAgo from "javascript-time-ago";
import en from "javascript-time-ago/locale/en";
import { getPreferenceString } from "./usePreferences.js";
TimeAgo.addDefaultLocale(en);
const timeAgoInstance = new TimeAgo("en-US");
export { TimeAgo };
export const TimeAgoConfig = { locale: "en" };
export function timeFormat(duration) {
var pad = function (num, size) {
return ("000" + num).slice(size * -1);
};
var time = parseFloat(duration).toFixed(3),
hours = Math.floor(time / 60 / 60),
minutes = Math.floor(time / 60) % 60,
seconds = Math.floor(time - minutes * 60);
var str = "";
if (hours > 0) str += hours + ":";
str += pad(minutes, 2) + ":" + pad(seconds, 2);
return str;
}
export function numberFormat(num) {
var loc = `${getPreferenceString("hl")}-${getPreferenceString("region")}`;
try {
Intl.getCanonicalLocales(loc);
} catch {
loc = undefined;
}
const formatter = Intl.NumberFormat(loc, {
notation: "compact",
});
return formatter.format(num);
}
export function addCommas(num) {
num = parseInt(num);
return num.toLocaleString("en-US");
}
export function timeAgo(time) {
return timeAgoInstance.format(time);
}
export async function getDefaultLanguage() {
const languages = window.navigator.languages;
for (let i = 0; i < languages.length; i++) {
try {
// Dynamic import of locale files
await import(`../locales/${languages[i]}.json`);
return languages[i];
} catch {
continue;
}
}
return "en";
}

View File

@@ -0,0 +1,39 @@
import { getPreferenceBoolean, getPreferenceString } from "./usePreferences.js";
export async function updateWatched(videos) {
if (window.db && getPreferenceBoolean("watchHistory", false)) {
var tx = window.db.transaction("watch_history", "readonly");
var store = tx.objectStore("watch_history");
videos.map(async video => {
var request = store.get(video.url.substr(-11));
request.onsuccess = function (event) {
if (event.target.result) {
video.watched = event.target.result.currentTime != 0;
video.currentTime = event.target.result.currentTime;
}
};
});
}
}
export function download(text, filename, mimeType) {
var file = new Blob([text], { type: mimeType });
const elem = document.createElement("a");
elem.href = URL.createObjectURL(file);
elem.download = filename;
elem.click();
elem.remove();
}
export function getHomePage() {
switch (getPreferenceString("homepage", "trending")) {
case "trending":
return "/trending";
case "feed":
return "/feed";
default:
return undefined;
}
}

View File

@@ -0,0 +1,252 @@
import { fetchJson, apiUrl, authApiUrl, getAuthToken, isAuthenticated } from "./useApi.js";
export async function getLocalPlaylist(playlistId) {
return await new Promise(resolve => {
var tx = window.db.transaction("playlists", "readonly");
var store = tx.objectStore("playlists");
const req = store.openCursor(playlistId);
let playlist = null;
req.onsuccess = e => {
playlist = e.target.result.value;
playlist.videos = JSON.parse(playlist.videoIds).length;
resolve(playlist);
};
});
}
export function createOrUpdateLocalPlaylist(playlist) {
var tx = window.db.transaction("playlists", "readwrite");
var store = tx.objectStore("playlists");
store.put(playlist);
}
// needs to handle both, streamInfo items and streams items
export function createLocalPlaylistVideo(videoId, videoInfo) {
if (videoInfo === undefined || videoId === null || videoInfo?.error) return;
var tx = window.db.transaction("playlist_videos", "readwrite");
var store = tx.objectStore("playlist_videos");
const video = {
videoId: videoId,
title: videoInfo.title,
type: "stream",
shortDescription: videoInfo.shortDescription ?? videoInfo.description,
url: `/watch?v=${videoId}`,
thumbnail: videoInfo.thumbnail ?? videoInfo.thumbnailUrl,
uploaderVerified: videoInfo.uploaderVerified,
duration: videoInfo.duration,
uploaderAvatar: videoInfo.uploaderAvatar,
uploaderUrl: videoInfo.uploaderUrl,
uploaderName: videoInfo.uploaderName ?? videoInfo.uploader,
};
store.put(video);
}
export async function getLocalPlaylistVideo(videoId) {
return await new Promise(resolve => {
var tx = window.db.transaction("playlist_videos", "readonly");
var store = tx.objectStore("playlist_videos");
const req = store.openCursor(videoId);
req.onsuccess = e => {
resolve(e.target.result?.value);
};
});
}
export async function getPlaylists() {
if (!isAuthenticated()) {
if (!window.db) return [];
return await new Promise(resolve => {
let playlists = [];
var tx = window.db.transaction("playlists", "readonly");
var store = tx.objectStore("playlists");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
let playlist = cursor.value;
playlist.videos = JSON.parse(playlist.videoIds).length;
playlists.push(playlist);
cursor.continue();
} else {
resolve(playlists);
}
};
});
}
return await fetchJson(authApiUrl() + "/user/playlists", null, {
headers: {
Authorization: getAuthToken(),
},
});
}
export async function getPlaylist(playlistId) {
if (playlistId.startsWith("local")) {
const playlist = await getLocalPlaylist(playlistId);
const videoIds = JSON.parse(playlist.videoIds);
const videosFuture = videoIds.map(videoId => getLocalPlaylistVideo(videoId));
playlist.relatedStreams = (await Promise.all(videosFuture)).filter(video => video !== undefined);
return playlist;
}
return await fetchJson(authApiUrl() + "/playlists/" + playlistId);
}
export async function createPlaylist(name) {
if (!isAuthenticated()) {
const uuid = crypto.randomUUID();
const playlistId = `local-${uuid}`;
createOrUpdateLocalPlaylist({
playlistId: playlistId,
// remapping needed for the playlists page
id: playlistId,
name: name,
description: "",
thumbnail: import.meta.env.VITE_PIPED_PROXY + "/?host=i.ytimg.com",
videoIds: "[]", // empty list
});
return { playlistId: playlistId };
}
return await fetchJson(authApiUrl() + "/user/playlists/create", null, {
method: "POST",
body: JSON.stringify({
name: name,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
}
export async function deletePlaylist(playlistId) {
if (!isAuthenticated()) {
const playlist = await getLocalPlaylist(playlistId);
var tx = window.db.transaction("playlists", "readwrite");
var store = tx.objectStore("playlists");
store.delete(playlistId);
// delete videos that don't need to be stored anymore
const playlists = await getPlaylists();
const usedVideoIds = playlists
.filter(playlist => playlist.id != playlistId)
.map(playlist => JSON.parse(playlist.videoIds))
.flat();
const potentialDeletableVideos = JSON.parse(playlist.videoIds);
var videoTx = window.db.transaction("playlist_videos", "readwrite");
var videoStore = videoTx.objectStore("playlist_videos");
for (let videoId of potentialDeletableVideos) {
if (!usedVideoIds.includes(videoId)) videoStore.delete(videoId);
}
return { message: "ok" };
}
return await fetchJson(authApiUrl() + "/user/playlists/delete", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
}
export async function renamePlaylist(playlistId, newName) {
if (!isAuthenticated()) {
const playlist = await getLocalPlaylist(playlistId);
playlist.name = newName;
createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await fetchJson(authApiUrl() + "/user/playlists/rename", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
newName: newName,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
}
export async function changePlaylistDescription(playlistId, newDescription) {
if (!isAuthenticated()) {
const playlist = await getLocalPlaylist(playlistId);
playlist.description = newDescription;
createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await fetchJson(authApiUrl() + "/user/playlists/description", null, {
method: "PATCH",
body: JSON.stringify({
playlistId: playlistId,
description: newDescription,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
}
export async function addVideosToPlaylist(playlistId, videoIds, videoInfos) {
if (!isAuthenticated()) {
const playlist = await getLocalPlaylist(playlistId);
const currentVideoIds = JSON.parse(playlist.videoIds);
currentVideoIds.push(...videoIds);
playlist.videoIds = JSON.stringify(currentVideoIds);
let streamInfos =
videoInfos ?? (await Promise.all(videoIds.map(videoId => fetchJson(apiUrl() + "/streams/" + videoId))));
playlist.thumbnail = streamInfos[0].thumbnail || streamInfos[0].thumbnailUrl;
createOrUpdateLocalPlaylist(playlist);
for (let i in videoIds) {
if (streamInfos[i].error) continue;
createLocalPlaylistVideo(videoIds[i], streamInfos[i]);
}
return { message: "ok" };
}
return await fetchJson(authApiUrl() + "/user/playlists/add", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
videoIds: videoIds,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
}
export async function removeVideoFromPlaylist(playlistId, index) {
if (!isAuthenticated()) {
const playlist = await getLocalPlaylist(playlistId);
const videoIds = JSON.parse(playlist.videoIds);
videoIds.splice(index, 1);
playlist.videoIds = JSON.stringify(videoIds);
if (videoIds.length == 0) playlist.thumbnail = import.meta.env.VITE_PIPED_PROXY + "/?host=i.ytimg.com";
createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await fetchJson(authApiUrl() + "/user/playlists/remove", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
index: index,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
}

View File

@@ -0,0 +1,65 @@
export function testLocalStorage() {
try {
if (window.localStorage !== undefined) localStorage;
return true;
} catch {
return false;
}
}
export function setPreference(key, value, disableAlert = false) {
try {
localStorage.setItem(key, value);
} catch {
if (!disableAlert) alert("Could not save preference to local storage.");
}
}
export function getPreferenceBoolean(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
switch (String(value).toLowerCase()) {
case "true":
case "1":
case "on":
case "yes":
return true;
default:
return false;
}
} else return defaultVal;
}
export function getPreferenceString(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
return value;
} else return defaultVal;
}
export function getPreferenceNumber(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
const num = Number(value);
return isNaN(num) ? defaultVal : num;
} else return defaultVal;
}
export function getPreferenceJSON(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(testLocalStorage() && (value = localStorage.getItem(key)) !== null)
) {
return JSON.parse(value);
} else return defaultVal;
}

View File

@@ -0,0 +1,137 @@
import { fetchJson, apiUrl, authApiUrl, getAuthToken, isAuthenticated } from "./useApi.js";
import { getPreferenceBoolean } from "./usePreferences.js";
export function getLocalSubscriptions() {
try {
return JSON.parse(localStorage.getItem("localSubscriptions"));
} catch {
return [];
}
}
export function isSubscribedLocally(channelId) {
const localSubscriptions = getLocalSubscriptions();
if (localSubscriptions == null) return false;
return localSubscriptions.includes(channelId);
}
export function handleLocalSubscriptions(channelId) {
var localSubscriptions = getLocalSubscriptions() ?? [];
if (localSubscriptions.includes(channelId)) localSubscriptions.splice(localSubscriptions.indexOf(channelId), 1);
else localSubscriptions.push(channelId);
// Sort for better cache hits
localSubscriptions.sort();
try {
localStorage.setItem("localSubscriptions", JSON.stringify(localSubscriptions));
return true;
} catch {
alert("Could not save subscriptions to local storage.");
}
return false;
}
export function getUnauthenticatedChannels() {
const localSubscriptions = getLocalSubscriptions() ?? [];
return localSubscriptions.join(",");
}
export async function fetchSubscriptions() {
if (isAuthenticated()) {
return await fetchJson(authApiUrl() + "/subscriptions", null, {
headers: {
Authorization: getAuthToken(),
},
});
} else {
const channels = getUnauthenticatedChannels();
const split = channels.split(",");
if (split.length > 100) {
return await fetchJson(authApiUrl() + "/subscriptions/unauthenticated", null, {
method: "POST",
body: JSON.stringify(split),
});
} else {
return await fetchJson(authApiUrl() + "/subscriptions/unauthenticated", {
channels: getUnauthenticatedChannels(),
});
}
}
}
export async function fetchFeed() {
if (isAuthenticated()) {
return await fetchJson(authApiUrl() + "/feed", {
authToken: getAuthToken(),
});
} else {
const channels = getUnauthenticatedChannels();
const split = channels.split(",");
if (split.length > 100) {
return await fetchJson(authApiUrl() + "/feed/unauthenticated", null, {
method: "POST",
body: JSON.stringify(split),
});
} else {
return await fetchJson(authApiUrl() + "/feed/unauthenticated", {
channels: channels,
});
}
}
}
export async function fetchSubscriptionStatus(channelId) {
if (!isAuthenticated()) {
return isSubscribedLocally(channelId);
}
const response = await fetchJson(
authApiUrl() + "/subscribed",
{
channelId: channelId,
},
{
headers: {
Authorization: getAuthToken(),
},
},
);
return response?.subscribed;
}
export async function toggleSubscriptionState(channelId, subscribed) {
if (!isAuthenticated()) return handleLocalSubscriptions(channelId);
const resp = await fetchJson(authApiUrl() + (subscribed ? "/unsubscribe" : "/subscribe"), null, {
method: "POST",
body: JSON.stringify({
channelId: channelId,
}),
headers: {
Authorization: getAuthToken(),
"Content-Type": "application/json",
},
});
return !resp.error;
}
export function fetchDeArrowContent(content) {
if (!getPreferenceBoolean("dearrow", false)) return;
const videoIds = content
.filter(item => item.type === "stream")
.map(item => item.url.substr(-11))
.sort();
if (videoIds.length === 0) return;
fetchJson(apiUrl() + "/dearrow", {
videoIds: videoIds.join(","),
}).then(json => {
Object.keys(json).forEach(videoId => {
const item = content.find(item => item.url.endsWith(videoId));
if (item) item.dearrow = json[videoId];
});
});
}

View File

@@ -2,625 +2,13 @@ import { createApp } from "vue";
import router from "@/router/router.js";
import App from "./App.vue";
import TimeAgo from "javascript-time-ago";
import en from "javascript-time-ago/locale/en";
TimeAgo.addDefaultLocale(en);
import { createI18n } from "vue-i18n";
import enLocale from "@/locales/en.json";
import "@unocss/reset/tailwind.css";
import "uno.css";
const timeAgo = new TimeAgo("en-US");
import("./registerServiceWorker");
const mixin = {
methods: {
timeFormat: function (duration) {
var pad = function (num, size) {
return ("000" + num).slice(size * -1);
};
var time = parseFloat(duration).toFixed(3),
hours = Math.floor(time / 60 / 60),
minutes = Math.floor(time / 60) % 60,
seconds = Math.floor(time - minutes * 60);
var str = "";
if (hours > 0) str += hours + ":";
str += pad(minutes, 2) + ":" + pad(seconds, 2);
return str;
},
numberFormat(num) {
var loc = `${this.getPreferenceString("hl")}-${this.getPreferenceString("region")}`;
try {
Intl.getCanonicalLocales(loc);
} catch {
loc = undefined;
}
const formatter = Intl.NumberFormat(loc, {
notation: "compact",
});
return formatter.format(num);
},
addCommas(num) {
num = parseInt(num);
return num.toLocaleString("en-US");
},
fetchJson: function (url, params, options) {
if (params) {
url = new URL(url);
for (var param in params) url.searchParams.set(param, params[param]);
}
return fetch(url, options).then(response => {
return response.json();
});
},
setPreference(key, value, disableAlert = false) {
try {
localStorage.setItem(key, value);
} catch {
if (!disableAlert) alert(this.$t("info.local_storage"));
}
},
getPreferenceBoolean(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
) {
switch (String(value).toLowerCase()) {
case "true":
case "1":
case "on":
case "yes":
return true;
default:
return false;
}
} else return defaultVal;
},
getPreferenceString(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
) {
return value;
} else return defaultVal;
},
getPreferenceNumber(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
) {
const num = Number(value);
return isNaN(num) ? defaultVal : num;
} else return defaultVal;
},
getPreferenceJSON(key, defaultVal) {
var value;
if (
(value = new URLSearchParams(window.location.search).get(key)) !== null ||
(this.testLocalStorage && (value = localStorage.getItem(key)) !== null)
) {
return JSON.parse(value);
} else return defaultVal;
},
apiUrl() {
return this.getPreferenceString("instance", import.meta.env.VITE_PIPED_API);
},
authApiUrl() {
if (this.getPreferenceBoolean("authInstance", false)) {
return this.getPreferenceString("auth_instance_url", this.apiUrl());
} else return this.apiUrl();
},
getAuthToken() {
return this.getPreferenceString("authToken" + this.hashCode(this.authApiUrl()));
},
hashCode(s) {
return s.split("").reduce(function (a, b) {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0);
},
timeAgo(time) {
return timeAgo.format(time);
},
async updateWatched(videos) {
if (window.db && this.getPreferenceBoolean("watchHistory", false)) {
var tx = window.db.transaction("watch_history", "readonly");
var store = tx.objectStore("watch_history");
videos.map(async video => {
var request = store.get(video.url.substr(-11));
request.onsuccess = function (event) {
if (event.target.result) {
video.watched = event.target.result.currentTime != 0;
video.currentTime = event.target.result.currentTime;
}
};
});
}
},
getLocalSubscriptions() {
try {
return JSON.parse(localStorage.getItem("localSubscriptions"));
} catch {
return [];
}
},
isSubscribedLocally(channelId) {
const localSubscriptions = this.getLocalSubscriptions();
if (localSubscriptions == null) return false;
return localSubscriptions.includes(channelId);
},
handleLocalSubscriptions(channelId) {
var localSubscriptions = this.getLocalSubscriptions() ?? [];
if (localSubscriptions.includes(channelId))
localSubscriptions.splice(localSubscriptions.indexOf(channelId), 1);
else localSubscriptions.push(channelId);
// Sort for better cache hits
localSubscriptions.sort();
try {
localStorage.setItem("localSubscriptions", JSON.stringify(localSubscriptions));
return true;
} catch {
alert(this.$t("info.local_storage"));
}
return false;
},
getUnauthenticatedChannels() {
const localSubscriptions = this.getLocalSubscriptions() ?? [];
return localSubscriptions.join(",");
},
async fetchSubscriptions() {
if (this.authenticated) {
return await this.fetchJson(this.authApiUrl() + "/subscriptions", null, {
headers: {
Authorization: this.getAuthToken(),
},
});
} else {
const channels = this.getUnauthenticatedChannels();
const split = channels.split(",");
if (split.length > 100) {
return await this.fetchJson(this.authApiUrl() + "/subscriptions/unauthenticated", null, {
method: "POST",
body: JSON.stringify(split),
});
} else {
return await this.fetchJson(this.authApiUrl() + "/subscriptions/unauthenticated", {
channels: this.getUnauthenticatedChannels(),
});
}
}
},
async fetchFeed() {
if (this.authenticated) {
return await this.fetchJson(this.authApiUrl() + "/feed", {
authToken: this.getAuthToken(),
});
} else {
const channels = this.getUnauthenticatedChannels();
const split = channels.split(",");
if (split.length > 100) {
return await this.fetchJson(this.authApiUrl() + "/feed/unauthenticated", null, {
method: "POST",
body: JSON.stringify(split),
});
} else {
return await this.fetchJson(this.authApiUrl() + "/feed/unauthenticated", {
channels: channels,
});
}
}
},
/* generate a temporary file and ask the user to download it */
download(text, filename, mimeType) {
var file = new Blob([text], { type: mimeType });
const elem = document.createElement("a");
elem.href = URL.createObjectURL(file);
elem.download = filename;
elem.click();
elem.remove();
},
async getChannelGroups() {
return new Promise(resolve => {
let channelGroups = [];
var tx = window.db.transaction("channel_groups", "readonly");
var store = tx.objectStore("channel_groups");
const cursor = store.index("groupName").openCursor();
cursor.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
const group = cursor.value;
channelGroups.push({
groupName: group.groupName,
channels: JSON.parse(group.channels),
});
cursor.continue();
} else {
resolve(channelGroups);
}
};
});
},
createOrUpdateChannelGroup(group) {
var tx = window.db.transaction("channel_groups", "readwrite");
var store = tx.objectStore("channel_groups");
store.put({
groupName: group.groupName,
channels: JSON.stringify(group.channels),
});
},
deleteChannelGroup(groupName) {
var tx = window.db.transaction("channel_groups", "readwrite");
var store = tx.objectStore("channel_groups");
store.delete(groupName);
},
async getLocalPlaylist(playlistId) {
return await new Promise(resolve => {
var tx = window.db.transaction("playlists", "readonly");
var store = tx.objectStore("playlists");
const req = store.openCursor(playlistId);
let playlist = null;
req.onsuccess = e => {
playlist = e.target.result.value;
playlist.videos = JSON.parse(playlist.videoIds).length;
resolve(playlist);
};
});
},
createOrUpdateLocalPlaylist(playlist) {
var tx = window.db.transaction("playlists", "readwrite");
var store = tx.objectStore("playlists");
store.put(playlist);
},
// needs to handle both, streamInfo items and streams items
createLocalPlaylistVideo(videoId, videoInfo) {
if (videoInfo === undefined || videoId === null || videoInfo?.error) return;
var tx = window.db.transaction("playlist_videos", "readwrite");
var store = tx.objectStore("playlist_videos");
const video = {
videoId: videoId,
title: videoInfo.title,
type: "stream",
shortDescription: videoInfo.shortDescription ?? videoInfo.description,
url: `/watch?v=${videoId}`,
thumbnail: videoInfo.thumbnail ?? videoInfo.thumbnailUrl,
uploaderVerified: videoInfo.uploaderVerified,
duration: videoInfo.duration,
uploaderAvatar: videoInfo.uploaderAvatar,
uploaderUrl: videoInfo.uploaderUrl,
uploaderName: videoInfo.uploaderName ?? videoInfo.uploader,
};
store.put(video);
},
async getLocalPlaylistVideo(videoId) {
return await new Promise(resolve => {
var tx = window.db.transaction("playlist_videos", "readonly");
var store = tx.objectStore("playlist_videos");
const req = store.openCursor(videoId);
req.onsuccess = e => {
resolve(e.target.result?.value);
};
});
},
async getPlaylists() {
if (!this.authenticated) {
if (!window.db) return [];
return await new Promise(resolve => {
let playlists = [];
var tx = window.db.transaction("playlists", "readonly");
var store = tx.objectStore("playlists");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = e => {
const cursor = e.target.result;
if (cursor) {
let playlist = cursor.value;
playlist.videos = JSON.parse(playlist.videoIds).length;
playlists.push(playlist);
cursor.continue();
} else {
resolve(playlists);
}
};
});
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists", null, {
headers: {
Authorization: this.getAuthToken(),
},
});
},
async getPlaylist(playlistId) {
if (playlistId.startsWith("local")) {
const playlist = await this.getLocalPlaylist(playlistId);
const videoIds = JSON.parse(playlist.videoIds);
const videosFuture = videoIds.map(videoId => this.getLocalPlaylistVideo(videoId));
playlist.relatedStreams = (await Promise.all(videosFuture)).filter(video => video !== undefined);
return playlist;
}
return await this.fetchJson(this.authApiUrl() + "/playlists/" + playlistId);
},
async createPlaylist(name) {
if (!this.authenticated) {
const uuid = crypto.randomUUID();
const playlistId = `local-${uuid}`;
this.createOrUpdateLocalPlaylist({
playlistId: playlistId,
// remapping needed for the playlists page
id: playlistId,
name: name,
description: "",
thumbnail: import.meta.env.VITE_PIPED_PROXY + "/?host=i.ytimg.com",
videoIds: "[]", // empty list
});
return { playlistId: playlistId };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/create", null, {
method: "POST",
body: JSON.stringify({
name: name,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async deletePlaylist(playlistId) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
var tx = window.db.transaction("playlists", "readwrite");
var store = tx.objectStore("playlists");
store.delete(playlistId);
// delete videos that don't need to be store anymore
const playlists = await this.getPlaylists();
const usedVideoIds = playlists
.filter(playlist => playlist.id != playlistId)
.map(playlist => JSON.parse(playlist.videoIds))
.flat();
const potentialDeletableVideos = JSON.parse(playlist.videoIds);
var videoTx = window.db.transaction("playlist_videos", "readwrite");
var videoStore = videoTx.objectStore("playlist_videos");
for (let videoId of potentialDeletableVideos) {
if (!usedVideoIds.includes(videoId)) videoStore.delete(videoId);
}
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/delete", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async renamePlaylist(playlistId, newName) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
playlist.name = newName;
this.createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/rename", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
newName: newName,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async changePlaylistDescription(playlistId, newDescription) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
playlist.description = newDescription;
this.createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/description", null, {
method: "PATCH",
body: JSON.stringify({
playlistId: playlistId,
description: newDescription,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
async addVideosToPlaylist(playlistId, videoIds, videoInfos) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
const currentVideoIds = JSON.parse(playlist.videoIds);
currentVideoIds.push(...videoIds);
playlist.videoIds = JSON.stringify(currentVideoIds);
let streamInfos =
videoInfos ??
(await Promise.all(videoIds.map(videoId => this.fetchJson(this.apiUrl() + "/streams/" + videoId))));
playlist.thumbnail = streamInfos[0].thumbnail || streamInfos[0].thumbnailUrl;
this.createOrUpdateLocalPlaylist(playlist);
for (let i in videoIds) {
if (streamInfos[i].error) continue;
this.createLocalPlaylistVideo(videoIds[i], streamInfos[i]);
}
return { message: "ok" };
}
return 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 removeVideoFromPlaylist(playlistId, index) {
if (!this.authenticated) {
const playlist = await this.getLocalPlaylist(playlistId);
const videoIds = JSON.parse(playlist.videoIds);
videoIds.splice(index, 1);
playlist.videoIds = JSON.stringify(videoIds);
if (videoIds.length == 0) playlist.thumbnail = import.meta.env.VITE_PIPED_PROXY + "/?host=i.ytimg.com";
this.createOrUpdateLocalPlaylist(playlist);
return { message: "ok" };
}
return await this.fetchJson(this.authApiUrl() + "/user/playlists/remove", null, {
method: "POST",
body: JSON.stringify({
playlistId: playlistId,
index: index,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
},
getHomePage(_this) {
switch (_this.getPreferenceString("homepage", "trending")) {
case "trending":
return "/trending";
case "feed":
return "/feed";
default:
return undefined;
}
},
fetchDeArrowContent(content) {
if (!this.getPreferenceBoolean("dearrow", false)) return;
const videoIds = content
.filter(item => item.type === "stream")
.map(item => item.url.substr(-11))
.sort();
if (videoIds.length === 0) return;
this.fetchJson(this.apiUrl() + "/dearrow", {
videoIds: videoIds.join(","),
}).then(json => {
Object.keys(json).forEach(videoId => {
const item = content.find(item => item.url.endsWith(videoId));
if (item) item.dearrow = json[videoId];
});
});
},
async fetchSubscriptionStatus(channelId) {
if (!this.authenticated) {
return this.isSubscribedLocally(channelId);
}
const response = await this.fetchJson(
this.authApiUrl() + "/subscribed",
{
channelId: channelId,
},
{
headers: {
Authorization: this.getAuthToken(),
},
},
);
return response?.subscribed;
},
async toggleSubscriptionState(channelId, subscribed) {
if (!this.authenticated) return this.handleLocalSubscriptions(channelId);
const resp = await this.fetchJson(this.authApiUrl() + (subscribed ? "/unsubscribe" : "/subscribe"), null, {
method: "POST",
body: JSON.stringify({
channelId: channelId,
}),
headers: {
Authorization: this.getAuthToken(),
"Content-Type": "application/json",
},
});
return !resp.error;
},
getCustomInstances() {
return JSON.parse(window.localStorage.getItem("customInstances")) ?? [];
},
addCustomInstance(instance) {
let customInstances = this.getCustomInstances();
customInstances.push(instance);
window.localStorage.setItem("customInstances", JSON.stringify(customInstances));
},
removeCustomInstance(instanceToDelete) {
let customInstances = this.getCustomInstances().filter(
instance => instance.api_url != instanceToDelete.api_url,
);
window.localStorage.setItem("customInstances", JSON.stringify(customInstances));
},
},
computed: {
authenticated(_this) {
return _this.getAuthToken() !== undefined;
},
testLocalStorage() {
try {
if (window.localStorage !== undefined) localStorage;
return true;
} catch {
return false;
}
},
async defaultLanguage() {
const languages = window.navigator.languages;
for (let i = 0; i < languages.length; i++) {
try {
await import(`./locales/${languages[i]}.json`);
return languages[i];
} catch {
continue;
}
}
return "en";
},
},
data() {
return {
TimeAgo: TimeAgo,
TimeAgoConfig: timeAgo,
};
},
};
const i18n = createI18n({
globalInjection: true,
legacy: false,
@@ -636,5 +24,4 @@ window.i18n = i18n;
const app = createApp(App);
app.use(i18n);
app.use(router);
app.mixin(mixin);
app.mount("#app");