new ui?!
This commit is contained in:
365
public/script.ts
365
public/script.ts
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user