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[]; } 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; constructor() { this.tg = (window as WindowWithTelegram).Telegram?.WebApp; this.loadRecentSearches(); this.init(); this.bindEvents(); } 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); } } 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(); } 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'); } // 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'); }); // 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 { const query = this.searchInput.value.trim(); if (!query) return; // 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, userId: this.tg?.initDataUnsafe?.user?.id || 'demo' }) }); if (!response.ok) { throw new Error('Search failed'); } const data: SearchResponse = await response.json(); // 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'; // 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, index) => { const item = document.createElement('div'); item.className = 'tg-list-item'; item.dataset.videoId = video.id; item.innerHTML = `
${this.escapeHtml(video.title)}
${this.formatDuration(video.duration)}
${this.escapeHtml(video.title)}
${this.escapeHtml(video.channel)}
0:00 ${this.formatDuration(video.duration)}
`; // 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'); } 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 { 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 = `
Конвертация...
`; 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) { existingMessage.remove(); } // Create message element const messageEl = document.createElement('div'); messageEl.className = `tg-status-message tg-status-message--${type}`; messageEl.textContent = message; // Add to body (fixed position, won't affect layout) document.body.appendChild(messageEl); // Auto-remove after 5 seconds setTimeout(() => { if (messageEl.parentNode) { messageEl.remove(); } }, 5000); } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } private async toggleAudioPreview(video: VideoResult, item: HTMLElement): Promise { 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 = ` `; // 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 = ` `; // 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 await this.currentAudio.play(); this.triggerHaptic('light'); } catch (error) { console.error('Preview error:', error); item.classList.remove('tg-list-item--loading-preview'); 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 = ` `; } this.currentPlayingItem = null; } } } const app = new QuixoticApp(); (window as any).app = app;