This commit is contained in:
Andrey Kondratev
2025-11-10 13:56:19 +05:00
parent 6db48b16a7
commit 82a9596370
13 changed files with 1086 additions and 152 deletions

View File

@@ -6,6 +6,11 @@ interface TelegramWebApp {
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;
@@ -48,13 +53,66 @@ class QuixoticApp {
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();
@@ -87,19 +145,27 @@ class QuixoticApp {
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;
}
// Show loading spinner immediately for better UX feedback
this.showLoading();
// Don't show loading immediately - wait for debounce to finish
// This prevents flickering when user is typing fast
// Set new timeout for search (300ms delay - best practice for instant search)
// Set new timeout for search (600ms delay - prevents missing characters)
this.searchTimeout = setTimeout(() => {
this.search();
}, 300);
}, 600);
});
// Still handle Enter key for immediate search
@@ -112,6 +178,67 @@ class QuixoticApp {
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 {
@@ -122,18 +249,25 @@ class QuixoticApp {
// 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;
// Save to recent searches
this.saveSearch(query);
this.showLoading();
try {
@@ -162,6 +296,7 @@ class QuixoticApp {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
this.triggerHaptic('light');
this.displayResults(data.videos);
} catch (error) {
console.error('Search error:', error);
@@ -174,6 +309,7 @@ class QuixoticApp {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
this.triggerHaptic('error');
this.showNoResults();
}
}
@@ -192,22 +328,31 @@ class QuixoticApp {
this.welcomePlaceholder.classList.add('tg-hidden');
this.welcomePlaceholder.style.display = 'none';
// Show loading spinner
this.loading.classList.remove('tg-hidden');
this.loading.classList.add('tg-spinner--visible');
// 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 {
@@ -230,7 +375,7 @@ class QuixoticApp {
// Use DocumentFragment for better performance
const fragment = document.createDocumentFragment();
videos.forEach(video => {
videos.forEach((video, index) => {
const item = document.createElement('div');
item.className = 'tg-list-item';
item.dataset.videoId = video.id;
@@ -244,16 +389,46 @@ class QuixoticApp {
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>
`;
// Use event listener instead of inline onclick
item.addEventListener('click', () => {
// 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);
});
@@ -330,6 +505,9 @@ class QuixoticApp {
videoElement.classList.remove('tg-list-item--active');
videoElement.classList.add('tg-list-item--converting');
// Add progress bar
this.showConversionProgress(videoElement);
}
try {
@@ -358,6 +536,7 @@ class QuixoticApp {
if (this.tg) {
const userId = this.tg?.initDataUnsafe?.user?.id;
if (!userId) {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка: не удается определить пользователя', 'error');
return;
}
@@ -377,11 +556,14 @@ class QuixoticApp {
});
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 {
@@ -411,14 +593,49 @@ class QuixoticApp {
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);
@@ -454,6 +671,126 @@ class QuixoticApp {
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
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 = `
<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();