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
|
// Background Service Worker for Reels Master
|
||||||
console.log('Reels Master: Background service worker loaded');
|
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(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
console.log('Reels Master: Extension installed');
|
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 {
|
.reels-master-download:hover::after {
|
||||||
opacity: 1;
|
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');
|
console.log('Reels Master: Content script loaded');
|
||||||
|
|
||||||
interface ReelsControls {
|
|
||||||
volumeSlider: HTMLInputElement | null;
|
|
||||||
downloadButton: HTMLButtonElement | null;
|
|
||||||
container: HTMLDivElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReelsMaster {
|
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 storedVolume: number = 0.5;
|
||||||
private storedMuted: boolean = false;
|
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() {
|
constructor() {
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(): void {
|
private init(): void {
|
||||||
// Сразу начинаем следить за всеми видео для мгновенного применения громкости
|
this.loadSettings();
|
||||||
this.setupGlobalVideoInterceptor();
|
|
||||||
|
this.setupVideoInterceptor();
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', () => this.start());
|
document.addEventListener('DOMContentLoaded', () => this.start());
|
||||||
@ -35,25 +25,47 @@ class ReelsMaster {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Перехватываем все видео сразу при их появлении и применяем сохраненную громкость
|
private loadSettings(): void {
|
||||||
private setupGlobalVideoInterceptor(): 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();
|
this.applyVolumeToAllVideos();
|
||||||
|
|
||||||
// Следим за новыми видео через MutationObserver
|
|
||||||
const videoObserver = new MutationObserver((mutations) => {
|
const videoObserver = new MutationObserver((mutations) => {
|
||||||
let hasNewVideo = false;
|
|
||||||
for (const mutation of mutations) {
|
for (const mutation of mutations) {
|
||||||
for (const node of mutation.addedNodes) {
|
for (const node of mutation.addedNodes) {
|
||||||
if (node instanceof HTMLVideoElement) {
|
if (node instanceof HTMLVideoElement) {
|
||||||
hasNewVideo = true;
|
|
||||||
this.applyVolumeToVideo(node);
|
this.applyVolumeToVideo(node);
|
||||||
} else if (node instanceof HTMLElement) {
|
} else if (node instanceof HTMLElement) {
|
||||||
const videos = node.querySelectorAll('video');
|
const videos = node.querySelectorAll('video');
|
||||||
if (videos.length > 0) {
|
videos.forEach(video => this.applyVolumeToVideo(video));
|
||||||
hasNewVideo = true;
|
|
||||||
videos.forEach(video => this.applyVolumeToVideo(video));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,6 +78,8 @@ class ReelsMaster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyVolumeToAllVideos(): void {
|
private applyVolumeToAllVideos(): void {
|
||||||
|
if (!window.location.pathname.includes('/reels/')) return;
|
||||||
|
|
||||||
document.querySelectorAll('video').forEach(video => {
|
document.querySelectorAll('video').forEach(video => {
|
||||||
this.applyVolumeToVideo(video);
|
this.applyVolumeToVideo(video);
|
||||||
});
|
});
|
||||||
@ -74,202 +88,135 @@ class ReelsMaster {
|
|||||||
private applyVolumeToVideo(video: HTMLVideoElement): void {
|
private applyVolumeToVideo(video: HTMLVideoElement): void {
|
||||||
if (!window.location.pathname.includes('/reels/')) return;
|
if (!window.location.pathname.includes('/reels/')) return;
|
||||||
|
|
||||||
// Применяем сохраненную громкость
|
|
||||||
video.volume = this.storedVolume;
|
video.volume = this.storedVolume;
|
||||||
video.muted = this.storedMuted;
|
video.muted = this.storedMuted;
|
||||||
|
|
||||||
// Добавляем слушатель на случай если Instagram перезапишет громкость
|
|
||||||
if (!this.videoVolumeListeners.has(video)) {
|
if (!this.videoVolumeListeners.has(video)) {
|
||||||
const listener = () => {
|
this.videoVolumeListeners.set(video, true);
|
||||||
// Если громкость изменилась не нами, восстанавливаем
|
|
||||||
if (Math.abs(video.volume - this.storedVolume) > 0.01 || video.muted !== this.storedMuted) {
|
let changeCount = 0;
|
||||||
|
const maxChanges = 10;
|
||||||
|
|
||||||
|
const enforceVolume = () => {
|
||||||
|
changeCount++;
|
||||||
|
if (changeCount <= maxChanges) {
|
||||||
video.volume = this.storedVolume;
|
video.volume = this.storedVolume;
|
||||||
video.muted = this.storedMuted;
|
video.muted = this.storedMuted;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Слушаем первые несколько изменений громкости для борьбы с Instagram
|
video.addEventListener('volumechange', enforceVolume);
|
||||||
let volumeChangeCount = 0;
|
video.addEventListener('loadedmetadata', enforceVolume);
|
||||||
const tempListener = () => {
|
video.addEventListener('play', enforceVolume);
|
||||||
volumeChangeCount++;
|
video.addEventListener('canplay', enforceVolume);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private start(): void {
|
private setupDOMObserver(): void {
|
||||||
console.log('Reels Master: Starting...');
|
this.domObserver = new MutationObserver((mutations) => {
|
||||||
this.checkForReels();
|
if (!window.location.pathname.includes('/reels/')) return;
|
||||||
this.observeUrlChanges();
|
|
||||||
this.observeDOMChanges();
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
this.checkForReels();
|
|
||||||
}, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private observeUrlChanges(): void {
|
let shouldCheck = false;
|
||||||
let lastUrl = location.href;
|
|
||||||
new MutationObserver(() => {
|
for (const mutation of mutations) {
|
||||||
const currentUrl = location.href;
|
for (const node of mutation.addedNodes) {
|
||||||
if (currentUrl !== lastUrl) {
|
if (node instanceof HTMLElement) {
|
||||||
lastUrl = currentUrl;
|
if (node.querySelector('video') || node.querySelector('svg[aria-label="Like"]')) {
|
||||||
console.log('Reels Master: URL changed to', currentUrl);
|
shouldCheck = true;
|
||||||
// Сразу применяем громкость ко всем видео
|
break;
|
||||||
this.applyVolumeToAllVideos();
|
}
|
||||||
setTimeout(() => this.checkForReels(), 300);
|
}
|
||||||
|
}
|
||||||
|
if (shouldCheck) break;
|
||||||
}
|
}
|
||||||
}).observe(document.querySelector('body')!, {
|
|
||||||
subtree: true,
|
|
||||||
childList: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private observeDOMChanges(): void {
|
if (shouldCheck) {
|
||||||
this.observer = new MutationObserver(() => {
|
requestAnimationFrame(() => {
|
||||||
this.checkForReels();
|
this.injectControlsToAllContainers();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.observer.observe(document.body, {
|
this.domObserver.observe(document.body, {
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true
|
subtree: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkForReels(): void {
|
private injectControlsToAllContainers(): void {
|
||||||
if (!window.location.pathname.includes('/reels/')) {
|
if (!window.location.pathname.includes('/reels/')) return;
|
||||||
this.cleanup();
|
|
||||||
return;
|
const actionContainers = this.findAllActionContainers();
|
||||||
|
|
||||||
|
console.log(`Reels Master: Found ${actionContainers.length} action containers`);
|
||||||
|
|
||||||
|
for (const container of actionContainers) {
|
||||||
|
this.injectControlsToContainer(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
const video = this.getActiveVideo();
|
|
||||||
if (!video || video === this.currentVideo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Reels Master: Found new video element');
|
|
||||||
this.currentVideo = video;
|
|
||||||
|
|
||||||
// Убедимся что громкость применена к текущему видео
|
|
||||||
this.applyVolumeToVideo(video);
|
|
||||||
|
|
||||||
this.injectControls();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getActiveVideo(): HTMLVideoElement | null {
|
private findAllActionContainers(): HTMLElement[] {
|
||||||
const videos = Array.from(document.querySelectorAll('video'));
|
const containers: HTMLElement[] = [];
|
||||||
if (videos.length === 0) return null;
|
|
||||||
|
|
||||||
const center = window.innerHeight / 2;
|
const likeButtons = document.querySelectorAll('svg[aria-label="Like"]');
|
||||||
let closestVideo: HTMLVideoElement | null = null;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
|
|
||||||
for (const video of videos) {
|
for (const likeButton of likeButtons) {
|
||||||
const rect = video.getBoundingClientRect();
|
const container = this.findActionContainerFromLikeButton(likeButton);
|
||||||
if (rect.height === 0) continue;
|
if (container && !containers.includes(container)) {
|
||||||
|
containers.push(container);
|
||||||
const videoCenter = rect.top + (rect.height / 2);
|
|
||||||
const distance = Math.abs(center - videoCenter);
|
|
||||||
|
|
||||||
if (distance < minDistance) {
|
|
||||||
minDistance = distance;
|
|
||||||
closestVideo = video;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return closestVideo;
|
return containers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private injectControls(): void {
|
private findActionContainerFromLikeButton(likeButton: Element): HTMLElement | null {
|
||||||
if (!this.currentVideo) return;
|
let parent = likeButton.parentElement;
|
||||||
|
|
||||||
// Ищем контейнер кнопок рядом с ТЕКУЩИМ видео, а не глобально
|
while (parent) {
|
||||||
const actionButtons = this.findActionButtonsContainer(this.currentVideo);
|
const hasLike = parent.querySelector('svg[aria-label="Like"]');
|
||||||
if (!actionButtons) {
|
const hasComment = parent.querySelector('svg[aria-label="Comment"]');
|
||||||
console.log('Reels Master: Action buttons container not found');
|
const hasShare = parent.querySelector('svg[aria-label="Share"]');
|
||||||
setTimeout(() => this.injectControls(), 500);
|
const hasSave = parent.querySelector('svg[aria-label="Save"]');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, есть ли уже наши контролы в этом контейнере
|
if (hasLike && hasComment && hasShare && hasSave) {
|
||||||
const existingControls = actionButtons.querySelector('.reels-master-controls');
|
const children = parent.children;
|
||||||
if (existingControls) {
|
if (children.length >= 4) {
|
||||||
console.log('Reels Master: Controls already exist in this container');
|
return parent as HTMLElement;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!reelContainer) {
|
parent = parent.parentElement;
|
||||||
reelContainer = document.body;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ищем кнопку лайка внутри контейнера текущего рила
|
if (parent === document.body) break;
|
||||||
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;
|
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 {
|
private createControlsContainer(): HTMLDivElement {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'reels-master-controls';
|
container.className = 'reels-master-controls';
|
||||||
@ -280,32 +227,23 @@ class ReelsMaster {
|
|||||||
const volumeControl = document.createElement('div');
|
const volumeControl = document.createElement('div');
|
||||||
volumeControl.className = 'reels-master-volume';
|
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');
|
const sliderContainer = document.createElement('div');
|
||||||
sliderContainer.className = 'reels-master-slider-container';
|
sliderContainer.className = 'reels-master-slider-container';
|
||||||
|
|
||||||
this.controls.volumeSlider = this.createVolumeSlider();
|
const slider = this.createVolumeSlider();
|
||||||
sliderContainer.appendChild(this.controls.volumeSlider);
|
sliderContainer.appendChild(slider);
|
||||||
|
|
||||||
|
const volumeButton = document.createElement('button');
|
||||||
|
volumeButton.className = 'reels-master-volume-button';
|
||||||
|
this.updateVolumeIcon(volumeButton);
|
||||||
|
|
||||||
volumeButton.onclick = () => {
|
volumeButton.onclick = () => {
|
||||||
if (this.currentVideo) {
|
this.storedMuted = !this.storedMuted;
|
||||||
this.storedMuted = !this.storedMuted;
|
|
||||||
|
|
||||||
// Применяем ко всем видео
|
this.saveSettings();
|
||||||
this.applyVolumeToAllVideos();
|
this.applyVolumeToAllVideos();
|
||||||
|
this.updateAllSliders();
|
||||||
if (this.controls.volumeSlider) {
|
this.updateAllVolumeIcons();
|
||||||
this.controls.volumeSlider.value = this.storedMuted ? '0' : String(this.storedVolume * 100);
|
|
||||||
}
|
|
||||||
this.updateVolumeIcon(volumeButton);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
volumeControl.appendChild(sliderContainer);
|
volumeControl.appendChild(sliderContainer);
|
||||||
@ -319,36 +257,39 @@ class ReelsMaster {
|
|||||||
slider.type = 'range';
|
slider.type = 'range';
|
||||||
slider.min = '0';
|
slider.min = '0';
|
||||||
slider.max = '100';
|
slider.max = '100';
|
||||||
|
slider.value = this.storedMuted ? '0' : String(this.storedVolume * 100);
|
||||||
// Используем сохраненную громкость
|
|
||||||
slider.value = String(this.storedVolume * 100);
|
|
||||||
|
|
||||||
slider.className = 'reels-master-volume-slider';
|
slider.className = 'reels-master-volume-slider';
|
||||||
|
|
||||||
slider.oninput = (e) => {
|
slider.oninput = (e) => {
|
||||||
if (this.currentVideo) {
|
const value = parseInt((e.target as HTMLInputElement).value);
|
||||||
const value = parseInt((e.target as HTMLInputElement).value);
|
this.storedVolume = value / 100;
|
||||||
const newVolume = value / 100;
|
this.storedMuted = value === 0;
|
||||||
const newMuted = value === 0;
|
|
||||||
|
|
||||||
// Сохраняем настройки
|
this.saveSettings();
|
||||||
this.storedVolume = newVolume;
|
this.applyVolumeToAllVideos();
|
||||||
this.storedMuted = newMuted;
|
this.updateAllSliders();
|
||||||
|
this.updateAllVolumeIcons();
|
||||||
// Применяем ко всем видео сразу
|
|
||||||
this.applyVolumeToAllVideos();
|
|
||||||
|
|
||||||
const volumeControl = slider.closest('.reels-master-volume');
|
|
||||||
const volumeButton = volumeControl?.querySelector('.reels-master-volume-button') as HTMLButtonElement;
|
|
||||||
if (volumeButton) {
|
|
||||||
this.updateVolumeIcon(volumeButton);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return slider;
|
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 {
|
private updateVolumeIcon(button: HTMLButtonElement): void {
|
||||||
const volume = this.storedMuted ? 0 : this.storedVolume;
|
const volume = this.storedMuted ? 0 : this.storedVolume;
|
||||||
|
|
||||||
@ -370,7 +311,7 @@ class ReelsMaster {
|
|||||||
button.innerHTML = icon;
|
button.innerHTML = icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDownloadButton(): HTMLButtonElement {
|
private createDownloadButton(actionContainer: HTMLElement): HTMLButtonElement {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'reels-master-download';
|
button.className = 'reels-master-download';
|
||||||
button.innerHTML = `
|
button.innerHTML = `
|
||||||
@ -380,77 +321,104 @@ class ReelsMaster {
|
|||||||
`;
|
`;
|
||||||
button.title = 'Download Reel';
|
button.title = 'Download Reel';
|
||||||
|
|
||||||
button.onclick = () => this.downloadReel();
|
button.onclick = () => this.downloadReel(actionContainer, button);
|
||||||
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async downloadReel(): Promise<void> {
|
private findVideoForContainer(actionContainer: HTMLElement): HTMLVideoElement | null {
|
||||||
if (!this.currentVideo) {
|
let parent = actionContainer.parentElement;
|
||||||
console.error('Reels Master: No video found');
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const videoUrl = this.currentVideo.src;
|
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>
|
||||||
|
`;
|
||||||
|
|
||||||
if (!videoUrl) {
|
console.log('Reels Master: Sending download request to background for', reelUrl);
|
||||||
alert('Unable to find video URL');
|
|
||||||
return;
|
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) {
|
button.innerHTML = `
|
||||||
this.controls.downloadButton.innerHTML = `
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<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"/>
|
||||||
<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>
|
||||||
</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(() => {
|
setTimeout(() => {
|
||||||
if (this.controls.downloadButton) {
|
button.innerHTML = `
|
||||||
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">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}, 2000);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanup(): void {
|
} catch (error) {
|
||||||
if (this.controls.container) {
|
console.error('Reels Master: Download failed', error);
|
||||||
this.controls.container.remove();
|
alert('Failed to download video: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
||||||
this.controls.container = null;
|
|
||||||
this.controls.volumeSlider = null;
|
button.innerHTML = `
|
||||||
this.controls.downloadButton = null;
|
<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"
|
"run_at": "document_end"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": ["storage"],
|
"permissions": ["storage", "downloads"],
|
||||||
"host_permissions": ["*://*.instagram.com/*", "*://*.cdninstagram.com/*"]
|
"host_permissions": ["*://*.instagram.com/*", "*://*.cdninstagram.com/*", "https://i.instagram.com/*", "*://*.fbcdn.net/*"]
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user