popup.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. document.addEventListener("alpine:init", () => {
  2. //TODO
  3. // - preserve playlist order (sort)
  4. // - "play/resume playlist" button (pick up where you left off)
  5. // X track timestamp to truly resume where you left off
  6. // - periodically remove watched videos
  7. // - or move to an "old" category, and add a screen to see the list
  8. // - consider separating context menu items (rather than having a sub-menu)
  9. // - view playback history (?)
  10. // - include currently open tabs in export
  11. // - support playlist management (add, remove, rename playlists)
  12. // - support input text box for adding to playlist (how to handle title?)
  13. // - option (probably a button) to add current page (url, title) to playlist
  14. // - button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
  15. // - long-term: replace youtube page? rss feeds? need server?
  16. // - add personal rating feature ("enjoyed", "this was important", etc)
  17. Alpine.data("playlistManager", () => ({
  18. playlists: {},
  19. currentIndices: {},
  20. openMenus: new Set(), // Track which menus are open
  21. init() {
  22. this.loadPlaylists();
  23. // Add document click handler to close menus
  24. document.addEventListener('click', (e) => {
  25. // If click is not on a more button or menu, close all menus
  26. if (!e.target.closest('.more-menu-container')) {
  27. this.closeAllMenus();
  28. }
  29. });
  30. },
  31. async loadPlaylists() {
  32. try {
  33. const result = await browser.storage.local.get("playlists");
  34. console.log("LOAD RESULT", result.playlists);
  35. this.playlists = result.playlists || {};
  36. if (result.playlists) {
  37. this.currentIndices = Object.keys(result.playlists).reduce(
  38. (acc, pln) => {
  39. const ind = result.playlists[pln].findIndex(
  40. (v) => v.status !== "done",
  41. );
  42. if (ind === -1) {
  43. acc[pln] = result.playlists[pln].length;
  44. } else {
  45. acc[pln] = ind;
  46. }
  47. return acc;
  48. },
  49. {},
  50. );
  51. } else {
  52. this.currentIndices = {};
  53. }
  54. } catch (error) {
  55. console.error("Error loading playlists:", error);
  56. }
  57. },
  58. formatPlaylistName(name) {
  59. // Convert "listening-1" to "Listening - 1"
  60. return name
  61. .split("-")
  62. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  63. .join(" - ");
  64. },
  65. openVideo(url) {
  66. browser.tabs.create({ url });
  67. },
  68. async removeVideo(playlistName, index) {
  69. const playlists = JSON.parse(JSON.stringify(this.playlists));
  70. // Make a copy of the current playlist
  71. const playlist = [...playlists[playlistName]];
  72. // Remove the video at the specified index
  73. playlist.splice(index, 1);
  74. // Create an updated playlists object with the remaining playlists unchanged
  75. const updatedPlaylists = {
  76. ...playlists,
  77. [playlistName]: playlist,
  78. };
  79. // Update the playlists in storage
  80. try {
  81. await browser.storage.local.set({ playlists: updatedPlaylists });
  82. this.playlists = updatedPlaylists;
  83. } catch (error) {
  84. console.error("Error removing video:", error);
  85. }
  86. },
  87. async moveVideoUp(playlistName, index) {
  88. // Can't move the first item up
  89. if (index <= 0) return;
  90. const playlists = JSON.parse(JSON.stringify(this.playlists));
  91. // Make a copy of the current playlist
  92. const playlist = [...playlists[playlistName]];
  93. // Swap the video with the one above it
  94. [playlist[index], playlist[index - 1]] = [
  95. playlist[index - 1],
  96. playlist[index],
  97. ];
  98. // Create an updated playlists object
  99. const updatedPlaylists = {
  100. ...playlists,
  101. [playlistName]: playlist,
  102. };
  103. // Update the playlists in storage
  104. try {
  105. await browser.storage.local.set({ playlists: updatedPlaylists });
  106. this.playlists = updatedPlaylists;
  107. } catch (error) {
  108. console.error("Error moving video up:", error);
  109. }
  110. },
  111. async moveVideoDown(playlistName, index) {
  112. const playlists = JSON.parse(JSON.stringify(this.playlists));
  113. const playlist = [...playlists[playlistName]];
  114. // Can't move the last item down
  115. if (index >= playlist.length - 1) return;
  116. // Swap the video with the one below it
  117. [playlist[index], playlist[index + 1]] = [
  118. playlist[index + 1],
  119. playlist[index],
  120. ];
  121. // Create an updated playlists object
  122. const updatedPlaylists = {
  123. ...playlists,
  124. [playlistName]: playlist,
  125. };
  126. // Update the playlists in storage
  127. try {
  128. await browser.storage.local.set({ playlists: updatedPlaylists });
  129. this.playlists = updatedPlaylists;
  130. } catch (error) {
  131. console.error("Error moving video down:", error);
  132. }
  133. },
  134. async exportPlaylists() {
  135. try {
  136. // Get current playlists
  137. const playlistResult = await browser.storage.local.get("playlists");
  138. const playlists = playlistResult.playlists || {};
  139. // Get playback history
  140. const historyResult = await browser.storage.local.get("history");
  141. const playbackHistory = historyResult.history || {};
  142. // Create export data object
  143. const exportData = {
  144. playlists,
  145. playbackHistory,
  146. exportDate: new Date().toISOString(),
  147. };
  148. // Convert to JSON
  149. const jsonString = JSON.stringify(exportData, null, 2);
  150. // Create download
  151. const blob = new Blob([jsonString], { type: "application/json" });
  152. const url = URL.createObjectURL(blob);
  153. // Trigger download
  154. const a = document.createElement("a");
  155. a.href = url;
  156. a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`;
  157. document.body.appendChild(a);
  158. a.click();
  159. // Clean up
  160. setTimeout(() => {
  161. document.body.removeChild(a);
  162. URL.revokeObjectURL(url);
  163. }, 100);
  164. } catch (error) {
  165. console.error("Error exporting playlists:", error);
  166. }
  167. },
  168. videotitle: {
  169. ["@click"]() {
  170. console.log("TITLE CLICK", this.$el);
  171. },
  172. },
  173. videoPlayLink: {
  174. ["@click.prevent"]() {
  175. browser.tabs.update({ url: this.$el.href });
  176. },
  177. },
  178. isCurrentVideo(playlistName, index) {
  179. const currentIndex = this.currentIndices[playlistName];
  180. return currentIndex === index;
  181. },
  182. isDoneVideo(playlistName, index) {
  183. const currentIndex = this.currentIndices[playlistName];
  184. return index < currentIndex;
  185. },
  186. videoItemClass: {
  187. [":class"]() {
  188. const playlistName = this.$el.dataset.playlistName;
  189. const index = parseInt(this.$el.dataset.playlistIndex);
  190. const video = this.playlists[playlistName][index];
  191. return {
  192. "current-video": this.isCurrentVideo(playlistName, index),
  193. "done-video":
  194. this.isDoneVideo(playlistName, index) && video.status === "done",
  195. };
  196. },
  197. },
  198. removeVideoButton: {
  199. ["@click"]() {
  200. this.removeVideo(
  201. this.$el.dataset.playlistName,
  202. this.$el.dataset.playlistIndex,
  203. );
  204. this.closeAllMenus();
  205. },
  206. },
  207. moveUpButton: {
  208. ["@click"]() {
  209. this.moveVideoUp(
  210. this.$el.dataset.playlistName,
  211. parseInt(this.$el.dataset.playlistIndex),
  212. );
  213. },
  214. [":disabled"]() {
  215. return parseInt(this.$el.dataset.playlistIndex) === 0;
  216. },
  217. },
  218. moveDownButton: {
  219. ["@click"]() {
  220. this.moveVideoDown(
  221. this.$el.dataset.playlistName,
  222. parseInt(this.$el.dataset.playlistIndex),
  223. );
  224. },
  225. [":disabled"]() {
  226. return (
  227. parseInt(this.$el.dataset.playlistIndex) ===
  228. this.playlists[this.$el.dataset.playlistName].length - 1
  229. );
  230. },
  231. },
  232. exportButton: {
  233. ["@click"]() {
  234. this.exportPlaylists();
  235. },
  236. },
  237. importButton: {
  238. ["@click"]() {
  239. document.getElementById("import-file-input").click();
  240. },
  241. },
  242. importFile(event) {
  243. const file = event.target.files[0];
  244. if (!file) return;
  245. const reader = new FileReader();
  246. reader.onload = (e) => {
  247. try {
  248. const importedData = JSON.parse(e.target.result);
  249. this.validateAndImportPlaylists(importedData);
  250. } catch (error) {
  251. console.error("Error parsing JSON file:", error);
  252. alert(
  253. "Invalid JSON file. Please select a valid playlist export file.",
  254. );
  255. } finally {
  256. // Reset the file input so the same file can be selected again
  257. event.target.value = "";
  258. }
  259. };
  260. reader.readAsText(file);
  261. },
  262. validateAndImportPlaylists(data) {
  263. // Validate the playlists structure
  264. if (!data.playlists || typeof data.playlists !== "object") {
  265. alert("Invalid file format: Missing or invalid 'playlists' property");
  266. return;
  267. }
  268. // Validate each playlist
  269. const validPlaylists = {};
  270. let hasErrors = false;
  271. for (const [playlistName, videos] of Object.entries(data.playlists)) {
  272. // Check if videos is an array
  273. if (!Array.isArray(videos)) {
  274. console.error(
  275. `Playlist '${playlistName}' does not contain a valid array of videos`,
  276. );
  277. hasErrors = true;
  278. continue;
  279. }
  280. // Validate each video in the playlist
  281. const validVideos = videos.filter((video) => {
  282. if (!video || typeof video !== "object") {
  283. console.error(`Invalid video object in '${playlistName}'`);
  284. return false;
  285. }
  286. if (
  287. !video.url ||
  288. typeof video.url !== "string" ||
  289. !video.title ||
  290. typeof video.title !== "string"
  291. ) {
  292. console.error(
  293. `Video in '${playlistName}' missing required properties (url, title)`,
  294. );
  295. return false;
  296. }
  297. return true;
  298. });
  299. // Add the validated playlist if it has valid videos
  300. if (validVideos.length > 0) {
  301. validPlaylists[playlistName] = validVideos;
  302. }
  303. }
  304. if (Object.keys(validPlaylists).length === 0) {
  305. alert("No valid playlists found in the import file");
  306. return;
  307. }
  308. if (hasErrors) {
  309. const confirmImport = confirm(
  310. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  311. );
  312. if (!confirmImport) return;
  313. }
  314. // Update storage and state
  315. this.updatePlaylists(validPlaylists);
  316. },
  317. async updatePlaylists(playlists) {
  318. try {
  319. await browser.storage.local.set({ playlists });
  320. this.playlists = playlists;
  321. alert("Playlists imported successfully!");
  322. } catch (error) {
  323. console.error("Error updating playlists:", error);
  324. alert("Error importing playlists: " + error.message);
  325. }
  326. },
  327. getMenuId(playlistName, index) {
  328. return `${playlistName}-${index}`;
  329. },
  330. isMenuOpen(playlistName, index) {
  331. return this.openMenus.has(this.getMenuId(playlistName, index));
  332. },
  333. toggleMenu(playlistName, index) {
  334. const menuId = this.getMenuId(playlistName, index);
  335. if (this.openMenus.has(menuId)) {
  336. this.openMenus.delete(menuId);
  337. } else {
  338. // Close all other menus first
  339. this.openMenus.clear();
  340. this.openMenus.add(menuId);
  341. }
  342. },
  343. closeAllMenus() {
  344. this.openMenus.clear();
  345. },
  346. moreMenuButton: {
  347. ["@click.stop"]() {
  348. this.toggleMenu(
  349. this.$el.dataset.playlistName,
  350. parseInt(this.$el.dataset.playlistIndex)
  351. );
  352. },
  353. },
  354. moreMenu: {
  355. ["x-show"]() {
  356. return this.isMenuOpen(
  357. this.$el.dataset.playlistName,
  358. parseInt(this.$el.dataset.playlistIndex)
  359. );
  360. },
  361. },
  362. }));
  363. });