initial commit

This commit is contained in:
Илья Глазунов 2026-01-20 20:54:51 +03:00
commit c5e137d4aa
12 changed files with 1909 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.env
.DS_Store

79
README.md Normal file
View File

@ -0,0 +1,79 @@
# Reels Master
Chrome расширение для улучшенного просмотра Instagram Reels с управлением громкостью и загрузкой видео.
## Возможности
- 🔊 **Управление громкостью** - Вертикальный слайдер для точной настройки громкости видео
- 📥 **Загрузка роликов** - Скачивайте рилсы одним кликом
- 🎯 **Удобное расположение** - Кнопки расположены рядом с лайками и комментариями
## Установка
### Разработка
1. Установите зависимости:
```bash
pnpm install
```
2. Соберите расширение:
```bash
pnpm run build
```
3. Загрузите расширение в Chrome:
- Откройте `chrome://extensions/`
- Включите "Режим разработчика" (Developer mode)
- Нажмите "Загрузить распакованное расширение" (Load unpacked)
- Выберите папку `dist`
### Режим разработки с автоперезагрузкой
```bash
pnpm run dev
```
## Использование
1. Откройте Instagram и перейдите к любому рилсу
2. Справа от видео, рядом с кнопками лайка и комментариев, появятся новые элементы управления:
- **Кнопка громкости** - Нажмите для включения/выключения звука
- **Слайдер громкости** - Перетащите для регулировки уровня громкости (0-100%)
- **Кнопка загрузки** - Нажмите для скачивания текущего рилса
## Технологии
- TypeScript
- Vite
- Chrome Extension Manifest V3
- WebExtensions Polyfill
## Структура проекта
```
reels-master/
├── src/
│ ├── manifest.json # Манифест расширения
│ ├── content/
│ │ ├── content.ts # Основной скрипт для Instagram
│ │ └── content.css # Стили для элементов управления
│ ├── background/
│ │ └── service-worker.ts # Фоновый service worker
│ ├── popup/
│ │ ├── popup.html
│ │ ├── popup.tsx
│ │ └── popup.css
│ └── options/
│ ├── options.html
│ └── options.tsx
├── public/
│ └── icons/ # Иконки расширения
├── package.json
├── tsconfig.json
└── vite.config.ts
```
## Лицензия
ISC

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "reels-master",
"version": "1.0.0",
"description": "Chrome extension for Instagram Reels with volume control and download functionality",
"main": "index.js",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"bundle": "vite build && node scripts/bundle.js",
"type-check": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.15.0",
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@types/chrome": "^0.1.35",
"@types/node": "^25.0.9",
"@types/webextension-polyfill": "^0.12.4",
"@vitejs/plugin-react": "^5.1.2",
"adm-zip": "^0.5.16",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"webextension-polyfill": "^0.12.0"
}
}

1144
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

0
public/icons/.gitkeep Normal file
View File

View File

@ -0,0 +1,7 @@
// Background Service Worker for Reels Master
console.log('Reels Master: Background service worker loaded');
// Listen for extension installation
chrome.runtime.onInstalled.addListener(() => {
console.log('Reels Master: Extension installed');
});

195
src/content/content.css Normal file
View File

@ -0,0 +1,195 @@
/* Reels Master - Custom Styles */
.reels-master-controls {
z-index: 9999;
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
align-items: center;
}
/* Volume Control */
.reels-master-volume {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.reels-master-volume-button {
background: none;
border: none;
cursor: pointer;
padding: 8px;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
position: relative;
z-index: 2;
}
.reels-master-volume-button:hover {
transform: scale(1.1);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
}
.reels-master-volume-button:active {
transform: scale(0.95) !important;
}
/* Slider Container */
.reels-master-slider-container {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 24px;
padding-bottom: 24px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: 0.5s;
will-change: transform, opacity;
}
.reels-master-volume:hover .reels-master-slider-container,
.reels-master-slider-container:hover {
opacity: 1;
visibility: visible;
pointer-events: all;
transform: translateX(-50%) translateY(0);
transition-delay: 0s;
}
/* Keep slider visible when hovering over it */
.reels-master-slider-container:hover {
transition-delay: 0s;
}
@keyframes slideUp {
from {
transform: translateX(-50%) translateY(10px);
opacity: 0;
}
to {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
}
/* Download Button */
.reels-master-download {
background: none;
border: none;
cursor: pointer;
padding: 8px;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.reels-master-download:hover {
transform: scale(1.1);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
}
.reels-master-download:active {
transform: scale(0.95) !important;
}
/* Volume Slider Styling */
.reels-master-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 4px;
height: 100px;
background: rgba(255, 255, 255, 0.3);
outline: none;
border-radius: 2px;
writing-mode: bt-lr;
-webkit-appearance: slider-vertical;
cursor: pointer;
}
/* Slider thumb for webkit browsers */
.reels-master-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: white;
cursor: pointer;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
transition: all 0.2s;
}
.reels-master-volume-slider::-webkit-slider-thumb:hover {
background: #f0f0f0;
transform: scale(1.2);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.6);
}
/* Slider thumb for Firefox */
.reels-master-volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
background: white;
cursor: pointer;
border-radius: 50%;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
transition: all 0.2s;
}
.reels-master-volume-slider::-moz-range-thumb:hover {
background: #f0f0f0;
transform: scale(1.2);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.6);
}
/* Track styling for webkit */
.reels-master-volume-slider::-webkit-slider-runnable-track {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
/* Track styling for Firefox */
.reels-master-volume-slider::-moz-range-track {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
/* Tooltip effect on hover */
.reels-master-download {
position: relative;
}
.reels-master-download::after {
content: 'Download';
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: -30px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
}
.reels-master-download:hover::after {
opacity: 1;
}

350
src/content/content.ts Normal file
View File

@ -0,0 +1,350 @@
import './content.css';
console.log('Reels Master: Content script loaded');
interface ReelsControls {
volumeSlider: HTMLInputElement | null;
downloadButton: HTMLButtonElement | null;
container: HTMLDivElement | null;
}
class ReelsMaster {
private currentVideo: HTMLVideoElement | null = null;
private controls: ReelsControls = {
volumeSlider: null,
downloadButton: null,
container: null
};
private observer: MutationObserver | null = null;
private storedVolume: number | null = null;
private storedMuted: boolean = false;
constructor() {
this.init();
}
private init(): void {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.start());
} else {
this.start();
}
}
private start(): void {
console.log('Reels Master: Starting...');
this.checkForReels();
this.observeUrlChanges();
this.observeDOMChanges();
window.addEventListener('scroll', () => {
this.checkForReels();
}, { passive: true });
}
private observeUrlChanges(): void {
let lastUrl = location.href;
new MutationObserver(() => {
const currentUrl = location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
console.log('Reels Master: URL changed to', currentUrl);
setTimeout(() => this.checkForReels(), 500);
}
}).observe(document.querySelector('body')!, {
subtree: true,
childList: true
});
}
private observeDOMChanges(): void {
this.observer = new MutationObserver(() => {
this.checkForReels();
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
private checkForReels(): void {
if (!window.location.pathname.includes('/reels/')) {
this.cleanup();
return;
}
const video = this.getActiveVideo();
if (!video || video === this.currentVideo) {
return;
}
console.log('Reels Master: Found new video element');
this.currentVideo = video;
this.injectControls();
}
private getActiveVideo(): HTMLVideoElement | null {
const videos = Array.from(document.querySelectorAll('video'));
if (videos.length === 0) return null;
const center = window.innerHeight / 2;
let closestVideo: HTMLVideoElement | null = null;
let minDistance = Infinity;
for (const video of videos) {
const rect = video.getBoundingClientRect();
if (rect.height === 0) continue;
const videoCenter = rect.top + (rect.height / 2);
const distance = Math.abs(center - videoCenter);
if (distance < minDistance) {
minDistance = distance;
closestVideo = video;
}
}
return closestVideo;
}
private injectControls(): void {
if (!this.currentVideo) return;
const actionButtons = this.findActionButtonsContainer();
if (!actionButtons) {
console.log('Reels Master: Action buttons container not found');
setTimeout(() => this.injectControls(), 1000);
return;
}
if (this.controls.container) {
this.controls.container.remove();
}
this.controls.container = this.createControlsContainer();
this.controls.volumeSlider = this.createVolumeSlider();
this.controls.downloadButton = this.createDownloadButton();
this.controls.container.appendChild(this.createVolumeControl());
this.controls.container.appendChild(this.controls.downloadButton);
actionButtons.insertBefore(this.controls.container, actionButtons.firstChild);
if (this.storedVolume !== null && this.currentVideo) {
this.currentVideo.volume = this.storedVolume;
}
if (this.currentVideo) {
this.currentVideo.muted = this.storedMuted;
}
console.log('Reels Master: Controls injected');
}
private findActionButtonsContainer(): HTMLElement | null {
const likeButton = document.querySelector('svg[aria-label="Like"]');
if (likeButton) {
let parent = likeButton.parentElement;
while (parent) {
const childDivs = parent.querySelectorAll(':scope > div');
if (childDivs.length >= 3) {
const hasLike = parent.querySelector('svg[aria-label="Like"]');
const hasComment = parent.querySelector('svg[aria-label="Comment"]');
const hasShare = parent.querySelector('svg[aria-label="Share"]');
if (hasLike && hasComment && hasShare) {
return parent as HTMLElement;
}
}
parent = parent.parentElement;
}
}
return null;
}
private createControlsContainer(): HTMLDivElement {
const container = document.createElement('div');
container.className = 'reels-master-controls';
return container;
}
private createVolumeControl(): HTMLDivElement {
const volumeControl = document.createElement('div');
volumeControl.className = 'reels-master-volume';
const volumeButton = document.createElement('button');
volumeButton.className = 'reels-master-volume-button';
volumeButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
`;
const sliderContainer = document.createElement('div');
sliderContainer.className = 'reels-master-slider-container';
this.controls.volumeSlider = this.createVolumeSlider();
sliderContainer.appendChild(this.controls.volumeSlider);
volumeButton.onclick = () => {
if (this.currentVideo) {
this.currentVideo.muted = !this.currentVideo.muted;
this.storedMuted = this.currentVideo.muted;
if (this.controls.volumeSlider) {
this.controls.volumeSlider.value = this.currentVideo.muted ? '0' : String(this.currentVideo.volume * 100);
}
this.updateVolumeIcon(volumeButton);
}
};
volumeControl.appendChild(sliderContainer);
volumeControl.appendChild(volumeButton);
return volumeControl;
}
private createVolumeSlider(): HTMLInputElement {
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
let initialValue = '50';
if (this.currentVideo) {
if (this.currentVideo.muted) {
initialValue = '0';
} else {
initialValue = String(this.currentVideo.volume * 100);
}
}
slider.value = initialValue;
slider.className = 'reels-master-volume-slider';
slider.oninput = (e) => {
if (this.currentVideo) {
const value = parseInt((e.target as HTMLInputElement).value);
this.currentVideo.volume = value / 100;
this.currentVideo.muted = value === 0;
this.storedVolume = this.currentVideo.volume;
this.storedMuted = this.currentVideo.muted;
const volumeControl = slider.closest('.reels-master-volume');
const volumeButton = volumeControl?.querySelector('.reels-master-volume-button') as HTMLButtonElement;
if (volumeButton) {
this.updateVolumeIcon(volumeButton);
}
}
};
return slider;
}
private updateVolumeIcon(button: HTMLButtonElement): void {
if (!this.currentVideo) return;
const volume = this.currentVideo.muted ? 0 : this.currentVideo.volume;
let icon = '';
if (volume === 0) {
icon = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>`;
} else if (volume < 0.5) {
icon = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 9v6h4l5 5V4l-5 5H7z"/>
</svg>`;
} else {
icon = `<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>`;
}
button.innerHTML = icon;
}
private createDownloadButton(): HTMLButtonElement {
const button = document.createElement('button');
button.className = 'reels-master-download';
button.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
`;
button.title = 'Download Reel';
button.onclick = () => this.downloadReel();
return button;
}
private async downloadReel(): Promise<void> {
if (!this.currentVideo) {
console.error('Reels Master: No video found');
return;
}
try {
const videoUrl = this.currentVideo.src;
if (!videoUrl) {
alert('Unable to find video URL');
return;
}
if (this.controls.downloadButton) {
this.controls.downloadButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
`;
}
const response = await fetch(videoUrl);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `reel_${Date.now()}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setTimeout(() => {
if (this.controls.downloadButton) {
this.controls.downloadButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
`;
}
}, 2000);
} catch (error) {
console.error('Reels Master: Download failed', error);
alert('Failed to download video. Please try again.');
if (this.controls.downloadButton) {
this.controls.downloadButton.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
`;
}
}
}
private cleanup(): void {
if (this.controls.container) {
this.controls.container.remove();
this.controls.container = null;
this.controls.volumeSlider = null;
this.controls.downloadButton = null;
}
this.currentVideo = null;
}
}
new ReelsMaster();

19
src/manifest.json Normal file
View File

@ -0,0 +1,19 @@
{
"manifest_version": 3,
"name": "Reels Master",
"version": "1.0.0",
"description": "Instagram Reels volume control and download",
"background": {
"service_worker": "background/background.js"
},
"content_scripts": [
{
"matches": ["*://*.instagram.com/*"],
"js": ["content/content.js"],
"css": ["assets/content.css"],
"run_at": "document_end"
}
],
"permissions": ["storage"],
"host_permissions": ["*://*.instagram.com/*", "*://*.cdninstagram.com/*"]
}

6
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.css' {
const content: string;
export default content;
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"isolatedModules": true,
"types": ["chrome", "node"]
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}

57
vite.config.ts Normal file
View File

@ -0,0 +1,57 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import { copyFileSync, existsSync } from 'fs';
import AdmZip from 'adm-zip';
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
background: resolve(__dirname, 'src/background/service-worker.ts'),
content: resolve(__dirname, 'src/content/content.ts'),
},
output: {
entryFileNames: '[name]/[name].js',
chunkFileNames: '[name].js',
assetFileNames: 'assets/[name].[ext]'
}
}
},
plugins: [
{
name: 'copy-manifest',
closeBundle() {
try {
copyFileSync(
resolve(__dirname, 'src/manifest.json'),
resolve(__dirname, 'dist/manifest.json')
);
console.log('✓ Copied manifest.json');
} catch (err) {
console.error('Error copying manifest.json:', err);
}
}
},
{
name: 'create-zip',
closeBundle() {
if (process.env.NODE_ENV === 'production' || !process.argv.includes('--watch')) {
try {
const zip = new AdmZip();
const distPath = resolve(__dirname, 'dist');
if (existsSync(distPath)) {
zip.addLocalFolder(distPath);
zip.writeZip(resolve(__dirname, 'reels-master.zip'));
console.log('Created reels-master.zip');
}
} catch (err) {
console.error('Error creating zip:', err);
}
}
}
}
]
});