new ui?!
This commit is contained in:
@@ -54,17 +54,13 @@
|
||||
<link rel="preconnect" href="https://telegram.org" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://telegram.org">
|
||||
|
||||
<!-- Preload critical resources -->
|
||||
<link rel="preload" href="style.css" as="style">
|
||||
<link rel="preload" href="https://telegram.org/js/telegram-web-app.js" as="script">
|
||||
|
||||
<!-- Critical CSS - inline the most important styles -->
|
||||
<style>
|
||||
:root{--tg-color-bg:var(--tg-theme-bg-color,#fff);--tg-color-secondary-bg:var(--tg-theme-secondary-bg-color,#f1f1f1);--tg-color-section-bg:var(--tg-theme-section-bg-color,#fff);--tg-color-text:var(--tg-theme-text-color,#000);--tg-color-hint:var(--tg-theme-hint-color,#999);--tg-color-button:var(--tg-theme-button-color,#007aff);--tg-color-button-text:var(--tg-theme-button-text-color,#fff);--tg-border-radius:12px;--tg-spacing-lg:16px;--tg-spacing-xl:20px;--tg-spacing-xxl:24px;--tg-font-size-md:16px;--tg-font-size-lg:17px;--tg-font-size-xl:20px;--tg-line-height-normal:1.4;--tg-line-height-relaxed:1.6}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',system-ui,sans-serif;-webkit-font-smoothing:antialiased}body{background:var(--tg-color-bg);color:var(--tg-color-text);font-size:var(--tg-font-size-md);line-height:var(--tg-line-height-normal);overflow-x:hidden}.tg-root{min-height:100vh;display:flex;flex-direction:column}.tg-content{flex:1;padding:var(--tg-spacing-lg);padding-bottom:100px;display:flex;flex-direction:column;gap:var(--tg-spacing-xl)}.tg-placeholder{text-align:center;padding:var(--tg-spacing-xxl) var(--tg-spacing-lg);max-width:300px;margin:0 auto}.tg-placeholder__icon{font-size:48px;margin-bottom:var(--tg-spacing-lg);opacity:.6}.tg-placeholder__title{font-size:var(--tg-font-size-xl);font-weight:600;margin-bottom:8px}.tg-placeholder__description{font-size:14px;color:var(--tg-color-hint);line-height:var(--tg-line-height-relaxed)}.tg-hidden{display:none!important}.tg-form{position:fixed;bottom:0;left:0;right:0;padding:var(--tg-spacing-lg);background:var(--tg-color-bg);border-top:1px solid var(--tg-color-secondary-bg);z-index:100}.tg-input-wrapper{position:relative}.tg-input{width:100%;height:48px;padding:0 var(--tg-spacing-lg);background:var(--tg-color-section-bg);border:2px solid var(--tg-color-secondary-bg);border-radius:var(--tg-border-radius);font-size:var(--tg-font-size-lg);color:var(--tg-color-text);transition:border-color .2s;outline:0}.tg-input::placeholder{color:var(--tg-color-hint)}.tg-input:focus{border-color:var(--tg-color-button)}
|
||||
</style>
|
||||
|
||||
<!-- Load full CSS asynchronously with fallback -->
|
||||
<link rel="preload" href="style.css?v=3" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<link rel="stylesheet" href="style.css?v=3" media="print" onload="this.media='all';this.onload=null">
|
||||
<noscript><link rel="stylesheet" href="style.css?v=3"></noscript>
|
||||
|
||||
<!-- Load Telegram script asynchronously (defer) -->
|
||||
@@ -84,6 +80,79 @@
|
||||
<div class="tg-spinner__text">Поиск музыки...</div>
|
||||
</div>
|
||||
|
||||
<div class="tg-skeleton-list tg-hidden" id="skeletonList">
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-skeleton-item">
|
||||
<div class="tg-skeleton-thumbnail"></div>
|
||||
<div class="tg-skeleton-text">
|
||||
<div class="tg-skeleton-line"></div>
|
||||
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tg-list tg-hidden" id="results">
|
||||
<!-- Search results will appear here -->
|
||||
</div>
|
||||
@@ -101,6 +170,12 @@
|
||||
id="searchInput"
|
||||
placeholder="Название песни или исполнитель..."
|
||||
autocomplete="off">
|
||||
<button class="tg-input-clear tg-hidden" id="clearButton" type="button" aria-label="Очистить поиск">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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();
|
||||
|
||||
197
public/style.css
197
public/style.css
@@ -71,6 +71,11 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tg-spacing-xl);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tg-content.tg-pulling {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Form components */
|
||||
@@ -93,6 +98,7 @@ body {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 var(--tg-spacing-lg);
|
||||
padding-right: 48px; /* Make room for clear button */
|
||||
background: var(--tg-color-section-bg);
|
||||
border: 2px solid var(--tg-color-secondary-bg);
|
||||
border-radius: var(--tg-border-radius);
|
||||
@@ -111,6 +117,33 @@ body {
|
||||
background: var(--tg-color-bg);
|
||||
}
|
||||
|
||||
.tg-input-clear {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--tg-color-hint);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.tg-input-clear:hover {
|
||||
background: var(--tg-color-secondary-bg);
|
||||
color: var(--tg-color-text);
|
||||
}
|
||||
|
||||
.tg-input-clear:active {
|
||||
transform: translateY(-50%) scale(0.9);
|
||||
}
|
||||
|
||||
/* Button components */
|
||||
.tg-button {
|
||||
position: relative;
|
||||
@@ -244,6 +277,66 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton loading screens */
|
||||
.tg-skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tg-spacing-xs);
|
||||
}
|
||||
|
||||
.tg-skeleton-list.tg-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tg-skeleton-item {
|
||||
display: flex;
|
||||
gap: var(--tg-spacing-md);
|
||||
padding: var(--tg-spacing-md);
|
||||
background: var(--tg-color-section-bg);
|
||||
border-radius: var(--tg-border-radius);
|
||||
}
|
||||
|
||||
.tg-skeleton-thumbnail {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--tg-color-secondary-bg) 25%,
|
||||
var(--tg-color-hint) 50%,
|
||||
var(--tg-color-secondary-bg) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--tg-border-radius-small);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tg-skeleton-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--tg-spacing-sm);
|
||||
}
|
||||
|
||||
.tg-skeleton-line {
|
||||
height: 16px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--tg-color-secondary-bg) 25%,
|
||||
var(--tg-color-hint) 50%,
|
||||
var(--tg-color-secondary-bg) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tg-skeleton-line--short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* List component */
|
||||
.tg-list {
|
||||
display: none;
|
||||
@@ -264,6 +357,7 @@ body {
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: relative;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hover effects for desktop */
|
||||
@@ -313,6 +407,34 @@ body {
|
||||
image-rendering: optimizeQuality;
|
||||
}
|
||||
|
||||
.tg-list-item__play-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tg-list-item__media:hover .tg-list-item__play-btn,
|
||||
.tg-list-item--playing .tg-list-item__play-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.tg-list-item__play-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tg-list-item__duration {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
@@ -371,6 +493,81 @@ body {
|
||||
animation: tg-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Conversion progress bar */
|
||||
.tg-conversion-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--tg-spacing-xs);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tg-conversion-progress__bar {
|
||||
height: 4px;
|
||||
background: var(--tg-color-secondary-bg);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tg-conversion-progress__fill {
|
||||
height: 100%;
|
||||
background: var(--tg-color-button);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 2px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.tg-conversion-progress__text {
|
||||
font-size: var(--tg-font-size-xs);
|
||||
color: var(--tg-color-hint);
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Audio player */
|
||||
.tg-audio-player {
|
||||
padding: var(--tg-spacing-sm) var(--tg-spacing-md);
|
||||
background: var(--tg-color-secondary-bg);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tg-audio-player__progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tg-spacing-xs);
|
||||
}
|
||||
|
||||
.tg-audio-player__progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tg-audio-player__progress-fill {
|
||||
height: 100%;
|
||||
background: var(--tg-color-button);
|
||||
width: 0%;
|
||||
transition: width 0.1s linear;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tg-audio-player__time {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--tg-font-size-xs);
|
||||
color: var(--tg-color-hint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tg-list-item--loading-preview {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Status message */
|
||||
.tg-status-message {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user