popup.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  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. history: {},
  21. sortedHistory: [],
  22. openMenus: new Set(), // Track which menus are open
  23. currentView: 'playlists', // Track current view: 'playlists' or 'history'
  24. init() {
  25. this.loadPlaylists();
  26. this.loadHistory();
  27. // Add document click handler to close menus
  28. document.addEventListener('click', (e) => {
  29. // If click is not on a more button or menu, close all menus
  30. if (!e.target.closest('.more-menu-container')) {
  31. this.closeAllMenus();
  32. }
  33. });
  34. },
  35. async loadPlaylists() {
  36. try {
  37. const result = await browser.storage.local.get("playlists");
  38. console.log("LOAD RESULT", result.playlists);
  39. this.playlists = result.playlists || {};
  40. if (result.playlists) {
  41. this.currentIndices = Object.keys(result.playlists).reduce(
  42. (acc, pln) => {
  43. const ind = result.playlists[pln].findIndex(
  44. (v) => v.status !== "done",
  45. );
  46. if (ind === -1) {
  47. acc[pln] = result.playlists[pln].length;
  48. } else {
  49. acc[pln] = ind;
  50. }
  51. return acc;
  52. },
  53. {},
  54. );
  55. } else {
  56. this.currentIndices = {};
  57. }
  58. } catch (error) {
  59. console.error("Error loading playlists:", error);
  60. }
  61. },
  62. async loadHistory() {
  63. try {
  64. const result = await browser.storage.local.get("history");
  65. console.log("LOAD HISTORY RESULT", result.history);
  66. this.history = result.history || {};
  67. this.sortHistoryByRecentInteraction();
  68. } catch (error) {
  69. console.error("Error loading history:", error);
  70. }
  71. },
  72. sortHistoryByRecentInteraction() {
  73. // Convert history object to array with video ID and sort by most recent interaction
  74. this.sortedHistory = Object.entries(this.history)
  75. .map(([videoId, videoData]) => {
  76. const lastInteraction = videoData.history.length > 0
  77. ? Math.max(...videoData.history.map(event => event.timestamp))
  78. : 0;
  79. // Pre-process events with formatted data for CSP compliance
  80. const processedEvents = videoData.history
  81. .slice()
  82. .reverse()
  83. .map((event, index) => ({
  84. ...event,
  85. formattedAction: this.formatActionName(event.action),
  86. formattedPosition: `at ${this.formatVideoPosition(event.position)}`,
  87. formattedTimestamp: this.formatTimestamp(event.timestamp),
  88. uniqueKey: `${videoId}-${event.timestamp}-${index}`
  89. }));
  90. return {
  91. videoId,
  92. formattedVideoId: `(${videoId})`,
  93. ...videoData,
  94. lastInteraction,
  95. processedEvents
  96. };
  97. })
  98. .sort((a, b) => b.lastInteraction - a.lastInteraction);
  99. },
  100. formatTimestamp(timestamp) {
  101. const date = new Date(timestamp);
  102. const now = new Date();
  103. const diffMs = now - date;
  104. const diffMins = Math.floor(diffMs / (1000 * 60));
  105. const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
  106. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  107. if (diffMins < 1) return 'Just now';
  108. if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
  109. if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
  110. if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
  111. return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
  112. },
  113. formatVideoPosition(seconds) {
  114. const minutes = Math.floor(seconds / 60);
  115. const remainingSeconds = Math.floor(seconds % 60);
  116. return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
  117. },
  118. formatActionName(action) {
  119. const actionMap = {
  120. 'play': 'Started',
  121. 'playing': 'Playing',
  122. 'pause': 'Paused',
  123. 'ended': 'Finished'
  124. };
  125. return actionMap[action] || action;
  126. },
  127. formatPlaylistName(name) {
  128. // Convert "listening-1" to "Listening - 1"
  129. return name
  130. .split("-")
  131. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  132. .join(" - ");
  133. },
  134. openVideo(url) {
  135. browser.tabs.create({ url });
  136. },
  137. async removeVideo(playlistName, index) {
  138. const playlists = JSON.parse(JSON.stringify(this.playlists));
  139. // Make a copy of the current playlist
  140. const playlist = [...playlists[playlistName]];
  141. // Remove the video at the specified index
  142. playlist.splice(index, 1);
  143. // Create an updated playlists object with the remaining playlists unchanged
  144. const updatedPlaylists = {
  145. ...playlists,
  146. [playlistName]: playlist,
  147. };
  148. // Update the playlists in storage
  149. try {
  150. await browser.storage.local.set({ playlists: updatedPlaylists });
  151. this.playlists = updatedPlaylists;
  152. } catch (error) {
  153. console.error("Error removing video:", error);
  154. }
  155. },
  156. async moveVideoUp(playlistName, index) {
  157. // Can't move the first item up
  158. if (index <= 0) return;
  159. const playlists = JSON.parse(JSON.stringify(this.playlists));
  160. // Make a copy of the current playlist
  161. const playlist = [...playlists[playlistName]];
  162. // Swap the video with the one above it
  163. [playlist[index], playlist[index - 1]] = [
  164. playlist[index - 1],
  165. playlist[index],
  166. ];
  167. // Create an updated playlists object
  168. const updatedPlaylists = {
  169. ...playlists,
  170. [playlistName]: playlist,
  171. };
  172. // Update the playlists in storage
  173. try {
  174. await browser.storage.local.set({ playlists: updatedPlaylists });
  175. this.playlists = updatedPlaylists;
  176. } catch (error) {
  177. console.error("Error moving video up:", error);
  178. }
  179. },
  180. async moveVideoDown(playlistName, index) {
  181. const playlists = JSON.parse(JSON.stringify(this.playlists));
  182. const playlist = [...playlists[playlistName]];
  183. // Can't move the last item down
  184. if (index >= playlist.length - 1) return;
  185. // Swap the video with the one below it
  186. [playlist[index], playlist[index + 1]] = [
  187. playlist[index + 1],
  188. playlist[index],
  189. ];
  190. // Create an updated playlists object
  191. const updatedPlaylists = {
  192. ...playlists,
  193. [playlistName]: playlist,
  194. };
  195. // Update the playlists in storage
  196. try {
  197. await browser.storage.local.set({ playlists: updatedPlaylists });
  198. this.playlists = updatedPlaylists;
  199. } catch (error) {
  200. console.error("Error moving video down:", error);
  201. }
  202. },
  203. async exportPlaylists() {
  204. try {
  205. // Get current playlists
  206. const playlistResult = await browser.storage.local.get("playlists");
  207. const playlists = playlistResult.playlists || {};
  208. // Get playback history
  209. const historyResult = await browser.storage.local.get("history");
  210. const playbackHistory = historyResult.history || {};
  211. // Create export data object
  212. const exportData = {
  213. playlists,
  214. playbackHistory,
  215. exportDate: new Date().toISOString(),
  216. };
  217. // Convert to JSON
  218. const jsonString = JSON.stringify(exportData, null, 2);
  219. // Create download
  220. const blob = new Blob([jsonString], { type: "application/json" });
  221. const url = URL.createObjectURL(blob);
  222. // Trigger download
  223. const a = document.createElement("a");
  224. a.href = url;
  225. a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`;
  226. document.body.appendChild(a);
  227. a.click();
  228. // Clean up
  229. setTimeout(() => {
  230. document.body.removeChild(a);
  231. URL.revokeObjectURL(url);
  232. }, 100);
  233. } catch (error) {
  234. console.error("Error exporting playlists:", error);
  235. }
  236. },
  237. videotitle: {
  238. ["@click"]() {
  239. console.log("TITLE CLICK", this.$el);
  240. },
  241. },
  242. videoPlayLink: {
  243. ["@click.prevent"]() {
  244. browser.tabs.update({ url: this.$el.href });
  245. },
  246. },
  247. isCurrentVideo(playlistName, index) {
  248. const currentIndex = this.currentIndices[playlistName];
  249. return currentIndex === index;
  250. },
  251. isDoneVideo(playlistName, index) {
  252. const currentIndex = this.currentIndices[playlistName];
  253. return index < currentIndex;
  254. },
  255. videoItemClass: {
  256. [":class"]() {
  257. const playlistName = this.$el.dataset.playlistName;
  258. const index = parseInt(this.$el.dataset.playlistIndex);
  259. const video = this.playlists[playlistName][index];
  260. return {
  261. "current-video": this.isCurrentVideo(playlistName, index),
  262. "done-video":
  263. this.isDoneVideo(playlistName, index) && video.status === "done",
  264. };
  265. },
  266. },
  267. removeVideoButton: {
  268. ["@click"]() {
  269. this.removeVideo(
  270. this.$el.dataset.playlistName,
  271. this.$el.dataset.playlistIndex,
  272. );
  273. this.closeAllMenus();
  274. },
  275. },
  276. moveUpButton: {
  277. ["@click"]() {
  278. this.moveVideoUp(
  279. this.$el.dataset.playlistName,
  280. parseInt(this.$el.dataset.playlistIndex),
  281. );
  282. },
  283. [":disabled"]() {
  284. return parseInt(this.$el.dataset.playlistIndex) === 0;
  285. },
  286. },
  287. moveDownButton: {
  288. ["@click"]() {
  289. this.moveVideoDown(
  290. this.$el.dataset.playlistName,
  291. parseInt(this.$el.dataset.playlistIndex),
  292. );
  293. },
  294. [":disabled"]() {
  295. return (
  296. parseInt(this.$el.dataset.playlistIndex) ===
  297. this.playlists[this.$el.dataset.playlistName].length - 1
  298. );
  299. },
  300. },
  301. exportButton: {
  302. ["@click"]() {
  303. this.exportPlaylists();
  304. },
  305. },
  306. importButton: {
  307. ["@click"]() {
  308. document.getElementById("import-file-input").click();
  309. },
  310. },
  311. importFile(event) {
  312. const file = event.target.files[0];
  313. if (!file) return;
  314. const reader = new FileReader();
  315. reader.onload = (e) => {
  316. try {
  317. const importedData = JSON.parse(e.target.result);
  318. this.validateAndImportPlaylists(importedData);
  319. } catch (error) {
  320. console.error("Error parsing JSON file:", error);
  321. alert(
  322. "Invalid JSON file. Please select a valid playlist export file.",
  323. );
  324. } finally {
  325. // Reset the file input so the same file can be selected again
  326. event.target.value = "";
  327. }
  328. };
  329. reader.readAsText(file);
  330. },
  331. validateAndImportPlaylists(data) {
  332. // Validate the playlists structure
  333. if (!data.playlists || typeof data.playlists !== "object") {
  334. alert("Invalid file format: Missing or invalid 'playlists' property");
  335. return;
  336. }
  337. // Validate each playlist
  338. const validPlaylists = {};
  339. let hasErrors = false;
  340. for (const [playlistName, videos] of Object.entries(data.playlists)) {
  341. // Check if videos is an array
  342. if (!Array.isArray(videos)) {
  343. console.error(
  344. `Playlist '${playlistName}' does not contain a valid array of videos`,
  345. );
  346. hasErrors = true;
  347. continue;
  348. }
  349. // Validate each video in the playlist
  350. const validVideos = videos.filter((video) => {
  351. if (!video || typeof video !== "object") {
  352. console.error(`Invalid video object in '${playlistName}'`);
  353. return false;
  354. }
  355. if (
  356. !video.url ||
  357. typeof video.url !== "string" ||
  358. !video.title ||
  359. typeof video.title !== "string"
  360. ) {
  361. console.error(
  362. `Video in '${playlistName}' missing required properties (url, title)`,
  363. );
  364. return false;
  365. }
  366. return true;
  367. });
  368. // Add the validated playlist if it has valid videos
  369. if (validVideos.length > 0) {
  370. validPlaylists[playlistName] = validVideos;
  371. }
  372. }
  373. if (Object.keys(validPlaylists).length === 0) {
  374. alert("No valid playlists found in the import file");
  375. return;
  376. }
  377. if (hasErrors) {
  378. const confirmImport = confirm(
  379. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  380. );
  381. if (!confirmImport) return;
  382. }
  383. // Update storage and state
  384. this.updatePlaylists(validPlaylists);
  385. },
  386. async updatePlaylists(playlists) {
  387. try {
  388. await browser.storage.local.set({ playlists });
  389. this.playlists = playlists;
  390. alert("Playlists imported successfully!");
  391. } catch (error) {
  392. console.error("Error updating playlists:", error);
  393. alert("Error importing playlists: " + error.message);
  394. }
  395. },
  396. getMenuId(playlistName, index) {
  397. return `${playlistName}-${index}`;
  398. },
  399. isMenuOpen(playlistName, index) {
  400. return this.openMenus.has(this.getMenuId(playlistName, index));
  401. },
  402. toggleMenu(playlistName, index) {
  403. const menuId = this.getMenuId(playlistName, index);
  404. if (this.openMenus.has(menuId)) {
  405. this.openMenus.delete(menuId);
  406. } else {
  407. // Close all other menus first
  408. this.openMenus.clear();
  409. this.openMenus.add(menuId);
  410. }
  411. },
  412. closeAllMenus() {
  413. this.openMenus.clear();
  414. },
  415. moreMenuButton: {
  416. ["@click.stop"]() {
  417. this.toggleMenu(
  418. this.$el.dataset.playlistName,
  419. parseInt(this.$el.dataset.playlistIndex)
  420. );
  421. },
  422. },
  423. moreMenu: {
  424. ["x-show"]() {
  425. return this.isMenuOpen(
  426. this.$el.dataset.playlistName,
  427. parseInt(this.$el.dataset.playlistIndex)
  428. );
  429. },
  430. },
  431. // View navigation methods
  432. showHistory() {
  433. this.currentView = 'history';
  434. this.loadHistory(); // Refresh history data when switching to history view
  435. },
  436. showPlaylists() {
  437. this.currentView = 'playlists';
  438. },
  439. // Button bindings for navigation
  440. historyButton: {
  441. ["@click"]() {
  442. this.showHistory();
  443. },
  444. },
  445. backButton: {
  446. ["@click"]() {
  447. this.showPlaylists();
  448. },
  449. },
  450. // CSP-compliant view bindings
  451. playlistsHeader: {
  452. ["x-show"]() {
  453. return this.currentView === 'playlists';
  454. },
  455. },
  456. historyHeader: {
  457. ["x-show"]() {
  458. return this.currentView === 'history';
  459. },
  460. },
  461. playlistsContainer: {
  462. ["x-show"]() {
  463. return this.currentView === 'playlists';
  464. },
  465. },
  466. historyContainer: {
  467. ["x-show"]() {
  468. return this.currentView === 'history';
  469. },
  470. },
  471. exportContainer: {
  472. ["x-show"]() {
  473. return this.currentView === 'playlists';
  474. },
  475. },
  476. // History-specific bindings for CSP compliance
  477. historyEmptyState: {
  478. ["x-show"]() {
  479. return this.sortedHistory.length === 0;
  480. },
  481. },
  482. }));
  483. });