Organize your favorite Twitch streamers into custom folders for quick access
Size
20.5 KB
Version
1.1.1
Created
Oct 22, 2025
Updated
1 day ago
1// ==UserScript==
2// @name Twitch Streamer Folder Organizer
3// @description Organize your favorite Twitch streamers into custom folders for quick access
4// @version 1.1.1
5// @match https://*.twitch.tv/*
6// @icon https://assets.twitch.tv/assets/favicon-32-e29e246c157142c94346.png
7// ==/UserScript==
8(function() {
9 'use strict';
10
11 console.log('Twitch Streamer Folder Organizer loaded');
12
13 // Utility function to debounce
14 function debounce(func, wait) {
15 let timeout;
16 return function executedFunction(...args) {
17 const later = () => {
18 clearTimeout(timeout);
19 func(...args);
20 };
21 clearTimeout(timeout);
22 timeout = setTimeout(later, wait);
23 };
24 }
25
26 // Data management
27 async function getFolders() {
28 const folders = await GM.getValue('streamer_folders', '[]');
29 return JSON.parse(folders);
30 }
31
32 async function saveFolders(folders) {
33 await GM.setValue('streamer_folders', JSON.stringify(folders));
34 }
35
36 async function createFolder(name) {
37 const folders = await getFolders();
38 const newFolder = {
39 id: Date.now().toString(),
40 name: name,
41 streamers: [],
42 expanded: true
43 };
44 folders.push(newFolder);
45 await saveFolders(folders);
46 return newFolder;
47 }
48
49 async function deleteFolder(folderId) {
50 const folders = await getFolders();
51 const updatedFolders = folders.filter(f => f.id !== folderId);
52 await saveFolders(updatedFolders);
53 }
54
55 async function addStreamerToFolder(folderId, streamerData) {
56 const folders = await getFolders();
57 const folder = folders.find(f => f.id === folderId);
58 if (folder) {
59 // Check if streamer already exists
60 const exists = folder.streamers.some(s => s.username === streamerData.username);
61 if (!exists) {
62 folder.streamers.push(streamerData);
63 await saveFolders(folders);
64 }
65 }
66 }
67
68 async function removeStreamerFromFolder(folderId, username) {
69 const folders = await getFolders();
70 const folder = folders.find(f => f.id === folderId);
71 if (folder) {
72 folder.streamers = folder.streamers.filter(s => s.username !== username);
73 await saveFolders(folders);
74 }
75 }
76
77 async function toggleFolderExpanded(folderId) {
78 const folders = await getFolders();
79 const folder = folders.find(f => f.id === folderId);
80 if (folder) {
81 folder.expanded = !folder.expanded;
82 await saveFolders(folders);
83 }
84 }
85
86 // UI Creation
87 function createFolderSection() {
88 const section = document.createElement('div');
89 section.id = 'streamer-folder-section';
90 section.className = 'Layout-sc-1xcs6mc-0 iGMbNn side-nav-section';
91 section.setAttribute('role', 'group');
92 section.setAttribute('aria-label', 'Streamer Folders');
93 section.style.cssText = 'margin-bottom: 20px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 10px;';
94
95 const header = document.createElement('div');
96 header.className = 'Layout-sc-1xcs6mc-0 fxkdFl side-nav-header';
97 header.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 5px 10px;';
98
99 const title = document.createElement('h3');
100 title.className = 'CoreText-sc-1txzju1-0 dzXkjr';
101 title.textContent = 'My Folders';
102 title.style.cssText = 'color: #efeff1; font-size: 13px; font-weight: 600; margin: 0;';
103
104 const addButton = document.createElement('button');
105 addButton.className = 'ScCoreButton-sc-ocjdkq-0 iPkwTD ScButtonIcon-sc-9yap0r-0 dcNXJO';
106 addButton.innerHTML = '<div style="width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 18px; color: #bf94ff;">+</div>';
107 addButton.style.cssText = 'background: transparent; border: none; cursor: pointer; padding: 4px; border-radius: 4px;';
108 addButton.title = 'Create New Folder';
109 addButton.addEventListener('mouseenter', () => {
110 addButton.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
111 });
112 addButton.addEventListener('mouseleave', () => {
113 addButton.style.backgroundColor = 'transparent';
114 });
115 addButton.addEventListener('click', async () => {
116 const folderName = prompt('Enter folder name:');
117 if (folderName && folderName.trim()) {
118 await createFolder(folderName.trim());
119 await renderFolders();
120 }
121 });
122
123 header.appendChild(title);
124 header.appendChild(addButton);
125
126 const foldersContainer = document.createElement('div');
127 foldersContainer.id = 'folders-container';
128 foldersContainer.style.cssText = 'padding: 0;';
129
130 section.appendChild(header);
131 section.appendChild(foldersContainer);
132
133 return section;
134 }
135
136 function createFolderElement(folder) {
137 const folderDiv = document.createElement('div');
138 folderDiv.className = 'folder-item';
139 folderDiv.style.cssText = 'margin: 5px 0;';
140
141 // Folder header
142 const folderHeader = document.createElement('div');
143 folderHeader.style.cssText = 'display: flex; align-items: center; padding: 5px 10px; cursor: pointer; border-radius: 4px;';
144 folderHeader.addEventListener('mouseenter', () => {
145 folderHeader.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
146 });
147 folderHeader.addEventListener('mouseleave', () => {
148 folderHeader.style.backgroundColor = 'transparent';
149 });
150
151 const expandIcon = document.createElement('span');
152 expandIcon.textContent = folder.expanded ? '▼' : '▶';
153 expandIcon.style.cssText = 'color: #adadb8; font-size: 10px; margin-right: 8px; transition: transform 0.2s;';
154
155 const folderName = document.createElement('span');
156 folderName.textContent = folder.name;
157 folderName.style.cssText = 'color: #efeff1; font-size: 13px; flex: 1; font-weight: 500;';
158
159 const deleteBtn = document.createElement('button');
160 deleteBtn.innerHTML = '×';
161 deleteBtn.style.cssText = 'background: transparent; border: none; color: #adadb8; cursor: pointer; font-size: 20px; padding: 0 5px; opacity: 0; transition: opacity 0.2s;';
162 deleteBtn.title = 'Delete Folder';
163 deleteBtn.addEventListener('click', async (e) => {
164 e.stopPropagation();
165 if (confirm(`Delete folder "${folder.name}"?`)) {
166 await deleteFolder(folder.id);
167 await renderFolders();
168 }
169 });
170
171 folderHeader.addEventListener('mouseenter', () => {
172 deleteBtn.style.opacity = '1';
173 });
174 folderHeader.addEventListener('mouseleave', () => {
175 deleteBtn.style.opacity = '0';
176 });
177
178 folderHeader.addEventListener('click', async () => {
179 await toggleFolderExpanded(folder.id);
180 await renderFolders();
181 });
182
183 folderHeader.appendChild(expandIcon);
184 folderHeader.appendChild(folderName);
185 folderHeader.appendChild(deleteBtn);
186
187 // Streamers list
188 const streamersList = document.createElement('div');
189 streamersList.style.cssText = `display: ${folder.expanded ? 'block' : 'none'}; padding-left: 10px;`;
190
191 if (folder.streamers.length === 0) {
192 const emptyMsg = document.createElement('div');
193 emptyMsg.textContent = 'Right-click a channel to add';
194 emptyMsg.style.cssText = 'color: #adadb8; font-size: 12px; padding: 8px 10px; font-style: italic;';
195 streamersList.appendChild(emptyMsg);
196 } else {
197 folder.streamers.forEach(streamer => {
198 const streamerCard = createStreamerCard(streamer, folder.id);
199 streamersList.appendChild(streamerCard);
200 });
201 }
202
203 folderDiv.appendChild(folderHeader);
204 folderDiv.appendChild(streamersList);
205
206 return folderDiv;
207 }
208
209 function createStreamerCard(streamer, folderId) {
210 const card = document.createElement('div');
211 card.className = 'Layout-sc-1xcs6mc-0 AoXTY side-nav-card';
212 card.style.cssText = 'margin: 2px 0; position: relative;';
213
214 const link = document.createElement('a');
215 link.className = 'ScCoreLink-sc-16kq0mq-0 fytYW InjectLayout-sc-1i43xsx-0 cnzybN side-nav-card__link tw-link';
216 link.href = `/${streamer.username}`;
217 link.style.cssText = 'display: flex; align-items: center; padding: 5px 10px; border-radius: 4px; text-decoration: none; position: relative;';
218
219 const avatar = document.createElement('div');
220 avatar.className = 'Layout-sc-1xcs6mc-0 kErOMx side-nav-card__avatar';
221 avatar.style.cssText = 'margin-right: 10px;';
222
223 const avatarImg = document.createElement('div');
224 avatarImg.className = 'ScAvatar-sc-144b42z-0 dLsNfm tw-avatar';
225 avatarImg.style.cssText = 'width: 30px; height: 30px; border-radius: 50%; overflow: hidden;';
226
227 if (streamer.avatarUrl) {
228 const img = document.createElement('img');
229 img.src = streamer.avatarUrl;
230 img.style.cssText = 'width: 100%; height: 100%;';
231 avatarImg.appendChild(img);
232 } else {
233 avatarImg.style.backgroundColor = '#9147ff';
234 }
235
236 avatar.appendChild(avatarImg);
237
238 const info = document.createElement('div');
239 info.style.cssText = 'flex: 1; min-width: 0;';
240
241 const username = document.createElement('p');
242 username.className = 'CoreText-sc-1txzju1-0 dTdgXA';
243 username.textContent = streamer.displayName || streamer.username;
244 username.style.cssText = 'color: #efeff1; font-size: 13px; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
245
246 info.appendChild(username);
247
248 const removeBtn = document.createElement('button');
249 removeBtn.innerHTML = '×';
250 removeBtn.style.cssText = 'position: absolute; right: 5px; top: 50%; transform: translateY(-50%); background: rgba(0, 0, 0, 0.6); border: none; color: #fff; cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 3px; opacity: 0; transition: opacity 0.2s;';
251 removeBtn.title = 'Remove from folder';
252 removeBtn.addEventListener('click', async (e) => {
253 e.preventDefault();
254 e.stopPropagation();
255 await removeStreamerFromFolder(folderId, streamer.username);
256 await renderFolders();
257 });
258
259 link.addEventListener('mouseenter', () => {
260 link.style.backgroundColor = 'rgba(255, 255, 255, 0.05)';
261 removeBtn.style.opacity = '1';
262 });
263 link.addEventListener('mouseleave', () => {
264 link.style.backgroundColor = 'transparent';
265 removeBtn.style.opacity = '0';
266 });
267
268 link.appendChild(avatar);
269 link.appendChild(info);
270 link.appendChild(removeBtn);
271 card.appendChild(link);
272
273 return card;
274 }
275
276 async function renderFolders() {
277 const container = document.getElementById('folders-container');
278 if (!container) return;
279
280 container.innerHTML = '';
281 const folders = await getFolders();
282
283 if (folders.length === 0) {
284 const emptyMsg = document.createElement('div');
285 emptyMsg.textContent = 'No folders yet. Click + to create one!';
286 emptyMsg.style.cssText = 'color: #adadb8; font-size: 12px; padding: 10px; text-align: center; font-style: italic;';
287 container.appendChild(emptyMsg);
288 } else {
289 folders.forEach(folder => {
290 const folderElement = createFolderElement(folder);
291 container.appendChild(folderElement);
292 });
293 }
294 }
295
296 // Context menu for adding streamers to folders
297 function addContextMenuToChannels() {
298 const channelCards = document.querySelectorAll('a[data-test-selector="recommended-channel"], a.side-nav-card__link[href^="/"]');
299
300 channelCards.forEach(card => {
301 if (card.dataset.folderContextMenuAdded) return;
302 card.dataset.folderContextMenuAdded = 'true';
303
304 card.addEventListener('contextmenu', async (e) => {
305 // Only handle if it's a channel link
306 const href = card.getAttribute('href');
307 if (!href || !href.startsWith('/')) return;
308
309 e.preventDefault();
310 e.stopPropagation();
311
312 // Extract streamer info
313 const username = href.substring(1).split('/')[0].split('?')[0];
314 const displayNameEl = card.querySelector('[data-a-target="side-nav-title"]');
315 const displayName = displayNameEl ? displayNameEl.textContent : username;
316 const avatarEl = card.querySelector('img.tw-image-avatar');
317 const avatarUrl = avatarEl ? avatarEl.src : '';
318
319 const streamerData = {
320 username,
321 displayName,
322 avatarUrl
323 };
324
325 // Show context menu
326 showFolderContextMenu(e.clientX, e.clientY, streamerData);
327 });
328 });
329 }
330
331 // Add "Add to Folder" button on stream pages
332 function addFolderButtonToStreamPage() {
333 // Check if we're on a stream page
334 const pathname = window.location.pathname;
335 if (pathname === '/' || pathname.startsWith('/directory') || pathname.startsWith('/search')) {
336 return;
337 }
338
339 // Find the follow button container
340 const followButton = document.querySelector('[data-a-target="follow-button"]');
341 if (!followButton) {
342 console.log('Follow button not found yet');
343 return;
344 }
345
346 const buttonContainer = followButton.closest('.Layout-sc-1xcs6mc-0');
347 if (!buttonContainer || document.getElementById('add-to-folder-btn')) {
348 return;
349 }
350
351 console.log('Adding folder button to stream page');
352
353 // Extract current streamer info
354 const username = pathname.substring(1).split('/')[0];
355 const channelNameEl = document.querySelector('h1[data-a-target="stream-title"]');
356 const displayName = channelNameEl?.closest('div')?.querySelector('a')?.textContent || username;
357 const avatarEl = document.querySelector('img[alt*="profile"]');
358 const avatarUrl = avatarEl?.src || '';
359
360 const streamerData = {
361 username,
362 displayName,
363 avatarUrl
364 };
365
366 // Create the "Add to Folder" button
367 const folderButton = document.createElement('div');
368 folderButton.id = 'add-to-folder-btn';
369 folderButton.className = 'Layout-sc-1xcs6mc-0 hxpxxi';
370 folderButton.style.cssText = 'margin-left: 5px;';
371
372 const button = document.createElement('button');
373 button.className = 'ScCoreButton-sc-ocjdkq-0 gxYeIp';
374 button.setAttribute('aria-label', 'Add to Folder');
375 button.style.cssText = 'display: flex; align-items: center; gap: 5px;';
376
377 const buttonLabel = document.createElement('div');
378 buttonLabel.className = 'ScCoreButtonLabel-sc-s7h2b7-0 kaIUar';
379
380 const buttonText = document.createElement('div');
381 buttonText.className = 'Layout-sc-1xcs6mc-0 bLZXTb';
382 buttonText.textContent = '📁 Add to Folder';
383 buttonText.style.cssText = 'display: flex; align-items: center;';
384
385 buttonLabel.appendChild(buttonText);
386 button.appendChild(buttonLabel);
387 folderButton.appendChild(button);
388
389 // Add click handler
390 button.addEventListener('click', async (e) => {
391 e.preventDefault();
392 const rect = button.getBoundingClientRect();
393 showFolderContextMenu(rect.left, rect.bottom + 5, streamerData);
394 });
395
396 // Insert after follow button
397 buttonContainer.parentElement.insertBefore(folderButton, buttonContainer.nextSibling);
398 }
399
400 function showFolderContextMenu(x, y, streamerData) {
401 // Remove existing menu
402 const existingMenu = document.getElementById('folder-context-menu');
403 if (existingMenu) existingMenu.remove();
404
405 const menu = document.createElement('div');
406 menu.id = 'folder-context-menu';
407 menu.style.cssText = `
408 position: fixed;
409 left: ${x}px;
410 top: ${y}px;
411 background: #18181b;
412 border: 1px solid #2e2e35;
413 border-radius: 6px;
414 padding: 5px 0;
415 z-index: 10000;
416 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
417 min-width: 180px;
418 `;
419
420 const title = document.createElement('div');
421 title.textContent = `Add "${streamerData.displayName}" to:`;
422 title.style.cssText = 'padding: 8px 12px; color: #adadb8; font-size: 12px; border-bottom: 1px solid #2e2e35;';
423 menu.appendChild(title);
424
425 getFolders().then(folders => {
426 if (folders.length === 0) {
427 const noFolders = document.createElement('div');
428 noFolders.textContent = 'No folders available';
429 noFolders.style.cssText = 'padding: 8px 12px; color: #adadb8; font-size: 13px; font-style: italic;';
430 menu.appendChild(noFolders);
431 } else {
432 folders.forEach(folder => {
433 const option = document.createElement('div');
434 option.textContent = folder.name;
435 option.style.cssText = 'padding: 8px 12px; color: #efeff1; font-size: 13px; cursor: pointer;';
436 option.addEventListener('mouseenter', () => {
437 option.style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
438 });
439 option.addEventListener('mouseleave', () => {
440 option.style.backgroundColor = 'transparent';
441 });
442 option.addEventListener('click', async () => {
443 await addStreamerToFolder(folder.id, streamerData);
444 await renderFolders();
445 menu.remove();
446 });
447 menu.appendChild(option);
448 });
449 }
450 });
451
452 document.body.appendChild(menu);
453
454 // Close menu on click outside
455 const closeMenu = (e) => {
456 if (!menu.contains(e.target)) {
457 menu.remove();
458 document.removeEventListener('click', closeMenu);
459 }
460 };
461 setTimeout(() => {
462 document.addEventListener('click', closeMenu);
463 }, 100);
464 }
465
466 // Initialize
467 async function init() {
468 console.log('Initializing Twitch Streamer Folder Organizer');
469
470 // Wait for sidebar to load
471 const waitForSidebar = setInterval(() => {
472 const liveChannelsSection = document.querySelector('div[aria-label="Live Channels"].side-nav-section');
473
474 if (liveChannelsSection && !document.getElementById('streamer-folder-section')) {
475 console.log('Sidebar found, injecting folder section');
476 clearInterval(waitForSidebar);
477
478 // Create and insert folder section
479 const folderSection = createFolderSection();
480 liveChannelsSection.parentElement.insertBefore(folderSection, liveChannelsSection);
481
482 // Render folders
483 renderFolders();
484
485 // Add context menu to existing channels
486 addContextMenuToChannels();
487
488 // Watch for new channel cards
489 const observer = new MutationObserver(debounce(() => {
490 addContextMenuToChannels();
491 }, 500));
492
493 observer.observe(document.body, {
494 childList: true,
495 subtree: true
496 });
497 }
498 }, 1000);
499
500 // Add folder button to stream pages
501 const waitForStreamPage = setInterval(() => {
502 addFolderButtonToStreamPage();
503 }, 1000);
504
505 // Watch for page navigation (Twitch is a SPA)
506 const pageObserver = new MutationObserver(debounce(() => {
507 addFolderButtonToStreamPage();
508 }, 500));
509
510 pageObserver.observe(document.body, {
511 childList: true,
512 subtree: true
513 });
514 }
515
516 // Start the extension
517 if (document.readyState === 'loading') {
518 document.addEventListener('DOMContentLoaded', init);
519 } else {
520 init();
521 }
522})();