Files
quixotic/public/script.ts
Andrey Kondratev cd2c3b6989 hide messages
2025-11-10 17:22:10 +05:00

1213 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
interface TelegramWebApp {
ready(): void;
expand(): void;
sendData(data: string): void;
MainButton: {
show(): void;
hide(): void;
};
HapticFeedback?: {
impactOccurred(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'): void;
notificationOccurred(type: 'error' | 'success' | 'warning'): void;
selectionChanged(): void;
};
initDataUnsafe?: {
user?: {
id: number;
};
};
}
interface WindowWithTelegram extends Window {
Telegram?: {
WebApp: TelegramWebApp;
};
}
interface VideoResult {
id: string;
title: string;
channel: string;
thumbnail: string;
duration: number;
url: string;
}
interface SearchResponse {
videos: VideoResult[];
hasMore?: boolean;
}
interface ConvertResponse {
audioUrl?: string;
title: string;
}
class QuixoticApp {
private tg?: TelegramWebApp;
private searchInput!: HTMLInputElement;
private loading!: HTMLElement;
private results!: HTMLElement;
private noResults!: HTMLElement;
private welcomePlaceholder!: HTMLElement;
private searchTimeout?: NodeJS.Timeout;
private currentVideos: VideoResult[] = [];
private loadingStartTime: number = 0;
private minLoadingDuration: number = 400; // Минимальное время показа спиннера (ms)
private recentSearches: string[] = [];
private maxRecentSearches: number = 5;
private currentAudio: HTMLAudioElement | null = null;
private currentPlayingItem: HTMLElement | null = null;
private currentPage: number = 1;
private hasMoreResults: boolean = false;
private isLoadingMore: boolean = false;
private scrollObserver: IntersectionObserver | null = null;
private currentVersion: string | null = null;
constructor() {
this.tg = (window as WindowWithTelegram).Telegram?.WebApp;
this.loadRecentSearches();
this.init();
this.bindEvents();
this.checkVersion(); // Check for updates on load
}
private async checkVersion(): Promise<void> {
try {
// Get current version from localStorage
const storedVersion = localStorage.getItem('appVersion');
// Fetch latest version from server
const response = await fetch('/api/version', {
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response.ok) return;
const versionData = await response.json();
const serverVersion = versionData.version;
this.currentVersion = serverVersion;
// If versions don't match, force reload
if (storedVersion && storedVersion !== serverVersion) {
console.log('🔄 New version detected, updating...');
// Clear cache and reload
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
// Store new version
localStorage.setItem('appVersion', serverVersion);
// Force hard reload
window.location.reload();
return;
}
// Store version for future checks
if (!storedVersion) {
localStorage.setItem('appVersion', serverVersion);
}
// Periodically check for updates (every 5 minutes)
setInterval(() => this.silentVersionCheck(), 5 * 60 * 1000);
} catch (error) {
console.warn('Version check failed:', error);
}
}
private async silentVersionCheck(): Promise<void> {
try {
const response = await fetch('/api/version', {
cache: 'no-cache',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response.ok) return;
const versionData = await response.json();
const serverVersion = versionData.version;
if (this.currentVersion && this.currentVersion !== serverVersion) {
console.log('🔄 Update available');
// Show update notification
this.showUpdateNotification();
}
} catch (error) {
console.warn('Silent version check failed:', error);
}
}
private showUpdateNotification(): void {
const notification = document.createElement('div');
notification.className = 'tg-update-notification';
notification.innerHTML = `
<div class="tg-update-notification__content">
<span>Доступно обновление</span>
<button class="tg-update-notification__button">Обновить</button>
</div>
`;
const button = notification.querySelector('button');
button?.addEventListener('click', () => {
window.location.reload();
});
document.body.appendChild(notification);
// Auto-dismiss after 30 seconds
setTimeout(() => {
notification.remove();
}, 30000);
}
private triggerHaptic(type: 'light' | 'medium' | 'heavy' | 'success' | 'error' = 'light'): void {
if (this.tg?.HapticFeedback) {
switch(type) {
case 'light':
this.tg.HapticFeedback.impactOccurred('light');
break;
case 'medium':
this.tg.HapticFeedback.impactOccurred('medium');
break;
case 'heavy':
this.tg.HapticFeedback.impactOccurred('heavy');
break;
case 'success':
this.tg.HapticFeedback.notificationOccurred('success');
break;
case 'error':
this.tg.HapticFeedback.notificationOccurred('error');
break;
}
}
}
private loadRecentSearches(): void {
try {
const saved = localStorage.getItem('recentSearches');
if (saved) {
this.recentSearches = JSON.parse(saved);
}
} catch (e) {
console.error('Failed to load recent searches:', e);
}
}
private saveSearch(query: string): void {
// Remove if already exists
this.recentSearches = this.recentSearches.filter(s => s !== query);
// Add to beginning
this.recentSearches.unshift(query);
// Limit count
this.recentSearches = this.recentSearches.slice(0, this.maxRecentSearches);
// Save
try {
localStorage.setItem('recentSearches', JSON.stringify(this.recentSearches));
} catch (e) {
console.error('Failed to save recent searches:', e);
}
// Update UI
this.updateRecentSearchesUI();
}
private updateRecentSearchesUI(): void {
const container = document.getElementById('recentSearches');
const list = document.getElementById('recentSearchesList');
if (!container || !list) return;
// Hide if no searches or if we have a current query
if (this.recentSearches.length === 0 || this.searchInput.value.trim() !== '') {
container.classList.add('tg-hidden');
return;
}
// Show and populate
container.classList.remove('tg-hidden');
list.innerHTML = '';
this.recentSearches.forEach(search => {
const button = document.createElement('button');
button.className = 'tg-recent-search-item';
button.innerHTML = `
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M8 4v4l3 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>${this.escapeHtml(search)}</span>
`;
button.addEventListener('click', () => {
this.triggerHaptic('light');
this.searchInput.value = search;
this.search();
});
list.appendChild(button);
});
}
private clearRecentSearches(): void {
this.recentSearches = [];
try {
localStorage.removeItem('recentSearches');
} catch (e) {
console.error('Failed to clear recent searches:', e);
}
this.updateRecentSearchesUI();
}
private init(): void {
if (this.tg) {
this.tg.ready();
this.tg.expand();
this.tg.MainButton.hide();
// Debug Telegram user info
console.log('🔧 WebApp ready, user:', this.tg.initDataUnsafe?.user?.id);
} else {
console.log('❌ Telegram WebApp not available');
}
this.searchInput = document.getElementById('searchInput') as HTMLInputElement;
this.loading = document.getElementById('loading') as HTMLElement;
this.results = document.getElementById('results') as HTMLElement;
this.noResults = document.getElementById('noResults') as HTMLElement;
this.welcomePlaceholder = document.getElementById('welcomePlaceholder') as HTMLElement;
// Initialize proper state - only welcome should be visible
this.resetToWelcomeState();
// Show recent searches initially if available
this.updateRecentSearchesUI();
}
private bindEvents(): void {
// Auto search with debounce timeout
this.searchInput.addEventListener('input', () => {
// Clear previous timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
const query = this.searchInput.value.trim();
// Show/hide clear button
const clearButton = document.getElementById('clearButton');
if (query) {
clearButton?.classList.remove('tg-hidden');
} else {
clearButton?.classList.add('tg-hidden');
}
// Update recent searches visibility
this.updateRecentSearchesUI();
// If input is empty, reset to welcome state immediately
if (query === '') {
this.resetToWelcomeState();
return;
}
// Don't show loading immediately - wait for debounce to finish
// This prevents flickering when user is typing fast
// Set new timeout for search (600ms delay - prevents missing characters)
this.searchTimeout = setTimeout(() => {
this.search();
}, 600);
});
// Still handle Enter key for immediate search
this.searchInput.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
// Clear timeout and search immediately
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.search();
}
});
// Clear button handler
const clearButton = document.getElementById('clearButton');
clearButton?.addEventListener('click', () => {
this.triggerHaptic('light');
this.searchInput.value = '';
this.searchInput.focus();
this.resetToWelcomeState();
clearButton.classList.add('tg-hidden');
});
// Clear recent searches button
const clearRecentBtn = document.getElementById('clearRecentBtn');
clearRecentBtn?.addEventListener('click', () => {
this.triggerHaptic('light');
this.clearRecentSearches();
});
// Setup pull-to-refresh
this.setupPullToRefresh();
}
private setupPullToRefresh(): void {
let touchStartY = 0;
let pullDistance = 0;
const threshold = 80;
const container = document.querySelector('.tg-content') as HTMLElement;
if (!container) return;
container.addEventListener('touchstart', (e) => {
if (window.scrollY === 0) {
touchStartY = e.touches[0].clientY;
}
}, { passive: true });
container.addEventListener('touchmove', (e) => {
if (touchStartY > 0) {
pullDistance = e.touches[0].clientY - touchStartY;
if (pullDistance > 0 && pullDistance < threshold * 2) {
// Visual indication of stretching
container.style.transform = `translateY(${pullDistance * 0.5}px)`;
// Add pulling class for indicator
if (pullDistance > threshold * 0.5) {
container.classList.add('tg-pulling');
}
}
}
}, { passive: true });
container.addEventListener('touchend', () => {
if (pullDistance > threshold) {
this.triggerHaptic('medium');
this.refreshResults();
}
container.style.transform = '';
container.classList.remove('tg-pulling');
touchStartY = 0;
pullDistance = 0;
});
}
private refreshResults(): void {
const query = this.searchInput.value.trim();
if (query) {
this.search();
}
}
private resetToWelcomeState(): void {
// Show only welcome placeholder
this.welcomePlaceholder.classList.remove('tg-hidden');
this.welcomePlaceholder.style.display = '';
// Hide all other states
this.loading.classList.add('tg-hidden');
this.loading.classList.remove('tg-spinner--visible');
const skeletonList = document.getElementById('skeletonList');
if (skeletonList) {
skeletonList.classList.add('tg-hidden');
}
this.results.classList.add('tg-hidden');
this.results.classList.remove('tg-list--visible');
this.noResults.classList.add('tg-hidden');
this.noResults.style.display = 'none';
}
private async search(): Promise<void> {
const query = this.searchInput.value.trim();
if (!query) return;
// Reset pagination state for new search
this.currentPage = 1;
this.hasMoreResults = true;
this.isLoadingMore = false;
// Save to recent searches
this.saveSearch(query);
this.showLoading();
try {
const response = await fetch('/api/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
page: 1,
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
})
});
if (!response.ok) {
throw new Error('Search failed');
}
const data: SearchResponse = await response.json();
// Set hasMoreResults based on API response
this.hasMoreResults = data.hasMore !== false;
// Ensure minimum loading time for better UX (prevents flashing)
const elapsedTime = Date.now() - this.loadingStartTime;
const remainingTime = Math.max(0, this.minLoadingDuration - elapsedTime);
if (remainingTime > 0) {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
this.triggerHaptic('light');
this.displayResults(data.videos);
} catch (error) {
console.error('Search error:', error);
// Ensure minimum loading time even for errors
const elapsedTime = Date.now() - this.loadingStartTime;
const remainingTime = Math.max(0, this.minLoadingDuration - elapsedTime);
if (remainingTime > 0) {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
this.triggerHaptic('error');
this.showNoResults();
}
}
private showLoading(): void {
// Record loading start time
this.loadingStartTime = Date.now();
// Clear any existing status messages
const existingMessage = document.querySelector('.tg-status-message');
if (existingMessage) {
existingMessage.remove();
}
// Hide welcome immediately when loading starts
this.welcomePlaceholder.classList.add('tg-hidden');
this.welcomePlaceholder.style.display = 'none';
// Hide recent searches
const recentSearches = document.getElementById('recentSearches');
if (recentSearches) {
recentSearches.classList.add('tg-hidden');
}
// Show skeleton screens instead of spinner for better UX
const skeletonList = document.getElementById('skeletonList');
if (skeletonList) {
skeletonList.classList.remove('tg-hidden');
}
// Hide spinner (we're using skeletons now)
this.loading.classList.add('tg-hidden');
this.loading.classList.remove('tg-spinner--visible');
// Hide other elements
this.results.classList.add('tg-hidden');
this.results.classList.remove('tg-list--visible');
this.noResults.classList.add('tg-hidden');
this.noResults.style.display = 'none';
}
private hideLoading(): void {
this.loading.classList.add('tg-hidden');
this.loading.classList.remove('tg-spinner--visible');
const skeletonList = document.getElementById('skeletonList');
if (skeletonList) {
skeletonList.classList.add('tg-hidden');
}
}
private displayResults(videos: VideoResult[]): void {
this.hideLoading();
if (!videos || videos.length === 0) {
this.showNoResults();
return;
}
// Store current videos for metadata access
this.currentVideos = videos;
// Hide welcome and no results
this.welcomePlaceholder.classList.add('tg-hidden');
this.welcomePlaceholder.style.display = 'none';
this.noResults.classList.add('tg-hidden');
this.noResults.style.display = 'none';
// Use DocumentFragment for better performance
const fragment = document.createDocumentFragment();
videos.forEach((video) => {
const item = document.createElement('div');
item.className = 'tg-list-item';
item.dataset.videoId = video.id;
item.innerHTML = `
<div class='tg-list-item__content'>
<div class='tg-list-item__media'>
<img class='tg-list-item__thumbnail'
src='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="60"%3E%3Crect fill="%23f1f1f1" width="80" height="60"/%3E%3C/svg%3E'
data-src='${video.thumbnail}'
alt='${this.escapeHtml(video.title)}'
loading='lazy'>
<div class='tg-list-item__duration'>${this.formatDuration(video.duration)}</div>
<button class='tg-list-item__play-btn' data-video-id='${video.id}' aria-label='Play preview'>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<path d="M10 8l6 4-6 4V8z" fill="white"/>
</svg>
</button>
</div>
<div class='tg-list-item__info'>
<div class='tg-list-item__title'>${this.escapeHtml(video.title)}</div>
<div class='tg-list-item__subtitle'>${this.escapeHtml(video.channel)}</div>
</div>
</div>
<div class='tg-audio-player tg-hidden' data-video-id='${video.id}'>
<div class='tg-audio-player__progress'>
<div class='tg-audio-player__progress-bar'>
<div class='tg-audio-player__progress-fill'></div>
</div>
<div class='tg-audio-player__time'>
<span class='tg-audio-player__current'>0:00</span>
<span class='tg-audio-player__duration'>${this.formatDuration(video.duration)}</span>
</div>
</div>
</div>
`;
// Handle play button click
const playBtn = item.querySelector('.tg-list-item__play-btn') as HTMLElement;
playBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.triggerHaptic('light');
this.toggleAudioPreview(video, item);
});
// Use event listener for download
item.addEventListener('click', (e) => {
// Don't trigger download if clicking play button
if ((e.target as HTMLElement).closest('.tg-list-item__play-btn')) {
return;
}
this.triggerHaptic('medium');
this.convertVideo(video.id, video.title, video.url);
});
// Reset visual state on touch end
item.addEventListener('touchend', () => {
setTimeout(() => {
item.blur();
item.style.background = '';
item.style.transform = '';
}, 100);
}, { passive: true });
// Also handle mouse leave for desktop
item.addEventListener('mouseleave', () => {
item.style.background = '';
item.style.transform = '';
});
fragment.appendChild(item);
});
// Clear and append once
this.results.innerHTML = '';
this.results.appendChild(fragment);
// Lazy load images with IntersectionObserver
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
}
});
}, { rootMargin: '50px' });
this.results.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
this.results.classList.remove('tg-hidden');
this.results.classList.add('tg-list--visible');
// Setup infinite scroll observer
this.setupInfiniteScroll();
}
private setupInfiniteScroll(): void {
// Remove existing sentinel if any
const existingSentinel = document.getElementById('scroll-sentinel');
if (existingSentinel) {
existingSentinel.remove();
}
// Disconnect existing observer
if (this.scrollObserver) {
this.scrollObserver.disconnect();
}
// Create and append sentinel element
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
this.results.appendChild(sentinel);
// Create intersection observer
this.scrollObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.isLoadingMore && this.hasMoreResults) {
this.loadMoreResults();
}
});
}, { rootMargin: '100px' });
this.scrollObserver.observe(sentinel);
}
private async loadMoreResults(): Promise<void> {
if (this.isLoadingMore || !this.hasMoreResults) return;
this.isLoadingMore = true;
this.currentPage++;
// Show loading indicator at bottom
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'tg-loading-more';
loadingIndicator.id = 'loading-more-indicator';
loadingIndicator.innerHTML = `
<div class="tg-spinner__icon"></div>
<span>Загрузка...</span>
`;
// Insert before sentinel
const sentinel = document.getElementById('scroll-sentinel');
if (sentinel) {
this.results.insertBefore(loadingIndicator, sentinel);
}
try {
const query = this.searchInput.value.trim();
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
page: this.currentPage,
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
})
});
if (!response.ok) {
throw new Error('Load more failed');
}
const data = await response.json();
this.hasMoreResults = data.hasMore !== false && data.videos && data.videos.length > 0;
if (data.videos && data.videos.length > 0) {
this.appendResults(data.videos);
this.triggerHaptic('light');
} else {
this.hasMoreResults = false;
}
} catch (error) {
console.error('Load more error:', error);
this.hasMoreResults = false;
} finally {
loadingIndicator.remove();
this.isLoadingMore = false;
}
}
private appendResults(videos: VideoResult[]): void {
// Add new videos to current list
this.currentVideos.push(...videos);
const fragment = document.createDocumentFragment();
const sentinel = document.getElementById('scroll-sentinel');
videos.forEach((video) => {
const item = document.createElement('div');
item.className = 'tg-list-item';
item.dataset.videoId = video.id;
item.innerHTML = `
<div class='tg-list-item__content'>
<div class='tg-list-item__media'>
<img class='tg-list-item__thumbnail'
src='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="60"%3E%3Crect fill="%23f1f1f1" width="80" height="60"/%3E%3C/svg%3E'
data-src='${video.thumbnail}'
alt='${this.escapeHtml(video.title)}'
loading='lazy'>
<div class='tg-list-item__duration'>${this.formatDuration(video.duration)}</div>
<button class='tg-list-item__play-btn' data-video-id='${video.id}' aria-label='Play preview'>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<path d="M10 8l6 4-6 4V8z" fill="white"/>
</svg>
</button>
</div>
<div class='tg-list-item__info'>
<div class='tg-list-item__title'>${this.escapeHtml(video.title)}</div>
<div class='tg-list-item__subtitle'>${this.escapeHtml(video.channel)}</div>
</div>
</div>
<div class='tg-audio-player tg-hidden' data-video-id='${video.id}'>
<div class='tg-audio-player__progress'>
<div class='tg-audio-player__progress-bar'>
<div class='tg-audio-player__progress-fill'></div>
</div>
<div class='tg-audio-player__time'>
<span class='tg-audio-player__current'>0:00</span>
<span class='tg-audio-player__duration'>${this.formatDuration(video.duration)}</span>
</div>
</div>
</div>
`;
// Handle play button click
const playBtn = item.querySelector('.tg-list-item__play-btn') as HTMLElement;
playBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.triggerHaptic('light');
this.toggleAudioPreview(video, item);
});
// Use event listener for download
item.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.tg-list-item__play-btn')) {
return;
}
this.triggerHaptic('medium');
this.convertVideo(video.id, video.title, video.url);
});
// Reset visual state on touch end
item.addEventListener('touchend', () => {
setTimeout(() => {
item.blur();
item.style.background = '';
item.style.transform = '';
}, 100);
}, { passive: true });
// Also handle mouse leave for desktop
item.addEventListener('mouseleave', () => {
item.style.background = '';
item.style.transform = '';
});
fragment.appendChild(item);
});
// Insert before sentinel
if (sentinel) {
this.results.insertBefore(fragment, sentinel);
} else {
this.results.appendChild(fragment);
}
// Lazy load new images
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
const src = img.getAttribute('data-src');
if (src) {
img.src = src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
}
});
}, { rootMargin: '50px' });
this.results.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
private showNoResults(): void {
this.hideLoading();
this.welcomePlaceholder.classList.add('tg-hidden');
this.welcomePlaceholder.style.display = 'none';
this.results.classList.add('tg-hidden');
this.results.classList.remove('tg-list--visible');
this.noResults.classList.remove('tg-hidden');
this.noResults.style.display = '';
}
public async convertVideo(videoId: string, title: string, url: string): Promise<void> {
console.log('🎵 Converting:', title);
// Find video metadata from current search results
const video = this.currentVideos.find(v => v.id.toString() === videoId);
const performer = video?.channel || 'Unknown Artist';
const thumbnail = video?.thumbnail || '';
// Find the clicked element using data attribute
const videoElement = document.querySelector(`[data-video-id="${videoId}"]`) as HTMLElement;
if (videoElement) {
// Force blur to reset any active/focus states
videoElement.blur();
// Remove any stuck hover/active classes on touch devices
videoElement.classList.remove('tg-list-item--active');
videoElement.classList.add('tg-list-item--converting');
// Add progress bar
this.showConversionProgress(videoElement);
}
try {
const response = await fetch('/api/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
videoId,
title,
url,
performer,
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
})
});
if (!response.ok) {
throw new Error(`Conversion failed with status: ${response.status}`);
}
const data: ConvertResponse = await response.json();
if (data.audioUrl) {
if (this.tg) {
const userId = this.tg?.initDataUnsafe?.user?.id;
if (!userId) {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка: не удается определить пользователя', 'error');
return;
}
// Send via direct API
try {
const directResponse = await fetch('/api/telegram-send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: userId,
audioUrl: data.audioUrl,
title: title,
performer: performer,
thumbnail: thumbnail
})
});
if (directResponse.ok) {
this.triggerHaptic('success');
this.showMessage('✅ MP3 отправлен в чат!', 'success');
} else {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка отправки в Telegram', 'error');
}
} catch {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка соединения с ботом', 'error');
}
} else {
// For testing in browser - download file
const link = document.createElement('a');
link.href = data.audioUrl;
link.download = `${title}.mp3`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.showMessage('✓ MP3 скачан!', 'success');
}
} else {
throw new Error('No audio URL received');
}
} catch (error: any) {
console.error('Conversion error:', error);
// Show specific error message
let errorMsg = 'Ошибка конвертации. Попробуйте другое видео.';
if (error.message.includes('No audio URL')) {
errorMsg = 'Не удалось получить аудио файл.';
} else if (error.message.includes('410')) {
errorMsg = 'Видео недоступно для скачивания.';
} else if (error.message.includes('403')) {
errorMsg = 'Видео заблокировано для скачивания.';
}
this.triggerHaptic('error');
this.showMessage(`${errorMsg}`, 'error');
} finally {
if (videoElement) {
videoElement.classList.remove('tg-list-item--converting');
// Remove progress bar
const progressBar = videoElement.querySelector('.tg-conversion-progress');
if (progressBar) {
progressBar.remove();
}
}
}
}
private showConversionProgress(element: HTMLElement): void {
const progressBar = document.createElement('div');
progressBar.className = 'tg-conversion-progress';
progressBar.innerHTML = `
<div class="tg-conversion-progress__bar">
<div class="tg-conversion-progress__fill"></div>
</div>
<div class="tg-conversion-progress__text">Конвертация...</div>
`;
element.appendChild(progressBar);
// Simulate progress (smooth animation)
const fill = progressBar.querySelector('.tg-conversion-progress__fill') as HTMLElement;
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 15;
if (progress > 90) progress = 90; // Stop at 90%
fill.style.width = `${progress}%`;
}, 200);
// Store cleanup function
(element as any).__progressCleanup = () => {
clearInterval(interval);
fill.style.width = '100%';
setTimeout(() => progressBar.remove(), 500);
};
}
private formatDuration(seconds: number): string {
if (!seconds) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
private showMessage(message: string, type: string = 'info'): void {
// Remove existing message if any
const existingMessage = document.querySelector('.tg-status-message');
if (existingMessage) {
this.hideMessage(existingMessage as HTMLElement);
}
// Create message element
const messageEl = document.createElement('div');
messageEl.className = `tg-status-message tg-status-message--${type}`;
messageEl.textContent = message;
// Add click handler to dismiss
let hideTimeout: NodeJS.Timeout;
const hideHandler = () => {
clearTimeout(hideTimeout);
this.hideMessage(messageEl);
};
messageEl.addEventListener('click', hideHandler);
// Add to body (fixed position, won't affect layout)
document.body.appendChild(messageEl);
// Auto-remove after 3 seconds (было 5)
hideTimeout = setTimeout(() => {
if (messageEl.parentNode) {
this.hideMessage(messageEl);
}
}, 3000);
}
private hideMessage(messageEl: HTMLElement): void {
// Add hiding class for fade-out animation
messageEl.classList.add('tg-status-message--hiding');
// Remove after animation completes (300ms)
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
}, 300);
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
private async toggleAudioPreview(video: VideoResult, item: HTMLElement): Promise<void> {
const playerContainer = item.querySelector('.tg-audio-player') as HTMLElement;
const playBtn = item.querySelector('.tg-list-item__play-btn') as HTMLElement;
// If this is already playing, pause it
if (this.currentPlayingItem === item && this.currentAudio) {
this.stopAudioPreview();
return;
}
// Stop any other playing audio
if (this.currentAudio) {
this.stopAudioPreview();
}
try {
// Show converting state
item.classList.add('tg-list-item--loading-preview');
playBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<circle cx="12" cy="12" r="6" stroke="white" stroke-width="2" stroke-dasharray="9.42 9.42" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
`;
// Get audio URL (we need to convert first)
const response = await fetch('/api/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: video.id,
title: video.title,
url: video.url,
performer: video.channel,
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
})
});
if (!response.ok) {
throw new Error('Failed to get audio');
}
const data = await response.json();
if (!data.audioUrl) {
throw new Error('No audio URL');
}
// Create audio element
this.currentAudio = new Audio(data.audioUrl);
this.currentPlayingItem = item;
// Show player
playerContainer.classList.remove('tg-hidden');
item.classList.remove('tg-list-item--loading-preview');
item.classList.add('tg-list-item--playing');
// Update play button to pause
playBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<rect x="9" y="8" width="2" height="8" fill="white"/>
<rect x="13" y="8" width="2" height="8" fill="white"/>
</svg>
`;
// Setup progress tracking
const progressFill = playerContainer.querySelector('.tg-audio-player__progress-fill') as HTMLElement;
const currentTimeEl = playerContainer.querySelector('.tg-audio-player__current') as HTMLElement;
this.currentAudio.addEventListener('timeupdate', () => {
if (!this.currentAudio) return;
const progress = (this.currentAudio.currentTime / this.currentAudio.duration) * 100;
progressFill.style.width = `${progress}%`;
currentTimeEl.textContent = this.formatDuration(Math.floor(this.currentAudio.currentTime));
});
this.currentAudio.addEventListener('ended', () => {
this.stopAudioPreview();
});
// Play audio with autoplay error handling
try {
await this.currentAudio.play();
this.triggerHaptic('light');
} catch (playError: any) {
console.error('Autoplay error:', playError);
// If autoplay is blocked, show message and clean up
if (playError.name === 'NotAllowedError') {
this.stopAudioPreview();
item.classList.remove('tg-list-item--loading-preview');
this.showMessage('🔇 Воспроизведение заблокировано. Нажмите еще раз.', 'warning');
return;
}
throw playError;
}
} catch (error) {
console.error('Preview error:', error);
item.classList.remove('tg-list-item--loading-preview');
this.stopAudioPreview();
this.showMessage('❌ Не удалось воспроизвести превью', 'error');
}
}
private stopAudioPreview(): void {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
}
if (this.currentPlayingItem) {
const playerContainer = this.currentPlayingItem.querySelector('.tg-audio-player') as HTMLElement;
const playBtn = this.currentPlayingItem.querySelector('.tg-list-item__play-btn') as HTMLElement;
playerContainer?.classList.add('tg-hidden');
this.currentPlayingItem.classList.remove('tg-list-item--playing');
// Reset play button
if (playBtn) {
playBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<path d="M10 8l6 4-6 4V8z" fill="white"/>
</svg>
`;
}
this.currentPlayingItem = null;
}
}
}
const app = new QuixoticApp();
(window as any).app = app;