popup.js 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653
  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. // X periodically remove watched videos
  7. // X 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. // X view playback history (?)
  10. // X include currently open tabs in export
  11. // - support playlist management (add, remove, rename playlists)
  12. // - support dynamic (on-demand) playlists
  13. // - support input text box for adding to playlist (how to handle title?)
  14. // - or raw json editing
  15. // X option (probably a button) to add current page (url, title) to playlist
  16. // X align with addLinkToPlaylist in background.js (no repeated videos)
  17. // X button to add channel to youtube page (copy gql mutation to clipboard) (like the automa version)
  18. // - long-term: replace youtube page? rss feeds? need server?
  19. // X add personal rating feature ("enjoyed", "this was important", etc)
  20. // - option to move video (including status) to another playlist
  21. // X button to copy current video to clipboard in wikitext format (for personal wiki list)
  22. Alpine.data("playlistManager", () => ({
  23. playlists: {},
  24. currentIndices: {},
  25. history: {},
  26. sortedHistory: [],
  27. openMenus: new Set(), // Track which menus are open
  28. moveSubmenuOpenFor: null, // Track which video has the move-to submenu open (menuId or null)
  29. currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
  30. currentPlaylistName: "", // Track which playlist is being viewed
  31. playlistsForDisplay: [], // Computed array for display
  32. playlistsScrollTop: 0, // Tracks document.body.scrollTop for jump-button reactivity
  33. currentPlaylistVideos: [], // Videos for current playlist view
  34. otherPlaylists: [], // Playlist names (with display labels) other than currentPlaylistName
  35. currentTab: null, // Current active tab info
  36. isCurrentTabYoutube: false, // Whether current tab is YouTube
  37. isCurrentTabChannelPage: false, // Whether current tab is YouTube videos page
  38. addCurrentPageButtonText: "Add Cur. Page", // Button text
  39. scatterCount: 4,
  40. // Save channel properties
  41. selectedCategory: "FOR BOTH", // Currently selected category
  42. availableCategories: [
  43. "FOR BOTH",
  44. "CHANNELS",
  45. "SCIFI",
  46. "POLI/REL",
  47. "REAL ESTATE",
  48. "HELICOPTER",
  49. "SAILING",
  50. "VAN LIFE",
  51. "BLOCKCHAIN",
  52. "MAKING",
  53. "DESIGN",
  54. "TECH CONF",
  55. "CREATIVE",
  56. ], // Available categories
  57. curlCommand: "", // Generated curl command
  58. checkInterval: 14, // Check interval in days
  59. // Wiki/insp properties
  60. wikitextCommand: "", // Generated wikitext
  61. isCurrentVideoYoutube: false, // Whether current video is YouTube
  62. hasVideoEnded: false, // Whether video has ended
  63. currentVideoTimestamp: "", // Current video timestamp
  64. includeTimestamp: true, // Whether to include timestamp line
  65. // Import properties
  66. importText: "", // Text area content for import
  67. init() {
  68. this.loadPlaylists();
  69. this.loadHistory();
  70. this.getCurrentTab();
  71. // Add document click handler to close menus
  72. document.addEventListener("click", (e) => {
  73. // If click is not on a more button or menu, close all menus
  74. if (!e.target.closest(".more-menu-container")) {
  75. this.closeAllMenus();
  76. }
  77. });
  78. // Track scroll position of the root scrolling element so the playlist
  79. // jump-scroll buttons' disabled state stays reactive. Whether the
  80. // scrollable box ends up being <body> or <html> is a browser-decided
  81. // CSS quirk (depends on which element's overflow gets propagated to
  82. // the viewport), so use window/scrollingElement rather than assuming body.
  83. window.addEventListener("scroll", () => {
  84. this.playlistsScrollTop = document.scrollingElement.scrollTop;
  85. });
  86. },
  87. async loadPlaylists() {
  88. try {
  89. const result = await browser.storage.local.get("playlists");
  90. console.log("LOAD RESULT", result.playlists);
  91. this.playlists = result.playlists || {};
  92. if (result.playlists) {
  93. this.currentIndices = Object.keys(result.playlists).reduce(
  94. (acc, pln) => {
  95. const ind = result.playlists[pln].findIndex(
  96. (v) => v.status !== "done",
  97. );
  98. if (ind === -1) {
  99. acc[pln] = result.playlists[pln].length;
  100. } else {
  101. acc[pln] = ind;
  102. }
  103. return acc;
  104. },
  105. {},
  106. );
  107. } else {
  108. this.currentIndices = {};
  109. }
  110. } catch (error) {
  111. console.error("Error loading playlists:", error);
  112. }
  113. this.updatePlaylistsForDisplay();
  114. },
  115. updatePlaylistsForDisplay() {
  116. this.playlistsForDisplay = Object.entries(this.playlists).map(
  117. ([playlistName, videos]) => {
  118. const currentIndex = this.currentIndices[playlistName] || 0;
  119. const shouldShowTruncation = currentIndex > 0;
  120. const truncationText = shouldShowTruncation
  121. ? `(${currentIndex} previous video${currentIndex === 1 ? "" : "s"})`
  122. : "";
  123. // Get visible videos from current index onwards with original indices
  124. const visibleVideos = videos
  125. .slice(currentIndex)
  126. .map((video, index) => ({
  127. ...video,
  128. originalIndex: currentIndex + index,
  129. doneButtonText:
  130. video.status === "done" ? "Remove Done Status" : "Mark as Done",
  131. isNonContiguousDone: this.isNonContiguousDone(
  132. playlistName,
  133. currentIndex + index,
  134. ),
  135. }));
  136. return {
  137. name: playlistName,
  138. videos: videos,
  139. visibleVideos: visibleVideos,
  140. shouldShowTruncation: shouldShowTruncation,
  141. truncationText: truncationText,
  142. otherPlaylists: Object.keys(this.playlists)
  143. .filter((n) => n !== playlistName)
  144. .map((n) => ({ name: n, display: this.formatPlaylistName(n) })),
  145. };
  146. },
  147. );
  148. },
  149. getPlaylistBoundaryOffsets() {
  150. const scroller = document.scrollingElement;
  151. // For the root scrolling element, getBoundingClientRect().top is
  152. // already -scrollTop, so adding scrollTop converts a viewport-relative
  153. // rect into a scroll-position-independent document offset.
  154. return Array.from(
  155. document.querySelectorAll(".playlist-name-clickable"),
  156. ).map((el) => el.getBoundingClientRect().top + scroller.scrollTop);
  157. },
  158. hasScrollableOverflow() {
  159. const scroller = document.scrollingElement;
  160. return scroller.scrollHeight - scroller.clientHeight > 1;
  161. },
  162. scrollToNextPlaylistBoundary() {
  163. const offsets = this.getPlaylistBoundaryOffsets();
  164. const currentScroll = document.scrollingElement.scrollTop;
  165. const next = offsets.find((offset) => offset > currentScroll + 1);
  166. if (next !== undefined) {
  167. window.scrollTo({ top: next, behavior: "smooth" });
  168. }
  169. },
  170. scrollToPreviousPlaylistBoundary() {
  171. const offsets = this.getPlaylistBoundaryOffsets();
  172. const currentScroll = document.scrollingElement.scrollTop;
  173. const previous = [...offsets]
  174. .reverse()
  175. .find((offset) => offset < currentScroll - 1);
  176. window.scrollTo({
  177. top: previous !== undefined ? previous : 0,
  178. behavior: "smooth",
  179. });
  180. },
  181. updateCurrentPlaylistVideos() {
  182. if (
  183. !this.currentPlaylistName ||
  184. !this.playlists[this.currentPlaylistName]
  185. ) {
  186. this.currentPlaylistVideos = [];
  187. this.otherPlaylists = [];
  188. } else {
  189. this.currentPlaylistVideos = this.playlists[
  190. this.currentPlaylistName
  191. ].map((video, index) => ({
  192. ...video,
  193. doneButtonText:
  194. video.status === "done" ? "Remove Done Status" : "Mark as Done",
  195. isNonContiguousDone: this.isNonContiguousDone(
  196. this.currentPlaylistName,
  197. index,
  198. ),
  199. }));
  200. this.otherPlaylists = Object.keys(this.playlists)
  201. .filter((name) => name !== this.currentPlaylistName)
  202. .map((name) => ({ name, display: this.formatPlaylistName(name) }));
  203. }
  204. },
  205. async loadHistory() {
  206. try {
  207. const result = await browser.storage.local.get("history");
  208. console.log("LOAD HISTORY RESULT", result.history);
  209. this.history = result.history || {};
  210. this.sortHistoryByRecentInteraction();
  211. } catch (error) {
  212. console.error("Error loading history:", error);
  213. }
  214. },
  215. async getCurrentTab() {
  216. try {
  217. const tabs = await browser.tabs.query({
  218. active: true,
  219. currentWindow: true,
  220. });
  221. if (tabs.length > 0) {
  222. this.currentTab = tabs[0];
  223. const url = new URL(this.currentTab.url);
  224. this.isCurrentTabYoutube = url.hostname === "www.youtube.com";
  225. this.isCurrentTabChannelPage =
  226. this.isCurrentTabYoutube && url.pathname.includes("/videos");
  227. this.isCurrentVideoYoutube =
  228. this.isCurrentTabYoutube && url.pathname.includes("/watch");
  229. // Get video timestamp if on YouTube video page
  230. if (this.isCurrentVideoYoutube) {
  231. await this.getVideoTimestamp();
  232. }
  233. this.updateAddCurrentPageButtonText();
  234. this.updateCurlCommand();
  235. this.updateWikitextCommand();
  236. }
  237. } catch (error) {
  238. console.error("Error getting current tab:", error);
  239. this.currentTab = null;
  240. this.isCurrentTabYoutube = false;
  241. this.isCurrentTabChannelPage = false;
  242. this.isCurrentVideoYoutube = false;
  243. this.updateAddCurrentPageButtonText();
  244. this.updateCurlCommand();
  245. this.updateWikitextCommand();
  246. }
  247. },
  248. async getVideoTimestamp() {
  249. try {
  250. // Execute script in the current tab to get video timestamp
  251. const results = await browser.tabs.executeScript(this.currentTab.id, {
  252. code: `
  253. (function() {
  254. const video = document.querySelector('video');
  255. if (video) {
  256. const currentTime = video.currentTime;
  257. const duration = video.duration;
  258. const ended = video.ended;
  259. const formatTime = (seconds) => {
  260. const hours = Math.floor(seconds / 3600);
  261. const minutes = Math.floor((seconds % 3600) / 60);
  262. const remainingSeconds = Math.floor(seconds % 60);
  263. if (hours > 0) {
  264. return hours + ':' + minutes.toString().padStart(2, '0') + ':' + remainingSeconds.toString().padStart(2, '0');
  265. } else {
  266. return minutes + ':' + remainingSeconds.toString().padStart(2, '0');
  267. }
  268. };
  269. return {
  270. currentTime: currentTime,
  271. formattedTime: formatTime(currentTime),
  272. duration: duration,
  273. ended: ended,
  274. isLongVideo: duration > 3600
  275. };
  276. }
  277. return null;
  278. })();
  279. `
  280. });
  281. if (results && results[0]) {
  282. const videoData = results[0];
  283. this.currentVideoTimestamp = videoData.formattedTime;
  284. this.hasVideoEnded = videoData.ended;
  285. } else {
  286. this.currentVideoTimestamp = "0:00";
  287. this.hasVideoEnded = false;
  288. }
  289. } catch (error) {
  290. console.error("Error getting video timestamp:", error);
  291. this.currentVideoTimestamp = "0:00";
  292. this.hasVideoEnded = false;
  293. }
  294. },
  295. updateAddCurrentPageButtonText() {
  296. if (!this.currentTab) {
  297. this.addCurrentPageButtonText = "Unable to get page";
  298. } else if (!this.isCurrentTabYoutube) {
  299. this.addCurrentPageButtonText = "Add Cur. Page (YT only)";
  300. } else {
  301. this.addCurrentPageButtonText = "Add Cur. Page";
  302. }
  303. },
  304. updateCurlCommand() {
  305. if (!this.currentTab || !this.isCurrentTabChannelPage) {
  306. this.curlCommand =
  307. "This feature is only available on YouTube channel pages (/videos).";
  308. return;
  309. }
  310. const title = this.currentTab.title.replace(" - YouTube", "");
  311. const url = this.currentTab.url;
  312. const category = this.selectedCategory;
  313. const interval = this.checkInterval;
  314. this.curlCommand = `curl -X POST -H 'content-type: application/json' -d '{"query": "mutation add{ addChannel(details: {category: \\"${category}\\", checkInterval: ${interval}, name: \\"${title}\\", url: \\"${url}\\"}){name} } "}' localhost:8543/data | jq '.'`;
  315. },
  316. async copyCurlCommand() {
  317. try {
  318. await navigator.clipboard.writeText(this.curlCommand);
  319. console.log("Curl command copied to clipboard");
  320. // Could add visual feedback here
  321. } catch (error) {
  322. console.error("Failed to copy to clipboard:", error);
  323. // Fallback for older browsers
  324. try {
  325. const textArea = document.createElement("textarea");
  326. textArea.value = this.curlCommand;
  327. document.body.appendChild(textArea);
  328. textArea.focus();
  329. textArea.select();
  330. document.execCommand("copy");
  331. document.body.removeChild(textArea);
  332. console.log("Curl command copied to clipboard (fallback)");
  333. } catch (fallbackError) {
  334. console.error("Fallback copy failed:", fallbackError);
  335. }
  336. }
  337. },
  338. selectCategory(category) {
  339. this.selectedCategory = category;
  340. this.updateCurlCommand();
  341. },
  342. updateCheckInterval(interval) {
  343. // Ensure it's a positive integer, default to 14 if invalid
  344. const parsedInterval = parseInt(interval);
  345. this.checkInterval = parsedInterval > 0 ? parsedInterval : 14;
  346. this.updateCurlCommand();
  347. },
  348. showSaveChannel() {
  349. this.currentView = "saveChannel";
  350. this.updateCurlCommand();
  351. },
  352. showWikiInsp() {
  353. this.currentView = "wikiInsp";
  354. // Refresh timestamp when opening the view
  355. if (this.isCurrentVideoYoutube) {
  356. this.getVideoTimestamp().then(() => {
  357. this.updateWikitextCommand();
  358. });
  359. } else {
  360. this.updateWikitextCommand();
  361. }
  362. },
  363. showImport() {
  364. this.currentView = "import";
  365. },
  366. async refreshVideoTimestamp() {
  367. if (this.isCurrentVideoYoutube) {
  368. await this.getVideoTimestamp();
  369. this.updateWikitextCommand();
  370. }
  371. },
  372. updateWikitextCommand() {
  373. if (!this.currentTab || !this.isCurrentVideoYoutube) {
  374. this.wikitextCommand = "This feature is only available on YouTube videos.";
  375. return;
  376. }
  377. const today = new Date();
  378. const dateString = today.toISOString().split('T')[0]; // YYYY-MM-DD format
  379. const title = this.currentTab.title.replace(" - YouTube", "").replace(/\|/g, "-");
  380. const url = this.currentTab.url;
  381. let wikitext = `!!! ${dateString}\n* [[${title}|${url}]] (yt)`;
  382. // Add timestamp if user has enabled it and video hasn't ended
  383. if (this.includeTimestamp && !this.hasVideoEnded) {
  384. wikitext += `\n** ${this.currentVideoTimestamp || "0:00"} - `;
  385. }
  386. this.wikitextCommand = wikitext;
  387. },
  388. async copyWikitextCommand() {
  389. try {
  390. await navigator.clipboard.writeText(this.wikitextCommand);
  391. console.log("Wikitext copied to clipboard");
  392. // Could add visual feedback here
  393. } catch (error) {
  394. console.error("Failed to copy to clipboard:", error);
  395. // Fallback for older browsers
  396. try {
  397. const textArea = document.createElement("textarea");
  398. textArea.value = this.wikitextCommand;
  399. document.body.appendChild(textArea);
  400. textArea.focus();
  401. textArea.select();
  402. document.execCommand("copy");
  403. document.body.removeChild(textArea);
  404. console.log("Wikitext copied to clipboard (fallback)");
  405. } catch (fallbackError) {
  406. console.error("Fallback copy failed:", fallbackError);
  407. }
  408. }
  409. },
  410. toggleTimestamp() {
  411. this.includeTimestamp = !this.includeTimestamp;
  412. this.updateWikitextCommand();
  413. },
  414. sortHistoryByRecentInteraction() {
  415. // Convert history object to array with video ID and sort by most recent interaction
  416. this.sortedHistory = Object.entries(this.history)
  417. .map(([videoId, videoData]) => {
  418. const lastInteraction =
  419. videoData.history.length > 0
  420. ? Math.max(...videoData.history.map((event) => event.timestamp))
  421. : 0;
  422. // Pre-process events with formatted data for CSP compliance
  423. const processedEvents = videoData.history
  424. .slice()
  425. .reverse()
  426. .map((event, index) => ({
  427. ...event,
  428. formattedAction: this.formatActionName(event.action),
  429. formattedPosition: `at ${this.formatVideoPosition(event.position)}`,
  430. formattedTimestamp: this.formatTimestamp(event.timestamp),
  431. uniqueKey: `${videoId}-${event.timestamp}-${index}`,
  432. }));
  433. return {
  434. videoId,
  435. formattedVideoId: `(${videoId})`,
  436. ...videoData,
  437. lastInteraction,
  438. processedEvents,
  439. tags: videoData.tags || [], // Ensure tags array exists
  440. };
  441. })
  442. .sort((a, b) => b.lastInteraction - a.lastInteraction);
  443. },
  444. formatTimestamp(timestamp) {
  445. const date = new Date(timestamp);
  446. const now = new Date();
  447. const diffMs = now - date;
  448. const diffMins = Math.floor(diffMs / (1000 * 60));
  449. const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
  450. const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  451. if (diffMins < 1) return "Just now";
  452. if (diffMins < 60)
  453. return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
  454. if (diffHours < 24)
  455. return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
  456. if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
  457. return (
  458. date.toLocaleDateString() +
  459. " " +
  460. date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
  461. );
  462. },
  463. formatVideoPosition(seconds) {
  464. const minutes = Math.floor(seconds / 60);
  465. const remainingSeconds = Math.floor(seconds % 60);
  466. return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
  467. },
  468. async toggleTag(videoId, tag) {
  469. // Create a deep copy of history to avoid proxy issues
  470. const history = JSON.parse(JSON.stringify(this.history));
  471. if (!history[videoId]) {
  472. console.error(`Video ${videoId} not found in history`);
  473. return;
  474. }
  475. const videoData = history[videoId];
  476. if (!videoData.tags) {
  477. videoData.tags = [];
  478. }
  479. const tagIndex = videoData.tags.indexOf(tag);
  480. if (tagIndex === -1) {
  481. // Add tag
  482. videoData.tags.push(tag);
  483. } else {
  484. // Remove tag
  485. videoData.tags.splice(tagIndex, 1);
  486. }
  487. // Save to storage and update local state
  488. try {
  489. await browser.storage.local.set({ history: history });
  490. this.history = history;
  491. this.sortHistoryByRecentInteraction(); // Refresh display
  492. } catch (error) {
  493. console.error("Error saving tag changes:", error);
  494. }
  495. },
  496. isTagActive(videoId, tag) {
  497. const videoData = this.history[videoId];
  498. return videoData && videoData.tags && videoData.tags.includes(tag);
  499. },
  500. formatActionName(action) {
  501. const actionMap = {
  502. play: "Started",
  503. playing: "Playing",
  504. pause: "Paused",
  505. ended: "Finished",
  506. };
  507. return actionMap[action] || action;
  508. },
  509. formatPlaylistName(name) {
  510. // Convert "listening-1" to "Listening - 1"
  511. return name
  512. .split("-")
  513. .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
  514. .join(" - ");
  515. },
  516. openVideo(url) {
  517. browser.tabs.create({ url });
  518. },
  519. async removeVideo(playlistName, index) {
  520. const playlists = JSON.parse(JSON.stringify(this.playlists));
  521. // Make a copy of the current playlist
  522. const playlist = [...playlists[playlistName]];
  523. // Remove the video at the specified index
  524. playlist.splice(index, 1);
  525. // Create an updated playlists object with the remaining playlists unchanged
  526. const updatedPlaylists = {
  527. ...playlists,
  528. [playlistName]: playlist,
  529. };
  530. // Update the playlists in storage
  531. try {
  532. await browser.storage.local.set({ playlists: updatedPlaylists });
  533. this.playlists = updatedPlaylists;
  534. this.updatePlaylistsForDisplay();
  535. this.updateCurrentPlaylistVideos();
  536. } catch (error) {
  537. console.error("Error removing video:", error);
  538. }
  539. },
  540. async moveVideoUp(playlistName, index) {
  541. // Can't move the first item up
  542. if (index <= 0) return;
  543. const playlists = JSON.parse(JSON.stringify(this.playlists));
  544. // Make a copy of the current playlist
  545. const playlist = [...playlists[playlistName]];
  546. // Swap the video with the one above it
  547. [playlist[index], playlist[index - 1]] = [
  548. playlist[index - 1],
  549. playlist[index],
  550. ];
  551. // Create an updated playlists object
  552. const updatedPlaylists = {
  553. ...playlists,
  554. [playlistName]: playlist,
  555. };
  556. // Update the playlists in storage
  557. try {
  558. await browser.storage.local.set({ playlists: updatedPlaylists });
  559. this.playlists = updatedPlaylists;
  560. this.updatePlaylistsForDisplay();
  561. this.updateCurrentPlaylistVideos();
  562. } catch (error) {
  563. console.error("Error moving video up:", error);
  564. }
  565. },
  566. async moveVideoDown(playlistName, index) {
  567. const playlists = JSON.parse(JSON.stringify(this.playlists));
  568. const playlist = [...playlists[playlistName]];
  569. // Can't move the last item down
  570. if (index >= playlist.length - 1) return;
  571. // Swap the video with the one below it
  572. [playlist[index], playlist[index + 1]] = [
  573. playlist[index + 1],
  574. playlist[index],
  575. ];
  576. // Create an updated playlists object
  577. const updatedPlaylists = {
  578. ...playlists,
  579. [playlistName]: playlist,
  580. };
  581. // Update the playlists in storage
  582. try {
  583. await browser.storage.local.set({ playlists: updatedPlaylists });
  584. this.playlists = updatedPlaylists;
  585. this.updatePlaylistsForDisplay();
  586. this.updateCurrentPlaylistVideos();
  587. } catch (error) {
  588. console.error("Error moving video down:", error);
  589. }
  590. },
  591. async moveVideoToTop(playlistName, index) {
  592. const playlists = JSON.parse(JSON.stringify(this.playlists));
  593. const playlist = [...playlists[playlistName]];
  594. const topPosition = this.findTopPosition(playlistName);
  595. if (index === topPosition + 1) return;
  596. const video = playlist.splice(index, 1)[0];
  597. playlist.splice(topPosition + 1, 0, video);
  598. const updatedPlaylists = {
  599. ...playlists,
  600. [playlistName]: playlist,
  601. };
  602. try {
  603. await browser.storage.local.set({ playlists: updatedPlaylists });
  604. this.playlists = updatedPlaylists;
  605. this.updatePlaylistsForDisplay();
  606. this.updateCurrentPlaylistVideos();
  607. } catch (error) {
  608. console.error("Error moving video to top:", error);
  609. }
  610. },
  611. async moveVideoToBottom(playlistName, index) {
  612. const playlists = JSON.parse(JSON.stringify(this.playlists));
  613. const playlist = [...playlists[playlistName]];
  614. if (index === playlist.length - 1) return;
  615. const video = playlist.splice(index, 1)[0];
  616. playlist.push(video);
  617. const updatedPlaylists = {
  618. ...playlists,
  619. [playlistName]: playlist,
  620. };
  621. try {
  622. await browser.storage.local.set({ playlists: updatedPlaylists });
  623. this.playlists = updatedPlaylists;
  624. this.updatePlaylistsForDisplay();
  625. this.updateCurrentPlaylistVideos();
  626. } catch (error) {
  627. console.error("Error moving video to bottom:", error);
  628. }
  629. },
  630. async moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName) {
  631. const playlists = JSON.parse(JSON.stringify(this.playlists));
  632. const video = playlists[fromPlaylistName].splice(videoIndex, 1)[0];
  633. playlists[toPlaylistName].push(video);
  634. try {
  635. await browser.storage.local.set({ playlists });
  636. this.playlists = playlists;
  637. this.updatePlaylistsForDisplay();
  638. this.updateCurrentPlaylistVideos();
  639. } catch (error) {
  640. console.error("Error moving video to playlist:", error);
  641. }
  642. },
  643. findTopPosition(playlistName) {
  644. const playlist = this.playlists[playlistName];
  645. if (!playlist) return 0;
  646. for (let i = 0; i < playlist.length; i++) {
  647. if (playlist[i].status !== "done") {
  648. return i;
  649. }
  650. }
  651. return playlist.length;
  652. },
  653. async scatterLastN(playlistName, count) {
  654. const playlists = JSON.parse(JSON.stringify(this.playlists));
  655. const playlist = playlists[playlistName];
  656. const n = playlist.length;
  657. if (!playlist || count < 2 || count >= n) return;
  658. const prefix = playlist.slice(0, n - count);
  659. const tail = playlist.slice(n - count);
  660. const insertions = [];
  661. let cursor = prefix.length;
  662. for (let i = count - 2; i >= 0; i--) {
  663. const gap = Math.floor(Math.random() * 3) + 1;
  664. cursor = Math.max(1, cursor - gap);
  665. insertions.push([cursor, tail[i]]);
  666. }
  667. for (const [idx, video] of insertions) {
  668. prefix.splice(idx, 0, video);
  669. }
  670. prefix.push(tail[count - 1]);
  671. playlists[playlistName] = prefix;
  672. try {
  673. await browser.storage.local.set({ playlists });
  674. this.playlists = playlists;
  675. this.updatePlaylistsForDisplay();
  676. this.updateCurrentPlaylistVideos();
  677. } catch (error) {
  678. console.error("Error scattering videos:", error);
  679. }
  680. },
  681. async toggleVideoDoneStatus(playlistName, index) {
  682. const playlists = JSON.parse(JSON.stringify(this.playlists));
  683. const playlist = [...playlists[playlistName]];
  684. const video = { ...playlist[index] };
  685. // Toggle the done status
  686. if (video.status === "done") {
  687. // Remove status property (undefined status means not done)
  688. delete video.status;
  689. } else {
  690. // Set status to done
  691. video.status = "done";
  692. }
  693. // Update the video in the playlist
  694. playlist[index] = video;
  695. // Create an updated playlists object
  696. const updatedPlaylists = {
  697. ...playlists,
  698. [playlistName]: playlist,
  699. };
  700. // Update the playlists in storage
  701. try {
  702. await browser.storage.local.set({ playlists: updatedPlaylists });
  703. this.playlists = updatedPlaylists;
  704. this.updatePlaylistsForDisplay();
  705. this.updateCurrentPlaylistVideos();
  706. } catch (error) {
  707. console.error("Error toggling video done status:", error);
  708. }
  709. },
  710. async addCurrentPageToPlaylist() {
  711. if (
  712. !this.currentTab ||
  713. !this.isCurrentTabYoutube ||
  714. !this.currentPlaylistName
  715. ) {
  716. return;
  717. }
  718. // Create video object using current tab info
  719. const video = {
  720. url: this.currentTab.url,
  721. title: this.currentTab.title,
  722. };
  723. // Use shared utility function to add video (handles duplicate checking)
  724. const wasAdded = await PlaylistUtils.addVideoToPlaylist(
  725. this.currentPlaylistName,
  726. video,
  727. );
  728. if (wasAdded) {
  729. // Refresh displays only if video was actually added
  730. this.loadPlaylists(); // This will update both display arrays
  731. } else {
  732. // Could show user feedback that video already exists
  733. console.log("Video already exists in a playlist");
  734. }
  735. },
  736. async exportPlaylists() {
  737. try {
  738. // Get current playlists
  739. const playlistResult = await browser.storage.local.get("playlists");
  740. const playlists = playlistResult.playlists || {};
  741. // Get playback history
  742. const historyResult = await browser.storage.local.get("history");
  743. const playbackHistory = historyResult.history || {};
  744. // Get open tabs
  745. let openTabs = [];
  746. try {
  747. const tabsResult = await browser.tabs.query({});
  748. openTabs = tabsResult
  749. .filter(
  750. (tab) =>
  751. tab.url &&
  752. !tab.url.startsWith("moz-extension://") &&
  753. !tab.url.startsWith("about:") &&
  754. !tab.url.startsWith("chrome://"),
  755. )
  756. .map((tab) => ({
  757. url: tab.url,
  758. title: tab.title || "Untitled",
  759. }));
  760. } catch (error) {
  761. console.warn("Could not retrieve open tabs:", error);
  762. openTabs = [];
  763. }
  764. // Create export data object
  765. const exportData = {
  766. playlists,
  767. playbackHistory,
  768. openTabs,
  769. exportDate: new Date().toISOString(),
  770. };
  771. // Convert to JSON
  772. const jsonString = JSON.stringify(exportData, null, 2);
  773. // Create download
  774. const blob = new Blob([jsonString], { type: "application/json" });
  775. const url = URL.createObjectURL(blob);
  776. // Trigger download
  777. const a = document.createElement("a");
  778. a.href = url;
  779. a.download = `playlists-export-${new Date().toISOString().split("T")[0]}.json`;
  780. document.body.appendChild(a);
  781. a.click();
  782. // Clean up
  783. setTimeout(() => {
  784. document.body.removeChild(a);
  785. URL.revokeObjectURL(url);
  786. }, 100);
  787. } catch (error) {
  788. console.error("Error exporting playlists:", error);
  789. }
  790. },
  791. videotitle: {
  792. ["@click"]() {
  793. console.log("TITLE CLICK", this.$el);
  794. },
  795. },
  796. videoPlayLink: {
  797. ["@click.prevent"]() {
  798. browser.tabs.update({ url: this.$el.href });
  799. },
  800. },
  801. isNonContiguousDone(playlistName, videoIndex) {
  802. const playlist = this.playlists[playlistName];
  803. if (
  804. !playlist ||
  805. !playlist[videoIndex] ||
  806. playlist[videoIndex].status !== "done"
  807. ) {
  808. return false;
  809. }
  810. // Check if there's any non-done video before this done video
  811. for (let i = 0; i < videoIndex; i++) {
  812. if (playlist[i].status !== "done") {
  813. return true;
  814. }
  815. }
  816. return false;
  817. },
  818. isCurrentVideo(playlistName, index) {
  819. const currentIndex = this.currentIndices[playlistName];
  820. return currentIndex === index;
  821. },
  822. isDoneVideo(playlistName, index) {
  823. const currentIndex = this.currentIndices[playlistName];
  824. return index < currentIndex;
  825. },
  826. isVideoDone(playlistName, index) {
  827. const video =
  828. this.playlists[playlistName] && this.playlists[playlistName][index];
  829. return video && video.status === "done";
  830. },
  831. videoItemClass: {
  832. [":class"]() {
  833. const playlistName = this.$el.dataset.playlistName;
  834. const index = parseInt(this.$el.dataset.playlistIndex);
  835. const video = this.playlists[playlistName][index];
  836. return {
  837. "current-video": this.isCurrentVideo(playlistName, index),
  838. "done-video":
  839. this.isDoneVideo(playlistName, index) && video.status === "done",
  840. "non-contiguous-done-video": this.isNonContiguousDone(
  841. playlistName,
  842. index,
  843. ),
  844. };
  845. },
  846. },
  847. removeVideoButton: {
  848. ["@click"]() {
  849. this.removeVideo(
  850. this.$el.dataset.playlistName,
  851. this.$el.dataset.playlistIndex,
  852. );
  853. this.closeAllMenus();
  854. },
  855. },
  856. toggleVideoDoneButton: {
  857. ["@click"]() {
  858. this.toggleVideoDoneStatus(
  859. this.$el.dataset.playlistName,
  860. parseInt(this.$el.dataset.playlistIndex),
  861. );
  862. this.closeAllMenus();
  863. },
  864. },
  865. moveUpButton: {
  866. ["@click"]() {
  867. this.moveVideoUp(
  868. this.$el.dataset.playlistName,
  869. parseInt(this.$el.dataset.playlistIndex),
  870. );
  871. },
  872. [":disabled"]() {
  873. return parseInt(this.$el.dataset.playlistIndex) === 0;
  874. },
  875. },
  876. moveDownButton: {
  877. ["@click"]() {
  878. this.moveVideoDown(
  879. this.$el.dataset.playlistName,
  880. parseInt(this.$el.dataset.playlistIndex),
  881. );
  882. },
  883. [":disabled"]() {
  884. return (
  885. parseInt(this.$el.dataset.playlistIndex) ===
  886. this.playlists[this.$el.dataset.playlistName].length - 1
  887. );
  888. },
  889. },
  890. moveToTopButton: {
  891. ["@click"]() {
  892. this.moveVideoToTop(
  893. this.$el.dataset.playlistName,
  894. parseInt(this.$el.dataset.playlistIndex),
  895. );
  896. this.closeAllMenus();
  897. },
  898. [":disabled"]() {
  899. const index = parseInt(this.$el.dataset.playlistIndex);
  900. const playlistName = this.$el.dataset.playlistName;
  901. const topPosition = this.findTopPosition(playlistName);
  902. return index === topPosition + 1;
  903. },
  904. },
  905. moveToBottomButton: {
  906. ["@click"]() {
  907. this.moveVideoToBottom(
  908. this.$el.dataset.playlistName,
  909. parseInt(this.$el.dataset.playlistIndex),
  910. );
  911. this.closeAllMenus();
  912. },
  913. [":disabled"]() {
  914. return (
  915. parseInt(this.$el.dataset.playlistIndex) ===
  916. this.playlists[this.$el.dataset.playlistName].length - 1
  917. );
  918. },
  919. },
  920. moveToSubmenuButton: {
  921. ["@click.stop"]() {
  922. const playlistName = this.$el.dataset.playlistName;
  923. const index = parseInt(this.$el.dataset.playlistIndex);
  924. const menuId = this.getMenuId(playlistName, index);
  925. this.moveSubmenuOpenFor = this.moveSubmenuOpenFor === menuId ? null : menuId;
  926. },
  927. },
  928. moveToSubmenu: {
  929. ["x-show"]() {
  930. const playlistName = this.$el.dataset.playlistName;
  931. const index = parseInt(this.$el.dataset.playlistIndex);
  932. return this.moveSubmenuOpenFor === this.getMenuId(playlistName, index);
  933. },
  934. },
  935. moveToPlaylistButton: {
  936. ["@click"]() {
  937. const fromPlaylistName = this.$el.dataset.fromPlaylistName;
  938. const videoIndex = parseInt(this.$el.dataset.playlistIndex);
  939. const toPlaylistName = this.$el.dataset.toPlaylistName;
  940. this.moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName);
  941. this.closeAllMenus();
  942. },
  943. },
  944. exportButton: {
  945. ["@click"]() {
  946. this.exportPlaylists();
  947. },
  948. },
  949. importButton: {
  950. ["@click"]() {
  951. this.showImport();
  952. },
  953. },
  954. async handleImport() {
  955. if (!this.importText.trim()) {
  956. alert("Please paste your JSON export data");
  957. return;
  958. }
  959. try {
  960. const importedData = JSON.parse(this.importText);
  961. this.validateAndImportPlaylists(importedData);
  962. this.importText = "";
  963. } catch (error) {
  964. console.error("Error parsing JSON:", error);
  965. alert(
  966. "Invalid JSON format. Please paste valid playlist export data.",
  967. );
  968. }
  969. },
  970. validateAndImportPlaylists(data) {
  971. // Validate the playlists structure
  972. if (!data.playlists || typeof data.playlists !== "object") {
  973. alert("Invalid file format: Missing or invalid 'playlists' property");
  974. return;
  975. }
  976. // Validate each playlist
  977. const validPlaylists = {};
  978. let hasErrors = false;
  979. for (const [playlistName, videos] of Object.entries(data.playlists)) {
  980. // Check if videos is an array
  981. if (!Array.isArray(videos)) {
  982. console.error(
  983. `Playlist '${playlistName}' does not contain a valid array of videos`,
  984. );
  985. hasErrors = true;
  986. continue;
  987. }
  988. // Validate each video in the playlist and filter out done videos
  989. const validVideos = videos.filter((video) => {
  990. if (!video || typeof video !== "object") {
  991. console.error(`Invalid video object in '${playlistName}'`);
  992. return false;
  993. }
  994. if (
  995. !video.url ||
  996. typeof video.url !== "string" ||
  997. !video.title ||
  998. typeof video.title !== "string"
  999. ) {
  1000. console.error(
  1001. `Video in '${playlistName}' missing required properties (url, title)`,
  1002. );
  1003. return false;
  1004. }
  1005. if (video.status === "done") {
  1006. return false;
  1007. }
  1008. return true;
  1009. });
  1010. // Add the validated playlist if it has valid videos
  1011. if (validVideos.length > 0) {
  1012. validPlaylists[playlistName] = validVideos;
  1013. }
  1014. }
  1015. if (Object.keys(validPlaylists).length === 0) {
  1016. alert("No valid playlists found in the import file");
  1017. return;
  1018. }
  1019. if (hasErrors) {
  1020. const confirmImport = confirm(
  1021. "Some playlists or videos were invalid and will be skipped. Do you want to continue with the import?",
  1022. );
  1023. if (!confirmImport) return;
  1024. }
  1025. // Update storage and state
  1026. this.appendToPlaylists(validPlaylists);
  1027. },
  1028. async appendToPlaylists(importedPlaylists) {
  1029. try {
  1030. const currentPlaylists = JSON.parse(JSON.stringify(this.playlists));
  1031. let addedCount = 0;
  1032. let skippedCount = 0;
  1033. for (const [playlistName, importedVideos] of Object.entries(importedPlaylists)) {
  1034. if (!currentPlaylists[playlistName]) {
  1035. currentPlaylists[playlistName] = [];
  1036. }
  1037. for (const video of importedVideos) {
  1038. const alreadyExists = PlaylistUtils.findPlaylist(currentPlaylists, video.url);
  1039. if (!alreadyExists) {
  1040. currentPlaylists[playlistName].push(video);
  1041. addedCount++;
  1042. } else {
  1043. skippedCount++;
  1044. }
  1045. }
  1046. }
  1047. await browser.storage.local.set({ playlists: currentPlaylists });
  1048. this.playlists = currentPlaylists;
  1049. this.updatePlaylistsForDisplay();
  1050. this.updateCurrentPlaylistVideos();
  1051. alert(`Import complete! Added ${addedCount} video(s), skipped ${skippedCount} duplicate(s).`);
  1052. } catch (error) {
  1053. console.error("Error appending playlists:", error);
  1054. alert("Error importing playlists: " + error.message);
  1055. }
  1056. },
  1057. getMenuId(playlistName, index) {
  1058. return `${playlistName}-${index}`;
  1059. },
  1060. isMenuOpen(playlistName, index) {
  1061. return this.openMenus.has(this.getMenuId(playlistName, index));
  1062. },
  1063. toggleMenu(playlistName, index) {
  1064. const menuId = this.getMenuId(playlistName, index);
  1065. if (this.openMenus.has(menuId)) {
  1066. this.openMenus.delete(menuId);
  1067. } else {
  1068. this.openMenus.clear();
  1069. this.openMenus.add(menuId);
  1070. }
  1071. this.moveSubmenuOpenFor = null;
  1072. },
  1073. closeAllMenus() {
  1074. this.openMenus.clear();
  1075. this.moveSubmenuOpenFor = null;
  1076. },
  1077. moreMenuButton: {
  1078. ["@click.stop"]() {
  1079. this.toggleMenu(
  1080. this.$el.dataset.playlistName,
  1081. parseInt(this.$el.dataset.playlistIndex),
  1082. );
  1083. },
  1084. },
  1085. moreMenu: {
  1086. ["x-show"]() {
  1087. return this.isMenuOpen(
  1088. this.$el.dataset.playlistName,
  1089. parseInt(this.$el.dataset.playlistIndex),
  1090. );
  1091. },
  1092. },
  1093. // View navigation methods
  1094. showHistory() {
  1095. this.currentView = "history";
  1096. this.loadHistory(); // Refresh history data when switching to history view
  1097. },
  1098. showPlaylists() {
  1099. this.currentView = "playlists";
  1100. },
  1101. showPlaylist(playlistName) {
  1102. this.currentView = "playlist";
  1103. this.currentPlaylistName = playlistName;
  1104. this.updateCurrentPlaylistVideos();
  1105. },
  1106. // Methods for truncated display
  1107. shouldShowTruncation(playlistName) {
  1108. const currentIndex = this.currentIndices[playlistName] || 0;
  1109. return currentIndex > 0;
  1110. },
  1111. getTruncationText(playlistName) {
  1112. const currentIndex = this.currentIndices[playlistName] || 0;
  1113. const count = currentIndex;
  1114. return `(${count} previous video${count === 1 ? "" : "s"})`;
  1115. },
  1116. getVisibleVideos(playlistName, videos) {
  1117. const currentIndex = this.currentIndices[playlistName] || 0;
  1118. // Show from current video onwards, but keep original indices
  1119. return videos.slice(currentIndex).map((video, index) => ({
  1120. ...video,
  1121. originalIndex: currentIndex + index,
  1122. }));
  1123. },
  1124. getCurrentPlaylistVideos() {
  1125. if (
  1126. !this.currentPlaylistName ||
  1127. !this.playlists[this.currentPlaylistName]
  1128. ) {
  1129. return [];
  1130. }
  1131. return this.playlists[this.currentPlaylistName];
  1132. },
  1133. // Button bindings for navigation
  1134. historyButton: {
  1135. ["@click"]() {
  1136. this.showHistory();
  1137. },
  1138. },
  1139. saveChannelButton: {
  1140. ["@click"]() {
  1141. this.showSaveChannel();
  1142. },
  1143. },
  1144. wikiInspButton: {
  1145. ["@click"]() {
  1146. this.showWikiInsp();
  1147. },
  1148. },
  1149. backButton: {
  1150. ["@click"]() {
  1151. this.showPlaylists();
  1152. },
  1153. },
  1154. // Event handlers for playlist navigation
  1155. playlistNameClick: {
  1156. ["@click"]() {
  1157. const playlistName = this.$el.dataset.playlistName;
  1158. this.showPlaylist(playlistName);
  1159. },
  1160. },
  1161. truncatedVideosClick: {
  1162. ["@click"]() {
  1163. const playlistName = this.$el.dataset.playlistName;
  1164. this.showPlaylist(playlistName);
  1165. },
  1166. },
  1167. truncatedVideosClick: {
  1168. ["@click"]() {
  1169. const playlistName = this.$el.dataset.playlistName;
  1170. this.showPlaylist(playlistName);
  1171. },
  1172. },
  1173. truncatedVideosDisplay: {
  1174. ["x-show"]() {
  1175. const playlistName = this.$el.dataset.playlistName;
  1176. const playlistData = this.playlistsForDisplay.find(
  1177. (p) => p.name === playlistName,
  1178. );
  1179. return playlistData ? playlistData.shouldShowTruncation : false;
  1180. },
  1181. ["@click"]() {
  1182. const playlistName = this.$el.dataset.playlistName;
  1183. this.showPlaylist(playlistName);
  1184. },
  1185. },
  1186. // CSP-compliant view bindings
  1187. playlistsHeader: {
  1188. ["x-show"]() {
  1189. return this.currentView === "playlists";
  1190. },
  1191. },
  1192. historyHeader: {
  1193. ["x-show"]() {
  1194. return this.currentView === "history";
  1195. },
  1196. },
  1197. playlistViewHeader: {
  1198. ["x-show"]() {
  1199. return this.currentView === "playlist";
  1200. },
  1201. },
  1202. saveChannelHeader: {
  1203. ["x-show"]() {
  1204. return this.currentView === "saveChannel";
  1205. },
  1206. },
  1207. wikiInspHeader: {
  1208. ["x-show"]() {
  1209. return this.currentView === "wikiInsp";
  1210. },
  1211. },
  1212. importHeader: {
  1213. ["x-show"]() {
  1214. return this.currentView === "import";
  1215. },
  1216. },
  1217. playlistsContainer: {
  1218. ["x-show"]() {
  1219. return this.currentView === "playlists";
  1220. },
  1221. },
  1222. historyContainer: {
  1223. ["x-show"]() {
  1224. return this.currentView === "history";
  1225. },
  1226. },
  1227. addCurrentPageContainer: {
  1228. ["x-show"]() {
  1229. return this.currentView === "playlist";
  1230. },
  1231. },
  1232. playlistViewContainer: {
  1233. ["x-show"]() {
  1234. return this.currentView === "playlist";
  1235. },
  1236. },
  1237. saveChannelContainer: {
  1238. ["x-show"]() {
  1239. return this.currentView === "saveChannel";
  1240. },
  1241. },
  1242. wikiInspContainer: {
  1243. ["x-show"]() {
  1244. return this.currentView === "wikiInsp";
  1245. },
  1246. },
  1247. importContainer: {
  1248. ["x-show"]() {
  1249. return this.currentView === "import";
  1250. },
  1251. },
  1252. exportContainer: {
  1253. ["x-show"]() {
  1254. return this.currentView === "playlists";
  1255. },
  1256. },
  1257. playlistJumpButtons: {
  1258. ["x-show"]() {
  1259. return this.currentView === "playlists";
  1260. },
  1261. },
  1262. scrollUpButton: {
  1263. ["@click"]() {
  1264. this.scrollToPreviousPlaylistBoundary();
  1265. },
  1266. [":disabled"]() {
  1267. // Read reactive properties unconditionally (before any early return)
  1268. // so Alpine always tracks them as dependencies for this binding,
  1269. // regardless of which branch below ends up short-circuiting.
  1270. const scrollTop = this.playlistsScrollTop;
  1271. const playlistCount = this.playlistsForDisplay.length;
  1272. if (playlistCount < 2 || !this.hasScrollableOverflow()) return true;
  1273. return scrollTop <= 0;
  1274. },
  1275. },
  1276. scrollDownButton: {
  1277. ["@click"]() {
  1278. this.scrollToNextPlaylistBoundary();
  1279. },
  1280. [":disabled"]() {
  1281. const scrollTop = this.playlistsScrollTop;
  1282. const playlistCount = this.playlistsForDisplay.length;
  1283. if (playlistCount < 2 || !this.hasScrollableOverflow()) return true;
  1284. const offsets = this.getPlaylistBoundaryOffsets();
  1285. if (offsets.length === 0) return true;
  1286. // The last playlist's heading may sit past the maximum scrollable
  1287. // position (there isn't enough content below it to scroll that far),
  1288. // so cap the target at whichever is reachable.
  1289. const scroller = document.scrollingElement;
  1290. const maxScrollTop = scroller.scrollHeight - scroller.clientHeight;
  1291. const lastReachableOffset = Math.min(
  1292. offsets[offsets.length - 1],
  1293. maxScrollTop,
  1294. );
  1295. return scrollTop >= lastReachableOffset - 1;
  1296. },
  1297. },
  1298. // History-specific bindings for CSP compliance
  1299. historyEmptyState: {
  1300. ["x-show"]() {
  1301. return this.sortedHistory.length === 0;
  1302. },
  1303. },
  1304. // Playlist view-specific bindings for CSP compliance
  1305. playlistViewEmptyState: {
  1306. ["x-show"]() {
  1307. return this.currentPlaylistVideos.length === 0;
  1308. },
  1309. },
  1310. // Tag chip bindings for CSP compliance
  1311. tagChip: {
  1312. ["@click"]() {
  1313. const videoId = this.$el.dataset.videoId;
  1314. const tag = this.$el.dataset.tag;
  1315. this.toggleTag(videoId, tag);
  1316. },
  1317. [":class"]() {
  1318. const videoId = this.$el.dataset.videoId;
  1319. const tag = this.$el.dataset.tag;
  1320. return {
  1321. active: this.isTagActive(videoId, tag),
  1322. };
  1323. },
  1324. },
  1325. // Save Channel specific bindings
  1326. copyCurlButton: {
  1327. ["@click"]() {
  1328. this.copyCurlCommand();
  1329. },
  1330. [":disabled"]() {
  1331. return !this.isCurrentTabChannelPage;
  1332. },
  1333. },
  1334. // Wiki/insp specific bindings
  1335. copyWikitextButton: {
  1336. ["@click"]() {
  1337. this.copyWikitextCommand();
  1338. },
  1339. [":disabled"]() {
  1340. return !this.isCurrentVideoYoutube;
  1341. },
  1342. },
  1343. timestampCheckbox: {
  1344. ["@change"]() {
  1345. this.toggleTimestamp();
  1346. },
  1347. [":checked"]() {
  1348. return this.includeTimestamp;
  1349. },
  1350. },
  1351. refreshTimestampButton: {
  1352. ["@click"]() {
  1353. this.refreshVideoTimestamp();
  1354. },
  1355. [":disabled"]() {
  1356. return !this.isCurrentVideoYoutube;
  1357. },
  1358. },
  1359. categoryButton: {
  1360. ["@click"]() {
  1361. const category = this.$el.dataset.category;
  1362. this.selectCategory(category);
  1363. },
  1364. [":class"]() {
  1365. const category = this.$el.dataset.category;
  1366. return {
  1367. active: this.selectedCategory === category,
  1368. };
  1369. },
  1370. },
  1371. saveChannelNotice: {
  1372. ["x-show"]() {
  1373. return !this.isCurrentTabChannelPage;
  1374. },
  1375. },
  1376. wikiInspNotice: {
  1377. ["x-show"]() {
  1378. return !this.isCurrentVideoYoutube;
  1379. },
  1380. },
  1381. intervalInput: {
  1382. [":value"]() {
  1383. return this.checkInterval;
  1384. },
  1385. ["@input"]() {
  1386. this.updateCheckInterval(this.$el.value);
  1387. },
  1388. },
  1389. scatterCountInput: {
  1390. [":value"]() {
  1391. return this.scatterCount;
  1392. },
  1393. [":max"]() {
  1394. const pl = this.playlists[this.currentPlaylistName];
  1395. return pl ? pl.length - 1 : 2;
  1396. },
  1397. ["@input"]() {
  1398. const pl = this.playlists[this.currentPlaylistName];
  1399. const max = pl ? pl.length - 1 : 2;
  1400. const v = Math.min(max, Math.max(2, parseInt(this.$el.value) || 2));
  1401. this.scatterCount = v;
  1402. },
  1403. },
  1404. scatterButton: {
  1405. ["@click"]() {
  1406. this.scatterLastN(this.currentPlaylistName, this.scatterCount);
  1407. },
  1408. [":disabled"]() {
  1409. const pl = this.playlists[this.currentPlaylistName];
  1410. return !pl || this.scatterCount >= pl.length;
  1411. },
  1412. },
  1413. // Add current page button binding
  1414. addCurrentPageButton: {
  1415. ["@click"]() {
  1416. this.addCurrentPageToPlaylist();
  1417. },
  1418. [":disabled"]() {
  1419. return (
  1420. !this.currentTab ||
  1421. !this.isCurrentTabYoutube ||
  1422. !this.currentPlaylistName
  1423. );
  1424. },
  1425. [":class"]() {
  1426. return {
  1427. disabled:
  1428. !this.currentTab ||
  1429. !this.isCurrentTabYoutube ||
  1430. !this.currentPlaylistName,
  1431. };
  1432. },
  1433. },
  1434. // Import view bindings
  1435. importTextarea: {
  1436. [":value"]() {
  1437. return this.importText;
  1438. },
  1439. ["@input"]() {
  1440. this.importText = this.$el.value;
  1441. },
  1442. },
  1443. importSubmitButton: {
  1444. ["@click"]() {
  1445. this.handleImport();
  1446. },
  1447. },
  1448. }));
  1449. });