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; 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); } // 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 = ` ${this.escapeHtml(search)} `; 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 { 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 = `
${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'); // 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 { 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 = `
Загрузка... `; // 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 = `
${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) => { 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 { 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;