Implement download functionality and volume control for Instagram Reels
This commit is contained in:
parent
0fa9c8599f
commit
4a3c25c369
@ -1,7 +1,173 @@
|
||||
// Background Service Worker for Reels Master
|
||||
console.log('Reels Master: Background service worker loaded');
|
||||
|
||||
// Listen for extension installation
|
||||
const ENCODING_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
function shortcodeToPk(shortcode: string): bigint {
|
||||
if (shortcode.length > 28) {
|
||||
shortcode = shortcode.slice(0, -28);
|
||||
}
|
||||
let pk = BigInt(0);
|
||||
for (const char of shortcode) {
|
||||
pk = pk * BigInt(64) + BigInt(ENCODING_CHARS.indexOf(char));
|
||||
}
|
||||
return pk;
|
||||
}
|
||||
|
||||
function extractShortcode(url: string): string | null {
|
||||
const match = url.match(/\/(?:p|tv|reels?(?!\/audio\/))\/([^/?#&]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
console.log('Reels Master: Extension installed');
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'DOWNLOAD_REEL') {
|
||||
handleDownload(message.url)
|
||||
.then(result => sendResponse(result))
|
||||
.catch(error => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleDownload(reelUrl: string): Promise<{ success: boolean; downloadUrl?: string; error?: string }> {
|
||||
try {
|
||||
console.log('Reels Master: Processing download for', reelUrl);
|
||||
|
||||
const shortcode = extractShortcode(reelUrl);
|
||||
if (!shortcode) {
|
||||
return { success: false, error: 'Could not extract shortcode from URL' };
|
||||
}
|
||||
|
||||
const pk = shortcodeToPk(shortcode);
|
||||
console.log('Reels Master: Shortcode:', shortcode, 'PK:', pk.toString());
|
||||
|
||||
const apiHeaders = {
|
||||
'X-IG-App-ID': '936619743392459',
|
||||
'X-ASBD-ID': '198387',
|
||||
'X-IG-WWW-Claim': '0',
|
||||
'Origin': 'https://www.instagram.com',
|
||||
'Accept': '*/*',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
};
|
||||
|
||||
const apiUrl = `https://i.instagram.com/api/v1/media/${pk}/info/`;
|
||||
console.log('Reels Master: Fetching from API:', apiUrl);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: apiHeaders,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('Reels Master: API response not ok:', response.status);
|
||||
return await tryGraphQLFallback(shortcode, apiHeaders);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Reels Master: API response received');
|
||||
|
||||
const videoUrl = extractVideoUrl(data);
|
||||
|
||||
if (!videoUrl) {
|
||||
console.log('Reels Master: No video URL in API response, trying fallback');
|
||||
return await tryGraphQLFallback(shortcode, apiHeaders);
|
||||
}
|
||||
|
||||
console.log('Reels Master: Found video URL, starting download');
|
||||
|
||||
await chrome.downloads.download({
|
||||
url: videoUrl,
|
||||
filename: `reel_${shortcode}_${Date.now()}.mp4`,
|
||||
});
|
||||
|
||||
return { success: true, downloadUrl: videoUrl };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Reels Master: Download error', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function tryGraphQLFallback(shortcode: string, headers: Record<string, string>): Promise<{ success: boolean; downloadUrl?: string; error?: string }> {
|
||||
try {
|
||||
console.log('Reels Master: Trying GraphQL fallback');
|
||||
|
||||
const variables = {
|
||||
shortcode: shortcode,
|
||||
child_comment_count: 3,
|
||||
fetch_comment_count: 40,
|
||||
parent_comment_count: 24,
|
||||
has_threaded_comments: true,
|
||||
};
|
||||
|
||||
const graphqlUrl = `https://www.instagram.com/graphql/query/?doc_id=8845758582119845&variables=${encodeURIComponent(JSON.stringify(variables))}`;
|
||||
|
||||
const response = await fetch(graphqlUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...headers,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Referer': `https://www.instagram.com/reel/${shortcode}/`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: `GraphQL request failed: ${response.status}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const media = data?.data?.xdt_shortcode_media;
|
||||
|
||||
if (!media) {
|
||||
return { success: false, error: 'No media data in GraphQL response. Try logging in to Instagram.' };
|
||||
}
|
||||
|
||||
const videoUrl = media.video_url;
|
||||
if (!videoUrl) {
|
||||
return { success: false, error: 'No video URL found in response' };
|
||||
}
|
||||
|
||||
await chrome.downloads.download({
|
||||
url: videoUrl,
|
||||
filename: `reel_${shortcode}_${Date.now()}.mp4`,
|
||||
});
|
||||
|
||||
return { success: true, downloadUrl: videoUrl };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Reels Master: GraphQL fallback error', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'GraphQL fallback failed' };
|
||||
}
|
||||
}
|
||||
|
||||
function extractVideoUrl(data: any): string | null {
|
||||
const items = data?.items;
|
||||
if (!items || !items.length) return null;
|
||||
|
||||
const item = items[0];
|
||||
|
||||
if (item.video_url) {
|
||||
return item.video_url;
|
||||
}
|
||||
|
||||
const videoVersions = item.video_versions;
|
||||
if (videoVersions && videoVersions.length > 0) {
|
||||
return videoVersions[0].url;
|
||||
}
|
||||
|
||||
const carouselMedia = item.carousel_media;
|
||||
if (carouselMedia && carouselMedia.length > 0) {
|
||||
for (const media of carouselMedia) {
|
||||
if (media.video_versions && media.video_versions.length > 0) {
|
||||
return media.video_versions[0].url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -193,3 +193,17 @@
|
||||
.reels-master-download:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Spinner animation for download */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.reels-master-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@ -2,31 +2,21 @@ 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 = 0.5;
|
||||
private storedMuted: boolean = false;
|
||||
private videoVolumeListeners: WeakMap<HTMLVideoElement, () => void> = new WeakMap();
|
||||
private processedContainers: WeakSet<HTMLElement> = new WeakSet();
|
||||
private videoVolumeListeners: WeakMap<HTMLVideoElement, boolean> = new WeakMap();
|
||||
private domObserver: MutationObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
// Сразу начинаем следить за всеми видео для мгновенного применения громкости
|
||||
this.setupGlobalVideoInterceptor();
|
||||
this.loadSettings();
|
||||
|
||||
this.setupVideoInterceptor();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => this.start());
|
||||
@ -35,25 +25,47 @@ class ReelsMaster {
|
||||
}
|
||||
}
|
||||
|
||||
// Перехватываем все видео сразу при их появлении и применяем сохраненную громкость
|
||||
private setupGlobalVideoInterceptor(): void {
|
||||
// Применяем к уже существующим видео
|
||||
private loadSettings(): void {
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
chrome.storage.local.get(['volume', 'muted'], (result) => {
|
||||
if (result.volume !== undefined) {
|
||||
this.storedVolume = result.volume;
|
||||
}
|
||||
if (result.muted !== undefined) {
|
||||
this.storedMuted = result.muted;
|
||||
}
|
||||
this.applyVolumeToAllVideos();
|
||||
this.updateAllSliders();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
||||
chrome.storage.local.set({
|
||||
volume: this.storedVolume,
|
||||
muted: this.storedMuted
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
console.log('Reels Master: Starting...');
|
||||
this.injectControlsToAllContainers();
|
||||
this.setupDOMObserver();
|
||||
}
|
||||
|
||||
private setupVideoInterceptor(): void {
|
||||
this.applyVolumeToAllVideos();
|
||||
|
||||
// Следим за новыми видео через MutationObserver
|
||||
const videoObserver = new MutationObserver((mutations) => {
|
||||
let hasNewVideo = false;
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node instanceof HTMLVideoElement) {
|
||||
hasNewVideo = true;
|
||||
this.applyVolumeToVideo(node);
|
||||
} else if (node instanceof HTMLElement) {
|
||||
const videos = node.querySelectorAll('video');
|
||||
if (videos.length > 0) {
|
||||
hasNewVideo = true;
|
||||
videos.forEach(video => this.applyVolumeToVideo(video));
|
||||
}
|
||||
videos.forEach(video => this.applyVolumeToVideo(video));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,6 +78,8 @@ class ReelsMaster {
|
||||
}
|
||||
|
||||
private applyVolumeToAllVideos(): void {
|
||||
if (!window.location.pathname.includes('/reels/')) return;
|
||||
|
||||
document.querySelectorAll('video').forEach(video => {
|
||||
this.applyVolumeToVideo(video);
|
||||
});
|
||||
@ -74,202 +88,135 @@ class ReelsMaster {
|
||||
private applyVolumeToVideo(video: HTMLVideoElement): void {
|
||||
if (!window.location.pathname.includes('/reels/')) return;
|
||||
|
||||
// Применяем сохраненную громкость
|
||||
video.volume = this.storedVolume;
|
||||
video.muted = this.storedMuted;
|
||||
|
||||
// Добавляем слушатель на случай если Instagram перезапишет громкость
|
||||
if (!this.videoVolumeListeners.has(video)) {
|
||||
const listener = () => {
|
||||
// Если громкость изменилась не нами, восстанавливаем
|
||||
if (Math.abs(video.volume - this.storedVolume) > 0.01 || video.muted !== this.storedMuted) {
|
||||
this.videoVolumeListeners.set(video, true);
|
||||
|
||||
let changeCount = 0;
|
||||
const maxChanges = 10;
|
||||
|
||||
const enforceVolume = () => {
|
||||
changeCount++;
|
||||
if (changeCount <= maxChanges) {
|
||||
video.volume = this.storedVolume;
|
||||
video.muted = this.storedMuted;
|
||||
}
|
||||
};
|
||||
|
||||
// Слушаем первые несколько изменений громкости для борьбы с Instagram
|
||||
let volumeChangeCount = 0;
|
||||
const tempListener = () => {
|
||||
volumeChangeCount++;
|
||||
if (volumeChangeCount <= 5) {
|
||||
video.volume = this.storedVolume;
|
||||
video.muted = this.storedMuted;
|
||||
} else {
|
||||
video.removeEventListener('volumechange', tempListener);
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('volumechange', tempListener);
|
||||
video.addEventListener('loadedmetadata', listener);
|
||||
video.addEventListener('play', listener);
|
||||
this.videoVolumeListeners.set(video, listener);
|
||||
|
||||
video.addEventListener('volumechange', enforceVolume);
|
||||
video.addEventListener('loadedmetadata', enforceVolume);
|
||||
video.addEventListener('play', enforceVolume);
|
||||
video.addEventListener('canplay', enforceVolume);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Сразу применяем громкость ко всем видео
|
||||
this.applyVolumeToAllVideos();
|
||||
setTimeout(() => this.checkForReels(), 300);
|
||||
private setupDOMObserver(): void {
|
||||
this.domObserver = new MutationObserver((mutations) => {
|
||||
if (!window.location.pathname.includes('/reels/')) return;
|
||||
|
||||
let shouldCheck = false;
|
||||
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node instanceof HTMLElement) {
|
||||
if (node.querySelector('video') || node.querySelector('svg[aria-label="Like"]')) {
|
||||
shouldCheck = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldCheck) break;
|
||||
}
|
||||
|
||||
if (shouldCheck) {
|
||||
requestAnimationFrame(() => {
|
||||
this.injectControlsToAllContainers();
|
||||
});
|
||||
}
|
||||
}).observe(document.querySelector('body')!, {
|
||||
subtree: true,
|
||||
childList: true
|
||||
});
|
||||
}
|
||||
|
||||
private observeDOMChanges(): void {
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.checkForReels();
|
||||
});
|
||||
|
||||
this.observer.observe(document.body, {
|
||||
this.domObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
private checkForReels(): void {
|
||||
if (!window.location.pathname.includes('/reels/')) {
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
private injectControlsToAllContainers(): void {
|
||||
if (!window.location.pathname.includes('/reels/')) return;
|
||||
|
||||
const video = this.getActiveVideo();
|
||||
if (!video || video === this.currentVideo) {
|
||||
return;
|
||||
}
|
||||
const actionContainers = this.findAllActionContainers();
|
||||
|
||||
console.log(`Reels Master: Found ${actionContainers.length} action containers`);
|
||||
|
||||
console.log('Reels Master: Found new video element');
|
||||
this.currentVideo = video;
|
||||
|
||||
// Убедимся что громкость применена к текущему видео
|
||||
this.applyVolumeToVideo(video);
|
||||
|
||||
this.injectControls();
|
||||
for (const container of actionContainers) {
|
||||
this.injectControlsToContainer(container);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
private findAllActionContainers(): HTMLElement[] {
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
const likeButtons = document.querySelectorAll('svg[aria-label="Like"]');
|
||||
|
||||
for (const likeButton of likeButtons) {
|
||||
const container = this.findActionContainerFromLikeButton(likeButton);
|
||||
if (container && !containers.includes(container)) {
|
||||
containers.push(container);
|
||||
}
|
||||
}
|
||||
|
||||
return closestVideo;
|
||||
}
|
||||
|
||||
private injectControls(): void {
|
||||
if (!this.currentVideo) return;
|
||||
|
||||
// Ищем контейнер кнопок рядом с ТЕКУЩИМ видео, а не глобально
|
||||
const actionButtons = this.findActionButtonsContainer(this.currentVideo);
|
||||
if (!actionButtons) {
|
||||
console.log('Reels Master: Action buttons container not found');
|
||||
setTimeout(() => this.injectControls(), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, есть ли уже наши контролы в этом контейнере
|
||||
const existingControls = actionButtons.querySelector('.reels-master-controls');
|
||||
if (existingControls) {
|
||||
console.log('Reels Master: Controls already exist in this container');
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем старые контролы из предыдущего контейнера
|
||||
if (this.controls.container && this.controls.container.parentElement !== actionButtons) {
|
||||
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.controls.volumeSlider) {
|
||||
this.controls.volumeSlider.value = String(this.storedVolume * 100);
|
||||
}
|
||||
|
||||
console.log('Reels Master: Controls injected');
|
||||
}
|
||||
|
||||
// Ищем контейнер кнопок относительно конкретного видео
|
||||
private findActionButtonsContainer(video: HTMLVideoElement): HTMLElement | null {
|
||||
// Ищем родительский контейнер рила для данного видео
|
||||
let reelContainer = video.closest('article') || video.closest('[role="presentation"]');
|
||||
|
||||
if (!reelContainer) {
|
||||
// Пробуем найти родителя вверх по дереву
|
||||
let parent = video.parentElement;
|
||||
for (let i = 0; i < 10 && parent; i++) {
|
||||
if (parent.querySelector('svg[aria-label="Like"]')) {
|
||||
reelContainer = parent;
|
||||
break;
|
||||
return containers;
|
||||
}
|
||||
|
||||
private findActionContainerFromLikeButton(likeButton: Element): HTMLElement | null {
|
||||
let parent = likeButton.parentElement;
|
||||
|
||||
while (parent) {
|
||||
const hasLike = parent.querySelector('svg[aria-label="Like"]');
|
||||
const hasComment = parent.querySelector('svg[aria-label="Comment"]');
|
||||
const hasShare = parent.querySelector('svg[aria-label="Share"]');
|
||||
const hasSave = parent.querySelector('svg[aria-label="Save"]');
|
||||
|
||||
if (hasLike && hasComment && hasShare && hasSave) {
|
||||
const children = parent.children;
|
||||
if (children.length >= 4) {
|
||||
return parent as HTMLElement;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
parent = parent.parentElement;
|
||||
|
||||
if (parent === document.body) break;
|
||||
}
|
||||
|
||||
if (!reelContainer) {
|
||||
reelContainer = document.body;
|
||||
}
|
||||
|
||||
// Ищем кнопку лайка внутри контейнера текущего рила
|
||||
const likeButton = reelContainer.querySelector('svg[aria-label="Like"]');
|
||||
if (likeButton) {
|
||||
let parent = likeButton.parentElement;
|
||||
while (parent && parent !== reelContainer) {
|
||||
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 injectControlsToContainer(container: HTMLElement): void {
|
||||
if (this.processedContainers.has(container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (container.querySelector('.reels-master-controls')) {
|
||||
this.processedContainers.add(container);
|
||||
return;
|
||||
}
|
||||
|
||||
const controlsContainer = this.createControlsContainer();
|
||||
const volumeControl = this.createVolumeControl();
|
||||
const downloadButton = this.createDownloadButton(container);
|
||||
|
||||
controlsContainer.appendChild(volumeControl);
|
||||
controlsContainer.appendChild(downloadButton);
|
||||
|
||||
container.insertBefore(controlsContainer, container.firstChild);
|
||||
|
||||
this.processedContainers.add(container);
|
||||
console.log('Reels Master: Controls injected to container');
|
||||
}
|
||||
|
||||
private createControlsContainer(): HTMLDivElement {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'reels-master-controls';
|
||||
@ -280,32 +227,23 @@ class ReelsMaster {
|
||||
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.storedMuted = !this.storedMuted;
|
||||
|
||||
// Применяем ко всем видео
|
||||
this.applyVolumeToAllVideos();
|
||||
const slider = this.createVolumeSlider();
|
||||
sliderContainer.appendChild(slider);
|
||||
|
||||
if (this.controls.volumeSlider) {
|
||||
this.controls.volumeSlider.value = this.storedMuted ? '0' : String(this.storedVolume * 100);
|
||||
}
|
||||
this.updateVolumeIcon(volumeButton);
|
||||
}
|
||||
const volumeButton = document.createElement('button');
|
||||
volumeButton.className = 'reels-master-volume-button';
|
||||
this.updateVolumeIcon(volumeButton);
|
||||
|
||||
volumeButton.onclick = () => {
|
||||
this.storedMuted = !this.storedMuted;
|
||||
|
||||
this.saveSettings();
|
||||
this.applyVolumeToAllVideos();
|
||||
this.updateAllSliders();
|
||||
this.updateAllVolumeIcons();
|
||||
};
|
||||
|
||||
volumeControl.appendChild(sliderContainer);
|
||||
@ -319,36 +257,39 @@ class ReelsMaster {
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
|
||||
// Используем сохраненную громкость
|
||||
slider.value = String(this.storedVolume * 100);
|
||||
|
||||
slider.value = this.storedMuted ? '0' : String(this.storedVolume * 100);
|
||||
slider.className = 'reels-master-volume-slider';
|
||||
|
||||
slider.oninput = (e) => {
|
||||
if (this.currentVideo) {
|
||||
const value = parseInt((e.target as HTMLInputElement).value);
|
||||
const newVolume = value / 100;
|
||||
const newMuted = value === 0;
|
||||
|
||||
// Сохраняем настройки
|
||||
this.storedVolume = newVolume;
|
||||
this.storedMuted = newMuted;
|
||||
|
||||
// Применяем ко всем видео сразу
|
||||
this.applyVolumeToAllVideos();
|
||||
|
||||
const volumeControl = slider.closest('.reels-master-volume');
|
||||
const volumeButton = volumeControl?.querySelector('.reels-master-volume-button') as HTMLButtonElement;
|
||||
if (volumeButton) {
|
||||
this.updateVolumeIcon(volumeButton);
|
||||
}
|
||||
}
|
||||
const value = parseInt((e.target as HTMLInputElement).value);
|
||||
this.storedVolume = value / 100;
|
||||
this.storedMuted = value === 0;
|
||||
|
||||
this.saveSettings();
|
||||
this.applyVolumeToAllVideos();
|
||||
this.updateAllSliders();
|
||||
this.updateAllVolumeIcons();
|
||||
};
|
||||
|
||||
return slider;
|
||||
}
|
||||
|
||||
private updateAllSliders(): void {
|
||||
const sliders = document.querySelectorAll('.reels-master-volume-slider') as NodeListOf<HTMLInputElement>;
|
||||
const value = this.storedMuted ? '0' : String(this.storedVolume * 100);
|
||||
|
||||
sliders.forEach(slider => {
|
||||
slider.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
private updateAllVolumeIcons(): void {
|
||||
const buttons = document.querySelectorAll('.reels-master-volume-button') as NodeListOf<HTMLButtonElement>;
|
||||
buttons.forEach(button => {
|
||||
this.updateVolumeIcon(button);
|
||||
});
|
||||
}
|
||||
|
||||
private updateVolumeIcon(button: HTMLButtonElement): void {
|
||||
const volume = this.storedMuted ? 0 : this.storedVolume;
|
||||
|
||||
@ -370,7 +311,7 @@ class ReelsMaster {
|
||||
button.innerHTML = icon;
|
||||
}
|
||||
|
||||
private createDownloadButton(): HTMLButtonElement {
|
||||
private createDownloadButton(actionContainer: HTMLElement): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'reels-master-download';
|
||||
button.innerHTML = `
|
||||
@ -380,77 +321,104 @@ class ReelsMaster {
|
||||
`;
|
||||
button.title = 'Download Reel';
|
||||
|
||||
button.onclick = () => this.downloadReel();
|
||||
button.onclick = () => this.downloadReel(actionContainer, button);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private async downloadReel(): Promise<void> {
|
||||
if (!this.currentVideo) {
|
||||
console.error('Reels Master: No video found');
|
||||
private findVideoForContainer(actionContainer: HTMLElement): HTMLVideoElement | null {
|
||||
let parent = actionContainer.parentElement;
|
||||
|
||||
while (parent) {
|
||||
const video = parent.querySelector('video');
|
||||
if (video) {
|
||||
return video;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
|
||||
if (parent === document.body) break;
|
||||
}
|
||||
|
||||
return this.getClosestVideoToElement(actionContainer);
|
||||
}
|
||||
|
||||
private getClosestVideoToElement(element: HTMLElement): HTMLVideoElement | null {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const elementCenterY = elementRect.top + elementRect.height / 2;
|
||||
|
||||
const videos = Array.from(document.querySelectorAll('video'));
|
||||
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(elementCenterY - videoCenter);
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestVideo = video;
|
||||
}
|
||||
}
|
||||
|
||||
return closestVideo;
|
||||
}
|
||||
|
||||
private async downloadReel(actionContainer: HTMLElement, button: HTMLButtonElement): Promise<void> {
|
||||
const reelUrl = window.location.href;
|
||||
|
||||
if (!reelUrl.includes('/reels/')) {
|
||||
alert('Unable to detect reel URL');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const videoUrl = this.currentVideo.src;
|
||||
|
||||
if (!videoUrl) {
|
||||
alert('Unable to find video URL');
|
||||
return;
|
||||
button.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="reels-master-spinner">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z" opacity="0.3"/>
|
||||
<path d="M12 2v4c4.42 0 8 3.58 8 8h4c0-6.63-5.37-12-12-12z"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
console.log('Reels Master: Sending download request to background for', reelUrl);
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'DOWNLOAD_REEL',
|
||||
url: reelUrl
|
||||
});
|
||||
|
||||
console.log('Reels Master: Background response', response);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Download failed');
|
||||
}
|
||||
|
||||
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);
|
||||
button.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>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.controls.container) {
|
||||
this.controls.container.remove();
|
||||
this.controls.container = null;
|
||||
this.controls.volumeSlider = null;
|
||||
this.controls.downloadButton = null;
|
||||
} catch (error) {
|
||||
console.error('Reels Master: Download failed', error);
|
||||
alert('Failed to download video: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
this.currentVideo = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,6 @@
|
||||
"run_at": "document_end"
|
||||
}
|
||||
],
|
||||
"permissions": ["storage"],
|
||||
"host_permissions": ["*://*.instagram.com/*", "*://*.cdninstagram.com/*"]
|
||||
"permissions": ["storage", "downloads"],
|
||||
"host_permissions": ["*://*.instagram.com/*", "*://*.cdninstagram.com/*", "https://i.instagram.com/*", "*://*.fbcdn.net/*"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user