Twitch Streamer Folder Organizer

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})();
Twitch Streamer Folder Organizer | Robomonkey