798 lines
30 KiB
TypeScript
798 lines
30 KiB
TypeScript
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<void> {
|
||
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 = `
|
||
<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');
|
||
}
|
||
|
||
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) {
|
||
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<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();
|
||
(window as any).app = app;
|