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

@@ -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>

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();

View File

@@ -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;