mirror of
https://github.com/TeamPiped/Piped.git
synced 2026-06-02 12:54:34 +00:00
Migrate code to composition api.
This commit is contained in:
213
src/App.vue
213
src/App.vue
@@ -13,127 +13,120 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
import NavBar from "./components/NavBar.vue";
|
import NavBar from "./components/NavBar.vue";
|
||||||
import FooterComponent from "./components/FooterComponent.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)");
|
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
export default {
|
const theme = ref("dark");
|
||||||
components: {
|
|
||||||
NavBar,
|
|
||||||
FooterComponent,
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
theme: "dark",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.setTheme();
|
|
||||||
darkModePreference.addEventListener("change", () => {
|
|
||||||
this.setTheme();
|
|
||||||
});
|
|
||||||
|
|
||||||
if ("indexedDB" in window) {
|
function setTheme() {
|
||||||
const request = indexedDB.open("piped-db", 6);
|
let themePref = getPreferenceString("theme", "dark");
|
||||||
request.onupgradeneeded = ev => {
|
const themes = {
|
||||||
const db = request.result;
|
dark: "dark",
|
||||||
console.log("Upgrading object store.");
|
light: "light",
|
||||||
if (!db.objectStoreNames.contains("watch_history")) {
|
auto: darkModePreference.matches ? "dark" : "light",
|
||||||
const store = db.createObjectStore("watch_history", { keyPath: "videoId" });
|
};
|
||||||
store.createIndex("video_id_idx", "videoId", { unique: true });
|
|
||||||
store.createIndex("id_idx", "id", { unique: true, autoIncrement: true });
|
theme.value = themes[themePref];
|
||||||
}
|
|
||||||
if (ev.oldVersion < 2) {
|
changeTitleBarColor();
|
||||||
const store = request.transaction.objectStore("watch_history");
|
|
||||||
store.createIndex("watchedAt", "watchedAt", { unique: false });
|
const root = document.querySelector(":root");
|
||||||
}
|
theme.value === "dark" ? root.classList.add("dark") : root.classList.remove("dark");
|
||||||
if (!db.objectStoreNames.contains("playlist_bookmarks")) {
|
}
|
||||||
const store = db.createObjectStore("playlist_bookmarks", { keyPath: "playlistId" });
|
|
||||||
store.createIndex("playlist_id_idx", "playlistId", { unique: true });
|
function changeTitleBarColor() {
|
||||||
store.createIndex("id_idx", "id", { unique: true, autoIncrement: true });
|
const currentColor = { dark: "#0F0F0F", light: "#FFF" };
|
||||||
}
|
const themeColor = document.querySelector("meta[name='theme-color']");
|
||||||
if (!db.objectStoreNames.contains("channel_groups")) {
|
themeColor.setAttribute("content", currentColor[theme.value]);
|
||||||
const store = db.createObjectStore("channel_groups", { keyPath: "groupName" });
|
}
|
||||||
store.createIndex("groupName", "groupName", { unique: true });
|
|
||||||
}
|
onMounted(() => {
|
||||||
if (!db.objectStoreNames.contains("playlists")) {
|
setTheme();
|
||||||
const playlistStore = db.createObjectStore("playlists", { keyPath: "playlistId" });
|
darkModePreference.addEventListener("change", () => {
|
||||||
playlistStore.createIndex("playlistId", "playlistId", { unique: true });
|
setTheme();
|
||||||
const playlistVideosStore = db.createObjectStore("playlist_videos", { keyPath: "videoId" });
|
});
|
||||||
playlistVideosStore.createIndex("videoId", "videoId", { unique: true });
|
|
||||||
}
|
if ("indexedDB" in window) {
|
||||||
// migration to fix an invalid previous length of channel ids: 11 -> 24
|
const request = indexedDB.open("piped-db", 6);
|
||||||
(async () => {
|
request.onupgradeneeded = ev => {
|
||||||
if (ev.oldVersion < 6) {
|
const db = request.result;
|
||||||
const subscriptions = await this.fetchSubscriptions();
|
console.log("Upgrading object store.");
|
||||||
const channelGroups = await this.getChannelGroups();
|
if (!db.objectStoreNames.contains("watch_history")) {
|
||||||
for (let group of channelGroups) {
|
const store = db.createObjectStore("watch_history", { keyPath: "videoId" });
|
||||||
for (let i = 0; i < group.channels.length; i++) {
|
store.createIndex("video_id_idx", "videoId", { unique: true });
|
||||||
const tooShortChannelId = group.channels[i];
|
store.createIndex("id_idx", "id", { unique: true, autoIncrement: true });
|
||||||
const foundChannel = subscriptions.find(
|
}
|
||||||
channel => channel.url.substr(-11) == tooShortChannelId,
|
if (ev.oldVersion < 2) {
|
||||||
);
|
const store = request.transaction.objectStore("watch_history");
|
||||||
if (foundChannel) group.channels[i] = foundChannel.url.substr(-24);
|
store.createIndex("watchedAt", "watchedAt", { unique: false });
|
||||||
}
|
}
|
||||||
this.createOrUpdateChannelGroup(group);
|
if (!db.objectStoreNames.contains("playlist_bookmarks")) {
|
||||||
|
const store = db.createObjectStore("playlist_bookmarks", { keyPath: "playlistId" });
|
||||||
|
store.createIndex("playlist_id_idx", "playlistId", { unique: true });
|
||||||
|
store.createIndex("id_idx", "id", { unique: true, autoIncrement: true });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains("channel_groups")) {
|
||||||
|
const store = db.createObjectStore("channel_groups", { keyPath: "groupName" });
|
||||||
|
store.createIndex("groupName", "groupName", { unique: true });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains("playlists")) {
|
||||||
|
const playlistStore = db.createObjectStore("playlists", { keyPath: "playlistId" });
|
||||||
|
playlistStore.createIndex("playlistId", "playlistId", { unique: true });
|
||||||
|
const playlistVideosStore = db.createObjectStore("playlist_videos", { keyPath: "videoId" });
|
||||||
|
playlistVideosStore.createIndex("videoId", "videoId", { unique: true });
|
||||||
|
}
|
||||||
|
// migration to fix an invalid previous length of channel ids: 11 -> 24
|
||||||
|
(async () => {
|
||||||
|
if (ev.oldVersion < 6) {
|
||||||
|
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];
|
||||||
|
const foundChannel = subscriptions.find(
|
||||||
|
channel => channel.url.substr(-11) == tooShortChannelId,
|
||||||
|
);
|
||||||
|
if (foundChannel) group.channels[i] = foundChannel.url.substr(-24);
|
||||||
}
|
}
|
||||||
|
createOrUpdateChannelGroup(group);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
};
|
|
||||||
request.onsuccess = e => {
|
|
||||||
window.db = e.target.result;
|
|
||||||
};
|
|
||||||
} 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 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;
|
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
request.onsuccess = e => {
|
||||||
|
window.db = e.target.result;
|
||||||
|
};
|
||||||
|
} else console.log("This browser doesn't support IndexedDB");
|
||||||
|
|
||||||
|
(async function () {
|
||||||
|
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) {
|
||||||
|
TimeAgo.addLocale(localeTime);
|
||||||
|
TimeAgoConfig.locale = locale;
|
||||||
}
|
}
|
||||||
if (window.i18n.global.locale.value !== locale) {
|
}
|
||||||
if (!window.i18n.global.availableLocales.includes(locale)) {
|
if (window.i18n.global.locale.value !== locale) {
|
||||||
const messages = await import(`./locales/${locale}.json`).then(module => module.default);
|
if (!window.i18n.global.availableLocales.includes(locale)) {
|
||||||
window.i18n.global.setLocaleMessage(locale, messages);
|
const messages = await import(`./locales/${locale}.json`).then(module => module.default);
|
||||||
}
|
window.i18n.global.setLocaleMessage(locale, messages);
|
||||||
window.i18n.global.locale.value = locale;
|
|
||||||
}
|
}
|
||||||
})();
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -25,56 +25,52 @@
|
|||||||
@close="showCreateGroupModal = false"
|
@close="showCreateGroupModal = false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
import CreateGroupModal from "./CreateGroupModal.vue";
|
import CreateGroupModal from "./CreateGroupModal.vue";
|
||||||
|
import { getChannelGroups, createOrUpdateChannelGroup } from "@/composables/useChannelGroups.js";
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
channelId: {
|
||||||
ModalComponent,
|
type: String,
|
||||||
CreateGroupModal,
|
required: true,
|
||||||
},
|
},
|
||||||
props: {
|
});
|
||||||
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)) {
|
|
||||||
group.channels.splice(index, 1);
|
|
||||||
} else {
|
|
||||||
group.channels.push(this.channelId);
|
|
||||||
}
|
|
||||||
this.createOrUpdateChannelGroup(group);
|
|
||||||
},
|
|
||||||
onCreateGroup(newGroupName) {
|
|
||||||
if (!newGroupName || this.channelGroups.some(group => group.groupName == newGroupName)) return;
|
|
||||||
|
|
||||||
const newGroup = {
|
defineEmits(["close"]);
|
||||||
groupName: newGroupName,
|
|
||||||
channels: [],
|
|
||||||
};
|
|
||||||
this.channelGroups.push(newGroup);
|
|
||||||
this.createOrUpdateChannelGroup(newGroup);
|
|
||||||
|
|
||||||
this.showCreateGroupModal = false;
|
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(props.channelId);
|
||||||
|
}
|
||||||
|
createOrUpdateChannelGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateGroup(newGroupName) {
|
||||||
|
if (!newGroupName || channelGroups.value.some(group => group.groupName == newGroupName)) return;
|
||||||
|
|
||||||
|
const newGroup = {
|
||||||
|
groupName: newGroupName,
|
||||||
|
channels: [],
|
||||||
|
};
|
||||||
|
channelGroups.value.push(newGroup);
|
||||||
|
createOrUpdateChannelGroup(newGroup);
|
||||||
|
|
||||||
|
showCreateGroupModal.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -36,37 +36,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, computed, onMounted } from "vue";
|
||||||
props: {
|
import { fetchSubscriptionStatus, toggleSubscriptionState } from "@/composables/useSubscriptions.js";
|
||||||
item: {
|
import { numberFormat } from "@/composables/useFormatting.js";
|
||||||
type: Object,
|
|
||||||
required: true,
|
const props = defineProps({
|
||||||
},
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
data() {
|
});
|
||||||
return {
|
|
||||||
subscribed: null,
|
const subscribed = ref(null);
|
||||||
};
|
|
||||||
},
|
const channelId = computed(() => props.item.url.substr(-24));
|
||||||
computed: {
|
|
||||||
channelId(_this) {
|
async function updateSubscribedStatus() {
|
||||||
return _this.item.url.substr(-24);
|
subscribed.value = await fetchSubscriptionStatus(channelId.value);
|
||||||
},
|
console.log(subscribed.value);
|
||||||
},
|
}
|
||||||
mounted() {
|
|
||||||
this.updateSubscribedStatus();
|
function subscribeHandler() {
|
||||||
},
|
toggleSubscriptionState(channelId.value, subscribed.value).then(success => {
|
||||||
methods: {
|
if (success) subscribed.value = !subscribed.value;
|
||||||
async updateSubscribedStatus() {
|
});
|
||||||
this.subscribed = await this.fetchSubscriptionStatus(this.channelId);
|
}
|
||||||
console.log(this.subscribed);
|
|
||||||
},
|
onMounted(() => {
|
||||||
subscribeHandler() {
|
updateSubscribedStatus();
|
||||||
this.toggleSubscriptionState(this.channelId, this.subscribed).then(success => {
|
});
|
||||||
if (success) this.subscribed = !this.subscribed;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -86,175 +86,186 @@
|
|||||||
</LoadingIndicatorPage>
|
</LoadingIndicatorPage>
|
||||||
</template>
|
</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 ErrorHandler from "./ErrorHandler.vue";
|
||||||
import ContentItem from "./ContentItem.vue";
|
import ContentItem from "./ContentItem.vue";
|
||||||
import WatchOnButton from "./WatchOnButton.vue";
|
import WatchOnButton from "./WatchOnButton.vue";
|
||||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||||
import CollapsableText from "./CollapsableText.vue";
|
import CollapsableText from "./CollapsableText.vue";
|
||||||
import AddToGroupModal from "./AddToGroupModal.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 {
|
const route = useRoute();
|
||||||
components: {
|
const { t } = useI18n();
|
||||||
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;
|
|
||||||
|
|
||||||
this.subscribed = await this.fetchSubscriptionStatus(this.channel.id);
|
const channel = ref(null);
|
||||||
},
|
const subscribed = ref(false);
|
||||||
async fetchChannel() {
|
const tabs = ref([]);
|
||||||
const url = this.$route.path.includes("@")
|
const selectedTab = ref(0);
|
||||||
? this.apiUrl() + "/@/" + this.$route.params.channelId
|
const contentItems = ref([]);
|
||||||
: this.apiUrl() + "/" + this.$route.params.path + "/" + this.$route.params.channelId;
|
const showGroupModal = ref(false);
|
||||||
return await this.fetchJson(url);
|
let loading = false;
|
||||||
},
|
|
||||||
async getChannelData() {
|
async function fetchSubscribedStatus() {
|
||||||
this.fetchChannel()
|
if (!channel.value.id) return;
|
||||||
.then(data => (this.channel = data))
|
subscribed.value = await fetchSubscriptionStatus(channel.value.id);
|
||||||
.then(() => {
|
}
|
||||||
if (!this.channel.error) {
|
|
||||||
document.title = this.channel.name + " - Piped";
|
async function fetchChannel() {
|
||||||
this.contentItems = this.channel.relatedStreams;
|
const url = route.path.includes("@")
|
||||||
this.fetchSubscribedStatus();
|
? apiUrl() + "/@/" + route.params.channelId
|
||||||
this.updateWatched(this.channel.relatedStreams);
|
: apiUrl() + "/" + route.params.path + "/" + route.params.channelId;
|
||||||
this.fetchDeArrowContent(this.channel.relatedStreams);
|
return await fetchJson(url);
|
||||||
this.tabs.push({
|
}
|
||||||
translatedName: this.$t("video.videos"),
|
|
||||||
});
|
async function getChannelData() {
|
||||||
const tabQuery = this.$route.query.tab;
|
fetchChannel()
|
||||||
for (let i = 0; i < this.channel.tabs.length; i++) {
|
.then(data => (channel.value = data))
|
||||||
let tab = this.channel.tabs[i];
|
.then(() => {
|
||||||
tab.translatedName = this.getTranslatedTabName(tab.name);
|
if (!channel.value.error) {
|
||||||
this.tabs.push(tab);
|
document.title = channel.value.name + " - Piped";
|
||||||
if (tab.name === tabQuery) this.loadTab(i + 1);
|
contentItems.value = channel.value.relatedStreams;
|
||||||
}
|
fetchSubscribedStatus();
|
||||||
}
|
updateWatched(channel.value.relatedStreams);
|
||||||
|
fetchDeArrowContent(channel.value.relatedStreams);
|
||||||
|
tabs.value.push({
|
||||||
|
translatedName: t("video.videos"),
|
||||||
});
|
});
|
||||||
},
|
const tabQuery = route.query.tab;
|
||||||
handleScroll() {
|
for (let i = 0; i < channel.value.tabs.length; i++) {
|
||||||
if (
|
let tab = channel.value.tabs[i];
|
||||||
this.loading ||
|
tab.translatedName = getTranslatedTabName(tab.name);
|
||||||
!this.channel ||
|
tabs.value.push(tab);
|
||||||
!this.channel.nextpage ||
|
if (tab.name === tabQuery) loadTab(i + 1);
|
||||||
(this.selectedTab != 0 && !this.tabs[this.selectedTab].tabNextPage)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
|
||||||
this.loading = true;
|
|
||||||
if (this.selectedTab == 0) {
|
|
||||||
this.fetchChannelNextPage();
|
|
||||||
} else {
|
|
||||||
this.fetchChannelTabNextPage();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
fetchChannelNextPage() {
|
}
|
||||||
this.fetchJson(this.apiUrl() + "/nextpage/channel/" + this.channel.id, {
|
|
||||||
nextpage: this.channel.nextpage,
|
|
||||||
}).then(json => {
|
|
||||||
this.channel.nextpage = json.nextpage;
|
|
||||||
this.loading = false;
|
|
||||||
this.updateWatched(json.relatedStreams);
|
|
||||||
this.contentItems.push(...json.relatedStreams);
|
|
||||||
this.fetchDeArrowContent(json.relatedStreams);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
fetchChannelTabNextPage() {
|
|
||||||
this.fetchJson(this.apiUrl() + "/channels/tabs", {
|
|
||||||
data: this.tabs[this.selectedTab].data,
|
|
||||||
nextpage: this.tabs[this.selectedTab].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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
subscribeHandler() {
|
|
||||||
this.toggleSubscriptionState(this.channel.id, this.subscribed).then(success => {
|
|
||||||
if (success) this.subscribed = !this.subscribed;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getTranslatedTabName(tabName) {
|
|
||||||
let translatedTabName = tabName;
|
|
||||||
switch (tabName) {
|
|
||||||
case "livestreams":
|
|
||||||
translatedTabName = this.$t("titles.livestreams");
|
|
||||||
break;
|
|
||||||
case "playlists":
|
|
||||||
translatedTabName = this.$t("titles.playlists");
|
|
||||||
break;
|
|
||||||
case "albums":
|
|
||||||
translatedTabName = this.$t("titles.albums");
|
|
||||||
break;
|
|
||||||
case "shorts":
|
|
||||||
translatedTabName = this.$t("video.shorts");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.error(`Tab name "${tabName}" is not translated yet!`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return translatedTabName;
|
|
||||||
},
|
|
||||||
loadTab(index) {
|
|
||||||
this.selectedTab = index;
|
|
||||||
|
|
||||||
// update the tab query in the url path
|
function handleScroll() {
|
||||||
const url = new URL(window.location);
|
if (
|
||||||
url.searchParams.set("tab", this.tabs[index].name ?? "videos");
|
loading ||
|
||||||
window.history.replaceState(window.history.state, "", url);
|
!channel.value ||
|
||||||
|
!channel.value.nextpage ||
|
||||||
|
(selectedTab.value != 0 && !tabs.value[selectedTab.value].tabNextPage)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||||
|
loading = true;
|
||||||
|
if (selectedTab.value == 0) {
|
||||||
|
fetchChannelNextPage();
|
||||||
|
} else {
|
||||||
|
fetchChannelTabNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (index == 0) {
|
function fetchChannelNextPage() {
|
||||||
this.contentItems = this.channel.relatedStreams;
|
fetchJson(apiUrl() + "/nextpage/channel/" + channel.value.id, {
|
||||||
return;
|
nextpage: channel.value.nextpage,
|
||||||
}
|
}).then(json => {
|
||||||
|
channel.value.nextpage = json.nextpage;
|
||||||
|
loading = false;
|
||||||
|
updateWatched(json.relatedStreams);
|
||||||
|
contentItems.value.push(...json.relatedStreams);
|
||||||
|
fetchDeArrowContent(json.relatedStreams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (this.tabs[index].content) {
|
function fetchChannelTabNextPage() {
|
||||||
this.contentItems = this.tabs[index].content;
|
fetchJson(apiUrl() + "/channels/tabs", {
|
||||||
return;
|
data: tabs.value[selectedTab.value].data,
|
||||||
}
|
nextpage: tabs.value[selectedTab.value].tabNextPage,
|
||||||
this.fetchJson(this.apiUrl() + "/channels/tabs", {
|
}).then(json => {
|
||||||
data: this.tabs[index].data,
|
tabs.value[selectedTab.value].tabNextPage = json.nextpage;
|
||||||
}).then(tab => {
|
loading = false;
|
||||||
this.contentItems = this.tabs[index].content = tab.content;
|
contentItems.value.push(...json.content);
|
||||||
this.fetchDeArrowContent(tab.content);
|
fetchDeArrowContent(json.content);
|
||||||
this.tabs[this.selectedTab].tabNextPage = tab.nextpage;
|
tabs.value[selectedTab.value].content = contentItems.value;
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
function subscribeHandler() {
|
||||||
|
toggleSubscriptionState(channel.value.id, subscribed.value).then(success => {
|
||||||
|
if (success) subscribed.value = !subscribed.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTranslatedTabName(tabName) {
|
||||||
|
let translatedTabName = tabName;
|
||||||
|
switch (tabName) {
|
||||||
|
case "livestreams":
|
||||||
|
translatedTabName = t("titles.livestreams");
|
||||||
|
break;
|
||||||
|
case "playlists":
|
||||||
|
translatedTabName = t("titles.playlists");
|
||||||
|
break;
|
||||||
|
case "albums":
|
||||||
|
translatedTabName = t("titles.albums");
|
||||||
|
break;
|
||||||
|
case "shorts":
|
||||||
|
translatedTabName = t("video.shorts");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error(`Tab name "${tabName}" is not translated yet!`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return translatedTabName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTab(index) {
|
||||||
|
selectedTab.value = index;
|
||||||
|
|
||||||
|
// update the tab query in the url path
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set("tab", tabs.value[index].name ?? "videos");
|
||||||
|
window.history.replaceState(window.history.state, "", url);
|
||||||
|
|
||||||
|
if (index == 0) {
|
||||||
|
contentItems.value = channel.value.relatedStreams;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabs.value[index].content) {
|
||||||
|
contentItems.value = tabs.value[index].content;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchJson(apiUrl() + "/channels/tabs", {
|
||||||
|
data: tabs.value[index].data,
|
||||||
|
}).then(tab => {
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
<ErrorHandler v-if="response && response.error" :message="response.message" :error="response.error" />
|
<ErrorHandler v-if="response && response.error" :message="response.message" :error="response.error" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, onActivated } from "vue";
|
||||||
activated() {
|
import { useRouter, useRoute } from "vue-router";
|
||||||
this.fetchJson(this.apiUrl() + "/clips/" + this.$route.params.clipId).then(response => {
|
import { fetchJson } from "@/composables/useApi.js";
|
||||||
this.response = response;
|
import { apiUrl } from "@/composables/useApi.js";
|
||||||
if (this.response.videoId) {
|
|
||||||
this.$router.push(`/watch?v=${this.response.videoId}`);
|
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>
|
</script>
|
||||||
|
|||||||
@@ -15,34 +15,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
|
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
props: {
|
text: {
|
||||||
text: {
|
type: String,
|
||||||
type: String,
|
default: null,
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
visibleLimit: {
|
|
||||||
type: Number,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
data() {
|
visibleLimit: {
|
||||||
return {
|
type: Number,
|
||||||
showFullText: false,
|
default: 100,
|
||||||
};
|
|
||||||
},
|
},
|
||||||
methods: {
|
});
|
||||||
fullText() {
|
|
||||||
return purifyHTML(rewriteDescription(this.text));
|
const showFullText = ref(false);
|
||||||
},
|
|
||||||
collapsedText() {
|
function fullText() {
|
||||||
return purifyHTML(rewriteDescription(this.text.slice(0, this.visibleLimit)));
|
return purifyHTML(rewriteDescription(props.text));
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
function collapsedText() {
|
||||||
|
return purifyHTML(rewriteDescription(props.text.slice(0, props.visibleLimit)));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -67,48 +67,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
import CollapsableText from "./CollapsableText.vue";
|
import CollapsableText from "./CollapsableText.vue";
|
||||||
|
import { fetchJson, apiUrl } from "@/composables/useApi.js";
|
||||||
|
import { numberFormat } from "@/composables/useFormatting.js";
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: { CollapsableText },
|
comment: {
|
||||||
props: {
|
type: Object,
|
||||||
comment: {
|
default: () => {
|
||||||
type: Object,
|
return {};
|
||||||
default: () => {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loadingReplies = true;
|
|
||||||
this.showingReplies = true;
|
|
||||||
this.fetchJson(this.apiUrl() + "/nextpage/comments/" + this.videoId, {
|
|
||||||
nextpage: this.nextpage || this.comment.repliesPage,
|
|
||||||
}).then(json => {
|
|
||||||
this.replies = this.replies.concat(json.comments);
|
|
||||||
this.nextpage = json.nextpage;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async hideReplies() {
|
|
||||||
this.showingReplies = false;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
uploader: { type: String, default: null },
|
||||||
|
uploaderAvatarUrl: { type: String, default: null },
|
||||||
|
videoId: { type: String, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
loadingReplies.value = true;
|
||||||
|
showingReplies.value = true;
|
||||||
|
fetchJson(apiUrl() + "/nextpage/comments/" + props.videoId, {
|
||||||
|
nextpage: nextpage.value || props.comment.repliesPage,
|
||||||
|
}).then(json => {
|
||||||
|
replies.value = replies.value.concat(json.comments);
|
||||||
|
nextpage.value = json.nextpage;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hideReplies() {
|
||||||
|
showingReplies.value = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,33 +10,31 @@
|
|||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted } from "vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
components: {
|
message: {
|
||||||
ModalComponent,
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
props: {
|
});
|
||||||
message: {
|
|
||||||
type: String,
|
const emit = defineEmits(["close", "confirm"]);
|
||||||
required: true,
|
|
||||||
},
|
function handleKeyDown(event) {
|
||||||
},
|
if (event.code === "Enter") {
|
||||||
emits: ["close", "confirm"],
|
emit("confirm");
|
||||||
mounted() {
|
event.preventDefault();
|
||||||
window.addEventListener("keydown", this.handleKeyDown);
|
}
|
||||||
},
|
}
|
||||||
unmounted() {
|
|
||||||
window.removeEventListener("keydown", this.handleKeyDown);
|
onMounted(() => {
|
||||||
},
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
methods: {
|
});
|
||||||
handleKeyDown(event) {
|
|
||||||
if (event.code === "Enter") {
|
onUnmounted(() => {
|
||||||
this.$emit("confirm");
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
event.preventDefault();
|
});
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,28 +8,23 @@
|
|||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: { ModalComponent },
|
onCreateGroup: {
|
||||||
props: {
|
required: true,
|
||||||
onCreateGroup: {
|
type: Function,
|
||||||
required: true,
|
|
||||||
type: Function,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
emits: ["close"],
|
});
|
||||||
data() {
|
|
||||||
return {
|
const emit = defineEmits(["close"]);
|
||||||
groupName: "",
|
|
||||||
};
|
const groupName = ref("");
|
||||||
},
|
|
||||||
methods: {
|
function createGroup() {
|
||||||
createGroup() {
|
props.onCreateGroup(groupName.value);
|
||||||
this.onCreateGroup(this.groupName);
|
emit("close");
|
||||||
this.$emit("close");
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,44 +11,41 @@
|
|||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
|
import { createPlaylist } from "@/composables/usePlaylists.js";
|
||||||
|
|
||||||
export default {
|
const emit = defineEmits(["created", "close"]);
|
||||||
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) {
|
|
||||||
if (event.code === "Enter") {
|
|
||||||
this.onCreatePlaylist();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onCreatePlaylist() {
|
|
||||||
if (!this.playlistName) return;
|
|
||||||
|
|
||||||
this.createPlaylist(this.playlistName).then(response => {
|
const playlistName = ref("");
|
||||||
if (response.error) alert(response.error);
|
const input = ref(null);
|
||||||
else {
|
|
||||||
this.$emit("created", response.playlistId, this.playlistName);
|
function handleKeyDown(event) {
|
||||||
this.$emit("close");
|
if (event.code === "Enter") {
|
||||||
}
|
onCreatePlaylist();
|
||||||
});
|
event.preventDefault();
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
function onCreatePlaylist() {
|
||||||
|
if (!playlistName.value) return;
|
||||||
|
|
||||||
|
createPlaylist(playlistName.value).then(response => {
|
||||||
|
if (response.error) alert(response.error);
|
||||||
|
else {
|
||||||
|
emit("created", response.playlistId, playlistName.value);
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
input.value.focus();
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -30,50 +30,51 @@
|
|||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
export default {
|
import { getCustomInstances, addCustomInstance, removeCustomInstance } from "@/composables/useCustomInstances.js";
|
||||||
components: { ModalComponent },
|
|
||||||
emits: ["close"],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
customInstances: [],
|
|
||||||
name: "",
|
|
||||||
url: "",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.customInstances = this.getCustomInstances();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async addInstance() {
|
|
||||||
const newInstance = {
|
|
||||||
name: this.name,
|
|
||||||
api_url: this.url,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!newInstance.name || !newInstance.api_url) {
|
const { t } = useI18n();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.isValidInstanceUrl(newInstance.api_url)) {
|
|
||||||
alert(this.$t("actions.invalid_url"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addCustomInstance(newInstance);
|
defineEmits(["close"]);
|
||||||
this.name = "";
|
|
||||||
this.url = "";
|
|
||||||
},
|
|
||||||
removeInstance(instance, index) {
|
|
||||||
this.customInstances.splice(index, 1);
|
|
||||||
|
|
||||||
this.removeCustomInstance(instance);
|
const customInstances = ref([]);
|
||||||
},
|
const name = ref("");
|
||||||
isValidInstanceUrl(str) {
|
const url = ref("");
|
||||||
var a = document.createElement("a");
|
|
||||||
a.href = str;
|
onMounted(() => {
|
||||||
return a.host && a.host != window.location.host;
|
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: name.value,
|
||||||
|
api_url: url.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!newInstance.name || !newInstance.api_url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidInstanceUrl(newInstance.api_url)) {
|
||||||
|
alert(t("actions.invalid_url"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCustomInstance(newInstance);
|
||||||
|
name.value = "";
|
||||||
|
url.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInstance(instance, index) {
|
||||||
|
customInstances.value.splice(index, 1);
|
||||||
|
removeCustomInstance(instance);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,16 +4,17 @@
|
|||||||
<p ref="stacktrace" class="whitespace-pre-wrap" hidden v-text="error" />
|
<p ref="stacktrace" class="whitespace-pre-wrap" hidden v-text="error" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref } from "vue";
|
||||||
props: {
|
|
||||||
error: { type: String, default: null },
|
defineProps({
|
||||||
message: { type: String, default: null },
|
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>
|
</script>
|
||||||
|
|||||||
@@ -32,111 +32,100 @@
|
|||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
|
import { download } from "@/composables/useMisc.js";
|
||||||
|
|
||||||
export default {
|
const exportOptions = ["playlist", "history"];
|
||||||
components: {
|
const exportAs = ref("playlist");
|
||||||
ModalComponent,
|
const fields = ["videoId", "title", "uploaderName", "uploaderUrl", "duration", "thumbnail", "watchedAt", "currentTime"];
|
||||||
},
|
const selectedFields = ref([
|
||||||
data() {
|
"videoId",
|
||||||
return {
|
"title",
|
||||||
exportOptions: ["playlist", "history"],
|
"uploaderName",
|
||||||
exportAs: "playlist",
|
"uploaderUrl",
|
||||||
fields: [
|
"duration",
|
||||||
"videoId",
|
"thumbnail",
|
||||||
"title",
|
"watchedAt",
|
||||||
"uploaderName",
|
"currentTime",
|
||||||
"uploaderUrl",
|
]);
|
||||||
"duration",
|
|
||||||
"thumbnail",
|
let exportVideos = [];
|
||||||
"watchedAt",
|
|
||||||
"currentTime",
|
async function fetchAllVideos() {
|
||||||
],
|
if (window.db) {
|
||||||
selectedFields: [
|
var tx = window.db.transaction("watch_history", "readonly");
|
||||||
"videoId",
|
var store = tx.objectStore("watch_history");
|
||||||
"title",
|
const request = store.getAll();
|
||||||
"uploaderName",
|
return new Promise((resolve, reject) => {
|
||||||
"uploaderUrl",
|
(request.onsuccess = e => {
|
||||||
"duration",
|
const videos = e.target.result;
|
||||||
"thumbnail",
|
exportVideos = videos;
|
||||||
"watchedAt",
|
resolve();
|
||||||
"currentTime",
|
}),
|
||||||
],
|
(request.onerror = e => {
|
||||||
};
|
reject(e);
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async fetchAllVideos() {
|
|
||||||
if (window.db) {
|
|
||||||
var tx = window.db.transaction("watch_history", "readonly");
|
|
||||||
var store = tx.objectStore("watch_history");
|
|
||||||
const request = store.getAll();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
(request.onsuccess = e => {
|
|
||||||
const videos = e.target.result;
|
|
||||||
this.exportVideos = videos;
|
|
||||||
resolve();
|
|
||||||
}),
|
|
||||||
(request.onerror = e => {
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
},
|
}
|
||||||
handleExport() {
|
}
|
||||||
if (this.exportAs === "playlist") {
|
|
||||||
this.fetchAllVideos()
|
function handleExport() {
|
||||||
.then(() => {
|
if (exportAs.value === "playlist") {
|
||||||
this.exportAsPlaylist();
|
fetchAllVideos()
|
||||||
})
|
.then(() => {
|
||||||
.catch(e => {
|
exportAsPlaylist();
|
||||||
console.error(e);
|
})
|
||||||
});
|
.catch(e => {
|
||||||
} else if (this.exportAs === "history") {
|
console.error(e);
|
||||||
this.fetchAllVideos()
|
});
|
||||||
.then(() => {
|
} else if (exportAs.value === "history") {
|
||||||
this.exportAsHistory();
|
fetchAllVideos()
|
||||||
})
|
.then(() => {
|
||||||
.catch(e => {
|
exportAsHistory();
|
||||||
console.error(e);
|
})
|
||||||
});
|
.catch(e => {
|
||||||
}
|
console.error(e);
|
||||||
},
|
});
|
||||||
exportAsPlaylist() {
|
}
|
||||||
const dateStr = new Date().toISOString().split(".")[0];
|
}
|
||||||
let json = {
|
|
||||||
format: "Piped",
|
function exportAsPlaylist() {
|
||||||
version: 1,
|
const dateStr = new Date().toISOString().split(".")[0];
|
||||||
playlists: [
|
let json = {
|
||||||
{
|
format: "Piped",
|
||||||
name: `Piped History ${dateStr}`,
|
version: 1,
|
||||||
type: "history",
|
playlists: [
|
||||||
visibility: "private",
|
{
|
||||||
videos: this.exportVideos.map(video => "https://youtube.com" + video.url),
|
name: `Piped History ${dateStr}`,
|
||||||
},
|
type: "history",
|
||||||
],
|
visibility: "private",
|
||||||
};
|
videos: exportVideos.map(video => "https://youtube.com" + video.url),
|
||||||
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
|
},
|
||||||
},
|
],
|
||||||
exportAsHistory() {
|
};
|
||||||
const dateStr = new Date().toISOString().split(".")[0];
|
download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
|
||||||
let json = {
|
}
|
||||||
format: "Piped",
|
|
||||||
version: 1,
|
function exportAsHistory() {
|
||||||
watchHistory: this.exportVideos.map(video => {
|
const dateStr = new Date().toISOString().split(".")[0];
|
||||||
let obj = {};
|
let json = {
|
||||||
this.selectedFields.forEach(field => {
|
format: "Piped",
|
||||||
obj[field] = video[field];
|
version: 1,
|
||||||
});
|
watchHistory: exportVideos.map(video => {
|
||||||
return obj;
|
let obj = {};
|
||||||
}),
|
selectedFields.value.forEach(field => {
|
||||||
};
|
obj[field] = video[field];
|
||||||
this.download(JSON.stringify(json), `piped_history_${dateStr}.json`, "application/json");
|
});
|
||||||
},
|
return obj;
|
||||||
formatField(field) {
|
}),
|
||||||
// camelCase to Title Case
|
};
|
||||||
return field.replace(/([A-Z])/g, " $1").replace(/^./, str => str.toUpperCase());
|
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>
|
</script>
|
||||||
|
|||||||
@@ -53,115 +53,119 @@
|
|||||||
</LoadingIndicatorPage>
|
</LoadingIndicatorPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onActivated, onDeactivated, onUnmounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import VideoItem from "./VideoItem.vue";
|
import VideoItem from "./VideoItem.vue";
|
||||||
import SortingSelector from "./SortingSelector.vue";
|
import SortingSelector from "./SortingSelector.vue";
|
||||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.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 {
|
const { t } = useI18n();
|
||||||
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 videos = this.getPreferenceBoolean("hideWatched", false)
|
let currentVideoCount = 0;
|
||||||
? this.videos.filter(video => !video.watched)
|
const videoStep = 100;
|
||||||
: this.videos;
|
let videosStore = null;
|
||||||
|
const videos = ref([]);
|
||||||
|
const availableFilters = ["all", "shorts", "videos"];
|
||||||
|
const selectedFilter = ref("all");
|
||||||
|
const selectedGroupName = ref("");
|
||||||
|
const channelGroups = ref([]);
|
||||||
|
|
||||||
return _this.selectedGroupName == ""
|
const getRssUrl = computed(() => {
|
||||||
? videos
|
if (isAuthenticated()) return authApiUrl() + "/feed/rss?authToken=" + getAuthToken();
|
||||||
: videos.filter(video => selectedGroup[0].channels.includes(video.uploaderUrl.substr(-24)));
|
else return authApiUrl() + "/feed/unauthenticated/rss?channels=" + getUnauthenticatedChannels();
|
||||||
},
|
});
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.fetchFeed().then(resp => {
|
|
||||||
if (resp.error) {
|
|
||||||
alert(resp.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.videosStore = resp;
|
const filteredVideos = computed(() => {
|
||||||
this.loadMoreVideos();
|
const selectedGroup = channelGroups.value.filter(group => group.groupName == selectedGroupName.value);
|
||||||
this.updateWatched(this.videos);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.selectedFilter = this.getPreferenceString("feedFilter") ?? "all";
|
const vids = getPreferenceBoolean("hideWatched", false)
|
||||||
|
? videos.value.filter(video => !video.watched)
|
||||||
|
: videos.value;
|
||||||
|
|
||||||
if (!window.db) return;
|
return selectedGroupName.value == ""
|
||||||
|
? vids
|
||||||
|
: vids.filter(video => selectedGroup[0].channels.includes(video.uploaderUrl.substr(-24)));
|
||||||
|
});
|
||||||
|
|
||||||
this.loadChannelGroups();
|
function loadMoreVideos() {
|
||||||
},
|
if (!videosStore) return;
|
||||||
activated() {
|
currentVideoCount = Math.min(currentVideoCount + videoStep, videosStore.length);
|
||||||
document.title = this.$t("titles.feed") + " - Piped";
|
if (videos.value.length != videosStore.length) {
|
||||||
if (this.videos.length > 0) this.updateWatched(this.videos);
|
fetchDeArrowContent(videosStore.slice(videos.value.length, currentVideoCount));
|
||||||
window.addEventListener("scroll", this.handleScroll);
|
videos.value = videosStore.slice(0, currentVideoCount);
|
||||||
},
|
}
|
||||||
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() {
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
|
||||||
this.loadMoreVideos();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onUpdateWatched(urls = null) {
|
|
||||||
if (urls === null) {
|
|
||||||
if (this.videos.length > 0) this.updateWatched(this.videos);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subset = this.videos.filter(({ url }) => urls.includes(url));
|
function handleScroll() {
|
||||||
if (subset.length > 0) this.updateWatched(subset);
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||||
},
|
loadMoreVideos();
|
||||||
shouldShowVideo(video) {
|
}
|
||||||
switch (this.selectedFilter.toLowerCase()) {
|
}
|
||||||
case "shorts":
|
|
||||||
return video.isShort;
|
function onUpdateWatched(urls = null) {
|
||||||
case "videos":
|
if (urls === null) {
|
||||||
return !video.isShort;
|
if (videos.value.length > 0) updateWatched(videos.value);
|
||||||
default:
|
return;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
},
|
const subset = videos.value.filter(({ url }) => urls.includes(url));
|
||||||
onFilterChange() {
|
if (subset.length > 0) updateWatched(subset);
|
||||||
this.setPreference("feedFilter", this.selectedFilter);
|
}
|
||||||
},
|
|
||||||
},
|
function shouldShowVideo(video) {
|
||||||
};
|
switch (selectedFilter.value.toLowerCase()) {
|
||||||
|
case "shorts":
|
||||||
|
return video.isShort;
|
||||||
|
case "videos":
|
||||||
|
return !video.isShort;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -27,28 +27,21 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, onMounted } from "vue";
|
||||||
data() {
|
import { fetchJson, apiUrl } from "@/composables/useApi.js";
|
||||||
return {
|
|
||||||
donationHref: null,
|
const donationHref = ref(null);
|
||||||
statusPageHref: null,
|
const statusPageHref = ref(null);
|
||||||
privacyPolicyHref: null,
|
const privacyPolicyHref = ref(null);
|
||||||
};
|
|
||||||
},
|
onMounted(() => {
|
||||||
mounted() {
|
fetchJson(apiUrl() + "/config").then(config => {
|
||||||
this.fetchConfig();
|
donationHref.value = config?.donationUrl;
|
||||||
},
|
statusPageHref.value = config?.statusPageUrl;
|
||||||
methods: {
|
privacyPolicyHref.value = config?.privacyPolicyUrl;
|
||||||
async fetchConfig() {
|
});
|
||||||
this.fetchJson(this.apiUrl() + "/config").then(config => {
|
});
|
||||||
this.donationHref = config?.donationUrl;
|
|
||||||
this.statusPageHref = config?.statusPageUrl;
|
|
||||||
this.privacyPolicyHref = config?.privacyPolicyUrl;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -42,107 +42,101 @@
|
|||||||
<ImportHistoryModal v-if="showImportModal" @close="showImportModal = false" />
|
<ImportHistoryModal v-if="showImportModal" @close="showImportModal = false" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onActivated, onDeactivated } from "vue";
|
||||||
import VideoItem from "./VideoItem.vue";
|
import VideoItem from "./VideoItem.vue";
|
||||||
import SortingSelector from "./SortingSelector.vue";
|
import SortingSelector from "./SortingSelector.vue";
|
||||||
import ExportHistoryModal from "./ExportHistoryModal.vue";
|
import ExportHistoryModal from "./ExportHistoryModal.vue";
|
||||||
import ImportHistoryModal from "./ImportHistoryModal.vue";
|
import ImportHistoryModal from "./ImportHistoryModal.vue";
|
||||||
|
import { getPreferenceBoolean, getPreferenceString, setPreference } from "@/composables/usePreferences.js";
|
||||||
|
|
||||||
export default {
|
let currentVideoCount = 0;
|
||||||
components: {
|
const videoStep = 100;
|
||||||
VideoItem,
|
const videosStore = [];
|
||||||
SortingSelector,
|
const videos = ref([]);
|
||||||
ExportHistoryModal,
|
const autoDeleteHistory = ref(false);
|
||||||
ImportHistoryModal,
|
const autoDeleteDelayHours = ref("24");
|
||||||
},
|
const showExportModal = ref(false);
|
||||||
data() {
|
const showImportModal = ref(false);
|
||||||
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");
|
|
||||||
|
|
||||||
(async () => {
|
function shouldRemoveVideo(video) {
|
||||||
if (window.db && this.getPreferenceBoolean("watchHistory", false)) {
|
if (!autoDeleteHistory.value) return false;
|
||||||
var tx = window.db.transaction("watch_history", "readwrite");
|
let maximumTimeDiff = Number(autoDeleteDelayHours.value) * 60 * 60 * 1000;
|
||||||
var store = tx.objectStore("watch_history");
|
return Date.now() - video.watchedAt > maximumTimeDiff;
|
||||||
const cursorRequest = store.index("watchedAt").openCursor(null, "prev");
|
}
|
||||||
const cursorPromise = new Promise(resolve => {
|
|
||||||
cursorRequest.onsuccess = e => {
|
function loadMoreVideos() {
|
||||||
const cursor = e.target.result;
|
currentVideoCount = Math.min(currentVideoCount + videoStep, videosStore.length);
|
||||||
if (cursor) {
|
if (videos.value.length != videosStore.length) videos.value = videosStore.slice(0, currentVideoCount);
|
||||||
const video = cursor.value;
|
}
|
||||||
if (!this.shouldRemoveVideo(video)) {
|
|
||||||
this.videosStore.push({
|
function handleScroll() {
|
||||||
url: "/watch?v=" + video.videoId,
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||||
title: video.title,
|
loadMoreVideos();
|
||||||
uploaderName: video.uploaderName,
|
}
|
||||||
uploaderUrl: video.uploaderUrl ?? "", // Router doesn't like undefined
|
}
|
||||||
duration: video.duration ?? 0, // Undefined duration shows "Live"
|
|
||||||
thumbnail: video.thumbnail,
|
function clearHistory() {
|
||||||
watchedAt: video.watchedAt,
|
if (window.db) {
|
||||||
watched: true,
|
var tx = window.db.transaction("watch_history", "readwrite");
|
||||||
currentTime: video.currentTime,
|
var store = tx.objectStore("watch_history");
|
||||||
});
|
store.clear();
|
||||||
} else {
|
}
|
||||||
store.delete(video.videoId);
|
videos.value = [];
|
||||||
}
|
}
|
||||||
if (this.videosStore.length < 1000) cursor.continue();
|
|
||||||
else resolve();
|
function onChange() {
|
||||||
} else resolve();
|
setPreference("autoDeleteWatchHistory", autoDeleteHistory.value);
|
||||||
};
|
setPreference("autoDeleteWatchHistoryDelayHours", autoDeleteDelayHours.value);
|
||||||
});
|
}
|
||||||
await cursorPromise;
|
|
||||||
}
|
onMounted(() => {
|
||||||
})().then(() => {
|
autoDeleteHistory.value = getPreferenceBoolean("autoDeleteWatchHistory", false);
|
||||||
this.loadMoreVideos();
|
autoDeleteDelayHours.value = getPreferenceString("autoDeleteWatchHistoryDelayHours", "24");
|
||||||
});
|
|
||||||
},
|
(async () => {
|
||||||
activated() {
|
if (window.db && getPreferenceBoolean("watchHistory", false)) {
|
||||||
document.title = "Watch History - Piped";
|
var tx = window.db.transaction("watch_history", "readwrite");
|
||||||
window.addEventListener("scroll", this.handleScroll);
|
var store = tx.objectStore("watch_history");
|
||||||
},
|
const cursorRequest = store.index("watchedAt").openCursor(null, "prev");
|
||||||
deactivated() {
|
const cursorPromise = new Promise(resolve => {
|
||||||
window.removeEventListener("scroll", this.handleScroll);
|
cursorRequest.onsuccess = e => {
|
||||||
},
|
const cursor = e.target.result;
|
||||||
methods: {
|
if (cursor) {
|
||||||
clearHistory() {
|
const video = cursor.value;
|
||||||
if (window.db) {
|
if (!shouldRemoveVideo(video)) {
|
||||||
var tx = window.db.transaction("watch_history", "readwrite");
|
videosStore.push({
|
||||||
var store = tx.objectStore("watch_history");
|
url: "/watch?v=" + video.videoId,
|
||||||
store.clear();
|
title: video.title,
|
||||||
}
|
uploaderName: video.uploaderName,
|
||||||
this.videos = [];
|
uploaderUrl: video.uploaderUrl ?? "",
|
||||||
},
|
duration: video.duration ?? 0,
|
||||||
onChange() {
|
thumbnail: video.thumbnail,
|
||||||
this.setPreference("autoDeleteWatchHistory", this.autoDeleteHistory);
|
watchedAt: video.watchedAt,
|
||||||
this.setPreference("autoDeleteWatchHistoryDelayHours", this.autoDeleteDelayHours);
|
watched: true,
|
||||||
},
|
currentTime: video.currentTime,
|
||||||
shouldRemoveVideo(video) {
|
});
|
||||||
if (!this.autoDeleteHistory) return false;
|
} else {
|
||||||
// convert from hours to milliseconds
|
store.delete(video.videoId);
|
||||||
let maximumTimeDiff = Number(this.autoDeleteDelayHours) * 60 * 60 * 1000;
|
}
|
||||||
return Date.now() - video.watchedAt > maximumTimeDiff;
|
if (videosStore.length < 1000) cursor.continue();
|
||||||
},
|
else resolve();
|
||||||
loadMoreVideos() {
|
} else resolve();
|
||||||
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);
|
await cursorPromise;
|
||||||
},
|
}
|
||||||
handleScroll() {
|
})().then(() => {
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
loadMoreVideos();
|
||||||
this.loadMoreVideos();
|
});
|
||||||
}
|
});
|
||||||
},
|
|
||||||
},
|
onActivated(() => {
|
||||||
};
|
document.title = "Watch History - Piped";
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,75 +34,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
|
|
||||||
export default {
|
const fileSelector = ref(null);
|
||||||
components: { ModalComponent },
|
const items = ref([]);
|
||||||
data() {
|
const override = ref(false);
|
||||||
return {
|
const index = ref(0);
|
||||||
items: [],
|
const success = ref(0);
|
||||||
override: false,
|
const error = ref(0);
|
||||||
index: 0,
|
const skipped = ref(0);
|
||||||
success: 0,
|
|
||||||
error: 0,
|
const itemsLength = computed(() => items.value.length);
|
||||||
skipped: 0,
|
|
||||||
};
|
function fileChange() {
|
||||||
},
|
const file = fileSelector.value.files[0];
|
||||||
computed: {
|
file.text().then(text => {
|
||||||
itemsLength() {
|
items.value = [];
|
||||||
return this.items.length;
|
const json = JSON.parse(text);
|
||||||
},
|
const parsed = json.watchHistory.map(video => {
|
||||||
},
|
return {
|
||||||
methods: {
|
...video,
|
||||||
fileChange() {
|
watchedAt: video.watchedAt ?? 0,
|
||||||
const file = this.$refs.fileSelector.files[0];
|
currentTime: video.currentTime ?? 0,
|
||||||
file.text().then(text => {
|
};
|
||||||
this.items = [];
|
});
|
||||||
const json = JSON.parse(text);
|
items.value = parsed.sort((a, b) => b.watchedAt - a.watchedAt);
|
||||||
const items = json.watchHistory.map(video => {
|
});
|
||||||
return {
|
}
|
||||||
...video,
|
|
||||||
watchedAt: video.watchedAt ?? 0,
|
function handleImport() {
|
||||||
currentTime: video.currentTime ?? 0,
|
if (window.db) {
|
||||||
|
var tx = window.db.transaction("watch_history", "readwrite");
|
||||||
|
var store = tx.objectStore("watch_history");
|
||||||
|
items.value.forEach(item => {
|
||||||
|
const dbItem = store.get(item.videoId);
|
||||||
|
dbItem.onsuccess = () => {
|
||||||
|
if (dbItem.result && dbItem.result.videoId === item.videoId) {
|
||||||
|
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 = () => {
|
||||||
|
index.value++;
|
||||||
|
success.value++;
|
||||||
};
|
};
|
||||||
});
|
request.onerror = () => {
|
||||||
this.items = items.sort((a, b) => b.watchedAt - a.watchedAt);
|
index.value++;
|
||||||
});
|
error.value++;
|
||||||
},
|
|
||||||
handleImport() {
|
|
||||||
if (window.db) {
|
|
||||||
var tx = window.db.transaction("watch_history", "readwrite");
|
|
||||||
var store = tx.objectStore("watch_history");
|
|
||||||
this.items.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++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const request = store.put(JSON.parse(JSON.stringify(item))); // prevent "Symbol could not be cloned." error
|
|
||||||
request.onsuccess = () => {
|
|
||||||
this.index++;
|
|
||||||
this.success++;
|
|
||||||
};
|
|
||||||
request.onerror = () => {
|
|
||||||
this.index++;
|
|
||||||
this.error++;
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.index++;
|
|
||||||
this.error++;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
});
|
} catch (err) {
|
||||||
}
|
console.error(err);
|
||||||
},
|
index.value++;
|
||||||
},
|
error.value++;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -57,120 +57,121 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, computed, onActivated } from "vue";
|
||||||
data() {
|
import { useI18n } from "vue-i18n";
|
||||||
return {
|
import { fetchJson, authApiUrl, getAuthToken, isAuthenticated } from "@/composables/useApi.js";
|
||||||
subscriptions: [],
|
import { getLocalSubscriptions } from "@/composables/useSubscriptions.js";
|
||||||
override: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
selectedSubscriptions() {
|
|
||||||
return this.subscriptions.length;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
activated() {
|
|
||||||
document.title = "Import - Piped";
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
fileChange() {
|
|
||||||
const file = this.$refs.fileSelector.files[0];
|
|
||||||
file.text().then(text => {
|
|
||||||
this.subscriptions = [];
|
|
||||||
|
|
||||||
// Invidious
|
const { t } = useI18n();
|
||||||
if (text.indexOf("opml") != -1) {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const xmlDoc = parser.parseFromString(text, "text/xml");
|
|
||||||
xmlDoc.querySelectorAll("outline[xmlUrl]").forEach(item => {
|
|
||||||
const url = item.getAttribute("xmlUrl");
|
|
||||||
const id = url.slice(-24);
|
|
||||||
this.subscriptions.push(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// NewPipe
|
|
||||||
else if (text.indexOf("subscriptions") != -1) {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
json.subscriptions
|
|
||||||
// if service_id is undefined, chances are it's a freetube export
|
|
||||||
.filter(item => item.service_id == 0 || item.service_id == undefined)
|
|
||||||
.forEach(item => {
|
|
||||||
const url = item.url;
|
|
||||||
const id = url.slice(-24);
|
|
||||||
this.subscriptions.push(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Invidious JSON
|
|
||||||
else if (text.indexOf("thin_mode") != -1) {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
this.subscriptions = json.subscriptions;
|
|
||||||
}
|
|
||||||
// FreeTube DB
|
|
||||||
else if (text.indexOf("allChannels") != -1) {
|
|
||||||
const lines = text.split("\n");
|
|
||||||
for (let line of lines) {
|
|
||||||
if (line === "") continue;
|
|
||||||
const json = JSON.parse(line);
|
|
||||||
json.subscriptions.forEach(item => {
|
|
||||||
this.subscriptions.push(item.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Google Takeout JSON
|
|
||||||
else if (text.indexOf("contentDetails") != -1) {
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
json.forEach(item => {
|
|
||||||
const id = item.snippet.resourceId.channelId;
|
|
||||||
this.subscriptions.push(id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google Takeout CSV
|
const fileSelector = ref(null);
|
||||||
else if (file.name.length >= 5 && file.name.slice(-4).toLowerCase() == ".csv") {
|
const subscriptions = ref([]);
|
||||||
const lines = text.split("\n");
|
const override = ref(false);
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
const selectedSubscriptions = computed(() => subscriptions.value.length);
|
||||||
const id = line.slice(0, line.indexOf(","));
|
|
||||||
if (id.length === 24) this.subscriptions.push(id);
|
onActivated(() => {
|
||||||
}
|
document.title = "Import - Piped";
|
||||||
}
|
});
|
||||||
|
|
||||||
|
function fileChange() {
|
||||||
|
const file = fileSelector.value.files[0];
|
||||||
|
file.text().then(text => {
|
||||||
|
subscriptions.value = [];
|
||||||
|
|
||||||
|
// Invidious
|
||||||
|
if (text.indexOf("opml") != -1) {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(text, "text/xml");
|
||||||
|
xmlDoc.querySelectorAll("outline[xmlUrl]").forEach(item => {
|
||||||
|
const url = item.getAttribute("xmlUrl");
|
||||||
|
const id = url.slice(-24);
|
||||||
|
subscriptions.value.push(id);
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
handleImport() {
|
// NewPipe
|
||||||
if (this.authenticated) {
|
else if (text.indexOf("subscriptions") != -1) {
|
||||||
this.fetchJson(
|
const json = JSON.parse(text);
|
||||||
this.authApiUrl() + "/import",
|
json.subscriptions
|
||||||
{
|
// if service_id is undefined, chances are it's a freetube export
|
||||||
override: this.override,
|
.filter(item => item.service_id == 0 || item.service_id == undefined)
|
||||||
},
|
.forEach(item => {
|
||||||
{
|
const url = item.url;
|
||||||
method: "POST",
|
const id = url.slice(-24);
|
||||||
headers: {
|
subscriptions.value.push(id);
|
||||||
Authorization: this.getAuthToken(),
|
});
|
||||||
},
|
}
|
||||||
body: JSON.stringify(this.subscriptions),
|
// Invidious JSON
|
||||||
},
|
else if (text.indexOf("thin_mode") != -1) {
|
||||||
).then(json => {
|
const json = JSON.parse(text);
|
||||||
if (json.message === "ok") window.location = "/feed";
|
subscriptions.value = json.subscriptions;
|
||||||
|
}
|
||||||
|
// FreeTube DB
|
||||||
|
else if (text.indexOf("allChannels") != -1) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (let line of lines) {
|
||||||
|
if (line === "") continue;
|
||||||
|
const json = JSON.parse(line);
|
||||||
|
json.subscriptions.forEach(item => {
|
||||||
|
subscriptions.value.push(item.id);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
this.importSubscriptionsLocally(this.subscriptions);
|
|
||||||
window.location = "/feed";
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
importSubscriptionsLocally(newChannels) {
|
// Google Takeout JSON
|
||||||
const subscriptions = this.override
|
else if (text.indexOf("contentDetails") != -1) {
|
||||||
? [...new Set(newChannels)]
|
const json = JSON.parse(text);
|
||||||
: [...new Set((this.getLocalSubscriptions() ?? []).concat(newChannels))];
|
json.forEach(item => {
|
||||||
// Sort for better cache hits
|
const id = item.snippet.resourceId.channelId;
|
||||||
subscriptions.sort();
|
subscriptions.value.push(id);
|
||||||
try {
|
});
|
||||||
localStorage.setItem("localSubscriptions", JSON.stringify(subscriptions));
|
}
|
||||||
} catch (e) {
|
|
||||||
alert(this.$t("info.local_storage"));
|
// Google Takeout CSV
|
||||||
|
else if (file.name.length >= 5 && file.name.slice(-4).toLowerCase() == ".csv") {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const id = line.slice(0, line.indexOf(","));
|
||||||
|
if (id.length === 24) subscriptions.value.push(id);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
fetchJson(
|
||||||
|
authApiUrl() + "/import",
|
||||||
|
{
|
||||||
|
override: override.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthToken(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(subscriptions.value),
|
||||||
|
},
|
||||||
|
).then(json => {
|
||||||
|
if (json.message === "ok") window.location = "/feed";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
importSubscriptionsLocally(subscriptions.value);
|
||||||
|
window.location = "/feed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function importSubscriptionsLocally(newChannels) {
|
||||||
|
const subs = override.value
|
||||||
|
? [...new Set(newChannels)]
|
||||||
|
: [...new Set((getLocalSubscriptions() ?? []).concat(newChannels))];
|
||||||
|
// Sort for better cache hits
|
||||||
|
subs.sort();
|
||||||
|
try {
|
||||||
|
localStorage.setItem("localSubscriptions", JSON.stringify(subs));
|
||||||
|
} catch (e) {
|
||||||
|
alert(t("info.local_storage"));
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,15 +7,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
defineProps({
|
||||||
props: {
|
showContent: {
|
||||||
showContent: {
|
type: Boolean,
|
||||||
type: Boolean,
|
required: true,
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -35,39 +35,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref, onMounted, onActivated } from "vue";
|
||||||
data() {
|
import { useRouter } from "vue-router";
|
||||||
return {
|
import { useI18n } from "vue-i18n";
|
||||||
username: null,
|
import { fetchJson, authApiUrl, getAuthToken, hashCode } from "@/composables/useApi.js";
|
||||||
password: null,
|
import { setPreference } from "@/composables/usePreferences.js";
|
||||||
};
|
|
||||||
},
|
const router = useRouter();
|
||||||
mounted() {
|
const { t } = useI18n();
|
||||||
//TODO: Add Server Side check
|
|
||||||
if (this.getAuthToken()) {
|
const username = ref(null);
|
||||||
this.$router.push(import.meta.env.BASE_URL);
|
const password = ref(null);
|
||||||
}
|
|
||||||
},
|
onMounted(() => {
|
||||||
activated() {
|
//TODO: Add Server Side check
|
||||||
document.title = this.$t("titles.login") + " - Piped";
|
if (getAuthToken()) {
|
||||||
},
|
router.push(import.meta.env.BASE_URL);
|
||||||
methods: {
|
}
|
||||||
login() {
|
});
|
||||||
if (!this.username || !this.password) return;
|
|
||||||
this.fetchJson(this.authApiUrl() + "/login", null, {
|
onActivated(() => {
|
||||||
method: "POST",
|
document.title = t("titles.login") + " - Piped";
|
||||||
body: JSON.stringify({
|
});
|
||||||
username: this.username,
|
|
||||||
password: this.password,
|
function login() {
|
||||||
}),
|
if (!username.value || !password.value) return;
|
||||||
}).then(resp => {
|
fetchJson(authApiUrl() + "/login", null, {
|
||||||
if (resp.token) {
|
method: "POST",
|
||||||
this.setPreference("authToken" + this.hashCode(this.authApiUrl()), resp.token);
|
body: JSON.stringify({
|
||||||
window.location = import.meta.env.BASE_URL; // done to bypass cache
|
username: username.value,
|
||||||
} else alert(resp.error);
|
password: password.value,
|
||||||
});
|
}),
|
||||||
},
|
}).then(resp => {
|
||||||
},
|
if (resp.token) {
|
||||||
};
|
setPreference("authToken" + hashCode(authApiUrl()), resp.token);
|
||||||
|
window.location = import.meta.env.BASE_URL; // done to bypass cache
|
||||||
|
} else alert(resp.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,28 +9,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { onMounted, onUnmounted } from "vue";
|
||||||
emits: ["close"],
|
|
||||||
mounted() {
|
const emit = defineEmits(["close"]);
|
||||||
window.addEventListener("keydown", this.handleKeyDown);
|
|
||||||
},
|
function handleKeyDown(event) {
|
||||||
unmounted() {
|
if (event.code === "Escape") {
|
||||||
window.removeEventListener("keydown", this.handleKeyDown);
|
emit("close");
|
||||||
},
|
} else return;
|
||||||
methods: {
|
event.preventDefault();
|
||||||
handleKeyDown(event) {
|
}
|
||||||
if (event.code === "Escape") {
|
|
||||||
this.$emit("close");
|
function handleClick(event) {
|
||||||
} else return;
|
if (event.target !== event.currentTarget) return;
|
||||||
event.preventDefault();
|
emit("close");
|
||||||
},
|
}
|
||||||
handleClick(event) {
|
|
||||||
if (event.target !== event.currentTarget) return;
|
onMounted(() => {
|
||||||
this.$emit("close");
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
},
|
});
|
||||||
},
|
|
||||||
};
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -119,107 +119,121 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import SearchSuggestions from "./SearchSuggestions.vue";
|
import SearchSuggestions from "./SearchSuggestions.vue";
|
||||||
import hotkeys from "hotkeys-js";
|
import hotkeys from "hotkeys-js";
|
||||||
export default {
|
import { fetchJson, authApiUrl, getAuthToken } from "@/composables/useApi.js";
|
||||||
components: {
|
import { getPreferenceBoolean, getPreferenceString } from "@/composables/usePreferences.js";
|
||||||
SearchSuggestions,
|
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: "",
|
function updateSearchTextFromURLSearchParams() {
|
||||||
suggestionsVisible: false,
|
const query = new URLSearchParams(window.location.search).get("search_query");
|
||||||
showTopNav: false,
|
if (query) onSearchTextChange(query);
|
||||||
homePagePath: import.meta.env.BASE_URL,
|
}
|
||||||
registrationDisabled: false,
|
|
||||||
};
|
function focusOnSearchBar() {
|
||||||
},
|
hotkeys("ctrl+k", event => {
|
||||||
computed: {
|
event.preventDefault();
|
||||||
shouldShowLogin(_this) {
|
videoSearch.value.focus();
|
||||||
return _this.getAuthToken() == null;
|
});
|
||||||
},
|
}
|
||||||
shouldShowRegister(_this) {
|
|
||||||
return _this.registrationDisabled == false ? _this.shouldShowLogin : false;
|
function onKeyUp(e) {
|
||||||
},
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||||
shouldShowHistory(_this) {
|
e.preventDefault();
|
||||||
return _this.getPreferenceBoolean("watchHistory", false);
|
}
|
||||||
},
|
searchSuggestions.value.onKeyUp(e);
|
||||||
shouldShowTrending(_this) {
|
}
|
||||||
return _this.getPreferenceString("homepage", "trending") != "trending";
|
|
||||||
},
|
function onKeyPress(e) {
|
||||||
showSearchHistory(_this) {
|
if (e.key === "Enter") {
|
||||||
return _this.getPreferenceBoolean("searchHistory", false) && localStorage.getItem("search_history");
|
submitSearch(e);
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
watch: {
|
|
||||||
$route() {
|
function onInputFocus() {
|
||||||
this.updateSearchTextFromURLSearchParams();
|
if (showSearchHistory.value) searchSuggestions.value.refreshSuggestions();
|
||||||
},
|
suggestionsVisible.value = true;
|
||||||
},
|
}
|
||||||
mounted() {
|
|
||||||
this.fetchAuthConfig();
|
function onInputBlur() {
|
||||||
this.updateSearchTextFromURLSearchParams();
|
setTimeout(() => (suggestionsVisible.value = false), 200);
|
||||||
this.focusOnSearchBar();
|
}
|
||||||
this.homePagePath = this.getHomePage(this);
|
|
||||||
},
|
function onSearchTextChange(text) {
|
||||||
methods: {
|
searchText.value = text;
|
||||||
updateSearchTextFromURLSearchParams() {
|
}
|
||||||
const query = new URLSearchParams(window.location.search).get("search_query");
|
|
||||||
if (query) this.onSearchTextChange(query);
|
async function fetchAuthConfig() {
|
||||||
},
|
fetchJson(authApiUrl() + "/config").then(config => {
|
||||||
// focus on search bar when Ctrl+k is pressed
|
registrationDisabled.value = config?.registrationDisabled === true;
|
||||||
focusOnSearchBar() {
|
});
|
||||||
hotkeys("ctrl+k", event => {
|
}
|
||||||
event.preventDefault();
|
|
||||||
this.$refs.videoSearch.focus();
|
function onSearchClick(e) {
|
||||||
});
|
submitSearch(e);
|
||||||
},
|
}
|
||||||
onKeyUp(e) {
|
|
||||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
function submitSearch(e) {
|
||||||
e.preventDefault();
|
e.target.blur();
|
||||||
}
|
if (searchText.value) {
|
||||||
this.$refs.searchSuggestions.onKeyUp(e);
|
router.push({
|
||||||
},
|
name: "SearchResults",
|
||||||
onKeyPress(e) {
|
query: { search_query: searchText.value },
|
||||||
if (e.key === "Enter") {
|
});
|
||||||
this.submitSearch(e);
|
} else {
|
||||||
}
|
router.push("/");
|
||||||
},
|
}
|
||||||
onInputFocus() {
|
return;
|
||||||
if (this.showSearchHistory) this.$refs.searchSuggestions.refreshSuggestions();
|
}
|
||||||
this.suggestionsVisible = true;
|
|
||||||
},
|
onMounted(() => {
|
||||||
onInputBlur() {
|
fetchAuthConfig();
|
||||||
// the search suggestions will be hidden after some seconds
|
updateSearchTextFromURLSearchParams();
|
||||||
// otherwise anchor links won't work!
|
focusOnSearchBar();
|
||||||
setTimeout(() => (this.suggestionsVisible = false), 200);
|
homePagePath.value = getHomePage();
|
||||||
},
|
});
|
||||||
onSearchTextChange(searchText) {
|
|
||||||
this.searchText = searchText;
|
|
||||||
},
|
|
||||||
async fetchAuthConfig() {
|
|
||||||
this.fetchJson(this.authApiUrl() + "/config").then(config => {
|
|
||||||
this.registrationDisabled = config?.registrationDisabled === true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSearchClick(e) {
|
|
||||||
this.submitSearch(e);
|
|
||||||
},
|
|
||||||
submitSearch(e) {
|
|
||||||
e.target.blur();
|
|
||||||
if (this.searchText) {
|
|
||||||
this.$router.push({
|
|
||||||
name: "SearchResults",
|
|
||||||
query: { search_query: this.searchText },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.$router.push("/");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -26,73 +26,76 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
import CreatePlaylistModal from "./CreatePlaylistModal.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 {
|
const { t } = useI18n();
|
||||||
components: {
|
|
||||||
ModalComponent,
|
|
||||||
CreatePlaylistModal,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
videoInfo: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
videoId: {
|
|
||||||
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);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleClick(playlistId) {
|
|
||||||
if (!playlistId) {
|
|
||||||
alert(this.$t("actions.please_select_playlist"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.processing) return;
|
const props = defineProps({
|
||||||
|
videoInfo: {
|
||||||
this.$refs.addButton.disabled = true;
|
type: Object,
|
||||||
this.processing = true;
|
required: true,
|
||||||
|
|
||||||
this.addVideosToPlaylist(playlistId, [this.videoId], [this.videoInfo]).then(json => {
|
|
||||||
this.setPreference("selectedPlaylist" + this.hashCode(this.authApiUrl()), playlistId);
|
|
||||||
this.$emit("close");
|
|
||||||
if (json.error) alert(json.error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
addCreatedPlaylist(playlistId, playlistName) {
|
|
||||||
this.playlists.push({ id: playlistId, name: playlistName });
|
|
||||||
this.selectedPlaylist = playlistId;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
videoId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(playlistId) {
|
||||||
|
if (!playlistId) {
|
||||||
|
alert(t("actions.please_select_playlist"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processing.value) return;
|
||||||
|
|
||||||
|
addButton.value.disabled = true;
|
||||||
|
processing.value = true;
|
||||||
|
|
||||||
|
addVideosToPlaylist(playlistId, [props.videoId], [props.videoInfo]).then(json => {
|
||||||
|
setPreference("selectedPlaylist" + hashCode(authApiUrl()), playlistId);
|
||||||
|
emit("close");
|
||||||
|
if (json.error) alert(json.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -54,158 +54,164 @@
|
|||||||
</LoadingIndicatorPage>
|
</LoadingIndicatorPage>
|
||||||
</template>
|
</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 ErrorHandler from "./ErrorHandler.vue";
|
||||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||||
import CollapsableText from "./CollapsableText.vue";
|
import CollapsableText from "./CollapsableText.vue";
|
||||||
import VideoItem from "./VideoItem.vue";
|
import VideoItem from "./VideoItem.vue";
|
||||||
import WatchOnButton from "./WatchOnButton.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 {
|
const route = useRoute();
|
||||||
components: {
|
const { t } = useI18n();
|
||||||
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))
|
|
||||||
.then(() => {
|
|
||||||
this.updateTitle();
|
|
||||||
this.updateTotalDuration();
|
|
||||||
this.updateWatched(this.playlist.relatedStreams);
|
|
||||||
this.fetchDeArrowContent(this.playlist.relatedStreams);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async updateTitle() {
|
|
||||||
document.title = this.playlist.name + " - Piped";
|
|
||||||
},
|
|
||||||
handleScroll() {
|
|
||||||
if (this.loading || !this.playlist || !this.playlist.nextpage) return;
|
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
|
||||||
this.loading = true;
|
|
||||||
this.fetchJson(this.authApiUrl() + "/nextpage/playlists/" + this.$route.query.list, {
|
|
||||||
nextpage: this.playlist.nextpage,
|
|
||||||
}).then(json => {
|
|
||||||
this.playlist.nextpage = json.nextpage;
|
|
||||||
this.loading = false;
|
|
||||||
this.playlist.relatedStreams.push(...json.relatedStreams);
|
|
||||||
this.updateTotalDuration();
|
|
||||||
this.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, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: this.getAuthToken(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
playlistId: this.$route.query.list,
|
|
||||||
}),
|
|
||||||
}).then(resp => {
|
|
||||||
if (!resp.error) {
|
|
||||||
alert(this.$t("actions.clone_playlist_success"));
|
|
||||||
} else alert(resp.error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
downloadPlaylistAsTxt() {
|
|
||||||
const data = this.playlist.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;
|
|
||||||
|
|
||||||
if (this.isBookmarked) {
|
const playlist = ref(null);
|
||||||
this.removePlaylistBookmark();
|
const totalDuration = ref(0);
|
||||||
return;
|
const admin = ref(false);
|
||||||
}
|
const isBookmarked = ref(false);
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
if (window.db) {
|
const getRssUrl = computed(() => {
|
||||||
const playlistId = this.$route.query.list;
|
return authApiUrl() + "/rss/playlists/" + route.query.list;
|
||||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
});
|
||||||
var store = tx.objectStore("playlist_bookmarks");
|
|
||||||
store.put({
|
const isPipedPlaylist = computed(() => {
|
||||||
playlistId: playlistId,
|
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);
|
||||||
name: this.playlist.name,
|
});
|
||||||
uploader: this.playlist.uploader,
|
|
||||||
uploaderUrl: this.playlist.uploaderUrl,
|
function updateTitle() {
|
||||||
thumbnail: this.playlist.thumbnailUrl,
|
document.title = playlist.value.name + " - Piped";
|
||||||
uploaderAvatar: this.playlist.uploaderAvatar,
|
}
|
||||||
videos: this.playlist.videos,
|
|
||||||
});
|
function updateTotalDuration() {
|
||||||
this.isBookmarked = true;
|
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(() => {
|
||||||
|
updateTitle();
|
||||||
|
updateTotalDuration();
|
||||||
|
updateWatched(playlist.value.relatedStreams);
|
||||||
|
fetchDeArrowContent(playlist.value.relatedStreams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
if (loading || !playlist.value || !playlist.value.nextpage) return;
|
||||||
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||||
|
loading = true;
|
||||||
|
fetchJson(authApiUrl() + "/nextpage/playlists/" + route.query.list, {
|
||||||
|
nextpage: playlist.value.nextpage,
|
||||||
|
}).then(json => {
|
||||||
|
playlist.value.nextpage = json.nextpage;
|
||||||
|
loading = false;
|
||||||
|
playlist.value.relatedStreams.push(...json.relatedStreams);
|
||||||
|
updateTotalDuration();
|
||||||
|
fetchDeArrowContent(json.relatedStreams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVideo(index) {
|
||||||
|
playlist.value.relatedStreams.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clonePlaylist() {
|
||||||
|
fetchJson(authApiUrl() + "/import/playlist", null, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthToken(),
|
||||||
},
|
},
|
||||||
async removePlaylistBookmark() {
|
body: JSON.stringify({
|
||||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
playlistId: route.query.list,
|
||||||
var store = tx.objectStore("playlist_bookmarks");
|
}),
|
||||||
store.delete(this.$route.query.list);
|
}).then(resp => {
|
||||||
this.isBookmarked = false;
|
if (!resp.error) {
|
||||||
},
|
alert(t("actions.clone_playlist_success"));
|
||||||
async isPlaylistBookmarked() {
|
} else alert(resp.error);
|
||||||
// needed in order to change the is bookmarked var later
|
});
|
||||||
const App = this;
|
}
|
||||||
const playlistId = this.$route.query.list;
|
|
||||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
function downloadPlaylistAsTxt() {
|
||||||
var store = tx.objectStore("playlist_bookmarks");
|
const data = playlist.value.relatedStreams
|
||||||
var req = store.openCursor(playlistId);
|
.map(video => {
|
||||||
req.onsuccess = function (e) {
|
return "https://piped.video" + video.url;
|
||||||
var cursor = e.target.result;
|
})
|
||||||
App.isBookmarked = cursor ? true : false;
|
.join("\n");
|
||||||
};
|
download(data, playlist.value.name + ".txt", "text/plain");
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
async function bookmarkPlaylist() {
|
||||||
|
if (!playlist.value) return;
|
||||||
|
|
||||||
|
if (isBookmarked.value) {
|
||||||
|
removePlaylistBookmark();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.db) {
|
||||||
|
const playlistId = route.query.list;
|
||||||
|
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||||
|
var store = tx.objectStore("playlist_bookmarks");
|
||||||
|
store.put({
|
||||||
|
playlistId: playlistId,
|
||||||
|
name: playlist.value.name,
|
||||||
|
uploader: playlist.value.uploader,
|
||||||
|
uploaderUrl: playlist.value.uploaderUrl,
|
||||||
|
thumbnail: playlist.value.thumbnailUrl,
|
||||||
|
uploaderAvatar: playlist.value.uploaderAvatar,
|
||||||
|
videos: playlist.value.videos,
|
||||||
|
});
|
||||||
|
isBookmarked.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePlaylistBookmark() {
|
||||||
|
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||||
|
var store = tx.objectStore("playlist_bookmarks");
|
||||||
|
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;
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -58,52 +58,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import { nextTick } from "vue";
|
import { ref, watch, onMounted, nextTick } from "vue";
|
||||||
import VideoThumbnail from "./VideoThumbnail.vue";
|
import VideoThumbnail from "./VideoThumbnail.vue";
|
||||||
export default {
|
import { updateWatched } from "@/composables/useMisc.js";
|
||||||
components: { VideoThumbnail },
|
|
||||||
props: {
|
const props = defineProps({
|
||||||
playlist: {
|
playlist: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
|
||||||
playlistId: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
selectedIndex: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
preferListen: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
watch: {
|
playlistId: {
|
||||||
playlist: {
|
type: String,
|
||||||
handler() {
|
required: true,
|
||||||
if (this.selectedIndex - 1 < this.playlist.relatedStreams.length)
|
|
||||||
nextTick(() => {
|
|
||||||
this.updateScroll();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
deep: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
mounted() {
|
selectedIndex: {
|
||||||
this.updateScroll();
|
type: Number,
|
||||||
this.updateWatched(this.playlist.relatedStreams);
|
required: true,
|
||||||
},
|
},
|
||||||
methods: {
|
preferListen: {
|
||||||
updateScroll() {
|
type: Boolean,
|
||||||
const elems = Array.from(this.$refs.scrollable.children).filter(elm => elm.matches("div"));
|
default: false,
|
||||||
const index = this.selectedIndex - 1;
|
|
||||||
if (index < elems.length)
|
|
||||||
this.$refs.scrollable.scrollTop =
|
|
||||||
elems[this.selectedIndex - 1].offsetTop - this.$refs.scrollable.offsetTop;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const scrollable = ref(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.playlist,
|
||||||
|
() => {
|
||||||
|
if (props.selectedIndex - 1 < props.playlist.relatedStreams.length)
|
||||||
|
nextTick(() => {
|
||||||
|
updateScroll();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ 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)
|
||||||
|
scrollable.value.scrollTop = elems[props.selectedIndex - 1].offsetTop - scrollable.value.offsetTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateScroll();
|
||||||
|
updateWatched(props.playlist.relatedStreams);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -99,164 +99,174 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onActivated } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import ConfirmModal from "./ConfirmModal.vue";
|
import ConfirmModal from "./ConfirmModal.vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
import CreatePlaylistModal from "./CreatePlaylistModal.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 {
|
const { t } = useI18n();
|
||||||
components: { ConfirmModal, ModalComponent, CreatePlaylistModal },
|
|
||||||
data() {
|
const fileSelector = ref(null);
|
||||||
return {
|
const playlists = ref([]);
|
||||||
playlists: [],
|
const bookmarks = ref([]);
|
||||||
bookmarks: [],
|
const playlistToDelete = ref(null);
|
||||||
playlistToDelete: null,
|
const playlistToEdit = ref(null);
|
||||||
playlistToEdit: null,
|
const newPlaylistName = ref("");
|
||||||
newPlaylistName: "",
|
const newPlaylistDescription = ref("");
|
||||||
newPlaylistDescription: "",
|
const showCreatePlaylistModal = ref(false);
|
||||||
showCreatePlaylistModal: false,
|
|
||||||
|
function fetchPlaylistsList() {
|
||||||
|
getPlaylists().then(json => {
|
||||||
|
playlists.value = json;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
renamePlaylist(selectedPlaylist.id, newName).then(json => {
|
||||||
|
if (json.error) alert(json.error);
|
||||||
|
else selectedPlaylist.name = newName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newDescription != selectedPlaylist.description) {
|
||||||
|
changePlaylistDescription(selectedPlaylist.id, newDescription).then(json => {
|
||||||
|
if (json.error) alert(json.error);
|
||||||
|
else selectedPlaylist.description = newDescription;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
playlistToEdit.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeletePlaylist(id) {
|
||||||
|
deletePlaylist(id).then(json => {
|
||||||
|
if (json.error) alert(json.error);
|
||||||
|
else playlists.value = playlists.value.filter(playlist => playlist.id !== id);
|
||||||
|
});
|
||||||
|
playlistToDelete.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPlaylists() {
|
||||||
|
if (!playlists.value) return;
|
||||||
|
let json = {
|
||||||
|
format: "Piped",
|
||||||
|
version: 1,
|
||||||
|
playlists: [],
|
||||||
|
};
|
||||||
|
let tasks = playlists.value.map(playlist => fetchPlaylistJson(playlist.id));
|
||||||
|
json.playlists = await Promise.all(tasks);
|
||||||
|
download(JSON.stringify(json), "playlists.json", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylistJson(playlistId) {
|
||||||
|
let playlist = await getPlaylist(playlistId);
|
||||||
|
return {
|
||||||
|
name: playlist.name,
|
||||||
|
type: "playlist",
|
||||||
|
visibility: "private",
|
||||||
|
videos: playlist.relatedStreams.map(stream => "https://youtube.com" + stream.url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPlaylists() {
|
||||||
|
const files = fileSelector.value.files;
|
||||||
|
for (let file of files) {
|
||||||
|
await importPlaylistFile(file);
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPlaylistFile(file) {
|
||||||
|
let text = (await file.text()).trim();
|
||||||
|
let tasks = [];
|
||||||
|
if (file.name.slice(-4).toLowerCase() == ".csv") {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
|
||||||
|
const playlistInfo = lines[1].split(",");
|
||||||
|
let videoListStartIndex = 0;
|
||||||
|
let playlistName = null;
|
||||||
|
if (playlistInfo.length > 2) {
|
||||||
|
playlistName = playlistInfo[4];
|
||||||
|
videoListStartIndex = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlist = {
|
||||||
|
name: playlistName ?? file.name.replace(".csv", ""),
|
||||||
|
videos: lines
|
||||||
|
.slice(videoListStartIndex, lines.length)
|
||||||
|
.filter(line => line != "")
|
||||||
|
.slice(1)
|
||||||
|
.map(line => `https://youtube.com/watch?v=${line.split(",")[0]}`),
|
||||||
};
|
};
|
||||||
},
|
tasks.push(createPlaylistWithVideos(playlist));
|
||||||
mounted() {
|
} else if (text.includes('"Piped"')) {
|
||||||
this.fetchPlaylists();
|
let parsedPlaylists = JSON.parse(text).playlists;
|
||||||
this.loadPlaylistBookmarks();
|
if (!parsedPlaylists.length) {
|
||||||
},
|
alert(t("actions.no_valid_playlists"));
|
||||||
activated() {
|
return;
|
||||||
document.title = this.$t("titles.playlists") + " - Piped";
|
}
|
||||||
},
|
for (let playlist of parsedPlaylists) {
|
||||||
methods: {
|
tasks.push(createPlaylistWithVideos(playlist));
|
||||||
fetchPlaylists() {
|
}
|
||||||
this.getPlaylists().then(json => {
|
} else {
|
||||||
this.playlists = json;
|
alert(t("actions.no_valid_playlists"));
|
||||||
});
|
return;
|
||||||
},
|
}
|
||||||
showPlaylistEditModal(playlist) {
|
await Promise.all(tasks);
|
||||||
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;
|
|
||||||
if (newName != selectedPlaylist.name) {
|
|
||||||
this.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 => {
|
|
||||||
if (json.error) alert(json.error);
|
|
||||||
else selectedPlaylist.description = newDescription;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.playlistToEdit = null;
|
|
||||||
},
|
|
||||||
onDeletePlaylist(id) {
|
|
||||||
this.deletePlaylist(id).then(json => {
|
|
||||||
if (json.error) alert(json.error);
|
|
||||||
else this.playlists = this.playlists.filter(playlist => playlist.id !== id);
|
|
||||||
});
|
|
||||||
this.playlistToDelete = null;
|
|
||||||
},
|
|
||||||
async exportPlaylists() {
|
|
||||||
if (!this.playlists) return;
|
|
||||||
let json = {
|
|
||||||
format: "Piped",
|
|
||||||
version: 1,
|
|
||||||
playlists: [],
|
|
||||||
};
|
|
||||||
let tasks = this.playlists.map(playlist => this.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);
|
|
||||||
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;
|
|
||||||
for (let file of files) {
|
|
||||||
await this.importPlaylistFile(file);
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
},
|
|
||||||
async 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
|
async function createPlaylistWithVideos(playlist) {
|
||||||
// new format: no information about playlist like name, ...
|
let newPlaylist = await createPlaylist(playlist.name);
|
||||||
// video list has two columns: videoId and date of addition
|
let videoIds = playlist.videos.map(url => url.substr(-11));
|
||||||
const playlistInfo = lines[1].split(",");
|
await addVideosToPlaylist(newPlaylist.playlistId, videoIds);
|
||||||
let videoListStartIndex = 0;
|
}
|
||||||
let playlistName = null;
|
|
||||||
if (playlistInfo.length > 2) {
|
|
||||||
playlistName = playlistInfo[4];
|
|
||||||
videoListStartIndex = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlist = {
|
async function loadPlaylistBookmarks() {
|
||||||
name: playlistName ?? file.name.replace(".csv", ""),
|
if (!window.db) return;
|
||||||
videos: lines
|
var tx = window.db.transaction("playlist_bookmarks", "readonly");
|
||||||
.slice(videoListStartIndex, lines.length)
|
var store = tx.objectStore("playlist_bookmarks");
|
||||||
.filter(line => line != "")
|
const cursorRequest = store.openCursor();
|
||||||
.slice(1)
|
cursorRequest.onsuccess = e => {
|
||||||
.map(line => `https://youtube.com/watch?v=${line.split(",")[0]}`),
|
const cursor = e.target.result;
|
||||||
};
|
if (cursor) {
|
||||||
tasks.push(this.createPlaylistWithVideos(playlist));
|
bookmarks.value.push(cursor.value);
|
||||||
} else if (text.includes('"Piped"')) {
|
cursor.continue();
|
||||||
// CSV from Google Takeout
|
}
|
||||||
let playlists = JSON.parse(text).playlists;
|
};
|
||||||
if (!playlists.length) {
|
}
|
||||||
alert(this.$t("actions.no_valid_playlists"));
|
|
||||||
return;
|
async function removeBookmark(index) {
|
||||||
}
|
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
||||||
for (let playlist of playlists) {
|
var store = tx.objectStore("playlist_bookmarks");
|
||||||
tasks.push(this.createPlaylistWithVideos(playlist));
|
store.delete(bookmarks.value[index].playlistId);
|
||||||
}
|
bookmarks.value.splice(index, 1);
|
||||||
} else {
|
}
|
||||||
alert(this.$t("actions.no_valid_playlists"));
|
|
||||||
return;
|
onMounted(() => {
|
||||||
}
|
fetchPlaylistsList();
|
||||||
await Promise.all(tasks);
|
loadPlaylistBookmarks();
|
||||||
},
|
});
|
||||||
async createPlaylistWithVideos(playlist) {
|
|
||||||
let newPlaylist = await this.createPlaylist(playlist.name);
|
onActivated(() => {
|
||||||
let videoIds = playlist.videos.map(url => url.substr(-11));
|
document.title = t("titles.playlists") + " - Piped";
|
||||||
await this.addVideosToPlaylist(newPlaylist.playlistId, videoIds);
|
});
|
||||||
},
|
|
||||||
async loadPlaylistBookmarks() {
|
|
||||||
if (!window.db) return;
|
|
||||||
var tx = window.db.transaction("playlist_bookmarks", "readonly");
|
|
||||||
var store = tx.objectStore("playlist_bookmarks");
|
|
||||||
const cursorRequest = store.openCursor();
|
|
||||||
cursorRequest.onsuccess = e => {
|
|
||||||
const cursor = e.target.result;
|
|
||||||
if (cursor) {
|
|
||||||
this.bookmarks.push(cursor.value);
|
|
||||||
cursor.continue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async removeBookmark(index) {
|
|
||||||
var tx = window.db.transaction("playlist_bookmarks", "readwrite");
|
|
||||||
var store = tx.objectStore("playlist_bookmarks");
|
|
||||||
store.delete(this.bookmarks[index].playlistId);
|
|
||||||
this.bookmarks.splice(index, 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -388,7 +388,7 @@
|
|||||||
<button v-t="'actions.reset_preferences'" class="btn" @click="showConfirmResetPrefsDialog = true" />
|
<button v-t="'actions.reset_preferences'" class="btn" @click="showConfirmResetPrefsDialog = true" />
|
||||||
<button v-t="'actions.backup_preferences'" class="btn mx-4" @click="backupPreferences()" />
|
<button v-t="'actions.backup_preferences'" class="btn mx-4" @click="backupPreferences()" />
|
||||||
<label v-t="'actions.restore_preferences'" for="fileSelector" class="btn" @click="restorePreferences()" />
|
<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
|
<ConfirmModal
|
||||||
v-if="showConfirmResetPrefsDialog"
|
v-if="showConfirmResetPrefsDialog"
|
||||||
:message="$t('actions.confirm_reset_preferences')"
|
:message="$t('actions.confirm_reset_preferences')"
|
||||||
@@ -404,325 +404,341 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import CountryMap from "@/utils/CountryMaps/en.json";
|
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 ConfirmModal from "./ConfirmModal.vue";
|
||||||
import CustomInstanceModal from "./CustomInstanceModal.vue";
|
import CustomInstanceModal from "./CustomInstanceModal.vue";
|
||||||
export default {
|
import {
|
||||||
components: {
|
testLocalStorage,
|
||||||
ConfirmModal,
|
getPreferenceString,
|
||||||
CustomInstanceModal,
|
getPreferenceBoolean,
|
||||||
},
|
getPreferenceNumber,
|
||||||
data() {
|
getPreferenceJSON,
|
||||||
return {
|
} from "@/composables/usePreferences";
|
||||||
mobileChapterLayout: "Vertical",
|
import { fetchJson, apiUrl, authApiUrl, getAuthToken, hashCode, isAuthenticated } from "@/composables/useApi";
|
||||||
selectedInstance: null,
|
import { getCustomInstances } from "@/composables/useCustomInstances";
|
||||||
authInstance: false,
|
import { download } from "@/composables/useMisc";
|
||||||
selectedAuthInstance: null,
|
import { getDefaultLanguage } from "@/composables/useFormatting";
|
||||||
customInstances: [],
|
|
||||||
publicInstances: [],
|
|
||||||
sponsorBlock: true,
|
|
||||||
skipOptions: new Map([
|
|
||||||
["sponsor", { value: "auto", label: "actions.skip_sponsors" }],
|
|
||||||
["intro", { value: "no", label: "actions.skip_intro" }],
|
|
||||||
["outro", { value: "no", label: "actions.skip_outro" }],
|
|
||||||
["preview", { value: "no", label: "actions.skip_preview" }],
|
|
||||||
["interaction", { value: "auto", label: "actions.skip_interaction" }],
|
|
||||||
["selfpromo", { value: "auto", label: "actions.skip_self_promo" }],
|
|
||||||
["music_offtopic", { value: "auto", label: "actions.skip_non_music" }],
|
|
||||||
["poi_highlight", { value: "no", label: "actions.skip_highlight" }],
|
|
||||||
["filler", { value: "no", label: "actions.skip_filler_tangent" }],
|
|
||||||
]),
|
|
||||||
showMarkers: true,
|
|
||||||
minSegmentLength: 0,
|
|
||||||
dearrow: false,
|
|
||||||
selectedTheme: "dark",
|
|
||||||
autoPlayVideo: true,
|
|
||||||
autoDisplayCaptions: false,
|
|
||||||
autoPlayNextCountdown: 5,
|
|
||||||
listen: false,
|
|
||||||
resolutions: [144, 240, 360, 480, 720, 1080, 1440, 2160, 4320],
|
|
||||||
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: [
|
|
||||||
{ code: "ar", name: "Arabic" },
|
|
||||||
{ code: "az", name: "Azərbaycan" },
|
|
||||||
{ code: "bg", name: "Български" },
|
|
||||||
{ code: "bn", name: "বাংলা" },
|
|
||||||
{ code: "bs", name: "Bosanski" },
|
|
||||||
{ code: "ca", name: "Català" },
|
|
||||||
{ code: "cs", name: "Čeština" },
|
|
||||||
{ code: "da", name: "Dansk" },
|
|
||||||
{ code: "de", name: "Deutsch" },
|
|
||||||
{ code: "el", name: "Ελληνικά" },
|
|
||||||
{ code: "es", name: "Español" },
|
|
||||||
{ code: "en", name: "English" },
|
|
||||||
{ code: "eo", name: "Esperanto" },
|
|
||||||
{ code: "et", name: "Eesti" },
|
|
||||||
{ code: "fa", name: "فارسی" },
|
|
||||||
{ code: "fi", name: "Suomi" },
|
|
||||||
{ code: "fr", name: "Français" },
|
|
||||||
{ code: "he", name: "עברית" },
|
|
||||||
{ code: "hi", name: "हिंदी" },
|
|
||||||
{ code: "id", name: "Indonesia" },
|
|
||||||
{ code: "is", name: "Íslenska" },
|
|
||||||
{ code: "kab", name: "Taqbaylit" },
|
|
||||||
{ code: "hr", name: "Hrvatski" },
|
|
||||||
{ code: "it", name: "Italiano" },
|
|
||||||
{ code: "ja", name: "日本語" },
|
|
||||||
{ code: "ko", name: "한국어" },
|
|
||||||
{ code: "lt", name: "Lietuvių kalba" },
|
|
||||||
{ code: "ml", name: "മലയാളം" },
|
|
||||||
{ code: "nb_NO", name: "Norwegian Bokmål" },
|
|
||||||
{ code: "nl", name: "Nederlands" },
|
|
||||||
{ code: "oc", name: "Occitan" },
|
|
||||||
{ code: "or", name: "ଓଡ଼ିଆ" },
|
|
||||||
{ code: "pl", name: "Polski" },
|
|
||||||
{ code: "pt", name: "Português" },
|
|
||||||
{ code: "pt_PT", name: "Português (Portugal)" },
|
|
||||||
{ code: "pt_BR", name: "Português (Brasil)" },
|
|
||||||
{ code: "ro", name: "Română" },
|
|
||||||
{ code: "ru", name: "Русский" },
|
|
||||||
{ code: "si", name: "සිංහල" },
|
|
||||||
{ code: "sl", name: "Slovenian" },
|
|
||||||
{ code: "sr", name: "Српски" },
|
|
||||||
{ code: "sv", name: "Svenska" },
|
|
||||||
{ code: "ta", name: "தமிழ்" },
|
|
||||||
{ code: "th", name: "ไทย" },
|
|
||||||
{ code: "tr", name: "Türkçe" },
|
|
||||||
{ code: "uk", name: "Українська" },
|
|
||||||
{ 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: {} });
|
|
||||||
|
|
||||||
this.fetchInstances();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
if (this.testLocalStorage) {
|
const fileSelectorEl = ref(null);
|
||||||
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);
|
|
||||||
|
|
||||||
this.sponsorBlock = this.getPreferenceBoolean("sponsorblock", true);
|
const mobileChapterLayout = ref("Vertical");
|
||||||
var skipOptions, skipList;
|
const selectedInstance = ref(null);
|
||||||
if ((skipOptions = this.getPreferenceJSON("skipOptions")) !== undefined) {
|
const authInstance = ref(false);
|
||||||
Object.entries(skipOptions).forEach(([key, value]) => {
|
const selectedAuthInstance = ref(null);
|
||||||
var opt = this.skipOptions.get(key);
|
const customInstances = ref([]);
|
||||||
if (opt !== undefined) opt.value = value;
|
const publicInstances = ref([]);
|
||||||
else console.log("Unknown sponsor type: " + key);
|
const sponsorBlock = ref(true);
|
||||||
});
|
const skipOptions = ref(
|
||||||
} else if ((skipList = this.getPreferenceString("selectedSkip")) !== undefined) {
|
new Map([
|
||||||
skipList = skipList.split(",");
|
["sponsor", { value: "auto", label: "actions.skip_sponsors" }],
|
||||||
this.skipOptions.forEach(opt => (opt.value = "no"));
|
["intro", { value: "no", label: "actions.skip_intro" }],
|
||||||
skipList.forEach(skip => {
|
["outro", { value: "no", label: "actions.skip_outro" }],
|
||||||
var opt = this.skipOptions.get(skip);
|
["preview", { value: "no", label: "actions.skip_preview" }],
|
||||||
if (opt !== undefined) opt.value = "auto";
|
["interaction", { value: "auto", label: "actions.skip_interaction" }],
|
||||||
else console.log("Unknown sponsor type: " + skip);
|
["selfpromo", { value: "auto", label: "actions.skip_self_promo" }],
|
||||||
});
|
["music_offtopic", { value: "auto", label: "actions.skip_non_music" }],
|
||||||
}
|
["poi_highlight", { value: "no", label: "actions.skip_highlight" }],
|
||||||
|
["filler", { value: "no", label: "actions.skip_filler_tangent" }],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
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: "Български" },
|
||||||
|
{ code: "bn", name: "বাংলা" },
|
||||||
|
{ code: "bs", name: "Bosanski" },
|
||||||
|
{ code: "ca", name: "Català" },
|
||||||
|
{ code: "cs", name: "Čeština" },
|
||||||
|
{ code: "da", name: "Dansk" },
|
||||||
|
{ code: "de", name: "Deutsch" },
|
||||||
|
{ code: "el", name: "Ελληνικά" },
|
||||||
|
{ code: "es", name: "Español" },
|
||||||
|
{ code: "en", name: "English" },
|
||||||
|
{ code: "eo", name: "Esperanto" },
|
||||||
|
{ code: "et", name: "Eesti" },
|
||||||
|
{ code: "fa", name: "فارسی" },
|
||||||
|
{ code: "fi", name: "Suomi" },
|
||||||
|
{ code: "fr", name: "Français" },
|
||||||
|
{ code: "he", name: "עברית" },
|
||||||
|
{ code: "hi", name: "हिंदी" },
|
||||||
|
{ code: "id", name: "Indonesia" },
|
||||||
|
{ code: "is", name: "Íslenska" },
|
||||||
|
{ code: "kab", name: "Taqbaylit" },
|
||||||
|
{ code: "hr", name: "Hrvatski" },
|
||||||
|
{ code: "it", name: "Italiano" },
|
||||||
|
{ code: "ja", name: "日本語" },
|
||||||
|
{ code: "ko", name: "한국어" },
|
||||||
|
{ code: "lt", name: "Lietuvių kalba" },
|
||||||
|
{ code: "ml", name: "മലയാളം" },
|
||||||
|
{ code: "nb_NO", name: "Norwegian Bokmål" },
|
||||||
|
{ code: "nl", name: "Nederlands" },
|
||||||
|
{ code: "oc", name: "Occitan" },
|
||||||
|
{ code: "or", name: "ଓଡ଼ିଆ" },
|
||||||
|
{ code: "pl", name: "Polski" },
|
||||||
|
{ code: "pt", name: "Português" },
|
||||||
|
{ code: "pt_PT", name: "Português (Portugal)" },
|
||||||
|
{ code: "pt_BR", name: "Português (Brasil)" },
|
||||||
|
{ code: "ro", name: "Română" },
|
||||||
|
{ code: "ru", name: "Русский" },
|
||||||
|
{ code: "si", name: "සිංහල" },
|
||||||
|
{ code: "sl", name: "Slovenian" },
|
||||||
|
{ code: "sr", name: "Српски" },
|
||||||
|
{ code: "sv", name: "Svenska" },
|
||||||
|
{ code: "ta", name: "தமிழ்" },
|
||||||
|
{ code: "th", name: "ไทย" },
|
||||||
|
{ code: "tr", name: "Türkçe" },
|
||||||
|
{ code: "uk", name: "Українська" },
|
||||||
|
{ code: "vi", name: "Tiếng Việt" },
|
||||||
|
{ code: "zh_Hant", name: "繁體中文" },
|
||||||
|
{ code: "zh_Hans", name: "简体中文" },
|
||||||
|
];
|
||||||
|
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.showMarkers = this.getPreferenceBoolean("showMarkers", true);
|
const authenticated = computed(() => isAuthenticated());
|
||||||
this.minSegmentLength = Math.max(this.getPreferenceNumber("minSegmentLength", 0), 0);
|
const instances = computed(() => [...publicInstances.value, ...customInstances.value]);
|
||||||
this.dearrow = this.getPreferenceBoolean("dearrow", false);
|
|
||||||
this.selectedTheme = this.getPreferenceString("theme", "dark");
|
onActivated(() => {
|
||||||
this.autoPlayVideo = this.getPreferenceBoolean("playerAutoPlay", true);
|
document.title = t("titles.preferences") + " - Piped";
|
||||||
this.autoDisplayCaptions = this.getPreferenceBoolean("autoDisplayCaptions", false);
|
});
|
||||||
this.autoPlayNextCountdown = this.getPreferenceNumber("autoPlayNextCountdown", 5);
|
|
||||||
this.listen = this.getPreferenceBoolean("listen", false);
|
onMounted(async () => {
|
||||||
this.defaultQuality = Number(localStorage.getItem("quality"));
|
if (Object.keys(route.query).length > 0) router.replace({ query: {} });
|
||||||
this.bufferingGoal = Math.max(Number(localStorage.getItem("bufferGoal")), 10);
|
|
||||||
this.countrySelected = this.getPreferenceString("region", "US");
|
fetchInstances();
|
||||||
this.defaultHomepage = this.getPreferenceString("homepage", "trending");
|
|
||||||
this.minimizeComments = this.getPreferenceBoolean("minimizeComments", false);
|
if (testLocalStorage()) {
|
||||||
this.minimizeDescription = this.getPreferenceBoolean("minimizeDescription", true);
|
selectedInstance.value = getPreferenceString("instance", import.meta.env.VITE_PIPED_API);
|
||||||
this.minimizeRecommendations = this.getPreferenceBoolean("minimizeRecommendations", false);
|
authInstance.value = getPreferenceBoolean("authInstance", false);
|
||||||
this.minimizeChapters = this.getPreferenceBoolean("minimizeChapters", false);
|
selectedAuthInstance.value = getPreferenceString("auth_instance_url", selectedInstance.value);
|
||||||
this.showWatchOnYouTube = this.getPreferenceBoolean("showWatchOnYouTube", false);
|
|
||||||
this.searchSuggestions = this.getPreferenceBoolean("searchSuggestions", true);
|
sponsorBlock.value = getPreferenceBoolean("sponsorblock", true);
|
||||||
this.watchHistory = this.getPreferenceBoolean("watchHistory", false);
|
var savedSkipOptions, skipList;
|
||||||
this.searchHistory = this.getPreferenceBoolean("searchHistory", false);
|
if ((savedSkipOptions = getPreferenceJSON("skipOptions")) !== undefined) {
|
||||||
this.selectedLanguage = this.getPreferenceString("hl", await this.defaultLanguage);
|
Object.entries(savedSkipOptions).forEach(([key, value]) => {
|
||||||
this.enabledCodecs = this.getPreferenceString("enabledCodecs", "vp9,avc").split(",");
|
var opt = skipOptions.value.get(key);
|
||||||
this.disableLBRY = this.getPreferenceBoolean("disableLBRY", false);
|
if (opt !== undefined) opt.value = value;
|
||||||
this.proxyLBRY = this.getPreferenceBoolean("proxyLBRY", false);
|
else console.log("Unknown sponsor type: " + key);
|
||||||
this.prefetchLimit = this.getPreferenceNumber("prefetchLimit", 2);
|
});
|
||||||
this.hideWatched = this.getPreferenceBoolean("hideWatched", false);
|
} else if ((skipList = getPreferenceString("selectedSkip")) !== undefined) {
|
||||||
this.mobileChapterLayout = this.getPreferenceString("mobileChapterLayout", "Vertical");
|
skipList = skipList.split(",");
|
||||||
if (this.selectedLanguage != "en") {
|
skipOptions.value.forEach(opt => (opt.value = "no"));
|
||||||
try {
|
skipList.forEach(skip => {
|
||||||
this.CountryMap = await import(`../utils/CountryMaps/${this.selectedLanguage}.json`).then(
|
var opt = skipOptions.value.get(skip);
|
||||||
val => val.default,
|
if (opt !== undefined) opt.value = "auto";
|
||||||
);
|
else console.log("Unknown sponsor type: " + skip);
|
||||||
} catch (e) {
|
});
|
||||||
console.error("Countries not translated into " + this.selectedLanguage);
|
}
|
||||||
}
|
|
||||||
|
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 {
|
||||||
|
countryMap.value = await import(`../utils/CountryMaps/${selectedLanguage.value}.json`).then(
|
||||||
|
val => val.default,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Countries not translated into " + selectedLanguage.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
methods: {
|
});
|
||||||
async onChange() {
|
|
||||||
if (this.testLocalStorage) {
|
|
||||||
var shouldReload = false;
|
|
||||||
|
|
||||||
if (
|
async function onChange() {
|
||||||
this.getPreferenceString("theme", "dark") !== this.selectedTheme ||
|
if (testLocalStorage()) {
|
||||||
this.getPreferenceBoolean("watchHistory", false) != this.watchHistory ||
|
var shouldReload = false;
|
||||||
this.getPreferenceString("hl", await this.defaultLanguage) !== this.selectedLanguage ||
|
|
||||||
this.getPreferenceString("enabledCodecs", "vp9,avc") !== this.enabledCodecs.join(",")
|
|
||||||
)
|
|
||||||
shouldReload = true;
|
|
||||||
|
|
||||||
localStorage.setItem("instance", this.selectedInstance);
|
if (
|
||||||
localStorage.setItem("authInstance", this.authInstance);
|
getPreferenceString("theme", "dark") !== selectedTheme.value ||
|
||||||
localStorage.setItem("auth_instance_url", this.selectedAuthInstance);
|
getPreferenceBoolean("watchHistory", false) != watchHistory.value ||
|
||||||
localStorage.setItem("sponsorblock", this.sponsorBlock);
|
getPreferenceString("hl", await getDefaultLanguage()) !== selectedLanguage.value ||
|
||||||
|
getPreferenceString("enabledCodecs", "vp9,avc") !== enabledCodecs.value.join(",")
|
||||||
|
)
|
||||||
|
shouldReload = true;
|
||||||
|
|
||||||
var skipOptions = {};
|
localStorage.setItem("instance", selectedInstance.value);
|
||||||
this.skipOptions.forEach((v, k) => (skipOptions[k] = v.value));
|
localStorage.setItem("authInstance", authInstance.value);
|
||||||
localStorage.setItem("skipOptions", JSON.stringify(skipOptions));
|
localStorage.setItem("auth_instance_url", selectedAuthInstance.value);
|
||||||
|
localStorage.setItem("sponsorblock", sponsorBlock.value);
|
||||||
|
|
||||||
localStorage.setItem("showMarkers", this.showMarkers);
|
var savedSkipObj = {};
|
||||||
localStorage.setItem("minSegmentLength", this.minSegmentLength);
|
skipOptions.value.forEach((v, k) => (savedSkipObj[k] = v.value));
|
||||||
|
localStorage.setItem("skipOptions", JSON.stringify(savedSkipObj));
|
||||||
|
|
||||||
localStorage.setItem("dearrow", this.dearrow);
|
localStorage.setItem("showMarkers", showMarkers.value);
|
||||||
|
localStorage.setItem("minSegmentLength", minSegmentLength.value);
|
||||||
|
|
||||||
localStorage.setItem("theme", this.selectedTheme);
|
localStorage.setItem("dearrow", dearrow.value);
|
||||||
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);
|
|
||||||
|
|
||||||
if (shouldReload) window.location.reload();
|
localStorage.setItem("theme", selectedTheme.value);
|
||||||
}
|
localStorage.setItem("playerAutoPlay", autoPlayVideo.value);
|
||||||
},
|
localStorage.setItem("autoDisplayCaptions", autoDisplayCaptions.value);
|
||||||
async fetchInstances() {
|
localStorage.setItem("autoPlayNextCountdown", autoPlayNextCountdown.value);
|
||||||
this.customInstances = this.getCustomInstances();
|
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);
|
||||||
|
|
||||||
this.fetchJson(import.meta.env.VITE_PIPED_INSTANCES).then(resp => {
|
if (shouldReload) window.location.reload();
|
||||||
this.publicInstances = resp;
|
}
|
||||||
if (!this.publicInstances.some(instance => instance.api_url == this.apiUrl()))
|
}
|
||||||
this.publicInstances.push({
|
|
||||||
name: "Selected Instance",
|
async function fetchInstances() {
|
||||||
api_url: this.apiUrl(),
|
customInstances.value = getCustomInstances();
|
||||||
locations: "Unknown",
|
|
||||||
cdn: false,
|
fetchJson(import.meta.env.VITE_PIPED_INSTANCES).then(resp => {
|
||||||
uptime_30d: 100,
|
publicInstances.value = resp;
|
||||||
});
|
if (!publicInstances.value.some(instance => instance.api_url == apiUrl()))
|
||||||
|
publicInstances.value.push({
|
||||||
|
name: "Selected Instance",
|
||||||
|
api_url: apiUrl(),
|
||||||
|
locations: "Unknown",
|
||||||
|
cdn: false,
|
||||||
|
uptime_30d: 100,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sslScore(url) {
|
||||||
|
return "https://www.ssllabs.com/ssltest/analyze.html?d=" + new URL(url).host + "&latest";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
fetchJson(authApiUrl() + "/user/delete", null, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthToken(),
|
||||||
},
|
},
|
||||||
sslScore(url) {
|
body: JSON.stringify({
|
||||||
return "https://www.ssllabs.com/ssltest/analyze.html?d=" + new URL(url).host + "&latest";
|
password: password.value,
|
||||||
|
}),
|
||||||
|
}).then(resp => {
|
||||||
|
if (!resp.error) {
|
||||||
|
logout();
|
||||||
|
} else alert(resp.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("authToken" + hashCode(authApiUrl()));
|
||||||
|
window.location = import.meta.env.BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPreferences() {
|
||||||
|
showConfirmResetPrefsDialog.value = false;
|
||||||
|
localStorage.clear();
|
||||||
|
window.location = import.meta.env.BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invalidateSession() {
|
||||||
|
fetchJson(authApiUrl() + "/logout", null, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthToken(),
|
||||||
},
|
},
|
||||||
async deleteAccount() {
|
}).then(resp => {
|
||||||
this.fetchJson(this.authApiUrl() + "/user/delete", null, {
|
if (!resp.error) {
|
||||||
method: "POST",
|
logout();
|
||||||
headers: {
|
} else alert(resp.error);
|
||||||
Authorization: this.getAuthToken(),
|
});
|
||||||
},
|
}
|
||||||
body: JSON.stringify({
|
|
||||||
password: this.password,
|
function backupPreferences() {
|
||||||
}),
|
const data = JSON.stringify(localStorage);
|
||||||
}).then(resp => {
|
download(data, "preferences.json", "application/json");
|
||||||
if (!resp.error) {
|
}
|
||||||
this.logout();
|
|
||||||
} else alert(resp.error);
|
function restorePreferences() {
|
||||||
});
|
var file = fileSelectorEl.value.files[0];
|
||||||
},
|
file.text().then(text => {
|
||||||
logout() {
|
const data = JSON.parse(text);
|
||||||
// reset the auth token
|
Object.keys(data).forEach(function (key) {
|
||||||
localStorage.removeItem("authToken" + this.hashCode(this.authApiUrl()));
|
localStorage.setItem(key, data[key]);
|
||||||
// redirect to trending page
|
});
|
||||||
window.location = import.meta.env.BASE_URL;
|
window.location.reload();
|
||||||
},
|
});
|
||||||
resetPreferences() {
|
}
|
||||||
this.showConfirmResetPrefsDialog = false;
|
|
||||||
// clear the local storage
|
|
||||||
localStorage.clear();
|
|
||||||
// redirect to the home page
|
|
||||||
window.location = import.meta.env.BASE_URL;
|
|
||||||
},
|
|
||||||
async invalidateSession() {
|
|
||||||
this.fetchJson(this.authApiUrl() + "/logout", null, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: this.getAuthToken(),
|
|
||||||
},
|
|
||||||
}).then(resp => {
|
|
||||||
if (!resp.error) {
|
|
||||||
this.logout();
|
|
||||||
} else alert(resp.error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
backupPreferences() {
|
|
||||||
const data = JSON.stringify(localStorage);
|
|
||||||
this.download(data, "preferences.json", "application/json");
|
|
||||||
},
|
|
||||||
restorePreferences() {
|
|
||||||
var file = this.$refs.fileSelector.files[0];
|
|
||||||
file.text().then(text => {
|
|
||||||
const data = JSON.parse(text);
|
|
||||||
Object.keys(data).forEach(function (key) {
|
|
||||||
localStorage.setItem(key, data[key]);
|
|
||||||
});
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<canvas ref="qrCodeCanvas" class="mx-auto my-2" />
|
<canvas ref="qrCodeCanvas" class="mx-auto my-2" />
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, watch, onMounted } from "vue";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
props: {
|
text: {
|
||||||
text: {
|
type: String,
|
||||||
type: String,
|
required: true,
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
watch: {
|
});
|
||||||
text() {
|
|
||||||
this.generateQrCode();
|
const qrCodeCanvas = ref(null);
|
||||||
},
|
|
||||||
},
|
function generateQrCode() {
|
||||||
mounted() {
|
QRCode.toCanvas(qrCodeCanvas.value, props.text, error => {
|
||||||
this.generateQrCode();
|
if (error) console.error(error);
|
||||||
},
|
});
|
||||||
methods: {
|
}
|
||||||
generateQrCode() {
|
|
||||||
QRCode.toCanvas(this.$refs.qrCodeCanvas, this.text, error => {
|
watch(() => props.text, generateQrCode);
|
||||||
if (error) console.error(error);
|
|
||||||
});
|
onMounted(() => {
|
||||||
},
|
generateQrCode();
|
||||||
},
|
});
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -62,56 +62,58 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</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 { isEmail } from "../utils/Misc.js";
|
||||||
import ConfirmModal from "./ConfirmModal.vue";
|
import ConfirmModal from "./ConfirmModal.vue";
|
||||||
|
import { fetchJson, authApiUrl, getAuthToken, hashCode } from "@/composables/useApi.js";
|
||||||
|
import { setPreference } from "@/composables/usePreferences.js";
|
||||||
|
|
||||||
export default {
|
const router = useRouter();
|
||||||
components: { ConfirmModal },
|
const { t } = useI18n();
|
||||||
data() {
|
|
||||||
return {
|
const username = ref(null);
|
||||||
username: null,
|
const password = ref(null);
|
||||||
password: null,
|
const passwordConfirm = ref(null);
|
||||||
passwordConfirm: null,
|
const showPassword = ref(false);
|
||||||
showPassword: false,
|
const showConfirmPassword = ref(false);
|
||||||
showConfirmPassword: false,
|
const showUnsecureRegisterDialog = ref(false);
|
||||||
showUnsecureRegisterDialog: false,
|
const forceUnsecureRegister = ref(false);
|
||||||
forceUnsecureRegister: false,
|
|
||||||
};
|
onMounted(() => {
|
||||||
},
|
//TODO: Add Server Side check
|
||||||
mounted() {
|
if (getAuthToken()) {
|
||||||
//TODO: Add Server Side check
|
router.push(import.meta.env.BASE_URL);
|
||||||
if (this.getAuthToken()) {
|
}
|
||||||
this.$router.push(import.meta.env.BASE_URL);
|
});
|
||||||
}
|
|
||||||
},
|
onActivated(() => {
|
||||||
activated() {
|
document.title = "Register - Piped";
|
||||||
document.title = "Register - Piped";
|
});
|
||||||
},
|
|
||||||
methods: {
|
function register() {
|
||||||
register() {
|
if (!username.value || !password.value) return;
|
||||||
if (!this.username || !this.password) return;
|
if (password.value != passwordConfirm.value) {
|
||||||
if (this.password != this.passwordConfirm) {
|
alert(t("login.passwords_incorrect"));
|
||||||
alert(this.$t("login.passwords_incorrect"));
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (isEmail(username.value) && !forceUnsecureRegister.value) {
|
||||||
if (isEmail(this.username) && !this.forceUnsecureRegister) {
|
showUnsecureRegisterDialog.value = true;
|
||||||
this.showUnsecureRegisterDialog = true;
|
return;
|
||||||
return;
|
}
|
||||||
}
|
fetchJson(authApiUrl() + "/register", null, {
|
||||||
this.fetchJson(this.authApiUrl() + "/register", null, {
|
method: "POST",
|
||||||
method: "POST",
|
body: JSON.stringify({
|
||||||
body: JSON.stringify({
|
username: username.value,
|
||||||
username: this.username,
|
password: password.value,
|
||||||
password: this.password,
|
}),
|
||||||
}),
|
}).then(resp => {
|
||||||
}).then(resp => {
|
if (resp.token) {
|
||||||
if (resp.token) {
|
setPreference("authToken" + hashCode(authApiUrl()), resp.token);
|
||||||
this.setPreference("authToken" + this.hashCode(this.authApiUrl()), resp.token);
|
window.location = import.meta.env.BASE_URL; // done to bypass cache
|
||||||
window.location = import.meta.env.BASE_URL; // done to bypass cache
|
} else alert(resp.error);
|
||||||
} else alert(resp.error);
|
});
|
||||||
});
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,115 +25,123 @@
|
|||||||
</LoadingIndicatorPage>
|
</LoadingIndicatorPage>
|
||||||
</template>
|
</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 ContentItem from "./ContentItem.vue";
|
||||||
import LoadingIndicatorPage from "./LoadingIndicatorPage.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 {
|
const route = useRoute();
|
||||||
components: {
|
const router = useRouter();
|
||||||
ContentItem,
|
|
||||||
LoadingIndicatorPage,
|
const results = ref(null);
|
||||||
},
|
const availableFilters = [
|
||||||
data() {
|
"all",
|
||||||
return {
|
"videos",
|
||||||
results: null,
|
"channels",
|
||||||
availableFilters: [
|
"playlists",
|
||||||
"all",
|
"music_songs",
|
||||||
"videos",
|
"music_videos",
|
||||||
"channels",
|
"music_albums",
|
||||||
"playlists",
|
"music_playlists",
|
||||||
"music_songs",
|
"music_artists",
|
||||||
"music_videos",
|
];
|
||||||
"music_albums",
|
const selectedFilter = ref(route.query.filter ?? "all");
|
||||||
"music_playlists",
|
let loading = false;
|
||||||
"music_artists",
|
|
||||||
],
|
async function fetchResultsData() {
|
||||||
selectedFilter: this.$route.query.filter ?? "all",
|
return await fetchJson(apiUrl() + "/search", {
|
||||||
};
|
q: route.query.search_query,
|
||||||
},
|
filter: route.query.filter ?? "all",
|
||||||
mounted() {
|
});
|
||||||
if (this.handleRedirect()) return;
|
}
|
||||||
this.updateResults();
|
|
||||||
this.saveQueryToHistory();
|
async function updateResults() {
|
||||||
},
|
document.title = route.query.search_query + " - Piped";
|
||||||
updated() {
|
fetchResultsData().then(json => {
|
||||||
if (this.$route.query.search_query !== undefined) {
|
results.value = json;
|
||||||
document.title = this.$route.query.search_query + " - Piped";
|
updateWatched(results.value.items);
|
||||||
}
|
});
|
||||||
},
|
}
|
||||||
activated() {
|
|
||||||
this.handleRedirect();
|
function updateFilter() {
|
||||||
window.addEventListener("scroll", this.handleScroll);
|
router.replace({
|
||||||
},
|
query: {
|
||||||
deactivated() {
|
search_query: route.query.search_query,
|
||||||
window.removeEventListener("scroll", this.handleScroll);
|
filter: selectedFilter.value,
|
||||||
},
|
|
||||||
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",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
async updateResults() {
|
});
|
||||||
document.title = this.$route.query.search_query + " - Piped";
|
}
|
||||||
this.results = this.fetchResults().then(json => {
|
|
||||||
this.results = json;
|
function handleScroll() {
|
||||||
this.updateWatched(this.results.items);
|
if (loading || !results.value || !results.value.nextpage) return;
|
||||||
});
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
||||||
},
|
loading = true;
|
||||||
updateFilter() {
|
fetchJson(apiUrl() + "/nextpage/search", {
|
||||||
this.$router.replace({
|
nextpage: results.value.nextpage,
|
||||||
query: {
|
q: route.query.search_query,
|
||||||
search_query: this.$route.query.search_query,
|
filter: route.query.filter ?? "all",
|
||||||
filter: this.selectedFilter,
|
}).then(json => {
|
||||||
},
|
results.value.nextpage = json.nextpage;
|
||||||
});
|
results.value.id = json.id;
|
||||||
},
|
loading = false;
|
||||||
handleScroll() {
|
json.items.map(stream => results.value.items.push(stream));
|
||||||
if (this.loading || !this.results || !this.results.nextpage) return;
|
});
|
||||||
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - window.innerHeight) {
|
}
|
||||||
this.loading = true;
|
}
|
||||||
this.fetchJson(this.apiUrl() + "/nextpage/search", {
|
|
||||||
nextpage: this.results.nextpage,
|
function handleRedirect() {
|
||||||
q: this.$route.query.search_query,
|
const query = route.query.search_query;
|
||||||
filter: this.$route.query.filter ?? "all",
|
const url =
|
||||||
}).then(json => {
|
/(?:http(?:s)?:\/\/)?(?:www\.)?youtube\.com(\/[/a-zA-Z0-9_?=&-]*)/gm.exec(query)?.[1] ??
|
||||||
this.results.nextpage = json.nextpage;
|
/(?:http(?:s)?:\/\/)?(?:www\.)?youtu\.be\/(?:watch\?v=)?([/a-zA-Z0-9_?=&-]*)/gm
|
||||||
this.results.id = json.id;
|
.exec(query)?.[1]
|
||||||
this.loading = false;
|
.replace(/^/, "/watch?v=");
|
||||||
json.items.map(stream => this.results.items.push(stream));
|
if (url) {
|
||||||
});
|
router.push(url);
|
||||||
}
|
return true;
|
||||||
},
|
}
|
||||||
handleRedirect() {
|
}
|
||||||
const query = this.$route.query.search_query;
|
|
||||||
const url =
|
function saveQueryToHistory() {
|
||||||
/(?:http(?:s)?:\/\/)?(?:www\.)?youtube\.com(\/[/a-zA-Z0-9_?=&-]*)/gm.exec(query)?.[1] ??
|
if (!getPreferenceBoolean("searchHistory", false)) return;
|
||||||
/(?:http(?:s)?:\/\/)?(?:www\.)?youtu\.be\/(?:watch\?v=)?([/a-zA-Z0-9_?=&-]*)/gm
|
const query = route.query.search_query;
|
||||||
.exec(query)?.[1]
|
if (!query) return;
|
||||||
.replace(/^/, "/watch?v=");
|
const searchHistory = JSON.parse(localStorage.getItem("search_history")) ?? [];
|
||||||
if (url) {
|
if (searchHistory.includes(query)) {
|
||||||
this.$router.push(url);
|
const index = searchHistory.indexOf(query);
|
||||||
return true;
|
searchHistory.splice(index, 1);
|
||||||
}
|
}
|
||||||
},
|
searchHistory.unshift(query);
|
||||||
saveQueryToHistory() {
|
if (searchHistory.length > 10) searchHistory.shift();
|
||||||
if (!this.getPreferenceBoolean("searchHistory", false)) return;
|
localStorage.setItem("search_history", JSON.stringify(searchHistory));
|
||||||
const query = this.$route.query.search_query;
|
}
|
||||||
if (!query) return;
|
|
||||||
const searchHistory = JSON.parse(localStorage.getItem("search_history")) ?? [];
|
onMounted(() => {
|
||||||
if (searchHistory.includes(query)) {
|
if (handleRedirect()) return;
|
||||||
const index = searchHistory.indexOf(query);
|
updateResults();
|
||||||
searchHistory.splice(index, 1);
|
saveQueryToHistory();
|
||||||
}
|
});
|
||||||
searchHistory.unshift(query);
|
|
||||||
if (searchHistory.length > 10) searchHistory.shift();
|
onUpdated(() => {
|
||||||
localStorage.setItem("search_history", JSON.stringify(searchHistory));
|
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>
|
</script>
|
||||||
|
|||||||
@@ -18,67 +18,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { ref } from "vue";
|
||||||
props: {
|
import { fetchJson, apiUrl } from "@/composables/useApi.js";
|
||||||
searchText: { type: String, default: "" },
|
import { getPreferenceBoolean } from "@/composables/usePreferences.js";
|
||||||
},
|
|
||||||
emits: ["searchchange"],
|
const props = defineProps({
|
||||||
data() {
|
searchText: { type: String, default: "" },
|
||||||
return {
|
});
|
||||||
selected: 0,
|
|
||||||
searchSuggestions: [],
|
const emit = defineEmits(["searchchange"]);
|
||||||
};
|
|
||||||
},
|
const selected = ref(0);
|
||||||
methods: {
|
const searchSuggestions = ref([]);
|
||||||
onKeyUp(e) {
|
|
||||||
if (e.key === "ArrowUp") {
|
function onKeyUp(e) {
|
||||||
if (this.selected <= 0) {
|
if (e.key === "ArrowUp") {
|
||||||
this.setSelected(this.searchSuggestions.length - 1);
|
if (selected.value <= 0) {
|
||||||
} else {
|
setSelected(searchSuggestions.value.length - 1);
|
||||||
this.setSelected(this.selected - 1);
|
} else {
|
||||||
}
|
setSelected(selected.value - 1);
|
||||||
e.preventDefault();
|
}
|
||||||
} else if (e.key === "ArrowDown") {
|
e.preventDefault();
|
||||||
if (this.selected >= this.searchSuggestions.length - 1) {
|
} else if (e.key === "ArrowDown") {
|
||||||
this.setSelected(0);
|
if (selected.value >= searchSuggestions.value.length - 1) {
|
||||||
} else {
|
setSelected(0);
|
||||||
this.setSelected(this.selected + 1);
|
} else {
|
||||||
}
|
setSelected(selected.value + 1);
|
||||||
e.preventDefault();
|
}
|
||||||
} else {
|
e.preventDefault();
|
||||||
this.refreshSuggestions();
|
} else {
|
||||||
}
|
refreshSuggestions();
|
||||||
},
|
}
|
||||||
async refreshSuggestions() {
|
}
|
||||||
if (!this.searchText) {
|
|
||||||
if (this.getPreferenceBoolean("searchHistory", false))
|
async function refreshSuggestions() {
|
||||||
this.searchSuggestions = JSON.parse(localStorage.getItem("search_history")) ?? [];
|
if (!props.searchText) {
|
||||||
} else if (this.getPreferenceBoolean("searchSuggestions", true)) {
|
if (getPreferenceBoolean("searchHistory", false))
|
||||||
this.searchSuggestions =
|
searchSuggestions.value = JSON.parse(localStorage.getItem("search_history")) ?? [];
|
||||||
(
|
} else if (getPreferenceBoolean("searchSuggestions", true)) {
|
||||||
await this.fetchJson(this.apiUrl() + "/opensearch/suggestions", {
|
searchSuggestions.value =
|
||||||
query: this.searchText,
|
(
|
||||||
})
|
await fetchJson(apiUrl() + "/opensearch/suggestions", {
|
||||||
)?.[1] ?? [];
|
query: props.searchText,
|
||||||
} else {
|
})
|
||||||
this.searchSuggestions = [];
|
)?.[1] ?? [];
|
||||||
return;
|
} else {
|
||||||
}
|
searchSuggestions.value = [];
|
||||||
this.searchSuggestions.unshift(this.searchText);
|
return;
|
||||||
this.setSelected(0);
|
}
|
||||||
},
|
searchSuggestions.value.unshift(props.searchText);
|
||||||
onMouseOver(i) {
|
setSelected(0);
|
||||||
if (i !== this.selected) {
|
}
|
||||||
this.selected = i;
|
|
||||||
}
|
function onMouseOver(i) {
|
||||||
},
|
if (i !== selected.value) {
|
||||||
setSelected(val) {
|
selected.value = i;
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -32,112 +32,109 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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"));
|
const QrCode = defineAsyncComponent(() => import("./QrCode.vue"));
|
||||||
</script>
|
|
||||||
|
const { t } = useI18n();
|
||||||
<script>
|
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
const props = defineProps({
|
||||||
|
videoId: {
|
||||||
export default {
|
type: String,
|
||||||
components: {
|
required: true,
|
||||||
ModalComponent,
|
},
|
||||||
},
|
currentTime: {
|
||||||
props: {
|
type: Number,
|
||||||
videoId: {
|
required: true,
|
||||||
type: String,
|
},
|
||||||
required: true,
|
playlistId: {
|
||||||
},
|
type: String,
|
||||||
currentTime: {
|
default: undefined,
|
||||||
type: Number,
|
},
|
||||||
required: true,
|
playlistIndex: {
|
||||||
},
|
type: Number,
|
||||||
playlistId: {
|
default: undefined,
|
||||||
type: String,
|
},
|
||||||
default: undefined,
|
});
|
||||||
},
|
|
||||||
playlistIndex: {
|
const durations = [1, 60, 60 * 60, 60 * 60 * 24];
|
||||||
type: Number,
|
|
||||||
default: undefined,
|
const withTimeCode = ref(true);
|
||||||
},
|
const pipedLink = ref(true);
|
||||||
},
|
const withPlaylist = ref(true);
|
||||||
data() {
|
const timeStamp = ref(null);
|
||||||
return {
|
const hasPlaylist = ref(false);
|
||||||
withTimeCode: true,
|
const showQrCode = ref(false);
|
||||||
pipedLink: true,
|
|
||||||
withPlaylist: true,
|
const generatedLink = computed(() => {
|
||||||
timeStamp: null,
|
const baseUrl = pipedLink.value
|
||||||
hasPlaylist: false,
|
? window.location.origin + "/watch?v=" + props.videoId
|
||||||
showQrCode: false,
|
: "https://youtu.be/" + props.videoId;
|
||||||
durations: [1, 60, 60 * 60, 60 * 60 * 24],
|
const url = new URL(baseUrl);
|
||||||
};
|
|
||||||
},
|
if (withTimeCode.value && timeStamp.value) url.searchParams.append("t", parseTimeStampToSeconds(timeStamp.value));
|
||||||
computed: {
|
|
||||||
generatedLink() {
|
if (hasPlaylist.value && withPlaylist.value) {
|
||||||
const baseUrl = this.pipedLink
|
url.searchParams.append("list", props.playlistId);
|
||||||
? window.location.origin + "/watch?v=" + this.videoId
|
url.searchParams.append("index", props.playlistIndex);
|
||||||
: "https://youtu.be/" + this.videoId;
|
}
|
||||||
const url = new URL(baseUrl);
|
|
||||||
|
return url.href;
|
||||||
if (this.withTimeCode && this.timeStamp)
|
});
|
||||||
url.searchParams.append("t", this.parseTimeStampToSeconds(this.timeStamp));
|
|
||||||
|
onMounted(() => {
|
||||||
if (this.hasPlaylist && this.withPlaylist) {
|
timeStamp.value = parseSecondsToTimeStamp(props.currentTime ?? 0);
|
||||||
url.searchParams.append("list", this.playlistId);
|
withTimeCode.value = getPreferenceBoolean("shareWithTimeCode", true);
|
||||||
url.searchParams.append("index", this.playlistIndex);
|
pipedLink.value = getPreferenceBoolean("shareAsPipedLink", true);
|
||||||
}
|
withPlaylist.value = getPreferenceBoolean("shareWithPlaylist", true);
|
||||||
|
hasPlaylist.value = props.playlistId != undefined && !isNaN(props.playlistIndex);
|
||||||
return url.href;
|
});
|
||||||
},
|
|
||||||
},
|
function followLink() {
|
||||||
mounted() {
|
window.open(generatedLink.value, "_blank").focus();
|
||||||
this.timeStamp = this.parseSecondsToTimeStamp(this.currentTime ?? 0);
|
}
|
||||||
this.withTimeCode = this.getPreferenceBoolean("shareWithTimeCode", true);
|
|
||||||
this.pipedLink = this.getPreferenceBoolean("shareAsPipedLink", true);
|
async function copyLink() {
|
||||||
this.withPlaylist = this.getPreferenceBoolean("shareWithPlaylist", true);
|
await copyURL(generatedLink.value);
|
||||||
this.hasPlaylist = this.playlistId != undefined && !isNaN(this.playlistIndex);
|
}
|
||||||
},
|
|
||||||
methods: {
|
async function copyURL(mytext) {
|
||||||
followLink() {
|
try {
|
||||||
window.open(this.generatedLink, "_blank").focus();
|
await navigator.clipboard.writeText(mytext);
|
||||||
},
|
alert(t("info.copied"));
|
||||||
async copyLink() {
|
} catch ($e) {
|
||||||
await this.copyURL(this.generatedLink);
|
alert(t("info.cannot_copy"));
|
||||||
},
|
}
|
||||||
async copyURL(mytext) {
|
}
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(mytext);
|
function parseTimeStampToSeconds(timestamp) {
|
||||||
alert(this.$t("info.copied"));
|
const timeArray = timestamp.split(":").reverse();
|
||||||
} catch ($e) {
|
let seconds = 0;
|
||||||
alert(this.$t("info.cannot_copy"));
|
for (let i = 0; i < timeArray.length; i++) {
|
||||||
}
|
seconds += timeArray[i] * durations[i];
|
||||||
},
|
}
|
||||||
parseTimeStampToSeconds(timestamp) {
|
return seconds;
|
||||||
const timeArray = timestamp.split(":").reverse();
|
}
|
||||||
let seconds = 0;
|
|
||||||
for (let i = 0; i < timeArray.length; i++) {
|
function parseSecondsToTimeStamp(seconds) {
|
||||||
seconds += timeArray[i] * this.durations[i];
|
const timeArray = [];
|
||||||
}
|
const durationsReversed = durations.toReversed();
|
||||||
return seconds;
|
for (let i in durationsReversed) {
|
||||||
},
|
const currentValue = Math.floor(seconds / durationsReversed[i]);
|
||||||
parseSecondsToTimeStamp(seconds) {
|
if (currentValue > 0) {
|
||||||
const timeArray = [];
|
timeArray.push(currentValue.toString().padStart(2, "0"));
|
||||||
const durationsReversed = this.durations.toReversed();
|
seconds -= currentValue * durationsReversed[i];
|
||||||
for (let i in durationsReversed) {
|
}
|
||||||
const currentValue = Math.floor(seconds / durationsReversed[i]);
|
}
|
||||||
if (currentValue > 0) {
|
return timeArray.join(":");
|
||||||
timeArray.push(currentValue.toString().padStart(2, "0"));
|
}
|
||||||
seconds -= currentValue * durationsReversed[i];
|
|
||||||
}
|
function onChange() {
|
||||||
}
|
setPreference("shareWithTimeCode", withTimeCode.value, true);
|
||||||
return timeArray.join(":");
|
setPreference("shareAsPipedLink", pipedLink.value, true);
|
||||||
},
|
setPreference("shareWithPlaylist", withPlaylist.value, true);
|
||||||
onChange() {
|
}
|
||||||
this.setPreference("shareWithTimeCode", this.withTimeCode, true);
|
|
||||||
this.setPreference("shareAsPipedLink", this.pipedLink, true);
|
|
||||||
this.setPreference("shareWithPlaylist", this.withPlaylist, true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -121,156 +121,160 @@
|
|||||||
</ModalComponent>
|
</ModalComponent>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onActivated } from "vue";
|
||||||
import ModalComponent from "./ModalComponent.vue";
|
import ModalComponent from "./ModalComponent.vue";
|
||||||
import CreateGroupModal from "./CreateGroupModal.vue";
|
import CreateGroupModal from "./CreateGroupModal.vue";
|
||||||
import ConfirmModal from "./ConfirmModal.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 {
|
const fileSelector = ref(null);
|
||||||
components: { ModalComponent, CreateGroupModal, ConfirmModal },
|
const subscriptions = ref([]);
|
||||||
data() {
|
const selectedGroup = ref({
|
||||||
return {
|
groupName: "",
|
||||||
subscriptions: [],
|
channels: [],
|
||||||
selectedGroup: {
|
});
|
||||||
groupName: "",
|
const channelGroups = ref([]);
|
||||||
channels: [],
|
const showCreateGroupModal = ref(false);
|
||||||
|
const showEditGroupModal = ref(false);
|
||||||
|
const editedGroupName = ref("");
|
||||||
|
const groupToDelete = ref(null);
|
||||||
|
|
||||||
|
const filteredSubscriptions = computed(() => {
|
||||||
|
return selectedGroup.value.groupName == ""
|
||||||
|
? subscriptions.value
|
||||||
|
: subscriptions.value.filter(channel => selectedGroup.value.channels.includes(channel.url.substr(-24)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleButton(subscription) {
|
||||||
|
const channelId = subscription.url.split("/")[2];
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
fetchJson(authApiUrl() + (subscription.subscribed ? "/unsubscribe" : "/subscribe"), null, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
channelId: channelId,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: getAuthToken(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscriptions = json;
|
|
||||||
this.subscriptions.forEach(subscription => (subscription.subscribed = true));
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
handleLocalSubscriptions(channelId);
|
||||||
|
}
|
||||||
|
subscription.subscribed = !subscription.subscribed;
|
||||||
|
}
|
||||||
|
|
||||||
this.channelGroups.push(this.selectedGroup);
|
function exportHandler() {
|
||||||
if (!window.db) return;
|
const subs = [];
|
||||||
|
subscriptions.value.forEach(subscription => {
|
||||||
|
subs.push({
|
||||||
|
url: "https://www.youtube.com" + subscription.url,
|
||||||
|
name: subscription.name,
|
||||||
|
service_id: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const json = JSON.stringify({
|
||||||
|
app_version: "",
|
||||||
|
app_version_int: 0,
|
||||||
|
subscriptions: subs,
|
||||||
|
});
|
||||||
|
download(json, "subscriptions.json", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
this.loadChannelGroups();
|
function selectGroup(group) {
|
||||||
},
|
selectedGroup.value = group;
|
||||||
activated() {
|
editedGroupName.value = group.groupName;
|
||||||
document.title = "Subscriptions - Piped";
|
}
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async loadChannelGroups() {
|
|
||||||
const groups = await this.getChannelGroups();
|
|
||||||
this.channelGroups.push(...groups);
|
|
||||||
},
|
|
||||||
handleButton(subscription) {
|
|
||||||
const channelId = subscription.url.split("/")[2];
|
|
||||||
if (this.authenticated) {
|
|
||||||
this.fetchJson(this.authApiUrl() + (subscription.subscribed ? "/unsubscribe" : "/subscribe"), null, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
channelId: channelId,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
Authorization: this.getAuthToken(),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.handleLocalSubscriptions(channelId);
|
|
||||||
}
|
|
||||||
subscription.subscribed = !subscription.subscribed;
|
|
||||||
},
|
|
||||||
exportHandler() {
|
|
||||||
const subscriptions = [];
|
|
||||||
this.subscriptions.forEach(subscription => {
|
|
||||||
subscriptions.push({
|
|
||||||
url: "https://www.youtube.com" + subscription.url,
|
|
||||||
name: subscription.name,
|
|
||||||
service_id: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const json = JSON.stringify({
|
|
||||||
app_version: "",
|
|
||||||
app_version_int: 0,
|
|
||||||
subscriptions: subscriptions,
|
|
||||||
});
|
|
||||||
this.download(json, "subscriptions.json", "application/json");
|
|
||||||
},
|
|
||||||
selectGroup(group) {
|
|
||||||
this.selectedGroup = group;
|
|
||||||
this.editedGroupName = group.groupName;
|
|
||||||
},
|
|
||||||
createGroup(newGroupName) {
|
|
||||||
if (!newGroupName || this.channelGroups.some(group => group.groupName == newGroupName)) return;
|
|
||||||
|
|
||||||
const newGroup = {
|
function createGroup(newGroupName) {
|
||||||
groupName: newGroupName,
|
if (!newGroupName || channelGroups.value.some(group => group.groupName == newGroupName)) return;
|
||||||
channels: [],
|
|
||||||
};
|
|
||||||
this.channelGroups.push(newGroup);
|
|
||||||
this.createOrUpdateChannelGroup(newGroup);
|
|
||||||
|
|
||||||
this.showCreateGroupModal = false;
|
const newGroup = {
|
||||||
},
|
groupName: newGroupName,
|
||||||
editGroupName() {
|
channels: [],
|
||||||
const oldGroupName = this.selectedGroup.groupName;
|
};
|
||||||
const newGroupName = this.editedGroupName;
|
channelGroups.value.push(newGroup);
|
||||||
|
createOrUpdateChannelGroup(newGroup);
|
||||||
|
|
||||||
// the group mustn't yet exist and the name can't be empty
|
showCreateGroupModal.value = false;
|
||||||
if (!newGroupName || newGroupName == oldGroupName) return;
|
}
|
||||||
if (this.channelGroups.some(group => group.groupName == newGroupName)) return;
|
|
||||||
|
|
||||||
// create a new group with the same info and delete the old one
|
function editGroupName() {
|
||||||
this.selectedGroup.groupName = newGroupName;
|
const oldGroupName = selectedGroup.value.groupName;
|
||||||
this.createOrUpdateChannelGroup(this.selectedGroup);
|
const newGroupName = editedGroupName.value;
|
||||||
this.deleteChannelGroup(oldGroupName);
|
|
||||||
|
|
||||||
this.showEditGroupModal = false;
|
if (!newGroupName || newGroupName == oldGroupName) return;
|
||||||
},
|
if (channelGroups.value.some(group => group.groupName == newGroupName)) return;
|
||||||
deleteGroup(group) {
|
|
||||||
this.deleteChannelGroup(group);
|
selectedGroup.value.groupName = newGroupName;
|
||||||
this.channelGroups = this.channelGroups.filter(g => g.groupName != group);
|
createOrUpdateChannelGroup(selectedGroup.value);
|
||||||
this.selectedGroup = this.channelGroups[0] || {};
|
deleteChannelGroup(oldGroupName);
|
||||||
this.groupToDelete = null;
|
|
||||||
},
|
showEditGroupModal.value = false;
|
||||||
checkedChange(subscription) {
|
}
|
||||||
const channelId = subscription.url.substr(-24);
|
|
||||||
this.selectedGroup.channels = this.selectedGroup.channels.includes(channelId)
|
function deleteGroup(group) {
|
||||||
? this.selectedGroup.channels.filter(channel => channel != channelId)
|
deleteChannelGroup(group);
|
||||||
: this.selectedGroup.channels.concat(channelId);
|
channelGroups.value = channelGroups.value.filter(g => g.groupName != group);
|
||||||
this.createOrUpdateChannelGroup(this.selectedGroup);
|
selectedGroup.value = channelGroups.value[0] || {};
|
||||||
},
|
groupToDelete.value = null;
|
||||||
async importGroupsHandler() {
|
}
|
||||||
const files = this.$refs.fileSelector.files;
|
|
||||||
for (let file of files) {
|
function checkedChange(subscription) {
|
||||||
const groups = JSON.parse(await file.text()).groups;
|
const channelId = subscription.url.substr(-24);
|
||||||
for (let group of groups) {
|
selectedGroup.value.channels = selectedGroup.value.channels.includes(channelId)
|
||||||
this.createOrUpdateChannelGroup(group);
|
? selectedGroup.value.channels.filter(channel => channel != channelId)
|
||||||
this.channelGroups.push(group);
|
: selectedGroup.value.channels.concat(channelId);
|
||||||
}
|
createOrUpdateChannelGroup(selectedGroup.value);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
exportGroupsHandler() {
|
async function importGroupsHandler() {
|
||||||
const json = {
|
const files = fileSelector.value.files;
|
||||||
format: "Piped",
|
for (let file of files) {
|
||||||
version: 1,
|
const groups = JSON.parse(await file.text()).groups;
|
||||||
groups: this.channelGroups.slice(1),
|
for (let group of groups) {
|
||||||
};
|
createOrUpdateChannelGroup(group);
|
||||||
this.download(JSON.stringify(json), "channel_groups.json", "application/json");
|
channelGroups.value.push(group);
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function exportGroupsHandler() {
|
||||||
|
const json = {
|
||||||
|
format: "Piped",
|
||||||
|
version: 1,
|
||||||
|
groups: channelGroups.value.slice(1),
|
||||||
|
};
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -5,15 +5,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
const emit = defineEmits(["dismissed"]);
|
||||||
emits: ["dismissed"],
|
|
||||||
methods: {
|
function dismiss() {
|
||||||
dismiss() {
|
emit("dismissed");
|
||||||
this.$emit("dismissed");
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -8,49 +8,49 @@
|
|||||||
</LoadingIndicatorPage>
|
</LoadingIndicatorPage>
|
||||||
</template>
|
</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 LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
||||||
import VideoItem from "./VideoItem.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 {
|
const route = useRoute();
|
||||||
components: {
|
const router = useRouter();
|
||||||
VideoItem,
|
const { t } = useI18n();
|
||||||
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");
|
|
||||||
|
|
||||||
this.fetchTrending(region).then(videos => {
|
const videos = ref([]);
|
||||||
this.videos = videos;
|
|
||||||
this.updateWatched(this.videos);
|
async function fetchTrending(region) {
|
||||||
this.fetchDeArrowContent(this.videos);
|
return await fetchJson(apiUrl() + "/trending", {
|
||||||
});
|
region: region || "US",
|
||||||
},
|
});
|
||||||
activated() {
|
}
|
||||||
document.title = this.$t("titles.trending") + " - Piped";
|
|
||||||
if (this.videos.length > 0) this.updateWatched(this.videos);
|
onMounted(() => {
|
||||||
if (this.$route.path == import.meta.env.BASE_URL) {
|
if (route.path == import.meta.env.BASE_URL && getPreferenceString("homepage", "trending") == "feed") {
|
||||||
let homepage = this.getHomePage(this);
|
return;
|
||||||
if (homepage !== undefined) this.$router.push(homepage);
|
}
|
||||||
}
|
let region = getPreferenceString("region", "US");
|
||||||
},
|
|
||||||
methods: {
|
fetchTrending(region).then(vids => {
|
||||||
async fetchTrending(region) {
|
videos.value = vids;
|
||||||
return await this.fetchJson(this.apiUrl() + "/trending", {
|
updateWatched(videos.value);
|
||||||
region: region || "US",
|
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>
|
</script>
|
||||||
|
|||||||
@@ -127,110 +127,107 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
import PlaylistAddModal from "./PlaylistAddModal.vue";
|
import PlaylistAddModal from "./PlaylistAddModal.vue";
|
||||||
import ShareModal from "./ShareModal.vue";
|
import ShareModal from "./ShareModal.vue";
|
||||||
import ConfirmModal from "./ConfirmModal.vue";
|
import ConfirmModal from "./ConfirmModal.vue";
|
||||||
import VideoThumbnail from "./VideoThumbnail.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 {
|
const props = defineProps({
|
||||||
components: { PlaylistAddModal, ConfirmModal, ShareModal, VideoThumbnail },
|
item: {
|
||||||
props: {
|
type: Object,
|
||||||
item: {
|
default: () => {
|
||||||
type: Object,
|
return {};
|
||||||
default: () => {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isFeed: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
height: { type: String, default: "118" },
|
|
||||||
width: { type: String, default: "210" },
|
|
||||||
hideChannel: { type: Boolean, default: false },
|
|
||||||
index: { type: Number, default: -1 },
|
|
||||||
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() {
|
isFeed: {
|
||||||
this.shouldShowVideo();
|
type: Boolean,
|
||||||
this.shouldShowMarkOnWatched();
|
default: false,
|
||||||
},
|
},
|
||||||
methods: {
|
height: { type: String, default: "118" },
|
||||||
removeVideo() {
|
width: { type: String, default: "210" },
|
||||||
this.$refs.removeButton.disabled = true;
|
hideChannel: { type: Boolean, default: false },
|
||||||
this.removeVideoFromPlaylist(this.playlistId, this.index).then(json => {
|
index: { type: Number, default: -1 },
|
||||||
if (json.error) alert(json.error);
|
playlistId: { type: String, default: null },
|
||||||
else this.$emit("remove");
|
preferListen: { type: Boolean, default: false },
|
||||||
});
|
admin: { type: Boolean, default: false },
|
||||||
},
|
});
|
||||||
shouldShowVideo() {
|
|
||||||
if (!this.isFeed || !this.getPreferenceBoolean("hideWatched", false)) return;
|
|
||||||
|
|
||||||
const objectStore = window.db.transaction("watch_history", "readonly").objectStore("watch_history");
|
const emit = defineEmits(["update:watched", "remove"]);
|
||||||
const request = objectStore.get(this.item.url.substr(-11));
|
|
||||||
request.onsuccess = event => {
|
const removeButton = ref(null);
|
||||||
const video = event.target.result;
|
const showPlaylistModal = ref(false);
|
||||||
if (video && (video.currentTime ?? 0) > video.duration * 0.9) {
|
const showShareModal = ref(false);
|
||||||
this.showVideo = false;
|
const showVideo = ref(true);
|
||||||
return;
|
const showConfirmRemove = ref(false);
|
||||||
}
|
const showMarkOnWatched = ref(false);
|
||||||
};
|
|
||||||
},
|
const title = computed(() => {
|
||||||
shouldShowMarkOnWatched() {
|
return props.item.dearrow?.titles[0]?.title ?? props.item.title;
|
||||||
this.showMarkOnWatched = this.getPreferenceBoolean("watchHistory", false);
|
});
|
||||||
},
|
|
||||||
toggleWatched(videoId) {
|
function removeVideo() {
|
||||||
if (window.db) {
|
removeButton.value.disabled = true;
|
||||||
var tx = window.db.transaction("watch_history", "readwrite");
|
removeVideoFromPlaylist(props.playlistId, props.index).then(json => {
|
||||||
var store = tx.objectStore("watch_history");
|
if (json.error) alert(json.error);
|
||||||
var instance = this;
|
else emit("remove");
|
||||||
var request = store.get(videoId);
|
});
|
||||||
request.onsuccess = function (event) {
|
}
|
||||||
var video = event.target.result;
|
|
||||||
if (video) {
|
function shouldShowVideo() {
|
||||||
video.watchedAt = Date.now();
|
if (!props.isFeed || !getPreferenceBoolean("hideWatched", false)) return;
|
||||||
} else {
|
|
||||||
video = {
|
const objectStore = window.db.transaction("watch_history", "readonly").objectStore("watch_history");
|
||||||
videoId: videoId,
|
const request = objectStore.get(props.item.url.substr(-11));
|
||||||
title: instance.item.title,
|
request.onsuccess = event => {
|
||||||
duration: instance.item.duration,
|
const video = event.target.result;
|
||||||
thumbnail: instance.item.thumbnail,
|
if (video && (video.currentTime ?? 0) > video.duration * 0.9) {
|
||||||
uploaderUrl: instance.item.uploaderUrl,
|
showVideo.value = false;
|
||||||
uploaderName: instance.item.uploaderName,
|
return;
|
||||||
watchedAt: Date.now(),
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
video.currentTime =
|
|
||||||
instance.item.currentTime < instance.item.duration * 0.9 ? instance.item.duration : 0;
|
function shouldShowMarkOnWatched() {
|
||||||
store.put(video);
|
showMarkOnWatched.value = getPreferenceBoolean("watchHistory", false);
|
||||||
instance.$emit("update:watched", [instance.item.url]);
|
}
|
||||||
instance.shouldShowVideo();
|
|
||||||
|
function toggleWatched(videoId) {
|
||||||
|
if (window.db) {
|
||||||
|
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: props.item.title,
|
||||||
|
duration: props.item.duration,
|
||||||
|
thumbnail: props.item.thumbnail,
|
||||||
|
uploaderUrl: props.item.uploaderUrl,
|
||||||
|
uploaderName: props.item.uploaderName,
|
||||||
|
watchedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
video.currentTime = props.item.currentTime < props.item.duration * 0.9 ? props.item.duration : 0;
|
||||||
},
|
store.put(video);
|
||||||
};
|
emit("update:watched", [props.item.url]);
|
||||||
|
shouldShowVideo();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
shouldShowVideo();
|
||||||
|
shouldShowMarkOnWatched();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,19 @@
|
|||||||
<div v-t="'actions.loading'" />
|
<div v-t="'actions.loading'" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { onActivated } from "vue";
|
||||||
activated() {
|
import { useRouter, useRoute } from "vue-router";
|
||||||
const videoId = this.$route.params.videoId;
|
|
||||||
if (videoId)
|
const router = useRouter();
|
||||||
this.$router.replace({
|
const route = useRoute();
|
||||||
path: "/watch",
|
|
||||||
query: { v: videoId, t: this.$route.query.t },
|
onActivated(() => {
|
||||||
});
|
const videoId = route.params.videoId;
|
||||||
},
|
if (videoId)
|
||||||
};
|
router.replace({
|
||||||
|
path: "/watch",
|
||||||
|
query: { v: videoId, t: route.query.t },
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -31,31 +31,23 @@
|
|||||||
<span v-if="item.watched" v-t="'video.watched'" class="thumbnail-overlay bottom-5px left-5px" />
|
<span v-if="item.watched" v-t="'video.watched'" class="thumbnail-overlay bottom-5px left-5px" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { timeFormat } from "@/composables/useFormatting.js";
|
||||||
props: {
|
|
||||||
item: {
|
defineProps({
|
||||||
type: Object,
|
item: {
|
||||||
default: () => {
|
type: Object,
|
||||||
return {};
|
default: () => {
|
||||||
},
|
return {};
|
||||||
},
|
|
||||||
small: {
|
|
||||||
type: Boolean,
|
|
||||||
default: () => {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
small: {
|
||||||
title() {
|
type: Boolean,
|
||||||
return this.item.dearrow?.titles[0]?.title ?? this.item.title;
|
default: () => {
|
||||||
},
|
return false;
|
||||||
thumbnail() {
|
|
||||||
return this.item.dearrow?.thumbnails[0]?.thumbnail ?? this.item.thumbnail;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { getPreferenceBoolean } from "@/composables/usePreferences.js";
|
||||||
props: {
|
|
||||||
link: {
|
defineProps({
|
||||||
type: String,
|
link: {
|
||||||
required: true,
|
type: String,
|
||||||
},
|
required: true,
|
||||||
platform: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "YouTube",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
platform: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: "YouTube",
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -152,7 +152,9 @@
|
|||||||
aria-label="RSS feed"
|
aria-label="RSS feed"
|
||||||
title="RSS feed"
|
title="RSS feed"
|
||||||
role="button"
|
role="button"
|
||||||
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${video.uploaderUrl.split('/')[2]}`"
|
:href="`${apiUrl()}/feed/unauthenticated/rss?channels=${
|
||||||
|
video.uploaderUrl.split('/')[2]
|
||||||
|
}`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="btn flex items-center"
|
class="btn flex items-center"
|
||||||
>
|
>
|
||||||
@@ -282,11 +284,11 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!comments?.disabled"
|
v-if="!comments?.disabled"
|
||||||
class="btn mb-2"
|
class="btn mb-2"
|
||||||
@click="toggleComments"
|
@click="toggleCommentsVisibility"
|
||||||
v-text="
|
v-text="
|
||||||
`${$t(showComments ? 'actions.minimize_comments' : 'actions.show_comments')} (${numberFormat(
|
`${$t(
|
||||||
comments?.commentCount,
|
showComments ? 'actions.minimize_comments' : 'actions.show_comments',
|
||||||
)})`
|
)} (${numberFormat(comments?.commentCount)})`
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +299,7 @@
|
|||||||
<div v-else-if="comments.disabled" class="">
|
<div v-else-if="comments.disabled" class="">
|
||||||
<p v-t="'comment.disabled'" class="mt-8 text-center"></p>
|
<p v-t="'comment.disabled'" class="mt-8 text-center"></p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else ref="comments" class="">
|
<div v-else ref="commentsEl" class="">
|
||||||
<CommentItem
|
<CommentItem
|
||||||
v-for="comment in comments.comments"
|
v-for="comment in comments.comments"
|
||||||
:key="comment.commentId"
|
:key="comment.commentId"
|
||||||
@@ -346,7 +348,9 @@
|
|||||||
</LoadingIndicatorPage>
|
</LoadingIndicatorPage>
|
||||||
</template>
|
</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 VideoPlayer from "./VideoPlayer.vue";
|
||||||
import ContentItem from "./ContentItem.vue";
|
import ContentItem from "./ContentItem.vue";
|
||||||
import ErrorHandler from "./ErrorHandler.vue";
|
import ErrorHandler from "./ErrorHandler.vue";
|
||||||
@@ -360,413 +364,428 @@ import LoadingIndicatorPage from "./LoadingIndicatorPage.vue";
|
|||||||
import ToastComponent from "./ToastComponent.vue";
|
import ToastComponent from "./ToastComponent.vue";
|
||||||
import { parseTimeParam } from "@/utils/Misc";
|
import { parseTimeParam } from "@/utils/Misc";
|
||||||
import { purifyHTML, rewriteDescription } from "@/utils/HtmlUtils";
|
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 {
|
const route = useRoute();
|
||||||
name: "App",
|
const router = useRouter();
|
||||||
components: {
|
|
||||||
VideoPlayer,
|
const smallViewQuery = window.matchMedia("(max-width: 640px)");
|
||||||
ContentItem,
|
|
||||||
ErrorHandler,
|
const videoPlayer = ref(null);
|
||||||
CommentItem,
|
const commentsEl = ref(null);
|
||||||
ChaptersBar,
|
|
||||||
PlaylistAddModal,
|
const video = ref(null);
|
||||||
ShareModal,
|
const playlistId = ref(null);
|
||||||
PlaylistVideos,
|
const playlist = ref(null);
|
||||||
WatchOnButton,
|
const index = ref(null);
|
||||||
LoadingIndicatorPage,
|
const sponsors = ref(null);
|
||||||
ToastComponent,
|
const selectedAutoLoop = ref(false);
|
||||||
},
|
const selectedAutoPlay = ref(null);
|
||||||
data() {
|
const showComments = ref(true);
|
||||||
const smallViewQuery = window.matchMedia("(max-width: 640px)");
|
const showDesc = ref(false);
|
||||||
return {
|
const showRecs = ref(true);
|
||||||
video: null,
|
const showChapters = ref(true);
|
||||||
playlistId: null,
|
const comments = ref(null);
|
||||||
playlist: null,
|
const subscribed = ref(false);
|
||||||
index: null,
|
const channelId = ref(null);
|
||||||
sponsors: null,
|
const active = ref(true);
|
||||||
selectedAutoLoop: false,
|
const smallView = ref(smallViewQuery.matches);
|
||||||
selectedAutoPlay: null,
|
const showModal = ref(false);
|
||||||
showComments: true,
|
const showShareModal = ref(false);
|
||||||
showDesc: false,
|
const isMobile = ref(true);
|
||||||
showRecs: true,
|
const currentTime = ref(0);
|
||||||
showChapters: true,
|
const shouldShowToast = ref(false);
|
||||||
comments: null,
|
let timeoutCounter = null;
|
||||||
subscribed: false,
|
const counter = ref(0);
|
||||||
channelId: null,
|
const theaterMode = ref(false);
|
||||||
active: true,
|
let loading = false;
|
||||||
smallViewQuery: smallViewQuery,
|
|
||||||
smallView: smallViewQuery.matches,
|
const isListening = computed(() => {
|
||||||
showModal: false,
|
return getPreferenceBoolean("listen", false);
|
||||||
showShareModal: false,
|
});
|
||||||
isMobile: true,
|
|
||||||
currentTime: 0,
|
const toggleListenUrl = computed(() => {
|
||||||
shouldShowToast: false,
|
const url = new URL(window.location.href);
|
||||||
timeoutCounter: null,
|
url.searchParams.set("listen", isListening.value ? "0" : "1");
|
||||||
counter: 0,
|
url.searchParams.set("t", Math.floor(currentTime.value));
|
||||||
theaterMode: false,
|
return url.pathname + url.search;
|
||||||
};
|
});
|
||||||
},
|
|
||||||
computed: {
|
const isEmbed = computed(() => {
|
||||||
isListening(_this) {
|
return String(route.path).indexOf("/embed/") == 0;
|
||||||
return _this.getPreferenceBoolean("listen", false);
|
});
|
||||||
},
|
|
||||||
toggleListenUrl(_this) {
|
const uploadDate = computed(() => {
|
||||||
const url = new URL(window.location.href);
|
return new Date(video.value.uploadDate).toLocaleString(undefined, {
|
||||||
url.searchParams.set("listen", _this.isListening ? "0" : "1");
|
month: "short",
|
||||||
url.searchParams.set("t", Math.floor(this.currentTime));
|
day: "numeric",
|
||||||
return url.pathname + url.search;
|
year: "numeric",
|
||||||
},
|
});
|
||||||
isEmbed(_this) {
|
});
|
||||||
return String(_this.$route.path).indexOf("/embed/") == 0;
|
|
||||||
},
|
const defaultCounter = computed(() => {
|
||||||
uploadDate(_this) {
|
return getPreferenceNumber("autoPlayNextCountdown", 5);
|
||||||
return new Date(_this.video.uploadDate).toLocaleString(undefined, {
|
});
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
const purifiedDescription = computed(() => {
|
||||||
year: "numeric",
|
return purifyHTML(video.value.description);
|
||||||
});
|
});
|
||||||
},
|
|
||||||
defaultCounter(_this) {
|
const youtubeVideoHref = computed(() => {
|
||||||
return _this.getPreferenceNumber("autoPlayNextCountdown", 5);
|
let link = `https://youtu.be/${getVideoId()}?t=${Math.round(currentTime.value)}`;
|
||||||
},
|
if (playlistId.value) link += `&list=${playlistId.value}`;
|
||||||
purifiedDescription() {
|
return link;
|
||||||
return purifyHTML(this.video.description);
|
});
|
||||||
},
|
|
||||||
youtubeVideoHref() {
|
function fetchVideo() {
|
||||||
let link = `https://youtu.be/${this.getVideoId()}?t=${Math.round(this.currentTime)}`;
|
return fetchJson(apiUrl() + "/streams/" + getVideoId());
|
||||||
if (this.playlistId) link += `&list=${this.playlistId}`;
|
}
|
||||||
return link;
|
|
||||||
},
|
async function fetchSponsors() {
|
||||||
},
|
var selectedSkip = getPreferenceString("selectedSkip", "sponsor,interaction,selfpromo,music_offtopic").split(",");
|
||||||
mounted() {
|
const skipOptions = getPreferenceJSON("skipOptions");
|
||||||
// check screen size
|
if (skipOptions !== undefined) {
|
||||||
this.isMobile = window.innerWidth < 1024;
|
selectedSkip = Object.keys(skipOptions).filter(k => skipOptions[k] !== undefined && skipOptions[k] !== "no");
|
||||||
// add an event listener to watch for screen size changes
|
}
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
this.isMobile = window.innerWidth < 1024;
|
const sponsorsData = await fetchJson(apiUrl() + "/sponsors/" + getVideoId(), {
|
||||||
});
|
category: JSON.stringify(selectedSkip),
|
||||||
this.getVideoData().then(() => {
|
});
|
||||||
(async () => {
|
|
||||||
const videoId = this.getVideoId();
|
sponsorsData?.segments?.forEach(segment => {
|
||||||
const instance = this;
|
const option = skipOptions?.[segment.category];
|
||||||
if (window.db && this.getPreferenceBoolean("watchHistory", false) && !this.video.error) {
|
segment.autoskip = option === undefined || option === "auto";
|
||||||
var tx = window.db.transaction("watch_history", "readwrite");
|
});
|
||||||
var store = tx.objectStore("watch_history");
|
|
||||||
var request = store.get(videoId);
|
const minSegmentLength = Math.max(getPreferenceNumber("minSegmentLength", 0), 0);
|
||||||
request.onsuccess = function (event) {
|
sponsorsData.segments = sponsorsData.segments?.filter(segment => {
|
||||||
var video = event.target.result;
|
const length = segment.segment[1] - segment.segment[0];
|
||||||
if (video) {
|
return length >= minSegmentLength;
|
||||||
video.watchedAt = Date.now();
|
});
|
||||||
} else {
|
|
||||||
video = {
|
return sponsorsData;
|
||||||
videoId: videoId,
|
}
|
||||||
title: instance.video.title,
|
|
||||||
duration: instance.video.duration,
|
function toggleCommentsVisibility() {
|
||||||
thumbnail: instance.video.thumbnailUrl,
|
showComments.value = !showComments.value;
|
||||||
uploaderUrl: instance.video.uploaderUrl,
|
if (showComments.value && comments.value === null) {
|
||||||
uploaderName: instance.video.uploader,
|
fetchCommentsData();
|
||||||
watchedAt: Date.now(),
|
}
|
||||||
};
|
}
|
||||||
}
|
|
||||||
store.put(video);
|
function fetchCommentsData() {
|
||||||
};
|
return fetchJson(apiUrl() + "/comments/" + getVideoId());
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
if (this.active) this.$refs.videoPlayer.loadVideo();
|
function onChange() {
|
||||||
});
|
setPreference("autoplay", selectedAutoPlay.value, true);
|
||||||
this.playlistId = this.$route.query.list;
|
}
|
||||||
this.index = Number(this.$route.query.index);
|
|
||||||
this.getPlaylistData();
|
async function getVideoData() {
|
||||||
this.getSponsors();
|
await fetchVideo()
|
||||||
if (!this.isEmbed && this.showComments) this.getComments();
|
.then(data => {
|
||||||
if (this.isEmbed) document.querySelector("html").style.overflow = "hidden";
|
video.value = data;
|
||||||
window.addEventListener("click", this.handleClick);
|
video.value.id = getVideoId();
|
||||||
window.addEventListener("resize", () => {
|
})
|
||||||
this.smallView = this.smallViewQuery.matches;
|
.then(() => {
|
||||||
});
|
if (!video.value.error) {
|
||||||
},
|
document.title = video.value.title + " - Piped";
|
||||||
activated() {
|
channelId.value = video.value.uploaderUrl.split("/")[2];
|
||||||
this.active = true;
|
if (!isEmbed.value) fetchSubscribedStatus();
|
||||||
this.theaterMode = this.getPreferenceBoolean(
|
|
||||||
"theaterMode",
|
const parser = new DOMParser();
|
||||||
window.innerWidth < (window.innerHeight * 4) / 3 + 467, //if the video player is limited by width rather than height, then clear up some horizontal room
|
const xmlDoc = parser.parseFromString(video.value.description, "text/html");
|
||||||
);
|
xmlDoc.querySelectorAll("a").forEach(elem => {
|
||||||
this.selectedAutoPlay = this.getPreferenceNumber("autoplay", 1);
|
if (!elem.innerText.match(/(?:[\d]{1,2}:)?(?:[\d]{1,2}):(?:[\d]{1,2})/))
|
||||||
this.showComments = !this.getPreferenceBoolean("minimizeComments", false);
|
elem.outerHTML = elem.getAttribute("href");
|
||||||
this.showDesc = !this.getPreferenceBoolean("minimizeDescription", true);
|
});
|
||||||
this.showRecs = !this.getPreferenceBoolean("minimizeRecommendations", false);
|
xmlDoc.querySelectorAll("br").forEach(elem => (elem.outerHTML = "\n"));
|
||||||
this.showChapters = !this.getPreferenceBoolean("minimizeChapters", false);
|
video.value.description = rewriteDescription(xmlDoc.querySelector("body").innerHTML);
|
||||||
if (this.video?.duration) {
|
updateWatched(video.value.relatedStreams);
|
||||||
document.title = this.video.title + " - Piped";
|
|
||||||
this.$refs.videoPlayer.loadVideo();
|
fetchDeArrowContent(video.value.relatedStreams);
|
||||||
}
|
|
||||||
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");
|
|
||||||
if (skipOptions !== undefined) {
|
|
||||||
selectedSkip = Object.keys(skipOptions).filter(
|
|
||||||
k => skipOptions[k] !== undefined && skipOptions[k] !== "no",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sponsors = await this.fetchJson(this.apiUrl() + "/sponsors/" + this.getVideoId(), {
|
async function getPlaylistData() {
|
||||||
category: JSON.stringify(selectedSkip),
|
if (playlistId.value) {
|
||||||
});
|
playlist.value = await getPlaylist(playlistId.value);
|
||||||
|
await fetchPlaylistPages().then(() => {
|
||||||
sponsors?.segments?.forEach(segment => {
|
if (!(index.value >= 0)) {
|
||||||
const option = skipOptions?.[segment.category];
|
for (let i = 0; i < playlist.value.relatedStreams.length; i++)
|
||||||
segment.autoskip = option === undefined || option === "auto";
|
if (playlist.value.relatedStreams[i].url.substr(-11) == getVideoId()) {
|
||||||
});
|
index.value = i + 1;
|
||||||
|
router.replace({
|
||||||
const minSegmentLength = Math.max(this.getPreferenceNumber("minSegmentLength", 0), 0);
|
query: { ...route.query, index: index.value },
|
||||||
sponsors.segments = sponsors.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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fetchComments() {
|
|
||||||
return this.fetchJson(this.apiUrl() + "/comments/" + this.getVideoId());
|
|
||||||
},
|
|
||||||
onChange() {
|
|
||||||
this.setPreference("autoplay", this.selectedAutoPlay, true);
|
|
||||||
},
|
|
||||||
async getVideoData() {
|
|
||||||
await this.fetchVideo()
|
|
||||||
.then(data => {
|
|
||||||
this.video = data;
|
|
||||||
this.video.id = this.getVideoId();
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
if (!this.video.error) {
|
|
||||||
document.title = this.video.title + " - Piped";
|
|
||||||
this.channelId = this.video.uploaderUrl.split("/")[2];
|
|
||||||
if (!this.isEmbed) this.fetchSubscribedStatus();
|
|
||||||
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const xmlDoc = parser.parseFromString(this.video.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"));
|
break;
|
||||||
this.video.description = rewriteDescription(xmlDoc.querySelector("body").innerHTML);
|
|
||||||
this.updateWatched(this.video.relatedStreams);
|
|
||||||
|
|
||||||
this.fetchDeArrowContent(this.video.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 },
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await this.fetchPlaylistPages();
|
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
async fetchPlaylistPages() {
|
await 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);
|
async function fetchPlaylistPages() {
|
||||||
},
|
if (playlist.value.nextpage) {
|
||||||
subscribeHandler() {
|
await fetchJson(apiUrl() + "/nextpage/playlists/" + playlistId.value, {
|
||||||
this.toggleSubscriptionState(this.channelId, this.subscribed).then(success => {
|
nextpage: playlist.value.nextpage,
|
||||||
if (success) this.subscribed = !this.subscribed;
|
}).then(json => {
|
||||||
});
|
playlist.value.relatedStreams.push(...json.relatedStreams);
|
||||||
},
|
playlist.value.nextpage = json.nextpage;
|
||||||
handleClick(event) {
|
fetchDeArrowContent(json.relatedStreams);
|
||||||
if (!event || !event.target) return;
|
});
|
||||||
if (!event.target.matches("a[href]")) return;
|
await fetchPlaylistPages();
|
||||||
const target = event.target;
|
}
|
||||||
if (!target.getAttribute("href")) return;
|
}
|
||||||
if (this.handleTimestampLinks(target)) {
|
|
||||||
event.preventDefault();
|
async function getSponsors() {
|
||||||
}
|
if (getPreferenceBoolean("sponsorblock", true)) fetchSponsors().then(data => (sponsors.value = data));
|
||||||
},
|
}
|
||||||
handleTimestampLinks(target) {
|
|
||||||
try {
|
async function getComments() {
|
||||||
const url = new URL(target.getAttribute("href"), document.baseURI);
|
comments.value = await fetchCommentsData();
|
||||||
if (
|
}
|
||||||
url.searchParams.size > 2 ||
|
|
||||||
url.searchParams.get("v") !== this.getVideoId() ||
|
async function fetchSubscribedStatus() {
|
||||||
!url.searchParams.has("t")
|
if (!channelId.value) return;
|
||||||
) {
|
subscribed.value = await fetchSubscriptionStatus(channelId.value);
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
const time = parseTimeParam(url.searchParams.get("t"));
|
function subscribeHandler() {
|
||||||
if (time) {
|
toggleSubscriptionState(channelId.value, subscribed.value).then(success => {
|
||||||
this.navigate(time);
|
if (success) subscribed.value = !subscribed.value;
|
||||||
}
|
});
|
||||||
return true;
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
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 (handleTimestampLinks(target)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimestampLinks(target) {
|
||||||
|
try {
|
||||||
|
const url = new URL(target.getAttribute("href"), document.baseURI);
|
||||||
|
if (url.searchParams.size > 2 || url.searchParams.get("v") !== getVideoId() || !url.searchParams.has("t")) {
|
||||||
return false;
|
return false;
|
||||||
},
|
}
|
||||||
handleScroll() {
|
const time = parseTimeParam(url.searchParams.get("t"));
|
||||||
if (this.loading || !this.comments || !this.comments.nextpage) return;
|
if (time) {
|
||||||
if (window.innerHeight + window.scrollY >= this.$refs.comments?.offsetHeight - window.innerHeight) {
|
navigate(time);
|
||||||
this.loading = true;
|
}
|
||||||
this.fetchJson(this.apiUrl() + "/nextpage/comments/" + this.getVideoId(), {
|
return true;
|
||||||
nextpage: this.comments.nextpage,
|
} catch (e) {
|
||||||
}).then(json => {
|
console.error(e);
|
||||||
this.comments.nextpage = json.nextpage;
|
}
|
||||||
this.loading = false;
|
return false;
|
||||||
this.comments.comments = this.comments.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];
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.$route.query.v || this.$route.params.v;
|
function handleScroll() {
|
||||||
},
|
if (loading || !comments.value || !comments.value.nextpage) return;
|
||||||
navigate(time) {
|
if (window.innerHeight + window.scrollY >= commentsEl.value?.offsetHeight - window.innerHeight) {
|
||||||
this.$refs.videoPlayer.seek(time);
|
loading = true;
|
||||||
},
|
fetchJson(apiUrl() + "/nextpage/comments/" + getVideoId(), {
|
||||||
onTimeUpdate(time) {
|
nextpage: comments.value.nextpage,
|
||||||
this.currentTime = time;
|
}).then(json => {
|
||||||
},
|
comments.value.nextpage = json.nextpage;
|
||||||
onVideoEnded() {
|
loading = false;
|
||||||
if (
|
comments.value.comments = comments.value.comments.concat(json.comments);
|
||||||
!this.selectedAutoLoop &&
|
});
|
||||||
((this.selectedAutoPlay >= 1 && this.playlist?.relatedStreams?.length > this.index) ||
|
}
|
||||||
(this.selectedAutoPlay >= 2 && this.video.relatedStreams.length > 0))
|
}
|
||||||
) {
|
|
||||||
this.showToast();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showToast() {
|
|
||||||
this.counter = this.defaultCounter;
|
|
||||||
if (this.counter < 1) {
|
|
||||||
this.navigateNext();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.timeoutCounter) clearInterval(this.timeoutCounter);
|
|
||||||
this.timeoutCounter = setInterval(() => {
|
|
||||||
this.counter--;
|
|
||||||
if (this.counter === 0) {
|
|
||||||
this.dismiss();
|
|
||||||
this.navigateNext();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
this.shouldShowToast = true;
|
|
||||||
},
|
|
||||||
dismiss() {
|
|
||||||
clearInterval(this.timeoutCounter);
|
|
||||||
this.shouldShowToast = false;
|
|
||||||
},
|
|
||||||
navigateNext() {
|
|
||||||
const params = this.$route.query;
|
|
||||||
const video_ids = this.$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}`;
|
|
||||||
} else {
|
|
||||||
url = this.video.relatedStreams[0].url;
|
|
||||||
}
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
for (var param in params)
|
|
||||||
switch (param) {
|
|
||||||
case "v":
|
|
||||||
case "t":
|
|
||||||
break;
|
|
||||||
case "index":
|
|
||||||
if (this.playlist && this.index < this.playlist.relatedStreams.length)
|
|
||||||
searchParams.set("index", this.index + 1);
|
|
||||||
break;
|
|
||||||
case "list":
|
|
||||||
if (this.index < this.playlist.relatedStreams.length) searchParams.set("list", params.list);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
searchParams.set(param, params[param]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// save the fullscreen state
|
|
||||||
searchParams.set("fullscreen", this.$refs.videoPlayer.$ui.getControls().isFullScreenEnabled());
|
|
||||||
const paramStr = searchParams.toString();
|
|
||||||
if (paramStr.length > 0) url += "&" + paramStr;
|
|
||||||
this.$router.push(url);
|
|
||||||
},
|
|
||||||
downloadCurrentFrame() {
|
|
||||||
const video = document.querySelector("video");
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
|
|
||||||
const context = canvas.getContext("2d");
|
function getVideoId() {
|
||||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
let link = document.createElement("a");
|
return route.query.v || route.params.v;
|
||||||
const currentTime = Math.round(video.currentTime * 1000) / 1000;
|
}
|
||||||
link.download = `${this.video.title}_${currentTime}s.png`;
|
|
||||||
link.href = canvas.toDataURL();
|
function navigate(time) {
|
||||||
link.click();
|
videoPlayer.value.seek(time);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
};
|
function onTimeUpdate(time) {
|
||||||
|
currentTime.value = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVideoEnded() {
|
||||||
|
if (
|
||||||
|
!selectedAutoLoop.value &&
|
||||||
|
((selectedAutoPlay.value >= 1 && playlist.value?.relatedStreams?.length > index.value) ||
|
||||||
|
(selectedAutoPlay.value >= 2 && video.value.relatedStreams.length > 0))
|
||||||
|
) {
|
||||||
|
showToast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast() {
|
||||||
|
counter.value = defaultCounter.value;
|
||||||
|
if (counter.value < 1) {
|
||||||
|
navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timeoutCounter) clearInterval(timeoutCounter);
|
||||||
|
timeoutCounter = setInterval(() => {
|
||||||
|
counter.value--;
|
||||||
|
if (counter.value === 0) {
|
||||||
|
dismiss();
|
||||||
|
navigateNext();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
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 (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 = video.value.relatedStreams[0].url;
|
||||||
|
}
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
for (var param in params)
|
||||||
|
switch (param) {
|
||||||
|
case "v":
|
||||||
|
case "t":
|
||||||
|
break;
|
||||||
|
case "index":
|
||||||
|
if (playlist.value && index.value < playlist.value.relatedStreams.length)
|
||||||
|
searchParams.set("index", index.value + 1);
|
||||||
|
break;
|
||||||
|
case "list":
|
||||||
|
if (index.value < playlist.value.relatedStreams.length) searchParams.set("list", params.list);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
searchParams.set(param, params[param]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
searchParams.set("fullscreen", videoPlayer.value.isFullScreenEnabled());
|
||||||
|
const paramStr = searchParams.toString();
|
||||||
|
if (paramStr.length > 0) url += "&" + paramStr;
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCurrentFrame() {
|
||||||
|
const videoEl = document.querySelector("video");
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = videoEl.videoWidth;
|
||||||
|
canvas.height = videoEl.videoHeight;
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
let link = document.createElement("a");
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
36
src/composables/useApi.js
Normal file
36
src/composables/useApi.js
Normal 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;
|
||||||
|
}
|
||||||
36
src/composables/useChannelGroups.js
Normal file
36
src/composables/useChannelGroups.js
Normal 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);
|
||||||
|
}
|
||||||
14
src/composables/useCustomInstances.js
Normal file
14
src/composables/useCustomInstances.js
Normal 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));
|
||||||
|
}
|
||||||
68
src/composables/useFormatting.js
Normal file
68
src/composables/useFormatting.js
Normal 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";
|
||||||
|
}
|
||||||
39
src/composables/useMisc.js
Normal file
39
src/composables/useMisc.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/composables/usePlaylists.js
Normal file
252
src/composables/usePlaylists.js
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
65
src/composables/usePreferences.js
Normal file
65
src/composables/usePreferences.js
Normal 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;
|
||||||
|
}
|
||||||
137
src/composables/useSubscriptions.js
Normal file
137
src/composables/useSubscriptions.js
Normal 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];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
613
src/main.js
613
src/main.js
@@ -2,625 +2,13 @@ import { createApp } from "vue";
|
|||||||
import router from "@/router/router.js";
|
import router from "@/router/router.js";
|
||||||
import App from "./App.vue";
|
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 { createI18n } from "vue-i18n";
|
||||||
import enLocale from "@/locales/en.json";
|
import enLocale from "@/locales/en.json";
|
||||||
import "@unocss/reset/tailwind.css";
|
import "@unocss/reset/tailwind.css";
|
||||||
import "uno.css";
|
import "uno.css";
|
||||||
|
|
||||||
const timeAgo = new TimeAgo("en-US");
|
|
||||||
|
|
||||||
import("./registerServiceWorker");
|
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({
|
const i18n = createI18n({
|
||||||
globalInjection: true,
|
globalInjection: true,
|
||||||
legacy: false,
|
legacy: false,
|
||||||
@@ -636,5 +24,4 @@ window.i18n = i18n;
|
|||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.mixin(mixin);
|
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|||||||
Reference in New Issue
Block a user