"
+ */
+ constructor(key) {
+ const [sortType, sortOrder] = key.split(":");
+ const sortTypes = PlaylistSorter.getSortTypes();
+ const SortStrategy = sortTypes[sortType].strategy;
+ this.strategy = new SortStrategy();
+ this.sortOrder = sortOrder;
+ }
+
+ /**
+ * Sorts a list of elements with specific strategy & order
+ * @param {Element[]} videos
+ * @returns {Element[]}
+ */
+ sort(videos) {
+ return this.strategy.sort(videos, this.sortOrder);
+ }
+
+ /**
+ * Generates an object containing information about each supported sort type
+ * @returns {Object}
+ */
+ static getSortTypes() {
+ return {
+ index: {
+ enabled: videoHasElement(elementSelectors.videoIndex),
+ label: {
+ asc: chrome.i18n.getMessage("sortType_index_label_asc"),
+ desc: chrome.i18n.getMessage("sortType_index_label_desc")
+ },
+ strategy: SortByIndexStrategy
+ },
+ duration: {
+ enabled: videoHasElement(elementSelectors.timestamp),
+ label: {
+ asc: chrome.i18n.getMessage("sortType_duration_label_asc"),
+ desc: chrome.i18n.getMessage("sortType_duration_label_desc")
+ },
+ strategy: SortByDurationStrategy
+ },
+ channelName: {
+ enabled: videoHasElement(elementSelectors.channelName),
+ label: {
+ asc: chrome.i18n.getMessage("sortType_channelName_label_asc"),
+ desc: chrome.i18n.getMessage("sortType_channelName_label_desc")
+ },
+ strategy: SortByChannelNameStrategy
+ },
+ views: {
+ enabled:
+ videoHasElement(elementSelectors.videoInfo) &&
+ SortByViewsStrategy.supportedLocales.includes(
+ document.documentElement.lang
+ ),
+ label: {
+ asc: chrome.i18n.getMessage("sortType_views_label_asc"),
+ desc: chrome.i18n.getMessage("sortType_views_label_desc")
+ },
+ strategy: SortByViewsStrategy
+ },
+ uploadDate: {
+ enabled:
+ videoHasElement(elementSelectors.videoInfo) &&
+ !pageHasNativeSortFeature() &&
+ SortByUploadDateStrategy.supportedLocales.includes(
+ document.documentElement.lang
+ ),
+ label: {
+ asc: chrome.i18n.getMessage("sortType_uploadDate_label_asc"),
+ desc: chrome.i18n.getMessage("sortType_uploadDate_label_desc")
+ },
+ strategy: SortByUploadDateStrategy
+ }
+ };
+ }
+
+ /**
+ * Generates a list of elements representing each type of sort
+ */
+ static getSortOptions() {
+ const sortTypes = PlaylistSorter.getSortTypes();
+ return Object.keys(sortTypes).flatMap((sortType) => {
+ const { enabled, label } = sortTypes[sortType];
+ if (!enabled) return [];
+ return Object.keys(label).map((sortOrder) => {
+ const optionElement = document.createElement("div");
+ optionElement.classList.add("ytpdc-sort-control-dropdown-option");
+ optionElement.setAttribute("value", `${sortType}:${sortOrder}`);
+ optionElement.textContent = label[sortOrder];
+ return optionElement;
+ });
+ });
+ }
+}
+
+/**
+ * Checks whether an element identified by identifier can be found within the
+ * first video element rendered in the playlist
+ * @param {string} identifier
+ * @returns {boolean}
+ */
+const videoHasElement = (identifier) => {
+ const videoElement = document.querySelector(elementSelectors.video);
+ return videoElement && videoElement.querySelector(identifier);
+};
+
+const pageHasNativeSortFeature = () => {
+ const nativeSortElement = document.querySelector(
+ "#filter-menu yt-sort-filter-sub-menu-renderer"
+ );
+ return nativeSortElement !== null;
+};
diff --git a/src/modules/sorting/sort-by-channel-name/index.js b/src/modules/sorting/sort-by-channel-name/index.js
new file mode 100644
index 0000000..ca7a3c1
--- /dev/null
+++ b/src/modules/sorting/sort-by-channel-name/index.js
@@ -0,0 +1,25 @@
+import { elementSelectors } from "src/shared/data/element-selectors";
+
+export class SortByChannelNameStrategy {
+ /**
+ * Sorts a list of videos by their channel name
+ * @param {Array} videos
+ * @param {"asc" | "desc"} sortOrder
+ * @returns {Array}
+ */
+ sort(videos, sortOrder) {
+ return [...videos].sort((videoA, videoB) => {
+ const selector = elementSelectors.channelName;
+ const channelNameA = videoA.querySelector(selector).innerText;
+ const channelNameB = videoB.querySelector(selector).innerText;
+
+ if (sortOrder === "asc") {
+ return channelNameA.localeCompare(channelNameB);
+ }
+
+ if (sortOrder === "desc") {
+ return channelNameB.localeCompare(channelNameA);
+ }
+ });
+ }
+}
diff --git a/src/modules/sorting/sort-by-duration/index.js b/src/modules/sorting/sort-by-duration/index.js
new file mode 100644
index 0000000..49403ec
--- /dev/null
+++ b/src/modules/sorting/sort-by-duration/index.js
@@ -0,0 +1,24 @@
+import { getTimestampFromVideo } from "src/shared/modules/timestamp";
+
+export class SortByDurationStrategy {
+ /**
+ * Sorts a list of videos by their duration
+ * @param {Array} videos
+ * @param {"asc" | "desc"} sortOrder
+ * @returns {Array}
+ */
+ sort(videos, sortOrder) {
+ return [...videos].sort((videoA, videoB) => {
+ const timestampA = getTimestampFromVideo(videoA);
+ const timestampB = getTimestampFromVideo(videoB);
+
+ if (sortOrder === "asc") {
+ return timestampA - timestampB;
+ }
+
+ if (sortOrder === "desc") {
+ return timestampB - timestampA;
+ }
+ });
+ }
+}
diff --git a/src/modules/sorting/sort-by-index/index.js b/src/modules/sorting/sort-by-index/index.js
new file mode 100644
index 0000000..98b7ee5
--- /dev/null
+++ b/src/modules/sorting/sort-by-index/index.js
@@ -0,0 +1,28 @@
+import { elementSelectors } from "src/shared/data/element-selectors";
+
+export class SortByIndexStrategy {
+ /**
+ * Sorts a list of videos by their index
+ * @param {Array} videos
+ * @param {"asc" | "desc"} sortOrder
+ * @returns {Array}
+ */
+ sort(videos, sortOrder) {
+ return [...videos].sort((videoA, videoB) => {
+ const indexA = videoA.querySelector(
+ elementSelectors.videoIndex
+ ).innerText;
+ const indexB = videoB.querySelector(
+ elementSelectors.videoIndex
+ ).innerText;
+
+ if (sortOrder === "asc") {
+ return Number(indexA) - Number(indexB);
+ }
+
+ if (sortOrder === "desc") {
+ return Number(indexB) - Number(indexA);
+ }
+ });
+ }
+}
diff --git a/src/modules/sorting/sort-by-upload-date/index.js b/src/modules/sorting/sort-by-upload-date/index.js
new file mode 100644
index 0000000..e5a639b
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/index.js
@@ -0,0 +1,60 @@
+import { elementSelectors } from "src/shared/data/element-selectors";
+import { getSupportedLocales, getUploadDateParser } from "./parsers";
+
+export class SortByUploadDateStrategy {
+ static supportedLocales = getSupportedLocales();
+
+ /**
+ * Sorts a list of videos by their upload date
+ * @param {Array} videos
+ * @param {"asc" | "desc"} sortOrder
+ * @returns {Array}
+ */
+ sort(videos, sortOrder) {
+ return [...videos].sort((videoA, videoB) => {
+ const videoInfoA = videoA.querySelector(elementSelectors.videoInfo);
+ const videoInfoB = videoB.querySelector(elementSelectors.videoInfo);
+
+ const secondsA = this.parseUploadDate(videoInfoA);
+ const secondsB = this.parseUploadDate(videoInfoB);
+
+ if (sortOrder === "asc") {
+ return secondsA - secondsB;
+ }
+
+ if (sortOrder === "desc") {
+ return secondsB - secondsA;
+ }
+ });
+ }
+
+ /**
+ * Extracts the upload date from the video info element & parses it as seconds
+ * @param {Element} videoInfo
+ * @returns {number}
+ */
+ parseUploadDate(videoInfo) {
+ const context = new UploadDateParserContext(document.documentElement.lang);
+ return context.parse(videoInfo);
+ }
+}
+
+export class UploadDateParserContext {
+ /** @param {string} locale */
+ constructor(locale) {
+ const Parser = getUploadDateParser(locale);
+ this.parser = new Parser();
+ }
+
+ /**
+ * Parses the upload date found within the video info element and returns its
+ * numerical value as seconds
+ * @param {Element} videoInfo
+ */
+ parse(videoInfo) {
+ if (!this.parser) {
+ throw new Error("No upload date parser defined");
+ }
+ return this.parser.parse(videoInfo);
+ }
+}
diff --git a/src/modules/sorting/sort-by-upload-date/parsers/en.js b/src/modules/sorting/sort-by-upload-date/parsers/en.js
new file mode 100644
index 0000000..2b13528
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/parsers/en.js
@@ -0,0 +1,22 @@
+export class EnUploadDateParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const secondsByUnit = {
+ minute: 60,
+ hour: 60 * 60,
+ day: 1 * 86400,
+ week: 7 * 86400,
+ month: 30 * 86400,
+ year: 365 * 86400
+ };
+
+ const uploadDateElement = videoInfo.children[2];
+ const uploadDateRegex = /(?:streamed )?(\d+) (\w+) ago/;
+ const [value, unit] = uploadDateElement.textContent
+ .toLowerCase()
+ .match(uploadDateRegex)
+ .slice(1);
+ const normalizedUnit = unit.endsWith("s") ? unit.slice(0, -1) : unit;
+ return parseFloat(value) * secondsByUnit[normalizedUnit];
+ }
+}
diff --git a/src/modules/sorting/sort-by-upload-date/parsers/es.js b/src/modules/sorting/sort-by-upload-date/parsers/es.js
new file mode 100644
index 0000000..5f5fb58
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/parsers/es.js
@@ -0,0 +1,27 @@
+export class EsUploadDateParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const secondsByUnit = {
+ minuto: 60,
+ hora: 60 * 60,
+ día: 1 * 86400,
+ semana: 7 * 86400,
+ mes: 30 * 86400,
+ meses: 30 * 86400,
+ año: 365 * 86400
+ };
+
+ const uploadDateElement = videoInfo.children[2];
+ const uploadDateRegex =
+ /(?:emitido )?hace (\d+) (minutos?|horas?|días?|semanas?|mes|meses|años?)/u;
+ const [value, unit] = uploadDateElement.textContent
+ .toLowerCase()
+ .match(uploadDateRegex)
+ .slice(1);
+
+ const seconds =
+ secondsByUnit[unit] ?? secondsByUnit[unit.slice(0, -1)] ?? 1;
+
+ return parseFloat(value) * seconds;
+ }
+}
diff --git a/src/modules/sorting/sort-by-upload-date/parsers/index.js b/src/modules/sorting/sort-by-upload-date/parsers/index.js
new file mode 100644
index 0000000..910bb26
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/parsers/index.js
@@ -0,0 +1,27 @@
+import { EnUploadDateParser } from "./en";
+import { EsUploadDateParser } from "./es";
+import { PtUploadDateParser } from "./pt";
+import { ZhHansCnUploadDateParser } from "./zh-Hans-CN";
+import { ZhHantTwUploadDateParser } from "./zh-Hant-TW";
+
+const UPLOAD_DATE_PARSERS_BY_LOCALE = {
+ "en": EnUploadDateParser,
+ "en-GB": EnUploadDateParser,
+ "en-IN": EnUploadDateParser,
+ "en-US": EnUploadDateParser,
+ "es-ES": EsUploadDateParser,
+ "es-419": EsUploadDateParser,
+ "es-US": EsUploadDateParser,
+ "pt-PT": PtUploadDateParser,
+ "pt-BR": PtUploadDateParser,
+ "zh-Hans-CN": ZhHansCnUploadDateParser,
+ "zh-Hant-TW": ZhHantTwUploadDateParser
+};
+
+/** @param {string} locale */
+export const getUploadDateParser = (locale) => {
+ return UPLOAD_DATE_PARSERS_BY_LOCALE[locale] ?? EnUploadDateParser;
+};
+
+export const getSupportedLocales = () =>
+ Object.keys(UPLOAD_DATE_PARSERS_BY_LOCALE);
diff --git a/src/modules/sorting/sort-by-upload-date/parsers/pt.js b/src/modules/sorting/sort-by-upload-date/parsers/pt.js
new file mode 100644
index 0000000..e7ffacd
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/parsers/pt.js
@@ -0,0 +1,28 @@
+export class PtUploadDateParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const secondsByUnit = {
+ minuto: 60,
+ hora: 60 * 60,
+ dia: 1 * 86400,
+ semana: 7 * 86400,
+ mês: 30 * 86400,
+ meses: 30 * 86400,
+ ano: 365 * 86400
+ };
+
+ const uploadDateElement = videoInfo.children[2];
+ const uploadDateRegex =
+ /(?:transmitido )?há (\d+) (minutos?|horas?|dias?|semanas?|mês|meses|anos?)/u;
+ const [value, unit] = uploadDateElement.textContent
+ .toLowerCase()
+ .replaceAll(/\s/g, " ")
+ .match(uploadDateRegex)
+ .slice(1);
+
+ const seconds =
+ secondsByUnit[unit] ?? secondsByUnit[unit.slice(0, -1)] ?? 1;
+
+ return parseFloat(value) * seconds;
+ }
+}
diff --git a/src/modules/sorting/sort-by-upload-date/parsers/zh-Hans-CN.js b/src/modules/sorting/sort-by-upload-date/parsers/zh-Hans-CN.js
new file mode 100644
index 0000000..bee2f35
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/parsers/zh-Hans-CN.js
@@ -0,0 +1,21 @@
+export class ZhHansCnUploadDateParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const secondsByUnit = {
+ "分钟": 60, // minute
+ "小时": 60 * 60,
+ "天": 1 * 86400,
+ "周": 7 * 86400,
+ "个月": 30 * 86400,
+ "年": 365 * 86400 // year
+ };
+
+ const uploadDateElement = videoInfo.children[2];
+ const uploadDateRegex = /(\d+)([\u4e00-\u9fa5]+)前/;
+ const [value, unit] = uploadDateElement.textContent
+ .toLowerCase()
+ .match(uploadDateRegex)
+ .slice(1); // This removes the 3rd match 前
+ return parseFloat(value) * secondsByUnit[unit];
+ }
+}
diff --git a/src/modules/sorting/sort-by-upload-date/parsers/zh-Hant-TW.js b/src/modules/sorting/sort-by-upload-date/parsers/zh-Hant-TW.js
new file mode 100644
index 0000000..07c549b
--- /dev/null
+++ b/src/modules/sorting/sort-by-upload-date/parsers/zh-Hant-TW.js
@@ -0,0 +1,23 @@
+export class ZhHantTwUploadDateParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const secondsByUnit = {
+ "分鐘": 60, // minute
+ "小時": 60 * 60,
+ "天": 1 * 86400,
+ "週": 7 * 86400,
+ "個月": 30 * 86400,
+ "年": 365 * 86400 // year
+ };
+
+ const uploadDateElement = videoInfo.children[2];
+ const uploadDateRegex = /(\d+)(.*)前/;
+ const [value, unit] = uploadDateElement.textContent
+ .toLowerCase()
+ .replaceAll(/\s/g, " ")
+ .match(uploadDateRegex)
+ .slice(1)
+ .map((x) => x.trim());
+ return parseFloat(value) * secondsByUnit[unit];
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/index.js b/src/modules/sorting/sort-by-views/index.js
new file mode 100644
index 0000000..ca7fa61
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/index.js
@@ -0,0 +1,63 @@
+import { elementSelectors } from "src/shared/data/element-selectors";
+import { getSupportedLocales, getViewsParser } from "./parsers";
+
+export class SortByViewsStrategy {
+ static supportedLocales = getSupportedLocales();
+
+ /**
+ * Sorts a list of videos by their view count
+ * @param {Array} videos
+ * @param {"asc" | "desc"} sortOrder
+ * @returns {Array}
+ */
+ sort(videos, sortOrder) {
+ return [...videos].sort((videoA, videoB) => {
+ const videoInfoA = videoA.querySelector(elementSelectors.videoInfo);
+ const videoInfoB = videoB.querySelector(elementSelectors.videoInfo);
+
+ if (
+ videoInfoA.children.length === 0 ||
+ videoInfoB.children.length === 0
+ ) {
+ return 0;
+ }
+
+ const viewCountA = this.extractViews(videoInfoA);
+ const viewCountB = this.extractViews(videoInfoB);
+
+ if (sortOrder === "asc") {
+ return viewCountA - viewCountB;
+ }
+
+ if (sortOrder === "desc") {
+ return viewCountB - viewCountA;
+ }
+ });
+ }
+
+ /**
+ * Extracts the view count as a number from a video info element
+ * @param {Element} videoInfo
+ * @returns {number}
+ */
+ extractViews(videoInfo) {
+ const context = new ViewsParserContext(document.documentElement.lang);
+ return context.parse(videoInfo);
+ }
+}
+
+export class ViewsParserContext {
+ /** @param {string} locale */
+ constructor(locale) {
+ const Parser = getViewsParser(locale);
+ this.parser = new Parser();
+ }
+
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ if (!this.parser) {
+ throw new Error("No views parser defined");
+ }
+ return this.parser.parse(videoInfo);
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/parsers/en-IN.js b/src/modules/sorting/sort-by-views/parsers/en-IN.js
new file mode 100644
index 0000000..7c9df4d
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/en-IN.js
@@ -0,0 +1,21 @@
+export class EnInViewsParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const viewsElement = videoInfo.firstElementChild;
+ const parts = viewsElement.textContent
+ .toLowerCase()
+ .replaceAll(/\s/g, " ")
+ .split(" ");
+ const baseViews = parseFloat(parts[0]);
+
+ if (parts.length === 3 && parts[1] === "lakh") {
+ return Math.round(baseViews * 100_000);
+ }
+
+ if (parts.length === 2 && parts[0].endsWith("k")) {
+ return Math.round(baseViews * 1000);
+ }
+
+ return Math.round(baseViews);
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/parsers/en.js b/src/modules/sorting/sort-by-views/parsers/en.js
new file mode 100644
index 0000000..8e1a41d
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/en.js
@@ -0,0 +1,24 @@
+export class EnViewsParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const viewsElement = videoInfo.firstElementChild;
+ const viewsRegex = /(\d+(\.\d+)?[km]?)/g;
+ const [viewsString] = viewsElement.textContent
+ .toLowerCase()
+ .match(viewsRegex);
+ const suffix = viewsString.slice(-1);
+ const baseViews = parseFloat(viewsString);
+
+ if (isNaN(baseViews)) {
+ return 0;
+ }
+
+ if (suffix === "k") {
+ return Math.round(baseViews * 1000);
+ } else if (suffix === "m") {
+ return Math.round(baseViews * 1_000_000);
+ } else {
+ return Math.round(baseViews);
+ }
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/parsers/es.js b/src/modules/sorting/sort-by-views/parsers/es.js
new file mode 100644
index 0000000..a17925b
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/es.js
@@ -0,0 +1,25 @@
+export class EsViewsParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const viewsElement = videoInfo.firstElementChild;
+ const [value, unit] = viewsElement.textContent
+ .trim()
+ .toLowerCase()
+ .replaceAll(/\s/g, " ")
+ .split(" ");
+
+ const baseViews = parseFloat(value.replace(",", "."));
+
+ if (isNaN(baseViews)) {
+ return 0;
+ }
+
+ if (unit === "k") {
+ return Math.round(baseViews * 1000);
+ } else if (unit === "m") {
+ return Math.round(baseViews * 1_000_000);
+ } else {
+ return Math.round(baseViews);
+ }
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/parsers/index.js b/src/modules/sorting/sort-by-views/parsers/index.js
new file mode 100644
index 0000000..69048b5
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/index.js
@@ -0,0 +1,27 @@
+import { EnViewsParser } from "./en";
+import { EnInViewsParser } from "./en-IN";
+import { EsViewsParser } from "./es";
+import { PtViewsParser } from "./pt";
+import { ZhHansCnViewsParser } from "./zh-Hans-CN";
+import { ZhHantTwViewsParser } from "./zh-Hant-TW";
+
+const VIEWS_PARSERS_BY_LOCALE = {
+ "en": EnViewsParser,
+ "en-GB": EnViewsParser,
+ "en-IN": EnInViewsParser,
+ "en-US": EnViewsParser,
+ "es-ES": EsViewsParser,
+ "es-419": EsViewsParser,
+ "es-US": EsViewsParser,
+ "pt-PT": PtViewsParser,
+ "pt-BR": PtViewsParser,
+ "zh-Hans-CN": ZhHansCnViewsParser,
+ "zh-Hant-TW": ZhHantTwViewsParser
+};
+
+/** @param {string} locale */
+export const getViewsParser = (locale) => {
+ return VIEWS_PARSERS_BY_LOCALE[locale] ?? EnViewsParser;
+};
+
+export const getSupportedLocales = () => Object.keys(VIEWS_PARSERS_BY_LOCALE);
diff --git a/src/modules/sorting/sort-by-views/parsers/pt.js b/src/modules/sorting/sort-by-views/parsers/pt.js
new file mode 100644
index 0000000..ac051de
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/pt.js
@@ -0,0 +1,25 @@
+export class PtViewsParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const viewsElement = videoInfo.firstElementChild;
+ const parts = viewsElement.textContent
+ .toLowerCase()
+ .replaceAll(/\s/g, " ")
+ .split(" ");
+ const baseViews = parseFloat(parts[0].replace(",", "."));
+
+ if (parts.length === 3 && parts[1] === "mil") {
+ return Math.round(baseViews * 1000);
+ }
+
+ if (
+ parts.length === 4 &&
+ ["m", "mi"].includes(parts[1]) &&
+ parts[2] === "de"
+ ) {
+ return Math.round(baseViews * 1_000_000);
+ }
+
+ return Math.round(baseViews);
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/parsers/zh-Hans-CN.js b/src/modules/sorting/sort-by-views/parsers/zh-Hans-CN.js
new file mode 100644
index 0000000..b782c1f
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/zh-Hans-CN.js
@@ -0,0 +1,22 @@
+export class ZhHansCnViewsParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const viewsElement = videoInfo.firstElementChild;
+ const viewsRegex = /(\d+(\.\d+)?万?)/g;
+ const [viewsString] = viewsElement.textContent
+ .toLowerCase()
+ .match(viewsRegex);
+ const suffix = viewsString.slice(-1);
+ const baseViews = parseFloat(viewsString);
+
+ if (isNaN(baseViews)) {
+ return 0;
+ }
+
+ if (suffix === "万") {
+ return Math.round(baseViews * 10_000);
+ }
+
+ return Math.round(baseViews);
+ }
+}
diff --git a/src/modules/sorting/sort-by-views/parsers/zh-Hant-TW.js b/src/modules/sorting/sort-by-views/parsers/zh-Hant-TW.js
new file mode 100644
index 0000000..799022e
--- /dev/null
+++ b/src/modules/sorting/sort-by-views/parsers/zh-Hant-TW.js
@@ -0,0 +1,22 @@
+export class ZhHantTwViewsParser {
+ /** @param {Element} videoInfo */
+ parse(videoInfo) {
+ const viewsElement = videoInfo.firstElementChild;
+ const parts = viewsElement.textContent.trim().toLowerCase().split(":"); // Note: This is not an ordinary colon character
+ const baseViews = parseFloat(parts[1]);
+
+ if (isNaN(baseViews)) {
+ return 0;
+ }
+
+ if (parts[1].endsWith("萬次")) {
+ return Math.round(baseViews * 10_000);
+ }
+
+ if (parts[1].endsWith("次")) {
+ return Math.round(baseViews);
+ }
+
+ return Math.round(baseViews);
+ }
+}
diff --git a/src/shared/data/element-selectors.js b/src/shared/data/element-selectors.js
new file mode 100644
index 0000000..108e6b3
--- /dev/null
+++ b/src/shared/data/element-selectors.js
@@ -0,0 +1,26 @@
+export const elementSelectors = {
+ timestamp: "ytd-thumbnail-overlay-time-status-renderer",
+ // Design anchor = Element that helps distinguish between old & new layouts
+ designAnchor: {
+ old: "ytd-playlist-sidebar-renderer",
+ new: "ytd-playlist-header-renderer"
+ },
+ playlistSummary: {
+ old: "#ytpdc-playlist-summary-old",
+ new: "#ytpdc-playlist-summary-new"
+ },
+ playlistMetadata: {
+ old: "ytd-playlist-sidebar-renderer #items",
+ new: ".immersive-header-content .metadata-action-bar"
+ },
+ video: "ytd-playlist-video-renderer",
+ playlist: "ytd-playlist-video-list-renderer #contents",
+ channelName: ".ytd-channel-name",
+ videoTitle: "#video-title",
+ videoIndex: "yt-formatted-string#index",
+ videoInfo: "yt-formatted-string#video-info",
+ stats: {
+ old: "#stats yt-formatted-string",
+ new: ".metadata-stats yt-formatted-string"
+ }
+};
diff --git a/src/shared/modules/timestamp.js b/src/shared/modules/timestamp.js
new file mode 100644
index 0000000..b08f141
--- /dev/null
+++ b/src/shared/modules/timestamp.js
@@ -0,0 +1,58 @@
+import { elementSelectors } from "src/shared/data/element-selectors";
+
+/**
+ * Converts a numerical amount of seconds to a textual timestamp formatted as
+ * hh:mm:ss
+ * @param {number} seconds
+ * @returns {string}
+ */
+export const convertSecondsToTimestamp = (seconds) => {
+ const hours = `${Math.floor(seconds / 3600)}`.padStart(2, "0");
+ seconds %= 3600;
+ const minutes = `${Math.floor(seconds / 60)}`.padStart(2, "0");
+ const remainingSeconds = `${seconds % 60}`.padStart(2, "0");
+ return `${hours}:${minutes}:${remainingSeconds}`;
+};
+
+/**
+ * Converts a textual timestamp formatted as hh:mm:ss to its numerical value
+ * represented in seconds
+ * @param {string} timestamp
+ * @returns {number}
+ */
+export const convertTimestampToSeconds = (timestamp) => {
+ let timeComponents = timestamp
+ .split(":")
+ .map((timeComponent) => parseInt(timeComponent, 10));
+
+ let seconds = 0;
+ let minutes = 1;
+
+ while (timeComponents.length > 0) {
+ let timeComponent = timeComponents.pop();
+ if (isNaN(timeComponent)) continue;
+
+ seconds += minutes * timeComponent;
+ minutes *= 60;
+ }
+
+ return seconds;
+};
+
+/**
+ * Extracts a timestamp from a video element
+ * @param {Element} video
+ * @returns {number}
+ */
+export const getTimestampFromVideo = (video) => {
+ if (!video) return null;
+
+ const timestampElement = video.querySelector(elementSelectors.timestamp);
+ if (!timestampElement) return null;
+
+ const timestamp = timestampElement.innerText;
+ if (!timestamp) return null;
+
+ const timestampAsSeconds = convertTimestampToSeconds(timestamp);
+ return timestampAsSeconds;
+};
diff --git a/vite.config.js b/vite.config.js
index eee206c..c7cb28f 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -5,7 +5,7 @@ function generateManifest() {
const manifest = readJsonFile("src/manifest.json");
const pkg = readJsonFile("package.json");
return {
- version: pkg.version,
+ version: pkg.version.split("-")[0],
...manifest
};
}
@@ -20,13 +20,24 @@ export default defineConfig(({ mode }) => {
browser: env.VITE_TARGET_BROWSER || "chrome",
webExtConfig: {
startUrl: [
- "https://www.youtube.com/playlist?list=PLAhTBeRe8IhMmRve_rSfAgL_dtEXkKh8Z"
+ "https://www.youtube.com/playlist?list=PLAhTBeRe8IhMmRve_rSfAgL_dtEXkKh8Z",
+ "https://www.youtube.com/playlist?list=PLrtg3MOb7tvG9h9rll5V9O96owfcBpROG",
+ "https://www.youtube.com/playlist?list=PLBsP89CPrMePWBCMIp0naluIz67UwRX9B",
+ "https://www.youtube.com/playlist?list=PLBsP89CPrMeM2MmF4suOeT0vsic9nEC2Y",
+ "https://www.youtube.com/playlist?list=PL3HWFB6aFvWBXGsbVJhJJK1ykcqlVz5lI"
]
}
})
],
build: {
- sourcemap: "inline"
+ sourcemap: "inline",
+ outDir: "dist",
+ emptyOutDir: true
+ },
+ resolve: {
+ alias: {
+ src: "/src"
+ }
}
};
});