- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3) - Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor - Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS - Configurare adresă platformă externalizată (build args / .env / config.js) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1428 lines
50 KiB
JavaScript
1428 lines
50 KiB
JavaScript
// DIDI Content Extractor - Content Script
|
||
// Extracts content from social media posts
|
||
|
||
// Facebook Extractor
|
||
class FacebookExtractor {
|
||
constructor() {
|
||
this.platform = 'facebook';
|
||
}
|
||
|
||
findPostContainer(element) {
|
||
let current = element;
|
||
let depth = 0;
|
||
const maxDepth = 30;
|
||
|
||
console.log('[Facebook] Starting search for post container from:', element.tagName);
|
||
|
||
while (current && depth < maxDepth) {
|
||
if (current.matches) {
|
||
// Standard Feed posts
|
||
if (current.matches('[role="article"]')) {
|
||
console.log('[Facebook] Found article container at depth:', depth);
|
||
return current;
|
||
}
|
||
|
||
// Groups posts, Stories, alternative layouts
|
||
if (current.matches('div[data-pagelet*="FeedUnit"]') ||
|
||
current.matches('div[class*="userContentWrapper"]')) {
|
||
console.log('[Facebook] Found pagelet container at depth:', depth);
|
||
return current;
|
||
}
|
||
|
||
// Look for divs that contain both author and content
|
||
// This is more generic and works across different FB layouts
|
||
const hasAuthor = current.querySelector('h2 a[role="link"], h3 a[role="link"], h4 a[role="link"]');
|
||
const hasContent = current.querySelector('div[dir="auto"]');
|
||
|
||
if (hasAuthor && hasContent && depth > 3) {
|
||
console.log('[Facebook] Found generic post container at depth:', depth);
|
||
return current;
|
||
}
|
||
}
|
||
|
||
current = current.parentElement;
|
||
depth++;
|
||
}
|
||
|
||
console.log('[Facebook] No post container found after searching', maxDepth, 'levels');
|
||
return null;
|
||
}
|
||
|
||
extractAuthor(postElement) {
|
||
const authorSelectors = [
|
||
'h2 a[role="link"]',
|
||
'h3 a[role="link"]',
|
||
'a[aria-label*="profile"]',
|
||
'strong > a'
|
||
];
|
||
|
||
for (const selector of authorSelectors) {
|
||
const authorEl = postElement.querySelector(selector);
|
||
if (authorEl && authorEl.textContent.trim()) {
|
||
const authorName = authorEl.textContent.trim();
|
||
console.log('[FB] Found author:', authorName);
|
||
return authorName;
|
||
}
|
||
}
|
||
|
||
console.log('[FB] Author not found');
|
||
return 'Unknown';
|
||
}
|
||
|
||
extractText(postElement) {
|
||
const textParts = [];
|
||
const seenTexts = new Set();
|
||
|
||
const textSelectors = [
|
||
'div[dir="auto"][style*="text-align"]',
|
||
'[data-ad-comet-preview="message"]',
|
||
'div[data-ad-preview="message"]'
|
||
];
|
||
|
||
for (const selector of textSelectors) {
|
||
const elements = postElement.querySelectorAll(selector);
|
||
|
||
elements.forEach(el => {
|
||
const text = el.textContent.trim();
|
||
|
||
const isUIText = [
|
||
'Like', 'Comment', 'Share', 'Send',
|
||
'Vezi mai mult', 'See more', 'Show more',
|
||
'Just now', 'min', 'hr', 'hrs',
|
||
'·', '•'
|
||
].some(noise => text === noise || text.length < 3);
|
||
|
||
if (!isUIText && !seenTexts.has(text) && text.length > 10) {
|
||
const hasNoTextChildren = Array.from(el.children).every(child =>
|
||
!child.textContent || child.textContent.trim().length === 0
|
||
);
|
||
|
||
if (hasNoTextChildren || text.length > 50) {
|
||
textParts.push(text);
|
||
seenTexts.add(text);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
const fullText = textParts.join('\n\n').trim();
|
||
console.log('[FB] Extracted text length:', fullText.length);
|
||
return fullText;
|
||
}
|
||
|
||
// Extract ALL images from post (not just primary)
|
||
extractAllImages(postElement) {
|
||
const images = postElement.querySelectorAll('img[src]');
|
||
const extractedImages = [];
|
||
console.log('[FB] Found total images:', images.length);
|
||
|
||
for (const img of images) {
|
||
const src = img.src || img.getAttribute('data-src');
|
||
const width = img.naturalWidth || img.width;
|
||
const height = img.naturalHeight || img.height;
|
||
|
||
// Filter out emoji, icons, avatars, profile pics
|
||
const isValidImage =
|
||
src &&
|
||
!src.includes('emoji') &&
|
||
!src.includes('icon') &&
|
||
!src.includes('avatar') &&
|
||
!src.includes('profile') &&
|
||
width >= 200 &&
|
||
height >= 200 &&
|
||
!img.closest('a[aria-label*="profile"]');
|
||
|
||
if (isValidImage) {
|
||
extractedImages.push({
|
||
url: src,
|
||
width: width,
|
||
height: height,
|
||
alt: img.alt || ''
|
||
});
|
||
}
|
||
}
|
||
|
||
console.log('[FB] Extracted valid images:', extractedImages.length);
|
||
return extractedImages;
|
||
}
|
||
|
||
// Keep for backward compatibility
|
||
extractPrimaryImage(postElement) {
|
||
const allImages = this.extractAllImages(postElement);
|
||
return allImages.length > 0 ? allImages[0] : null;
|
||
}
|
||
|
||
extractTimestamp(postElement) {
|
||
const timeSelectors = [
|
||
'a[aria-label*="ago"]',
|
||
'a[aria-label*="min"]',
|
||
'a[aria-label*="hour"]',
|
||
'abbr',
|
||
'span[title]'
|
||
];
|
||
|
||
for (const selector of timeSelectors) {
|
||
const timeEl = postElement.querySelector(selector);
|
||
if (timeEl) {
|
||
const timestamp = timeEl.getAttribute('title') ||
|
||
timeEl.getAttribute('aria-label') ||
|
||
timeEl.textContent;
|
||
console.log('[FB] Found timestamp:', timestamp);
|
||
return timestamp;
|
||
}
|
||
}
|
||
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
extractPostUrl(postElement) {
|
||
const permalinkSelectors = [
|
||
'a[aria-label*="ago"]',
|
||
'a[href*="/posts/"]',
|
||
'a[href*="/photos/"]',
|
||
'a[href*="/videos/"]'
|
||
];
|
||
|
||
for (const selector of permalinkSelectors) {
|
||
const link = postElement.querySelector(selector);
|
||
if (link && link.href) {
|
||
console.log('[FB] Found post URL:', link.href);
|
||
return link.href;
|
||
}
|
||
}
|
||
|
||
return window.location.href;
|
||
}
|
||
|
||
// Extract all external links from post
|
||
extractLinks(postElement) {
|
||
const links = [];
|
||
const anchorElements = postElement.querySelectorAll('a[href]');
|
||
|
||
anchorElements.forEach(anchor => {
|
||
const href = anchor.href;
|
||
|
||
// Filter out internal Facebook navigation links
|
||
const isExternalLink =
|
||
href &&
|
||
!href.includes('/profile/') &&
|
||
!href.includes('/groups/') &&
|
||
!href.includes('/hashtag/') &&
|
||
!href.includes('facebook.com/photo') &&
|
||
!href.includes('facebook.com/watch') &&
|
||
(href.startsWith('http://') || href.startsWith('https://')) &&
|
||
!href.includes('facebook.com');
|
||
|
||
if (isExternalLink && !links.includes(href)) {
|
||
links.push(href);
|
||
}
|
||
});
|
||
|
||
console.log('[FB] Extracted external links:', links.length);
|
||
return links;
|
||
}
|
||
|
||
extract(element) {
|
||
console.log('[FB] Starting Facebook extraction...');
|
||
|
||
const postContainer = this.findPostContainer(element);
|
||
if (!postContainer) {
|
||
console.log('[FB] No post container found, returning null');
|
||
return null;
|
||
}
|
||
|
||
const author = this.extractAuthor(postContainer);
|
||
const text = this.extractText(postContainer);
|
||
const allImages = this.extractAllImages(postContainer);
|
||
const primaryImage = allImages.length > 0 ? allImages[0] : null;
|
||
const links = this.extractLinks(postContainer);
|
||
const timestamp = this.extractTimestamp(postContainer);
|
||
const postUrl = this.extractPostUrl(postContainer);
|
||
|
||
let contentType = 'text';
|
||
if (text && allImages.length > 0) {
|
||
contentType = 'text+image';
|
||
} else if (allImages.length > 0) {
|
||
contentType = 'image';
|
||
}
|
||
|
||
const result = {
|
||
platform: this.platform,
|
||
type: contentType,
|
||
author: author,
|
||
text: text,
|
||
image: primaryImage, // Keep for backward compatibility
|
||
images: allImages, // NEW: All images
|
||
links: links, // NEW: External links
|
||
timestamp: timestamp,
|
||
postUrl: postUrl,
|
||
extractedAt: new Date().toISOString()
|
||
};
|
||
|
||
console.log('[FB] Extraction complete:', {
|
||
type: result.type,
|
||
author: result.author,
|
||
textLength: result.text.length,
|
||
imageCount: allImages.length,
|
||
linkCount: links.length
|
||
});
|
||
|
||
return result;
|
||
}
|
||
}
|
||
|
||
// Twitter Extractor
|
||
class TwitterExtractor {
|
||
constructor() {
|
||
this.platform = 'twitter';
|
||
}
|
||
|
||
findPostContainer(element) {
|
||
let current = element;
|
||
let depth = 0;
|
||
const maxDepth = 20;
|
||
|
||
while (current && depth < maxDepth) {
|
||
if (current.matches && current.matches('article[data-testid="tweet"]')) {
|
||
console.log('[Twitter] Found tweet container at depth:', depth);
|
||
return current;
|
||
}
|
||
current = current.parentElement;
|
||
depth++;
|
||
}
|
||
|
||
console.log('[Twitter] No tweet container found');
|
||
return null;
|
||
}
|
||
|
||
extractAuthor(tweetElement) {
|
||
const authorSelectors = [
|
||
'[data-testid="User-Name"] span',
|
||
'a[role="link"] span'
|
||
];
|
||
|
||
for (const selector of authorSelectors) {
|
||
const authorEl = tweetElement.querySelector(selector);
|
||
if (authorEl && authorEl.textContent.trim() && !authorEl.textContent.includes('@')) {
|
||
const authorName = authorEl.textContent.trim();
|
||
console.log('[Twitter] Found author:', authorName);
|
||
return authorName;
|
||
}
|
||
}
|
||
|
||
return 'Unknown';
|
||
}
|
||
|
||
extractText(tweetElement) {
|
||
const textEl = tweetElement.querySelector('[data-testid="tweetText"]');
|
||
if (textEl) {
|
||
const text = textEl.textContent.trim();
|
||
console.log('[Twitter] Extracted text length:', text.length);
|
||
return text;
|
||
}
|
||
|
||
console.log('[Twitter] No text found');
|
||
return '';
|
||
}
|
||
|
||
extractPrimaryImage(tweetElement) {
|
||
const imgSelectors = [
|
||
'[data-testid="tweetPhoto"] img',
|
||
'img[alt*="Image"]'
|
||
];
|
||
|
||
for (const selector of imgSelectors) {
|
||
const img = tweetElement.querySelector(selector);
|
||
if (img && img.src) {
|
||
console.log('[Twitter] Found primary image');
|
||
return {
|
||
url: img.src,
|
||
width: img.naturalWidth || img.width,
|
||
height: img.naturalHeight || img.height,
|
||
alt: img.alt || ''
|
||
};
|
||
}
|
||
}
|
||
|
||
console.log('[Twitter] No primary image found');
|
||
return null;
|
||
}
|
||
|
||
extractTimestamp(tweetElement) {
|
||
const timeEl = tweetElement.querySelector('time');
|
||
if (timeEl) {
|
||
const timestamp = timeEl.getAttribute('datetime') || timeEl.textContent;
|
||
console.log('[Twitter] Found timestamp:', timestamp);
|
||
return timestamp;
|
||
}
|
||
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
extractPostUrl(tweetElement) {
|
||
const timeLink = tweetElement.querySelector('time');
|
||
if (timeLink && timeLink.parentElement && timeLink.parentElement.href) {
|
||
console.log('[Twitter] Found tweet URL:', timeLink.parentElement.href);
|
||
return timeLink.parentElement.href;
|
||
}
|
||
|
||
return window.location.href;
|
||
}
|
||
|
||
extract(element) {
|
||
console.log('[Twitter] Starting Twitter extraction...');
|
||
|
||
const tweetContainer = this.findPostContainer(element);
|
||
if (!tweetContainer) {
|
||
console.log('[Twitter] No tweet container found');
|
||
return null;
|
||
}
|
||
|
||
const author = this.extractAuthor(tweetContainer);
|
||
const text = this.extractText(tweetContainer);
|
||
const primaryImage = this.extractPrimaryImage(tweetContainer);
|
||
const timestamp = this.extractTimestamp(tweetContainer);
|
||
const postUrl = this.extractPostUrl(tweetContainer);
|
||
|
||
let contentType = 'text';
|
||
if (text && primaryImage) {
|
||
contentType = 'text+image';
|
||
} else if (primaryImage) {
|
||
contentType = 'image';
|
||
}
|
||
|
||
const result = {
|
||
platform: this.platform,
|
||
type: contentType,
|
||
author: author,
|
||
text: text,
|
||
image: primaryImage,
|
||
timestamp: timestamp,
|
||
postUrl: postUrl,
|
||
extractedAt: new Date().toISOString()
|
||
};
|
||
|
||
console.log('[Twitter] Extraction complete:', {
|
||
type: result.type,
|
||
author: result.author,
|
||
textLength: result.text.length,
|
||
hasImage: !!result.image
|
||
});
|
||
|
||
return result;
|
||
}
|
||
}
|
||
|
||
let selectionBox = null;
|
||
let isSelecting = false;
|
||
let selectedElement = null;
|
||
let currentPlatform = null;
|
||
let extractor = null;
|
||
|
||
// Snipping tool variables
|
||
let isSnipping = false;
|
||
let snipStartX = 0;
|
||
let snipStartY = 0;
|
||
let snipBox = null;
|
||
|
||
console.log('[DIDI] Content script loaded on:', window.location.href);
|
||
|
||
// Initialize extension
|
||
function init() {
|
||
console.log('[DIDI] Content script initialized');
|
||
createSelectionBox();
|
||
|
||
// Listen for messages from popup
|
||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||
console.log('[DIDI] Message received:', message);
|
||
|
||
if (message.action === 'activateSelection') {
|
||
currentPlatform = message.platform;
|
||
console.log('[DIDI] Activating selection for platform:', currentPlatform);
|
||
|
||
// Initialize platform-specific extractor
|
||
if (currentPlatform === 'facebook') {
|
||
extractor = new FacebookExtractor();
|
||
console.log('[DIDI] Initialized Facebook extractor');
|
||
} else if (currentPlatform === 'twitter') {
|
||
extractor = new TwitterExtractor();
|
||
console.log('[DIDI] Initialized Twitter extractor');
|
||
}
|
||
|
||
activateSelectionMode();
|
||
sendResponse({ success: true });
|
||
}
|
||
|
||
if (message.action === 'activateSnipping') {
|
||
console.log('[DIDI] Activating snipping mode');
|
||
ensureUploadConsent().then((accepted) => {
|
||
if (accepted) activateSnippingMode();
|
||
sendResponse({ success: accepted });
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (message.action === 'activateVideoSnipping') {
|
||
console.log('[DIDI] Activating video snipping mode');
|
||
ensureUploadConsent().then((accepted) => {
|
||
if (accepted) activateVideoSnippingMode();
|
||
sendResponse({ success: accepted });
|
||
});
|
||
return true;
|
||
}
|
||
|
||
if (message.action === 'requestUploadConsent') {
|
||
ensureUploadConsent().then((accepted) => sendResponse({ accepted }));
|
||
return true;
|
||
}
|
||
|
||
if (message.action === 'showToast') {
|
||
showNotification(message.message, message.type || 'info');
|
||
sendResponse({ success: true });
|
||
}
|
||
|
||
if (message.action === 'showAnalysis') {
|
||
console.log('[DIDI] Showing analysis result modal (pushed from background)');
|
||
showAnalysisResult(message.analysis, message.filename);
|
||
sendResponse({ success: true });
|
||
}
|
||
|
||
if (message.action === 'analysisFailed') {
|
||
console.warn('[DIDI] Analysis failed:', message.error);
|
||
showNotification('❌ Analysis failed: ' + (message.error || 'unknown'), 'error');
|
||
sendResponse({ success: true });
|
||
}
|
||
|
||
return true; // Keep channel open
|
||
});
|
||
}
|
||
|
||
// Create selection box overlay
|
||
function createSelectionBox() {
|
||
console.log('[DIDI] Creating selection box');
|
||
// Remove existing box if any
|
||
const existing = document.getElementById('didi-selection-box');
|
||
if (existing) {
|
||
existing.remove();
|
||
}
|
||
|
||
selectionBox = document.createElement('div');
|
||
selectionBox.id = 'didi-selection-box';
|
||
selectionBox.style.cssText = `
|
||
position: absolute;
|
||
border: 3px solid #009198;
|
||
background: rgba(0,145,152, 0.1);
|
||
pointer-events: none;
|
||
z-index: 999999;
|
||
display: none;
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 20px rgba(0,145,152, 0.4);
|
||
`;
|
||
document.body.appendChild(selectionBox);
|
||
console.log('[DIDI] Selection box created');
|
||
}
|
||
|
||
// Activate selection mode
|
||
function activateSelectionMode() {
|
||
console.log('[DIDI] Selection mode activating...');
|
||
isSelecting = true;
|
||
document.body.style.cursor = 'crosshair';
|
||
|
||
// Add event listeners
|
||
document.addEventListener('mousemove', handleMouseMove, true);
|
||
document.addEventListener('click', handleClick, true);
|
||
|
||
showNotification('✓ Selection mode active - Hover over any element and click');
|
||
console.log('[DIDI] Selection mode activated - cursor should be crosshair');
|
||
}
|
||
|
||
// Deactivate selection mode
|
||
function deactivateSelectionMode() {
|
||
console.log('[DIDI] Selection mode deactivating...');
|
||
isSelecting = false;
|
||
document.body.style.cursor = 'default';
|
||
if (selectionBox) {
|
||
selectionBox.style.display = 'none';
|
||
}
|
||
|
||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||
document.removeEventListener('click', handleClick, true);
|
||
|
||
showNotification('Selection mode deactivated');
|
||
}
|
||
|
||
// Handle mouse movement - highlight post containers
|
||
function handleMouseMove(e) {
|
||
if (!isSelecting || !extractor) return;
|
||
|
||
// Find the post container using platform-specific extractor
|
||
const postContainer = extractor.findPostContainer(e.target);
|
||
|
||
if (!postContainer || !selectionBox) return;
|
||
|
||
// Highlight the entire post container
|
||
const rect = postContainer.getBoundingClientRect();
|
||
selectionBox.style.display = 'block';
|
||
selectionBox.style.top = (rect.top + window.scrollY) + 'px';
|
||
selectionBox.style.left = (rect.left + window.scrollX) + 'px';
|
||
selectionBox.style.width = rect.width + 'px';
|
||
selectionBox.style.height = rect.height + 'px';
|
||
|
||
selectedElement = postContainer;
|
||
}
|
||
|
||
// Handle click - extract content
|
||
function handleClick(e) {
|
||
console.log('[DIDI] Click event detected!');
|
||
|
||
if (!isSelecting || !extractor) {
|
||
console.log('[DIDI] Not in selecting mode or no extractor');
|
||
return;
|
||
}
|
||
|
||
if (!selectedElement) {
|
||
console.log('[DIDI] No element selected');
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
console.log('[DIDI] Extracting from element:', selectedElement);
|
||
|
||
// Use platform-specific extractor
|
||
const content = extractor.extract(selectedElement);
|
||
|
||
console.log('[DIDI] Extracted content:', content);
|
||
|
||
if (content) {
|
||
// Store in chrome storage FIRST
|
||
chrome.storage.local.set({
|
||
[`${currentPlatform}_content`]: content,
|
||
lastExtractedContent: content,
|
||
lastExtractedPlatform: currentPlatform,
|
||
extractedAt: new Date().toISOString()
|
||
}, () => {
|
||
console.log('[DIDI] Content stored in chrome.storage');
|
||
});
|
||
|
||
// Then send to popup (if it's open)
|
||
chrome.runtime.sendMessage({
|
||
action: 'contentExtracted',
|
||
data: content,
|
||
platform: currentPlatform
|
||
}, (response) => {
|
||
console.log('[DIDI] Message sent to popup, response:', response);
|
||
});
|
||
|
||
// Show detailed notification — background starts the analysis automatically
|
||
const summary = `✓ Postare extrasă (${content.type})\n` +
|
||
`📝 Text: ${content.text ? content.text.length + ' caractere' : 'fără'}\n` +
|
||
`🔗 Linkuri: ${content.links ? content.links.length : 0}\n\n` +
|
||
`DiDi analizează… rezultatul apare aici în 30–90s.`;
|
||
|
||
showNotification(summary, 'success');
|
||
} else {
|
||
console.log('[DIDI] No content found');
|
||
showNotification('No content found', 'error');
|
||
}
|
||
|
||
deactivateSelectionMode();
|
||
}
|
||
|
||
// Legacy extraction function - no longer used
|
||
// Platform-specific extraction is now handled by FacebookExtractor and TwitterExtractor classes
|
||
|
||
// === UPLOAD CONSENT (cerință caiet: consimțământ explicit pentru fișiere/capturi) ===
|
||
// Shown once before the first capture/image upload; the choice is persisted.
|
||
|
||
function ensureUploadConsent() {
|
||
return new Promise((resolve) => {
|
||
chrome.storage.local.get(['didiUploadConsent'], (data) => {
|
||
if (data.didiUploadConsent === true) return resolve(true);
|
||
|
||
document.querySelectorAll('.didi-consent-modal').forEach((n) => n.remove());
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'didi-consent-modal';
|
||
overlay.style.cssText = `
|
||
position: fixed; inset: 0; z-index: 10000000;
|
||
background: rgba(13, 20, 36, 0.55);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
`;
|
||
const card = document.createElement('div');
|
||
card.setAttribute('role', 'alertdialog');
|
||
card.setAttribute('aria-modal', 'true');
|
||
card.setAttribute('aria-label', 'Consimțământ pentru trimiterea capturii');
|
||
card.style.cssText = `
|
||
background: #fbfbfd; color: #0d1424; max-width: 420px; margin: 16px;
|
||
border-radius: 22px; padding: 24px; box-shadow: 0 24px 64px rgba(13,20,36,0.35);
|
||
border: 1px solid rgba(13,20,36,0.08); font-size: 14px; line-height: 1.6;
|
||
`;
|
||
card.innerHTML = `
|
||
<div style="font-weight: 700; font-size: 16px; letter-spacing: -0.02em; margin-bottom: 10px;">
|
||
Consimțământ pentru analiză
|
||
</div>
|
||
<div style="margin-bottom: 16px;">
|
||
Captura sau imaginea selectată va fi <strong>trimisă platformei DiDi</strong> pentru analiză
|
||
anti-dezinformare. Conținutul este folosit exclusiv pentru generarea raportului de analiză,
|
||
conform politicii de confidențialitate. Trimite doar fragmentul selectat — nimic în plus.
|
||
</div>
|
||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||
<button type="button" class="didi-consent-decline" style="
|
||
padding: 9px 16px; border-radius: 12px; border: 1px solid rgba(13,20,36,0.15);
|
||
background: transparent; color: #0d1424; font-size: 13px; font-weight: 600; cursor: pointer;
|
||
">Anulează</button>
|
||
<button type="button" class="didi-consent-accept" style="
|
||
padding: 9px 16px; border-radius: 12px; border: none;
|
||
background: #009198; color: #ffffff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||
">Sunt de acord</button>
|
||
</div>
|
||
`;
|
||
overlay.appendChild(card);
|
||
document.body.appendChild(overlay);
|
||
|
||
const finish = (accepted) => {
|
||
overlay.remove();
|
||
if (accepted) {
|
||
chrome.storage.local.set({ didiUploadConsent: true, didiUploadConsentAt: new Date().toISOString() });
|
||
}
|
||
resolve(accepted);
|
||
};
|
||
card.querySelector('.didi-consent-accept').addEventListener('click', () => finish(true));
|
||
card.querySelector('.didi-consent-decline').addEventListener('click', () => finish(false));
|
||
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') finish(false); });
|
||
card.querySelector('.didi-consent-accept').focus();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Show notification
|
||
function showNotification(message, type = 'info') {
|
||
console.log('[DIDI] Showing notification:', message, type);
|
||
|
||
// Remove existing notifications
|
||
const existing = document.querySelectorAll('.didi-notification');
|
||
existing.forEach(n => n.remove());
|
||
|
||
const notification = document.createElement('div');
|
||
notification.className = 'didi-notification';
|
||
notification.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 16px 24px;
|
||
background: ${type === 'success' ? '#009198' : type === 'error' ? '#E63946' : '#009198'};
|
||
color: white;
|
||
border-radius: 12px;
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
line-height: 1.6;
|
||
z-index: 9999999;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||
animation: slideIn 0.3s ease;
|
||
white-space: pre-wrap;
|
||
max-width: 350px;
|
||
`;
|
||
notification.textContent = message;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => {
|
||
notification.style.animation = 'slideOut 0.3s ease';
|
||
setTimeout(() => notification.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// === ANALYSIS RESULT MODAL — uses shared DidiRender module (render.js) ===
|
||
|
||
function showAnalysisResult(analysis, filename) {
|
||
console.log('[DIDI] Showing analysis result modal');
|
||
|
||
if (!window.DidiRender || typeof window.DidiRender.renderModalHTML !== 'function') {
|
||
console.error('[DIDI] DidiRender not loaded — render.js was not injected. Reload the page.');
|
||
showNotification('⚠️ Result ready but renderer missing — reload this page and check History', 'error');
|
||
return;
|
||
}
|
||
|
||
document.querySelectorAll('.didi-analysis-modal').forEach((n) => n.remove());
|
||
saveToLocalHistory(analysis, filename);
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'didi-analysis-modal';
|
||
modal.innerHTML = window.DidiRender.renderModalHTML(analysis);
|
||
|
||
// Inject animation keyframes once
|
||
if (!document.getElementById('didi-modal-anim-styles')) {
|
||
const animStyle = document.createElement('style');
|
||
animStyle.id = 'didi-modal-anim-styles';
|
||
animStyle.textContent = '@keyframes didiFadeIn{from{opacity:0}to{opacity:1}}@keyframes didiSlideUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}';
|
||
document.head.appendChild(animStyle);
|
||
}
|
||
document.body.appendChild(modal);
|
||
|
||
// Wire close buttons (modal markup uses class hooks).
|
||
const closeBtn = modal.querySelector('.didi-modal-close');
|
||
const gotItBtn = modal.querySelector('.didi-modal-gotit');
|
||
if (closeBtn) closeBtn.addEventListener('click', () => modal.remove());
|
||
if (gotItBtn) gotItBtn.addEventListener('click', () => modal.remove());
|
||
// Close on click outside the inner card (= click on the dark overlay).
|
||
// The overlay is the modal's first child; the inner card is its grandchild.
|
||
// We must compare against the actual overlay element, not "first element child"
|
||
// which would also fire when clicking on the inner card's siblings.
|
||
const overlay = modal.firstElementChild; // the fullscreen <div> with rgba(5,5,16,0.88)
|
||
if (overlay) {
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) modal.remove(); // click hit the dark padding only
|
||
});
|
||
}
|
||
}
|
||
|
||
// Save analysis to local chrome.storage history
|
||
function saveToLocalHistory(analysis, filename) {
|
||
chrome.storage.local.get(['didiHistory'], (data) => {
|
||
const history = data.didiHistory || [];
|
||
history.unshift({
|
||
analysis: analysis,
|
||
filename: filename,
|
||
timestamp: new Date().toISOString(),
|
||
url: window.location.href
|
||
});
|
||
// Keep max 50 items
|
||
if (history.length > 50) history.length = 50;
|
||
chrome.storage.local.set({ didiHistory: history });
|
||
});
|
||
}
|
||
|
||
// ===== SNIPPING TOOL MODE =====
|
||
|
||
function activateSnippingMode() {
|
||
console.log('[DIDI] Snipping mode activating...');
|
||
isSnipping = true;
|
||
document.body.style.cursor = 'crosshair';
|
||
|
||
// Create snipping overlay
|
||
createSnippingOverlay();
|
||
|
||
// Add event listeners
|
||
document.addEventListener('mousedown', handleSnipStart, true);
|
||
document.addEventListener('mousemove', handleSnipDrag, true);
|
||
document.addEventListener('mouseup', handleSnipEnd, true);
|
||
document.addEventListener('keydown', handleSnipCancel, true);
|
||
|
||
showNotification('✂️ Snipping mode active - Click and drag to select area (ESC to cancel)', 'info');
|
||
}
|
||
|
||
function createSnippingOverlay() {
|
||
// Remove existing overlay
|
||
const existing = document.getElementById('didi-snip-overlay');
|
||
if (existing) existing.remove();
|
||
|
||
// Create semi-transparent overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'didi-snip-overlay';
|
||
overlay.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: rgba(0, 0, 0, 0.3);
|
||
z-index: 999998;
|
||
pointer-events: none;
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
|
||
// Create snip box
|
||
snipBox = document.createElement('div');
|
||
snipBox.id = 'didi-snip-box';
|
||
snipBox.style.cssText = `
|
||
position: absolute;
|
||
border: 3px solid #009198;
|
||
background: rgba(0,145,152, 0.15);
|
||
z-index: 999999;
|
||
display: none;
|
||
box-shadow: 0 0 20px rgba(0,145,152, 0.6), inset 0 0 20px rgba(0,145,152, 0.1);
|
||
pointer-events: none;
|
||
`;
|
||
document.body.appendChild(snipBox);
|
||
console.log('[DIDI] Snip box created');
|
||
}
|
||
|
||
function handleSnipStart(e) {
|
||
if (!isSnipping) return;
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
snipStartX = e.pageX;
|
||
snipStartY = e.pageY;
|
||
|
||
snipBox.style.left = snipStartX + 'px';
|
||
snipBox.style.top = snipStartY + 'px';
|
||
snipBox.style.width = '0px';
|
||
snipBox.style.height = '0px';
|
||
snipBox.style.display = 'block';
|
||
}
|
||
|
||
function handleSnipDrag(e) {
|
||
if (!isSnipping || !snipBox || snipBox.style.display === 'none') return;
|
||
|
||
e.preventDefault();
|
||
|
||
const currentX = e.pageX;
|
||
const currentY = e.pageY;
|
||
|
||
const width = Math.abs(currentX - snipStartX);
|
||
const height = Math.abs(currentY - snipStartY);
|
||
const left = Math.min(currentX, snipStartX);
|
||
const top = Math.min(currentY, snipStartY);
|
||
|
||
snipBox.style.width = width + 'px';
|
||
snipBox.style.height = height + 'px';
|
||
snipBox.style.left = left + 'px';
|
||
snipBox.style.top = top + 'px';
|
||
|
||
// Add size indicator inside the box
|
||
snipBox.innerHTML = `
|
||
<div style="
|
||
position: absolute;
|
||
top: 5px;
|
||
left: 5px;
|
||
background: rgba(0,145,152, 0.9);
|
||
color: white;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
font-family: 'Inter', monospace;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
pointer-events: none;
|
||
">
|
||
${Math.round(width)} × ${Math.round(height)}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function handleSnipEnd(e) {
|
||
if (!isSnipping || !snipBox || snipBox.style.display === 'none') return;
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const rect = {
|
||
left: parseInt(snipBox.style.left),
|
||
top: parseInt(snipBox.style.top),
|
||
width: parseInt(snipBox.style.width),
|
||
height: parseInt(snipBox.style.height)
|
||
};
|
||
|
||
console.log('[DIDI] Snip complete:', rect);
|
||
console.log('[DIDI] Window scroll position:', { scrollX: window.scrollX, scrollY: window.scrollY });
|
||
console.log('[DIDI] Device pixel ratio:', window.devicePixelRatio);
|
||
|
||
// Capture screenshot of the selected area
|
||
captureSnippedArea(rect);
|
||
|
||
// Deactivate snipping mode
|
||
deactivateSnippingMode();
|
||
}
|
||
|
||
function handleSnipCancel(e) {
|
||
if (e.key === 'Escape' && isSnipping) {
|
||
console.log('[DIDI] Snipping cancelled');
|
||
deactivateSnippingMode();
|
||
showNotification('Snipping cancelled', 'info');
|
||
}
|
||
}
|
||
|
||
function captureSnippedArea(rect) {
|
||
if (rect.width < 10 || rect.height < 10) {
|
||
showNotification('❌ Selection too small', 'error');
|
||
return;
|
||
}
|
||
|
||
showNotification('📸 Capturing screenshot...', 'info');
|
||
|
||
// Convert page coordinates to viewport coordinates
|
||
// captureVisibleTab captures only the visible viewport, not the whole page
|
||
const viewportRect = {
|
||
left: rect.left - window.scrollX,
|
||
top: rect.top - window.scrollY,
|
||
width: rect.width,
|
||
height: rect.height
|
||
};
|
||
|
||
console.log('[DIDI] Original rect (page coords):', rect);
|
||
console.log('[DIDI] Viewport rect (screen coords):', viewportRect);
|
||
|
||
showNotification('📸 Capturing & analyzing… this may take 60–120s', 'info');
|
||
|
||
// Background acks immediately (success: true, accepted: true). The actual
|
||
// analysis result arrives later via chrome.tabs.sendMessage → 'showAnalysis'
|
||
// (see message handler at top of init()).
|
||
chrome.runtime.sendMessage({
|
||
action: 'snipCaptured',
|
||
rect: viewportRect,
|
||
pageUrl: window.location.href,
|
||
timestamp: new Date().toISOString(),
|
||
devicePixelRatio: window.devicePixelRatio || 1,
|
||
}, (response) => {
|
||
console.log('[DIDI] Snip ack from background:', response);
|
||
if (!response || !response.success) {
|
||
showNotification('❌ Screenshot failed', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
function downloadImageFallback(dataUrl, filename) {
|
||
console.log('[DIDI] Downloading via fallback method, filename:', filename);
|
||
|
||
try {
|
||
// Create a temporary <a> element
|
||
const link = document.createElement('a');
|
||
link.href = dataUrl;
|
||
link.download = filename || 'didi-screenshot.png';
|
||
link.style.display = 'none';
|
||
|
||
// Append to body, click, then remove
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
|
||
// Clean up after a short delay
|
||
setTimeout(() => {
|
||
document.body.removeChild(link);
|
||
console.log('[DIDI] ✅ Fallback download triggered successfully');
|
||
}, 100);
|
||
} catch (error) {
|
||
console.error('[DIDI] Fallback download failed:', error);
|
||
showNotification('❌ Download failed: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function deactivateSnippingMode() {
|
||
console.log('[DIDI] Snipping mode deactivating...');
|
||
isSnipping = false;
|
||
document.body.style.cursor = 'default';
|
||
|
||
// Remove overlay and box
|
||
const overlay = document.getElementById('didi-snip-overlay');
|
||
if (overlay) overlay.remove();
|
||
|
||
if (snipBox) {
|
||
snipBox.remove();
|
||
snipBox = null;
|
||
}
|
||
|
||
// Remove event listeners
|
||
document.removeEventListener('mousedown', handleSnipStart, true);
|
||
document.removeEventListener('mousemove', handleSnipDrag, true);
|
||
document.removeEventListener('mouseup', handleSnipEnd, true);
|
||
document.removeEventListener('keydown', handleSnipCancel, true);
|
||
}
|
||
|
||
// ===== VIDEO REGION RECORDING =====
|
||
// Uses Region Capture API (Chrome 116+) — getDisplayMedia + MediaStreamTrack.cropTo()
|
||
// Records ONLY the selected rectangle, not the whole tab.
|
||
|
||
let isVideoSnipping = false;
|
||
let videoSnipBox = null;
|
||
let videoSnipStartX = 0;
|
||
let videoSnipStartY = 0;
|
||
let videoMediaRecorder = null;
|
||
let videoStream = null;
|
||
let videoChunks = [];
|
||
let videoStartTime = 0;
|
||
let videoTimerInterval = null;
|
||
let videoMaxDurationMs = 60000; // 60s max
|
||
let savedScrollOverflow = '';
|
||
let savedHtmlOverflow = '';
|
||
|
||
function activateVideoSnippingMode() {
|
||
console.log('[DIDI VIDEO] Mode activating');
|
||
if (!('CropTarget' in window) || !window.CropTarget?.fromElement) {
|
||
showNotification('❌ Region Capture not supported. Update Chrome to 116+', 'error');
|
||
return;
|
||
}
|
||
isVideoSnipping = true;
|
||
document.body.style.cursor = 'crosshair';
|
||
createVideoSnipOverlay();
|
||
|
||
document.addEventListener('mousedown', handleVideoSnipStart, true);
|
||
document.addEventListener('mousemove', handleVideoSnipDrag, true);
|
||
document.addEventListener('mouseup', handleVideoSnipEnd, true);
|
||
document.addEventListener('keydown', handleVideoSnipCancel, true);
|
||
|
||
showNotification('🎥 Video region: drag to select recording area (ESC to cancel)', 'info');
|
||
}
|
||
|
||
function createVideoSnipOverlay() {
|
||
const existing = document.getElementById('didi-video-overlay');
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'didi-video-overlay';
|
||
overlay.style.cssText = `position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.3); z-index:999998; pointer-events:none;`;
|
||
document.body.appendChild(overlay);
|
||
|
||
videoSnipBox = document.createElement('div');
|
||
videoSnipBox.id = 'didi-video-snipbox';
|
||
videoSnipBox.style.cssText = `position:absolute; border:3px solid #EF4444; background:rgba(239,68,68,0.10); z-index:999999; display:none; box-shadow:0 0 24px rgba(239,68,68,0.55), inset 0 0 20px rgba(239,68,68,0.10); pointer-events:none;`;
|
||
document.body.appendChild(videoSnipBox);
|
||
}
|
||
|
||
function handleVideoSnipStart(e) {
|
||
if (!isVideoSnipping) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
videoSnipStartX = e.pageX; videoSnipStartY = e.pageY;
|
||
videoSnipBox.style.left = videoSnipStartX + 'px';
|
||
videoSnipBox.style.top = videoSnipStartY + 'px';
|
||
videoSnipBox.style.width = '0px';
|
||
videoSnipBox.style.height = '0px';
|
||
videoSnipBox.style.display = 'block';
|
||
}
|
||
|
||
function handleVideoSnipDrag(e) {
|
||
if (!isVideoSnipping || !videoSnipBox || videoSnipBox.style.display === 'none') return;
|
||
e.preventDefault();
|
||
const w = Math.abs(e.pageX - videoSnipStartX);
|
||
const h = Math.abs(e.pageY - videoSnipStartY);
|
||
const l = Math.min(e.pageX, videoSnipStartX);
|
||
const t = Math.min(e.pageY, videoSnipStartY);
|
||
videoSnipBox.style.width = w + 'px';
|
||
videoSnipBox.style.height = h + 'px';
|
||
videoSnipBox.style.left = l + 'px';
|
||
videoSnipBox.style.top = t + 'px';
|
||
videoSnipBox.innerHTML = `<div style="position:absolute; top:5px; left:5px; background:rgba(239,68,68,0.95); color:white; padding:4px 8px; border-radius:4px; font-family:'Inter',monospace; font-size:12px; font-weight:600; pointer-events:none;">${Math.round(w)} × ${Math.round(h)}</div>`;
|
||
}
|
||
|
||
function handleVideoSnipEnd(e) {
|
||
if (!isVideoSnipping || !videoSnipBox || videoSnipBox.style.display === 'none') return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
|
||
const w = parseInt(videoSnipBox.style.width);
|
||
const h = parseInt(videoSnipBox.style.height);
|
||
if (w < 50 || h < 50) {
|
||
showNotification('❌ Selection too small (min 50×50)', 'error');
|
||
deactivateVideoSnippingMode();
|
||
return;
|
||
}
|
||
|
||
// Stop selection — selection is locked, show "Start Recording" panel
|
||
document.removeEventListener('mousedown', handleVideoSnipStart, true);
|
||
document.removeEventListener('mousemove', handleVideoSnipDrag, true);
|
||
document.removeEventListener('mouseup', handleVideoSnipEnd, true);
|
||
document.body.style.cursor = 'default';
|
||
|
||
showStartRecordingPanel();
|
||
}
|
||
|
||
function handleVideoSnipCancel(e) {
|
||
if (e.key === 'Escape' && isVideoSnipping) {
|
||
console.log('[DIDI VIDEO] Cancelled');
|
||
deactivateVideoSnippingMode();
|
||
showNotification('Cancelled', 'info');
|
||
}
|
||
}
|
||
|
||
function showStartRecordingPanel() {
|
||
const panel = document.createElement('div');
|
||
panel.id = 'didi-video-start-panel';
|
||
panel.style.cssText = `position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:#231832; border:1px solid rgba(239,68,68,0.4); border-radius:12px; padding:14px 18px; z-index:99999999; box-shadow:0 12px 40px rgba(0,0,0,0.6); display:flex; align-items:center; gap:14px; font-family:'Inter',sans-serif;`;
|
||
panel.innerHTML = `
|
||
<div style="display:flex; flex-direction:column; gap:6px;">
|
||
<div style="color:#FFF; font-size:13px; font-weight:600;">Region selected. Ready to record?</div>
|
||
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; color:#9CA3AF; font-size:12px;">
|
||
<input type="checkbox" id="didi-audio-toggle" style="cursor:pointer; accent-color:#009198;"/>
|
||
Include tab audio
|
||
</label>
|
||
</div>
|
||
<button id="didi-start-rec-btn" style="background:linear-gradient(135deg,#EF4444 0%,#DC2626 100%); color:#FFF; border:none; padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px;"><span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#FFF;"></span> Start Recording</button>
|
||
<button id="didi-cancel-rec-btn" style="background:rgba(255,255,255,0.06); color:#9CA3AF; border:1px solid rgba(255,255,255,0.1); padding:10px 14px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px;">Cancel</button>
|
||
`;
|
||
document.body.appendChild(panel);
|
||
|
||
panel.querySelector('#didi-start-rec-btn').addEventListener('click', async () => {
|
||
const audioEnabled = panel.querySelector('#didi-audio-toggle').checked;
|
||
panel.remove();
|
||
await startVideoRecording(audioEnabled);
|
||
});
|
||
panel.querySelector('#didi-cancel-rec-btn').addEventListener('click', () => {
|
||
panel.remove();
|
||
deactivateVideoSnippingMode();
|
||
});
|
||
}
|
||
|
||
async function startVideoRecording(audioEnabled) {
|
||
console.log('[DIDI VIDEO] Starting recording, audio:', audioEnabled);
|
||
|
||
try {
|
||
// Lock scroll to keep region anchored to viewport
|
||
savedHtmlOverflow = document.documentElement.style.overflow;
|
||
savedScrollOverflow = document.body.style.overflow;
|
||
document.documentElement.style.overflow = 'hidden';
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// 1. Mark region for crop
|
||
const cropTarget = await CropTarget.fromElement(videoSnipBox);
|
||
|
||
// 2. Ask browser for tab capture
|
||
videoStream = await navigator.mediaDevices.getDisplayMedia({
|
||
video: { displaySurface: 'browser' },
|
||
audio: audioEnabled,
|
||
preferCurrentTab: true,
|
||
selfBrowserSurface: 'include'
|
||
});
|
||
|
||
// 3. Crop the video track to our region
|
||
const [videoTrack] = videoStream.getVideoTracks();
|
||
if (typeof videoTrack.cropTo !== 'function') {
|
||
throw new Error('cropTo() not available on this Chrome version');
|
||
}
|
||
await videoTrack.cropTo(cropTarget);
|
||
|
||
// Stop recording when user revokes share via browser bar
|
||
videoTrack.addEventListener('ended', () => {
|
||
console.log('[DIDI VIDEO] Track ended (user revoked sharing)');
|
||
if (videoMediaRecorder && videoMediaRecorder.state !== 'inactive') {
|
||
stopVideoRecording(true);
|
||
}
|
||
});
|
||
|
||
// 4. Record the cropped stream
|
||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
|
||
? 'video/webm;codecs=vp9,opus'
|
||
: 'video/webm;codecs=vp8,opus';
|
||
videoChunks = [];
|
||
videoMediaRecorder = new MediaRecorder(videoStream, { mimeType, videoBitsPerSecond: 2_500_000 });
|
||
videoMediaRecorder.ondataavailable = (e) => {
|
||
if (e.data && e.data.size > 0) videoChunks.push(e.data);
|
||
};
|
||
videoMediaRecorder.onstop = () => onVideoRecordingStopped(mimeType);
|
||
videoMediaRecorder.start(1000); // chunk every 1s
|
||
videoStartTime = Date.now();
|
||
|
||
showRecordingControls();
|
||
startVideoTimer();
|
||
|
||
// Auto-stop at max duration
|
||
setTimeout(() => {
|
||
if (videoMediaRecorder && videoMediaRecorder.state === 'recording') {
|
||
showNotification(`⏰ Max duration ${videoMaxDurationMs/1000}s reached`, 'info');
|
||
stopVideoRecording(false);
|
||
}
|
||
}, videoMaxDurationMs);
|
||
|
||
} catch (err) {
|
||
console.error('[DIDI VIDEO] Start failed:', err);
|
||
showNotification('❌ ' + (err.name === 'NotAllowedError' ? 'Permission denied' : err.message), 'error');
|
||
cleanupVideoSession();
|
||
}
|
||
}
|
||
|
||
function showRecordingControls() {
|
||
videoSnipBox.style.borderColor = '#DC2626';
|
||
videoSnipBox.style.animation = 'didiPulseRec 1.2s ease-in-out infinite';
|
||
videoSnipBox.innerHTML = '';
|
||
|
||
const controls = document.createElement('div');
|
||
controls.id = 'didi-video-controls';
|
||
controls.style.cssText = `position:fixed; bottom:24px; left:50%; transform:translateX(-50%); background:#231832; border:1px solid rgba(239,68,68,0.5); border-radius:12px; padding:12px 16px; z-index:99999999; box-shadow:0 12px 40px rgba(0,0,0,0.6); display:flex; align-items:center; gap:14px; font-family:'Inter',sans-serif;`;
|
||
controls.innerHTML = `
|
||
<div style="display:flex; align-items:center; gap:8px; color:#EF4444; font-size:13px; font-weight:600;">
|
||
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#EF4444; animation:didiPulseRec 1s infinite;"></span>
|
||
<span>REC</span>
|
||
<span id="didi-rec-timer" style="color:#FFF; font-family:Consolas,monospace; min-width:42px;">0:00</span>
|
||
<span style="color:#6B7280; font-size:11px;">/ ${Math.floor(videoMaxDurationMs/60000)}:${String(Math.floor((videoMaxDurationMs%60000)/1000)).padStart(2,'0')}</span>
|
||
</div>
|
||
<button id="didi-stop-rec-btn" style="background:#EF4444; color:#FFF; border:none; padding:9px 16px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; font-weight:600; display:flex; align-items:center; gap:6px;">
|
||
<span style="display:inline-block; width:10px; height:10px; background:#FFF; border-radius:2px;"></span> Stop
|
||
</button>
|
||
`;
|
||
document.body.appendChild(controls);
|
||
|
||
controls.querySelector('#didi-stop-rec-btn').addEventListener('click', () => {
|
||
stopVideoRecording(false);
|
||
});
|
||
}
|
||
|
||
function startVideoTimer() {
|
||
const timerEl = () => document.getElementById('didi-rec-timer');
|
||
videoTimerInterval = setInterval(() => {
|
||
const el = timerEl(); if (!el) return;
|
||
const elapsedMs = Date.now() - videoStartTime;
|
||
const m = Math.floor(elapsedMs / 60000);
|
||
const s = Math.floor((elapsedMs % 60000) / 1000);
|
||
el.textContent = `${m}:${String(s).padStart(2, '0')}`;
|
||
}, 200);
|
||
}
|
||
|
||
function stopVideoRecording(silentlyStopped) {
|
||
if (videoTimerInterval) { clearInterval(videoTimerInterval); videoTimerInterval = null; }
|
||
if (videoMediaRecorder && videoMediaRecorder.state !== 'inactive') {
|
||
videoMediaRecorder.stop();
|
||
}
|
||
// onstop handler will fire onVideoRecordingStopped()
|
||
const ctrl = document.getElementById('didi-video-controls');
|
||
if (ctrl) ctrl.remove();
|
||
}
|
||
|
||
function onVideoRecordingStopped(mimeType) {
|
||
// Stop all media tracks
|
||
if (videoStream) {
|
||
videoStream.getTracks().forEach(t => t.stop());
|
||
videoStream = null;
|
||
}
|
||
|
||
const blob = new Blob(videoChunks, { type: mimeType });
|
||
videoChunks = [];
|
||
console.log('[DIDI VIDEO] Recording stopped. Blob size:', blob.size, 'bytes, type:', mimeType);
|
||
|
||
if (blob.size === 0) {
|
||
showNotification('❌ Recording empty', 'error');
|
||
cleanupVideoSession();
|
||
return;
|
||
}
|
||
|
||
showSendConfirmation(blob);
|
||
}
|
||
|
||
function showSendConfirmation(blob) {
|
||
// Restore scroll behind the modal
|
||
document.documentElement.style.overflow = savedHtmlOverflow;
|
||
document.body.style.overflow = savedScrollOverflow;
|
||
|
||
const sizeMB = (blob.size / 1024 / 1024).toFixed(2);
|
||
const durationMs = Date.now() - videoStartTime;
|
||
const durSec = (durationMs / 1000).toFixed(1);
|
||
|
||
// Generate preview URL for inline <video>
|
||
const previewUrl = URL.createObjectURL(blob);
|
||
|
||
const modal = document.createElement('div');
|
||
modal.id = 'didi-video-confirm-modal';
|
||
modal.style.cssText = `position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.85); z-index:99999999; display:flex; align-items:center; justify-content:center; font-family:'Inter',sans-serif;`;
|
||
modal.innerHTML = `
|
||
<div style="background:#231832; border-radius:16px; padding:24px; max-width:560px; width:90%; box-shadow:0 24px 60px rgba(0,0,0,0.7);">
|
||
<h2 style="margin:0 0 4px 0; font-size:20px; font-weight:700; color:#FFF;">Send video for analysis?</h2>
|
||
<p style="margin:0 0 16px 0; color:#9CA3AF; font-size:13px;">Duration: ${durSec}s · Size: ${sizeMB} MB</p>
|
||
<video src="${previewUrl}" controls style="width:100%; max-height:340px; border-radius:8px; background:#000; margin-bottom:16px;"></video>
|
||
<div style="background:rgba(239,68,68,0.08); border-left:3px solid #EF4444; padding:10px 12px; border-radius:6px; margin-bottom:16px; color:#FCA5A5; font-size:12px;">
|
||
⚠️ This will use credits. Backend video analysis takes ~30-180s.
|
||
</div>
|
||
<div style="display:flex; gap:10px; justify-content:flex-end;">
|
||
<button id="didi-discard-btn" style="background:rgba(255,255,255,0.06); color:#9CA3AF; border:1px solid rgba(255,255,255,0.1); padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px;">Discard</button>
|
||
<button id="didi-download-btn" style="background:rgba(0,145,152,0.15); color:#1fb6bd; border:1px solid #009198; padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px;">Download Only</button>
|
||
<button id="didi-send-btn" style="background:linear-gradient(135deg,#009198 0%,#1fb6bd 100%); color:#FFF; border:none; padding:10px 18px; border-radius:8px; cursor:pointer; font-family:inherit; font-size:13px; font-weight:600;">Send for Analysis</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const cleanup = () => {
|
||
URL.revokeObjectURL(previewUrl);
|
||
modal.remove();
|
||
cleanupVideoSession();
|
||
};
|
||
|
||
modal.querySelector('#didi-discard-btn').addEventListener('click', cleanup);
|
||
modal.querySelector('#didi-download-btn').addEventListener('click', () => {
|
||
const ts = new Date().toISOString().replace(/:/g, '-').split('.')[0];
|
||
downloadImageFallback(previewUrl, `didi-recording-${ts}.webm`);
|
||
cleanup();
|
||
});
|
||
modal.querySelector('#didi-send-btn').addEventListener('click', async () => {
|
||
modal.querySelector('#didi-send-btn').disabled = true;
|
||
modal.querySelector('#didi-send-btn').textContent = 'Uploading...';
|
||
try {
|
||
const dataUrl = await blobToDataUrl(blob);
|
||
const filename = `didi-video-${new Date().toISOString().replace(/:/g, '-').split('.')[0]}.webm`;
|
||
cleanup();
|
||
showNotification('📤 Uploading video… analysis takes 60–180s', 'info');
|
||
// Background acks immediately; result is pushed back via 'showAnalysis' message.
|
||
chrome.runtime.sendMessage({ action: 'analyzeVideo', dataUrl, filename }, (response) => {
|
||
if (!response?.success) {
|
||
showNotification('❌ Video upload failed: ' + (response?.error || 'unknown'), 'error');
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error('[DIDI VIDEO] Send failed:', err);
|
||
showNotification('❌ ' + err.message, 'error');
|
||
cleanup();
|
||
}
|
||
});
|
||
}
|
||
|
||
function blobToDataUrl(blob) {
|
||
return new Promise((resolve, reject) => {
|
||
const r = new FileReader();
|
||
r.onloadend = () => resolve(r.result);
|
||
r.onerror = reject;
|
||
r.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
function cleanupVideoSession() {
|
||
if (videoStream) { videoStream.getTracks().forEach(t => t.stop()); videoStream = null; }
|
||
if (videoTimerInterval) { clearInterval(videoTimerInterval); videoTimerInterval = null; }
|
||
document.documentElement.style.overflow = savedHtmlOverflow;
|
||
document.body.style.overflow = savedScrollOverflow;
|
||
deactivateVideoSnippingMode();
|
||
}
|
||
|
||
function deactivateVideoSnippingMode() {
|
||
isVideoSnipping = false;
|
||
document.body.style.cursor = 'default';
|
||
const overlay = document.getElementById('didi-video-overlay');
|
||
if (overlay) overlay.remove();
|
||
if (videoSnipBox) { videoSnipBox.remove(); videoSnipBox = null; }
|
||
const controls = document.getElementById('didi-video-controls');
|
||
if (controls) controls.remove();
|
||
const startPanel = document.getElementById('didi-video-start-panel');
|
||
if (startPanel) startPanel.remove();
|
||
|
||
document.removeEventListener('mousedown', handleVideoSnipStart, true);
|
||
document.removeEventListener('mousemove', handleVideoSnipDrag, true);
|
||
document.removeEventListener('mouseup', handleVideoSnipEnd, true);
|
||
document.removeEventListener('keydown', handleVideoSnipCancel, true);
|
||
}
|
||
|
||
// Add CSS animations
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes slideIn {
|
||
from {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes slideOut {
|
||
from {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: translateX(400px);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
@keyframes didiPulseRec {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.4; }
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|