├── pnpm-workspace.yaml ├── src ├── ytb │ ├── Playlist.ts │ ├── Video.ts │ ├── getCurrentPlaylist.ts │ ├── addVideosToQueue.ts │ ├── editPlaylist.ts │ └── getAllPlaylists.ts ├── assets │ ├── img │ │ └── settings.png │ └── css │ │ ├── variables.scss │ │ └── main.scss ├── utils │ ├── sleep.ts │ ├── findDelayedElement.ts │ ├── findDelayedElementAll.ts │ └── tryFn.ts ├── @types │ ├── shims-vue.d.ts │ └── index.d.ts ├── components │ ├── UserscriptApp.vue │ └── PlaylistPage │ │ ├── useObserver.ts │ │ ├── PlaylistPage.vue │ │ ├── SettingsDialogHiddenPlaylists.vue │ │ ├── usePlaylistPageCheckboxes.ts │ │ ├── usePlaylistPageDropZones.ts │ │ ├── triggerDropZoneAction.ts │ │ ├── usePlaylistPageDragListeners.ts │ │ ├── PlaylistPageBulkActionButtons.vue │ │ ├── SettingsDialog.vue │ │ └── PlaylistPageOrganizer.vue ├── createVueApp.ts ├── Constants.ts ├── main.ts └── store │ ├── useSettingStore.ts │ └── usePlaylistPageStore.ts ├── .github ├── img │ └── preview.gif └── workflows │ └── release-build.yml ├── .editorconfig ├── tsconfig.json ├── package.json ├── README.md ├── .gitignore ├── webpack.config.ts ├── eslint.config.js └── LICENSE /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | ignoredBuiltDependencies: 2 | - '@parcel/watcher' 3 | - esbuild 4 | -------------------------------------------------------------------------------- /src/ytb/Playlist.ts: -------------------------------------------------------------------------------- 1 | export type Playlist = { 2 | playlistId: string 3 | name: string 4 | } 5 | -------------------------------------------------------------------------------- /.github/img/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trinovantes/userscript-youtube-playlist-organizer/HEAD/.github/img/preview.gif -------------------------------------------------------------------------------- /src/assets/img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Trinovantes/userscript-youtube-playlist-organizer/HEAD/src/assets/img/settings.png -------------------------------------------------------------------------------- /src/assets/css/variables.scss: -------------------------------------------------------------------------------- 1 | $btn-size: 48px; 2 | $padding: 16px; 3 | 4 | $border-radius: 4px; 5 | $border: 1px solid #ddd; 6 | $text-color: #111; 7 | -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export async function sleep(ms: number): Promise { 2 | await new Promise((resolve) => setTimeout(resolve, ms)) 3 | } 4 | -------------------------------------------------------------------------------- /src/@types/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { ComponentOptions } from 'vue' 3 | const component: ComponentOptions 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /src/components/UserscriptApp.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /src/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | const __IS_DEV__: boolean 3 | const __NAME__: string 4 | const __PRODUCT_NAME__: string 5 | const __AUTHOR__: string 6 | const __DESC__: string 7 | const __VERSION__: string 8 | const __REPO_URL__: string 9 | } 10 | 11 | export {} 12 | -------------------------------------------------------------------------------- /src/ytb/Video.ts: -------------------------------------------------------------------------------- 1 | export type Video = { 2 | videoId: string 3 | ytdPlaylistVideoRenderer: Element 4 | } 5 | 6 | export function getVideoId(url: string): string { 7 | const videoId = /\/watch\?v=(?[\w-]+)&?/.exec(url)?.groups?.videoId 8 | if (!videoId) { 9 | throw new Error(`Failed to get videoId from "${url}"`) 10 | } 11 | 12 | return videoId 13 | } 14 | -------------------------------------------------------------------------------- /src/components/PlaylistPage/useObserver.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted } from 'vue' 2 | import { findDelayedElement } from '../../utils/findDelayedElement.ts' 3 | 4 | export function useObserver(selector: string, opts: MutationObserverInit, onMutation: MutationCallback) { 5 | const observer = new MutationObserver(onMutation) 6 | 7 | onMounted(async () => { 8 | const node = await findDelayedElement(selector) 9 | observer.observe(node, { 10 | childList: true, 11 | }) 12 | }) 13 | 14 | onUnmounted(() => { 15 | observer.disconnect() 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/createVueApp.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { createApp } from 'vue' 3 | import { useSettingStore } from './store/useSettingStore.ts' 4 | import UserscriptApp from './components/UserscriptApp.vue' 5 | import { usePlaylistPageStore } from './store/usePlaylistPageStore.ts' 6 | 7 | export async function createVueApp() { 8 | const app = createApp(UserscriptApp) 9 | 10 | const pinia = createPinia() 11 | app.use(pinia) 12 | 13 | const settingStore = useSettingStore(pinia) 14 | await settingStore.load() 15 | 16 | const playlistPageStore = usePlaylistPageStore(pinia) 17 | await playlistPageStore.init() 18 | 19 | return app 20 | } 21 | -------------------------------------------------------------------------------- /src/ytb/getCurrentPlaylist.ts: -------------------------------------------------------------------------------- 1 | import type { Playlist } from './Playlist.ts' 2 | 3 | export function getCurrentPlaylist(): Playlist | null { 4 | const playlistId = /\/playlist\?.*list=(?[\w-]+)/.exec(location.href)?.groups?.playlistId 5 | if (!playlistId) { 6 | console.warn('Failed to determineCurrentPlaylist (invalid url)', location.href) 7 | return null 8 | } 9 | 10 | const titleTag = document.querySelector('title') 11 | const fullTitle = titleTag?.textContent 12 | const name = fullTitle?.replace(/- YouTube$/, '').trim() 13 | if (!name) { 14 | console.warn('Failed to determineCurrentPlaylist (invalid titleTag)', titleTag) 15 | return null 16 | } 17 | 18 | return { 19 | playlistId, 20 | name, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Constants.ts: -------------------------------------------------------------------------------- 1 | export const projectTitle = `${__PRODUCT_NAME__} ${__VERSION__}` 2 | export const projectUrl = __REPO_URL__ 3 | 4 | export const DRAG_EVENT_DATA_KEY_VIDEO_ID = 'dragVideoId' 5 | export const DATA_ATTR_VIDEO_ID = 'data-video-id' 6 | 7 | export const UI_WAIT_TIME = 150 // ms 8 | export const MAX_UI_WAIT_ATTEMPTS = 5 9 | 10 | export const YTB_PLAYLIST_VISIBILITIES = new Set(['Public', 'Private', 'Unlisted']) 11 | export const YTB_WATCH_LATER_LIST_ID = 'WL' 12 | export const YTB_LIKED_LIST_ID = 'LL' 13 | 14 | // Same as defined in variables.scss 15 | export const BTN_SIZE = 48 16 | export const PADDING = 16 17 | 18 | export const YTB_MASTHEAD_HEIGHT = 56 19 | export const YTB_PLAYER_MARGIN = 12 20 | export const YTB_PLAYER_HEIGHT = 250 + 64 + (YTB_PLAYER_MARGIN) 21 | export const YTB_PLAYER_WIDTH = 400 + (YTB_PLAYER_MARGIN * 2) 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/css/main.scss' 2 | import { createVueApp } from './createVueApp.ts' 3 | 4 | async function main() { 5 | // Do not activate inside youtube's ad iframe 6 | if (window.self !== window.top) { 7 | return 8 | } 9 | 10 | // Do not activate on ytb music 11 | if (window.location.origin === 'https://music.youtube.com') { 12 | return 13 | } 14 | 15 | const node = document.createElement('div') 16 | node.id = __NAME__ 17 | document.querySelector('body')?.appendChild(node) 18 | 19 | const app = await createVueApp() 20 | app.mount(node) 21 | } 22 | 23 | if (document.readyState !== 'loading') { 24 | main().catch((err: unknown) => { 25 | console.warn(__NAME__, err) 26 | }) 27 | } else { 28 | window.addEventListener('DOMContentLoaded', () => { 29 | main().catch((err: unknown) => { 30 | console.warn(__NAME__, err) 31 | }) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/findDelayedElement.ts: -------------------------------------------------------------------------------- 1 | import { MAX_UI_WAIT_ATTEMPTS, UI_WAIT_TIME } from '../Constants.ts' 2 | import { sleep } from './sleep.ts' 3 | 4 | export async function findDelayedElement(selector: string, parent?: Element): Promise { 5 | let target: HTMLElement | null = null 6 | const logTarget = (msg: string) => { 7 | console.groupCollapsed(__NAME__, `findDelayedElement("${selector}")`, msg) 8 | console.info(target) 9 | console.groupEnd() 10 | } 11 | 12 | for (let attempts = 0; attempts < MAX_UI_WAIT_ATTEMPTS; attempts++) { 13 | if (parent) { 14 | target = parent.querySelector(selector) 15 | } else { 16 | target = document.querySelector(selector) 17 | } 18 | 19 | if (target) { 20 | logTarget('[FOUND]') 21 | break 22 | } 23 | 24 | // Exponential back off 25 | const delay = UI_WAIT_TIME * Math.pow(2, attempts) 26 | logTarget(`[Waiting ${delay}ms]`) 27 | await sleep(delay) 28 | } 29 | 30 | if (!target) { 31 | throw new Error(`findDelayedElement() failed to find "${selector}"`) 32 | } 33 | 34 | return target 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/findDelayedElementAll.ts: -------------------------------------------------------------------------------- 1 | import { MAX_UI_WAIT_ATTEMPTS, UI_WAIT_TIME } from '../Constants.ts' 2 | import { sleep } from './sleep.ts' 3 | 4 | export async function findDelayedElementAll(selector: string, parent?: HTMLElement): Promise> { 5 | let target: NodeListOf | null = null 6 | const logTarget = (msg: string) => { 7 | console.groupCollapsed(__NAME__, `findDelayedElementAll("${selector}")`, msg) 8 | console.info(target) 9 | console.groupEnd() 10 | } 11 | 12 | for (let attempts = 0; attempts < MAX_UI_WAIT_ATTEMPTS; attempts++) { 13 | if (parent) { 14 | target = parent.querySelectorAll(selector) 15 | } else { 16 | target = document.querySelectorAll(selector) 17 | } 18 | 19 | if (target.length > 0) { 20 | logTarget('[FOUND]') 21 | break 22 | } 23 | 24 | // Exponential back off 25 | const delay = UI_WAIT_TIME * Math.pow(2, attempts) 26 | logTarget(`[Waiting ${delay}ms]`) 27 | await sleep(delay) 28 | } 29 | 30 | if (!target) { 31 | throw new Error(`findDelayedElementAll() failed to find "${selector}"`) 32 | } 33 | 34 | return [...target] 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "transpileOnly": true, 4 | "compilerOptions": { 5 | "module": "commonjs", 6 | "moduleResolution": "node10", 7 | }, 8 | }, 9 | "compilerOptions": { 10 | "strict": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "declarationMap": false, 14 | 15 | "target": "esnext", 16 | "module": "nodenext", 17 | "moduleResolution": "nodenext", 18 | 19 | "allowImportingTsExtensions": true, 20 | "allowSyntheticDefaultImports": true, 21 | "erasableSyntaxOnly": true, 22 | "esModuleInterop": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "noEmit": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noImplicitOverride": true, 27 | "noImplicitReturns": true, 28 | "noImplicitThis": true, 29 | "noUnusedLocals": true, 30 | "resolveJsonModule": true, 31 | "skipLibCheck": true, 32 | "strictFunctionTypes": true, 33 | "strictNullChecks": true, 34 | "useUnknownInCatchVariables": true, 35 | "verbatimModuleSyntax": true, 36 | 37 | "outDir": "dist", 38 | "baseUrl": ".", 39 | }, 40 | "exclude": [ 41 | "**/dist*", 42 | "**/raw", 43 | "**/*.html", 44 | "node_modules", 45 | "libs", 46 | ], 47 | "include": [ 48 | "**/*", 49 | ".vitepress/**/*", 50 | "eslint.config.*js", 51 | ], 52 | } 53 | -------------------------------------------------------------------------------- /src/ytb/addVideosToQueue.ts: -------------------------------------------------------------------------------- 1 | import type { Video } from './Video.ts' 2 | 3 | type YtbThumbnail = HTMLElement & { 4 | resolveCommand: (command: unknown) => void 5 | } 6 | 7 | export function addVideosToQueue(videos: Array