6 Commits d7ae8eac4b ... 928cd90aaf

Auteur SHA1 Message Date
  Brandon Wong 928cd90aaf restyle scatter control: narrow input, green button, grouped border il y a 1 semaine
  Brandon Wong eebdfbac04 add scatter button to interleave last N videos in playlist il y a 1 semaine
  Brandon Wong d6fac515b6 move add-cur-page btn above playlist card, resize to match export buttons il y a 1 semaine
  Brandon Wong 4401111325 move to top inserts after current top video il y a 2 semaines
  Brandon Wong eb0c9fb19a add move-to-playlist to main playlists view more menu il y a 2 semaines
  Brandon Wong 36580f6c70 add move-to-playlist option in video more menu il y a 2 semaines

+ 43 - 0
2026-06-08-claude-move-video-between-playlists.md

@@ -0,0 +1,43 @@
+# Move Video Between Playlists
+
+**Date:** 2026-06-08
+
+## User Request
+
+> I would like the option of moving a video in one playlist to a different playlist (while maintaining the guarantee that a video will be in at most one playlist). what would you recommend to support this action in the UI?
+
+## What Was Done
+
+Added a "Move to…" entry to the ⋯ more menu in the playlist detail view (single-playlist view). Clicking it expands an inline sub-list of the other five playlists; selecting one atomically removes the video from the current playlist and appends it to the target.
+
+### Files Changed
+
+**`popup/popup.js`**
+- Added `moveSubmenuOpenFor: null` state (tracks which video's move submenu is open by menuId key)
+- Added `otherPlaylists: []` state (array of `{name, display}` objects for the other playlists)
+- Updated `updateCurrentPlaylistVideos()` to recompute `otherPlaylists` using `formatPlaylistName()` for display labels
+- Added `async moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName)` — splices video from source array, appends to target, single `storage.set` call (preserves video object including status)
+- Updated `closeAllMenus()` and `toggleMenu()` to reset `moveSubmenuOpenFor`
+- Added three new binding objects: `moveToSubmenuButton` (toggle), `moveToSubmenu` (x-show), `moveToPlaylistButton` (execute move)
+
+**`popup/popup.html`**
+- Added "Move to…" button and collapsible sub-list inside the playlist detail view's ⋯ more menu, after the Remove button
+- Sub-list uses `x-for="pl in otherPlaylists"` with `x-text="pl.display"` and `data-to-playlist-name="pl.name"`
+
+**`popup/popup.css`**
+- Added styles for `.move-to-playlist-item`, `.move-to-submenu` (separator border), and `.move-to-playlist-option` (indented, blue text with hover)
+
+## Follow-up Request (2026-06-10)
+
+> copy the "move" button to the triple-dot menu on the main screen, not just the playlist view screen
+
+Added the same "Move to…" button and submenu to the ⋯ more menu in the main playlists view. Each `playlistData` object in `playlistsForDisplay` now carries its own `otherPlaylists` array (computed in `updatePlaylistsForDisplay()`), so the `x-for="pl in playlistData.otherPlaylists"` loop in the main view template has the correct per-playlist options without needing any inline expressions.
+
+**Additional files changed:** `popup/popup.js` (`updatePlaylistsForDisplay`), `popup/popup.html` (main playlists view more menu)
+
+### Design Notes
+
+- The one-playlist-per-video invariant is maintained because `moveVideoToPlaylist` splices from the source before appending to the target in a single atomic storage write — no window where the video exists in two playlists
+- Video status (done, timestamp) is preserved on move
+- All Alpine.js bindings are CSP-compliant (property accesses only, no inline expressions)
+- `otherPlaylists` is pre-computed with formatted display names so `x-text="pl.display"` needs no method call in the template

+ 21 - 0
2026-06-10-claude-move-to-top-inserts-after-top.md

@@ -0,0 +1,21 @@
+# Session Report: Move "Move to Top" to insert after top
+
+## Request
+
+> in the popup main view, the menu has a "move to top" button, which brings the video to the "top" of the playlist. I would like to modify it to move it to the position after the top of the playlist.
+
+## Changes
+
+**File:** `popup/popup.js`
+
+Three targeted edits:
+
+1. **Insertion position** in `moveVideoToTop()`: changed `playlist.splice(topPosition, 0, video)` → `playlist.splice(topPosition + 1, 0, video)` so the video lands right *after* the current top video instead of displacing it.
+
+2. **Early-return guard** in `moveVideoToTop()`: updated `if (index === topPosition) return` → `if (index === topPosition + 1) return` to match the new target position.
+
+3. **Disabled binding** in `moveToTopButton`: changed `return index === topPosition` → `return index === topPosition + 1` so the button disables when the video is already at the 2nd (post-top) position.
+
+The button label "Move to Top" was kept as-is per user preference.
+
+The change applies to both the main playlists view and the individual playlist view since both share the same `moveToTopButton` binding and `moveVideoToTop()` function.

Fichier diff supprimé car celui-ci est trop grand
+ 40 - 0
2026-06-18-claude-add-scatter-button.md


+ 23 - 0
2026-06-18-claude-move-add-cur-page-btn-to-top.md

@@ -0,0 +1,23 @@
+# Move "Add Cur. Page" Button to Top of Playlist View
+
+## Request
+
+> in the individual playlist view of the popup window, move the "add current page to playlist" button from the bottom to the top. shrink it so that it's the same size and shape as the "export playlists" buttons on the main view - shorten the wording of the button if necessary (like "add cur. page" or something similar).
+
+## Changes
+
+**`popup/popup.html`**
+- Moved the `add-current-page-container` div fully outside of `.playlist-view-container`, placing it above the card (mirroring how `.export-container` sits above `.playlists-container`).
+- Added `x-bind="addCurrentPageContainer"` to the container for visibility control.
+
+**`popup/popup.css`**
+- Restyled `.add-current-page-container` as a flex row (matching `.export-container`) so the button is auto-sized rather than block-stretched.
+- Restyled `.add-current-page-btn` to match the export buttons: `padding: 8px 12px`, `border-radius: 4px` (was `6px`), removed `width: 100%`, removed hover animation.
+
+**`popup/popup.js`**
+- Added `addCurrentPageContainer` binding with `x-show` tied to `currentView === "playlist"`.
+- Shortened all button text strings:
+  - Default: `"Add Current Page"` → `"Add Cur. Page"`
+  - YouTube only: `"Add Current Page (YouTube only)"` → `"Add Cur. Page (YT only)"`
+  - Normal state: `"Add Current Page to Playlist"` → `"Add Cur. Page"`
+  - Error state: `"Unable to get current page"` → `"Unable to get page"`

+ 64 - 13
popup/popup.css

@@ -412,26 +412,25 @@ button:hover {
 
 /* Add current page button styles */
 .add-current-page-container {
-  margin-top: 16px;
-  padding-top: 12px;
-  border-top: 1px solid #eee;
+  display: flex;
+  flex-wrap: wrap;
+  row-gap: 6px;
+  column-gap: 4px;
+  margin-bottom: 12px;
 }
 
 .add-current-page-btn {
-  width: 100%;
+  width: fit-content;
   background-color: #4285f4;
   color: white;
-  padding: 10px 16px;
-  border-radius: 6px;
+  padding: 8px 12px;
+  border-radius: 4px;
   font-size: 14px;
   font-weight: 500;
-  transition: all 0.2s ease;
 }
 
 .add-current-page-btn:hover:not(:disabled) {
   background-color: #3367d6;
-  transform: translateY(-1px);
-  box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3);
 }
 
 .add-current-page-btn:disabled,
@@ -439,15 +438,50 @@ button:hover {
   background-color: #ccc;
   color: #888;
   cursor: not-allowed;
-  transform: none;
-  box-shadow: none;
 }
 
 .add-current-page-btn:disabled:hover,
 .add-current-page-btn.disabled:hover {
   background-color: #ccc;
-  transform: none;
-  box-shadow: none;
+}
+
+.scatter-control {
+  display: inline-flex;
+  align-items: stretch;
+  border: 1px solid #aaa;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.scatter-count-input {
+  width: 36px;
+  padding: 8px 2px;
+  border: none;
+  border-right: 1px solid #aaa;
+  font-size: 14px;
+  text-align: center;
+  outline: none;
+  box-sizing: border-box;
+}
+
+.scatter-btn {
+  background-color: #388e3c;
+  color: white;
+  padding: 8px 12px;
+  border: none;
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.scatter-btn:hover:not(:disabled) {
+  background-color: #2e7d32;
+  cursor: pointer;
+}
+
+.scatter-btn:disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: not-allowed;
 }
 
 /* Save Channel view styles */
@@ -900,3 +934,20 @@ button:hover {
   column-gap: 4px;
   margin-bottom: 12px;
 }
+
+.move-to-playlist-item {
+  color: #555;
+}
+
+.move-to-submenu {
+  border-top: 1px solid #eee;
+}
+
+.move-to-playlist-option {
+  padding-left: 20px;
+  color: #4285f4;
+}
+
+.move-to-playlist-option:hover {
+  background-color: #e8f0fe;
+}

+ 68 - 8
popup/popup.html

@@ -161,6 +161,31 @@
                         >
                           Remove
                         </button>
+                        <button
+                          :data-playlist-name="playlistData.name"
+                          :data-playlist-index="video.originalIndex"
+                          x-bind="moveToSubmenuButton"
+                          class="menu-item move-to-playlist-item"
+                        >
+                          Move to…
+                        </button>
+                        <div
+                          class="move-to-submenu"
+                          :data-playlist-name="playlistData.name"
+                          :data-playlist-index="video.originalIndex"
+                          x-bind="moveToSubmenu"
+                        >
+                          <template x-for="pl in playlistData.otherPlaylists" :key="pl.name">
+                            <button
+                              class="menu-item move-to-playlist-option"
+                              :data-from-playlist-name="playlistData.name"
+                              :data-playlist-index="video.originalIndex"
+                              :data-to-playlist-name="pl.name"
+                              x-bind="moveToPlaylistButton"
+                              x-text="pl.display"
+                            ></button>
+                          </template>
+                        </div>
                       </div>
                     </div>
                   </div>
@@ -351,6 +376,24 @@
         </div>
       </div>
 
+      <!-- Add current page button (above playlist view card) -->
+      <div class="add-current-page-container" x-bind="addCurrentPageContainer">
+        <button
+          class="add-current-page-btn"
+          x-bind="addCurrentPageButton"
+          x-text="addCurrentPageButtonText"
+        ></button>
+        <div class="scatter-control">
+          <input
+            class="scatter-count-input"
+            type="number"
+            min="2"
+            x-bind="scatterCountInput"
+          />
+          <button class="scatter-btn" x-bind="scatterButton">Scatter</button>
+        </div>
+      </div>
+
       <!-- Individual playlist view -->
       <div class="playlist-view-container" x-bind="playlistViewContainer">
         <div class="playlist-full-view">
@@ -435,6 +478,31 @@
                     >
                       Remove
                     </button>
+                    <button
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="moveToSubmenuButton"
+                      class="menu-item move-to-playlist-item"
+                    >
+                      Move to…
+                    </button>
+                    <div
+                      class="move-to-submenu"
+                      :data-playlist-name="currentPlaylistName"
+                      :data-playlist-index="index"
+                      x-bind="moveToSubmenu"
+                    >
+                      <template x-for="pl in otherPlaylists" :key="pl.name">
+                        <button
+                          class="menu-item move-to-playlist-option"
+                          :data-from-playlist-name="currentPlaylistName"
+                          :data-playlist-index="index"
+                          :data-to-playlist-name="pl.name"
+                          x-bind="moveToPlaylistButton"
+                          x-text="pl.display"
+                        ></button>
+                      </template>
+                    </div>
                   </div>
                 </div>
               </div>
@@ -444,14 +512,6 @@
             No videos in this playlist
           </div>
         </div>
-        <!-- Add current page button -->
-        <div class="add-current-page-container">
-          <button
-            class="add-current-page-btn"
-            x-bind="addCurrentPageButton"
-            x-text="addCurrentPageButtonText"
-          ></button>
-        </div>
       </div>
 
     </div>

+ 124 - 8
popup/popup.js

@@ -25,14 +25,17 @@ document.addEventListener("alpine:init", () => {
     history: {},
     sortedHistory: [],
     openMenus: new Set(), // Track which menus are open
+    moveSubmenuOpenFor: null, // Track which video has the move-to submenu open (menuId or null)
     currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
     currentPlaylistName: "", // Track which playlist is being viewed
     playlistsForDisplay: [], // Computed array for display
     currentPlaylistVideos: [], // Videos for current playlist view
+    otherPlaylists: [], // Playlist names (with display labels) other than currentPlaylistName
     currentTab: null, // Current active tab info
     isCurrentTabYoutube: false, // Whether current tab is YouTube
     isCurrentTabChannelPage: false, // Whether current tab is YouTube videos page
-    addCurrentPageButtonText: "Add Current Page", // Button text
+    addCurrentPageButtonText: "Add Cur. Page", // Button text
+    scatterCount: 4,
 
     // Save channel properties
     selectedCategory: "FOR BOTH", // Currently selected category
@@ -135,6 +138,9 @@ document.addEventListener("alpine:init", () => {
             visibleVideos: visibleVideos,
             shouldShowTruncation: shouldShowTruncation,
             truncationText: truncationText,
+            otherPlaylists: Object.keys(this.playlists)
+              .filter((n) => n !== playlistName)
+              .map((n) => ({ name: n, display: this.formatPlaylistName(n) })),
           };
         },
       );
@@ -146,6 +152,7 @@ document.addEventListener("alpine:init", () => {
         !this.playlists[this.currentPlaylistName]
       ) {
         this.currentPlaylistVideos = [];
+        this.otherPlaylists = [];
       } else {
         this.currentPlaylistVideos = this.playlists[
           this.currentPlaylistName
@@ -158,6 +165,9 @@ document.addEventListener("alpine:init", () => {
             index,
           ),
         }));
+        this.otherPlaylists = Object.keys(this.playlists)
+          .filter((name) => name !== this.currentPlaylistName)
+          .map((name) => ({ name, display: this.formatPlaylistName(name) }));
       }
     },
 
@@ -262,11 +272,11 @@ document.addEventListener("alpine:init", () => {
 
     updateAddCurrentPageButtonText() {
       if (!this.currentTab) {
-        this.addCurrentPageButtonText = "Unable to get current page";
+        this.addCurrentPageButtonText = "Unable to get page";
       } else if (!this.isCurrentTabYoutube) {
-        this.addCurrentPageButtonText = "Add Current Page (YouTube only)";
+        this.addCurrentPageButtonText = "Add Cur. Page (YT only)";
       } else {
-        this.addCurrentPageButtonText = "Add Current Page to Playlist";
+        this.addCurrentPageButtonText = "Add Cur. Page";
       }
     },
 
@@ -613,10 +623,10 @@ document.addEventListener("alpine:init", () => {
 
       const topPosition = this.findTopPosition(playlistName);
 
-      if (index === topPosition) return;
+      if (index === topPosition + 1) return;
 
       const video = playlist.splice(index, 1)[0];
-      playlist.splice(topPosition, 0, video);
+      playlist.splice(topPosition + 1, 0, video);
 
       const updatedPlaylists = {
         ...playlists,
@@ -657,6 +667,20 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
+    async moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName) {
+      const playlists = JSON.parse(JSON.stringify(this.playlists));
+      const video = playlists[fromPlaylistName].splice(videoIndex, 1)[0];
+      playlists[toPlaylistName].push(video);
+      try {
+        await browser.storage.local.set({ playlists });
+        this.playlists = playlists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
+      } catch (error) {
+        console.error("Error moving video to playlist:", error);
+      }
+    },
+
     findTopPosition(playlistName) {
       const playlist = this.playlists[playlistName];
       if (!playlist) return 0;
@@ -670,6 +694,38 @@ document.addEventListener("alpine:init", () => {
       return playlist.length;
     },
 
+    async scatterLastN(playlistName, count) {
+      const playlists = JSON.parse(JSON.stringify(this.playlists));
+      const playlist = playlists[playlistName];
+      const n = playlist.length;
+      if (!playlist || count < 2 || count >= n) return;
+
+      const prefix = playlist.slice(0, n - count);
+      const tail = playlist.slice(n - count);
+      const insertions = [];
+      let cursor = prefix.length;
+
+      for (let i = count - 2; i >= 0; i--) {
+        const gap = Math.floor(Math.random() * 3) + 1;
+        cursor = Math.max(1, cursor - gap);
+        insertions.push([cursor, tail[i]]);
+      }
+      for (const [idx, video] of insertions) {
+        prefix.splice(idx, 0, video);
+      }
+      prefix.push(tail[count - 1]);
+
+      playlists[playlistName] = prefix;
+      try {
+        await browser.storage.local.set({ playlists });
+        this.playlists = playlists;
+        this.updatePlaylistsForDisplay();
+        this.updateCurrentPlaylistVideos();
+      } catch (error) {
+        console.error("Error scattering videos:", error);
+      }
+    },
+
     async toggleVideoDoneStatus(playlistName, index) {
       const playlists = JSON.parse(JSON.stringify(this.playlists));
       const playlist = [...playlists[playlistName]];
@@ -922,7 +978,7 @@ document.addEventListener("alpine:init", () => {
         const index = parseInt(this.$el.dataset.playlistIndex);
         const playlistName = this.$el.dataset.playlistName;
         const topPosition = this.findTopPosition(playlistName);
-        return index === topPosition;
+        return index === topPosition + 1;
       },
     },
 
@@ -942,6 +998,33 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    moveToSubmenuButton: {
+      ["@click.stop"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        const index = parseInt(this.$el.dataset.playlistIndex);
+        const menuId = this.getMenuId(playlistName, index);
+        this.moveSubmenuOpenFor = this.moveSubmenuOpenFor === menuId ? null : menuId;
+      },
+    },
+
+    moveToSubmenu: {
+      ["x-show"]() {
+        const playlistName = this.$el.dataset.playlistName;
+        const index = parseInt(this.$el.dataset.playlistIndex);
+        return this.moveSubmenuOpenFor === this.getMenuId(playlistName, index);
+      },
+    },
+
+    moveToPlaylistButton: {
+      ["@click"]() {
+        const fromPlaylistName = this.$el.dataset.fromPlaylistName;
+        const videoIndex = parseInt(this.$el.dataset.playlistIndex);
+        const toPlaylistName = this.$el.dataset.toPlaylistName;
+        this.moveVideoToPlaylist(fromPlaylistName, videoIndex, toPlaylistName);
+        this.closeAllMenus();
+      },
+    },
+
     exportButton: {
       ["@click"]() {
         this.exportPlaylists();
@@ -1089,14 +1172,15 @@ document.addEventListener("alpine:init", () => {
       if (this.openMenus.has(menuId)) {
         this.openMenus.delete(menuId);
       } else {
-        // Close all other menus first
         this.openMenus.clear();
         this.openMenus.add(menuId);
       }
+      this.moveSubmenuOpenFor = null;
     },
 
     closeAllMenus() {
       this.openMenus.clear();
+      this.moveSubmenuOpenFor = null;
     },
 
     moreMenuButton: {
@@ -1274,6 +1358,12 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    addCurrentPageContainer: {
+      ["x-show"]() {
+        return this.currentView === "playlist";
+      },
+    },
+
     playlistViewContainer: {
       ["x-show"]() {
         return this.currentView === "playlist";
@@ -1406,6 +1496,32 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    scatterCountInput: {
+      [":value"]() {
+        return this.scatterCount;
+      },
+      [":max"]() {
+        const pl = this.playlists[this.currentPlaylistName];
+        return pl ? pl.length - 1 : 2;
+      },
+      ["@input"]() {
+        const pl = this.playlists[this.currentPlaylistName];
+        const max = pl ? pl.length - 1 : 2;
+        const v = Math.min(max, Math.max(2, parseInt(this.$el.value) || 2));
+        this.scatterCount = v;
+      },
+    },
+
+    scatterButton: {
+      ["@click"]() {
+        this.scatterLastN(this.currentPlaylistName, this.scatterCount);
+      },
+      [":disabled"]() {
+        const pl = this.playlists[this.currentPlaylistName];
+        return !pl || this.scatterCount >= pl.length;
+      },
+    },
+
     // Add current page button binding
     addCurrentPageButton: {
       ["@click"]() {