Shadow Reader MVP: import TXT, select text, TTS playback, settings
This commit is contained in:
parent
30fb33db5c
commit
33ea48f395
203
AGENTS.md
Normal file
203
AGENTS.md
Normal file
@ -0,0 +1,203 @@
|
||||
<!-- vibe42-agents-version: v5-memory-2026-07-07 -->
|
||||
# Vibe42 — учебная песочница для лендингов
|
||||
|
||||
Workspace юзера `kairakbek_a`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ТВОЯ РОЛЬ
|
||||
|
||||
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
|
||||
1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/kairakbek_a/<repo>/`,
|
||||
2. ощущением «это было легко» — без серверов, БД, токенов, конфигов.
|
||||
|
||||
Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код.
|
||||
|
||||
---
|
||||
|
||||
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
|
||||
|
||||
1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?»
|
||||
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
|
||||
- Промо хобби (фотография / музыка / спорт)
|
||||
- Резюме / personal page с контактами
|
||||
- Афиша мероприятия (концерт, день рождения, мастер-класс)
|
||||
- Меню заведения / прайс услуг
|
||||
- Лендинг продукта или будущего проекта (waitlist)
|
||||
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
|
||||
4. Сразу делай `./new-project <name>` и собирай страницу. Не спрашивай разрешения на каждый шаг.
|
||||
|
||||
---
|
||||
|
||||
## 👀 Предпросмотр — говори про него юзеру
|
||||
|
||||
- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах».
|
||||
- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона».
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Память о юзере — веди её сам
|
||||
|
||||
Файл памяти: `/srv/opencode/workspaces/users/kairakbek_a/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`.
|
||||
|
||||
**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/kairakbek_a/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию.
|
||||
|
||||
**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь:
|
||||
- как обращаться к юзеру (имя/ник);
|
||||
- язык общения (русский / казахский / английский);
|
||||
- уровень (новичок / уверенный);
|
||||
- интересы и тематику;
|
||||
- проекты: `repo → тема, статус`;
|
||||
- стиль-предпочтения (тёмный / яркий, минимализм, ...);
|
||||
- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»).
|
||||
|
||||
**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи.
|
||||
|
||||
**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц.
|
||||
|
||||
**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом.
|
||||
|
||||
---
|
||||
|
||||
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
|
||||
|
||||
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
|
||||
|
||||
Скажи: «Давай определимся, я задам 3 коротких вопроса:
|
||||
1. Это для тебя лично, для проекта/бизнеса, или для события?
|
||||
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
|
||||
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
|
||||
|
||||
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
|
||||
|
||||
---
|
||||
|
||||
## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ
|
||||
|
||||
| Запрос | Что делаем вместо |
|
||||
|--------|-------------------|
|
||||
| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram |
|
||||
| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) |
|
||||
| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium |
|
||||
| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp |
|
||||
| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) |
|
||||
| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` |
|
||||
|
||||
**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут.
|
||||
|
||||
---
|
||||
|
||||
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
|
||||
|
||||
### A — Промо продукта/услуги
|
||||
**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер).
|
||||
|
||||
### B — Personal / резюме
|
||||
**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки).
|
||||
|
||||
### C — Афиша мероприятия
|
||||
**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт).
|
||||
|
||||
### D — Меню / прайс
|
||||
**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка).
|
||||
|
||||
### E — Waitlist для будущего проекта
|
||||
**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма).
|
||||
|
||||
Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
|
||||
|
||||
Как только готов первый рабочий вариант (даже грубый):
|
||||
|
||||
1. **Сразу запушь:**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "v1"
|
||||
git push origin HEAD:pages
|
||||
```
|
||||
2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**:
|
||||
> 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/kairakbek_a/<repo>/**
|
||||
3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?»
|
||||
4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
|
||||
|
||||
1. **Только статика — HTML + CSS + JS в браузере.**
|
||||
2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
|
||||
3. **Никакой аутентификации / OAuth / JWT.**
|
||||
4. **Никакого Docker, nginx, sudo, системных настроек.**
|
||||
5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
|
||||
6. **НИКОГДА `git init` в workspace root (`/srv/opencode/workspaces/users/kairakbek_a`)** — это папка-контейнер юзера, не репозиторий.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ВСЕГДА работай через `./new-project`
|
||||
|
||||
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
|
||||
|
||||
```bash
|
||||
cd /srv/opencode/workspaces/users/kairakbek_a
|
||||
./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/
|
||||
cd NAME
|
||||
# теперь создавай index.html / style.css / script.js внутри ./NAME
|
||||
```
|
||||
|
||||
`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Git и публикация
|
||||
|
||||
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
|
||||
|
||||
- Профиль юзера: https://git.vibe42.kz/kairakbek_a
|
||||
- Pages (живые лендинги): https://pages.git.vibe42.kz/kairakbek_a/<repo>/
|
||||
- Креды уже в `/srv/opencode/workspaces/users/kairakbek_a/.git-credentials` — git push/clone работают без пароля
|
||||
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
|
||||
|
||||
### Опубликовать лендинг
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "site"
|
||||
git push origin HEAD:pages
|
||||
```
|
||||
|
||||
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Когда что-то идёт не так
|
||||
|
||||
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
|
||||
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
|
||||
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
|
||||
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
|
||||
|
||||
---
|
||||
|
||||
## ❌ Чего НЕ делать НИКОГДА
|
||||
|
||||
- ❌ `git init` в workspace root
|
||||
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
|
||||
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
|
||||
- ❌ Использовать `gh` CLI или GitHub API
|
||||
- ❌ Вызывать Gitea Pages-API (его нет)
|
||||
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
|
||||
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
|
||||
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
|
||||
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
|
||||
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
|
||||
- ❌ Говорить «это сложно» — переформулируй в простое
|
||||
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
|
||||
|
||||
---
|
||||
|
||||
## 🎨 design.md
|
||||
|
||||
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.
|
||||
142
design.md
Normal file
142
design.md
Normal file
@ -0,0 +1,142 @@
|
||||
<!-- vibe42-design-version: v2-recipes-2026-07-07 -->
|
||||
# Design system — Vibe42 песочница
|
||||
|
||||
Базовые цвета и типографика для лендингов. Начинай с этого, можно отклоняться.
|
||||
|
||||
## Палитра
|
||||
|
||||
| Token | Hex | Использование |
|
||||
|-------|-----|---------------|
|
||||
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
|
||||
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
|
||||
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
|
||||
| `--white` | `#FFFFFF` | Основной фон |
|
||||
| `--gray-500` | `#5B6573` | Вторичный текст |
|
||||
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
|
||||
|
||||
## Типографика
|
||||
|
||||
```css
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||
```
|
||||
|
||||
| Уровень | Размер | Вес | line-height |
|
||||
|---------|--------|-----|-------------|
|
||||
| h1 (hero) | 56px | 800 | 1.05 |
|
||||
| h2 (section) | 36px | 700 | 1.15 |
|
||||
| h3 | 22px | 700 | 1.3 |
|
||||
| body | 17px | 400 | 1.6 |
|
||||
|
||||
На мобиле — h1 → 36px, h2 → 28px.
|
||||
|
||||
## Лейаут
|
||||
|
||||
- max-width контента: **1140px**
|
||||
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
|
||||
- border-radius: `8px` (кнопки), `16px` (карточки)
|
||||
|
||||
## Стартер `index.html`
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Мой проект</title>
|
||||
<style>
|
||||
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
|
||||
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
|
||||
.hero{background:var(--ink);color:var(--white)}
|
||||
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
|
||||
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
|
||||
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
|
||||
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
|
||||
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
|
||||
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero"><div class="container">
|
||||
<h1>Заголовок</h1>
|
||||
<p>Подзаголовок — пара предложений о чём это.</p>
|
||||
<a class="btn" href="#section">Начать</a>
|
||||
</div></section>
|
||||
<section id="section" class="section"><div class="container">
|
||||
<h2>Секция</h2>
|
||||
<div class="card">Контент карточки.</div>
|
||||
<div class="card">Контент карточки.</div>
|
||||
</div></section>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Что НЕ юзать
|
||||
|
||||
- Bootstrap / Material UI / Chakra / Ant Design — слишком тяжело
|
||||
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
|
||||
- jQuery — vanilla JS достаточно
|
||||
|
||||
## Что МОЖНО (если очень надо)
|
||||
|
||||
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>`
|
||||
- **Placeholder картинки**: `https://picsum.photos/800/600`
|
||||
- **Google Fonts через `<link>`** в head
|
||||
|
||||
## 📦 Готовые рецепты (используй их, не изобретай)
|
||||
|
||||
### Фото без ключей
|
||||
- Детерминированные (одно и то же слово → одна и та же картинка): `https://picsum.photos/seed/coffee/800/600`. Меняй `coffee` на любое слово-seed.
|
||||
- Тематические (по тегу): `https://loremflickr.com/800/600/coffee` — тег прямо в URL (`/coffee`, `/mountains`, `/food`). Тема не гарантируется на 100%, но обычно в кассу.
|
||||
|
||||
### Кнопка WhatsApp
|
||||
```html
|
||||
<a href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!" style="display:inline-block;background:#25D366;color:#fff;padding:12px 22px;border-radius:8px;font-weight:700;text-decoration:none">Написать в WhatsApp</a>
|
||||
```
|
||||
`7XXXXXXXXXX` — номер в межд. формате без `+`. `text=` — предзаполненное сообщение.
|
||||
|
||||
### Форма заявки без бэкенда
|
||||
```html
|
||||
<form action="https://formsubmit.co/EMAIL" method="POST">
|
||||
<input name="name" placeholder="Имя" required>
|
||||
<input name="phone" placeholder="Телефон" required>
|
||||
<button type="submit">Отправить</button>
|
||||
</form>
|
||||
```
|
||||
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует, письмо-подтверждение придёт один раз).
|
||||
|
||||
### Google Fonts (максимум 2 шрифта: один display + один text)
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;700&family=Unbounded:wght@700&display=swap" rel="stylesheet">
|
||||
```
|
||||
В CSS: `font-family:'Unbounded',sans-serif` для заголовков, `'Manrope',sans-serif` для текста.
|
||||
|
||||
### Favicon-эмодзи (inline SVG data-URI, без файла)
|
||||
```html
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>">
|
||||
```
|
||||
Меняй эмодзи `☕` на подходящее теме.
|
||||
|
||||
### Карта (Google Maps embed, без ключа)
|
||||
На maps.google.com найди место → «Поделиться» → «Встроить карту» → скопируй iframe. Вставь как есть:
|
||||
```html
|
||||
<iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320" style="border:0;border-radius:12px" loading="lazy"></iframe>
|
||||
```
|
||||
|
||||
### Плавные появления при скролле (IntersectionObserver)
|
||||
```html
|
||||
<style>
|
||||
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.in{opacity:1;transform:none}
|
||||
</style>
|
||||
<script>
|
||||
var io=new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){ if(e.isIntersecting) e.target.classList.add('in'); });
|
||||
},{threshold:.15});
|
||||
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
|
||||
</script>
|
||||
```
|
||||
Добавь класс `reveal` любой секции/карточке — появится плавно при прокрутке.
|
||||
450
index.html
Normal file
450
index.html
Normal file
@ -0,0 +1,450 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Shadow Reader — Practice Speaking English</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗣️</text></svg>">
|
||||
<style>
|
||||
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--bg:var(--ink);--surface:#1A1F2E;--text:var(--white);--text-muted:#9aa3b2;--border:#2A3040;--accent:var(--cyan);--accent-hover:#00c8e0;--selection-bg:rgba(0,229,255,.18);--reader-bg:#141824}
|
||||
[data-theme=light]{--bg:#f5f6fa;--surface:#fff;--text:var(--ink);--text-muted:var(--gray-500);--border:#e1e4e8;--reader-bg:#fff}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--text);background:var(--bg);min-height:100vh;transition:background .3s,color .3s}
|
||||
|
||||
/* === WELCOME SCREEN === */
|
||||
.welcome{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:40px 24px;text-align:center}
|
||||
.welcome .logo{font-size:64px;margin-bottom:16px}
|
||||
.welcome h1{font-size:42px;font-weight:800;line-height:1.1;margin-bottom:12px;background:linear-gradient(135deg,var(--cyan),#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.welcome p{color:var(--text-muted);max-width:480px;margin-bottom:32px;font-size:18px}
|
||||
.dropzone{width:100%;max-width:520px;border:2px dashed var(--border);border-radius:16px;padding:48px 32px;cursor:pointer;transition:border-color .2s,background .2s;display:flex;flex-direction:column;align-items:center;gap:12px}
|
||||
.dropzone:hover,.dropzone.dragover{border-color:var(--accent);background:var(--selection-bg)}
|
||||
.dropzone .icon{font-size:48px}
|
||||
.dropzone span{color:var(--text-muted);font-size:15px}
|
||||
.dropzone strong{color:var(--text)}
|
||||
#fileInput{display:none}
|
||||
.sample-btn{margin-top:16px;background:none;border:1px solid var(--border);color:var(--text-muted);padding:10px 20px;border-radius:8px;cursor:pointer;font-size:14px;transition:all .2s}
|
||||
.sample-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
|
||||
/* === APP LAYOUT === */
|
||||
.app{display:none;min-height:100vh;flex-direction:column}
|
||||
.app.active{display:flex}
|
||||
|
||||
/* HEADER */
|
||||
.header{display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border);background:var(--surface);position:sticky;top:0;z-index:100;gap:12px}
|
||||
.header-left{display:flex;align-items:center;gap:12px}
|
||||
.header .logo-sm{font-size:24px}
|
||||
.header h1{font-size:18px;font-weight:700}
|
||||
.header-right{display:flex;align-items:center;gap:8px}
|
||||
.icon-btn{background:none;border:1px solid var(--border);color:var(--text-muted);width:38px;height:38px;border-radius:8px;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:all .2s;position:relative}
|
||||
.icon-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
|
||||
/* READER */
|
||||
.reader{flex:1;overflow-y:auto;padding:40px 24px;max-width:800px;margin:0 auto;width:100%;background:var(--reader-bg)}
|
||||
.reader-content{font-size:19px;line-height:1.85;color:var(--text)}
|
||||
.reader-content p{margin-bottom:1.4em;text-align:justify}
|
||||
.reader-content ::selection{background:var(--selection-bg);color:var(--cyan)}
|
||||
|
||||
/* FLOATING PLAY BUTTON */
|
||||
.floating-play{position:fixed;z-index:999;display:flex;align-items:center;gap:8px;background:var(--accent);color:var(--ink);padding:10px 20px;border-radius:30px;font-weight:700;font-size:15px;cursor:pointer;box-shadow:0 8px 32px rgba(0,229,255,.35);transform:translate(-50%,-120%);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;user-select:none}
|
||||
.floating-play.visible{opacity:1;pointer-events:auto;transform:translate(-50%,-120%)}
|
||||
.floating-play .play-icon{font-size:18px}
|
||||
.floating-play:hover{transform:translate(-50%,-120%) scale(1.05)}
|
||||
.floating-play:active{transform:translate(-50%,-120%) scale(.97)}
|
||||
|
||||
/* PLAYBACK BAR */
|
||||
.playback-bar{position:fixed;bottom:0;left:0;right:0;background:var(--surface);border-top:1px solid var(--border);padding:12px 20px;display:none;align-items:center;gap:16px;z-index:200;flex-wrap:wrap}
|
||||
.playback-bar.active{display:flex}
|
||||
.playback-bar .track-info{flex:1;min-width:200px;font-size:14px;color:var(--text-muted);overflow:hidden;text-ellipsis;white-space:nowrap}
|
||||
.playback-bar .controls{display:flex;align-items:center;gap:8px}
|
||||
.playback-bar .ctrl-btn{width:42px;height:42px;border-radius:50%;border:none;background:var(--accent);color:var(--ink);font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:transform .15s}
|
||||
.playback-bar .ctrl-btn:hover{transform:scale(1.1)}
|
||||
.playback-bar .ctrl-btn.secondary{background:var(--border);color:var(--text-muted);width:36px;height:36px;font-size:14px}
|
||||
.playback-bar .progress-track{flex:1;min-width:120px;height:4px;background:var(--border);border-radius:2px;cursor:pointer;position:relative}
|
||||
.playback-bar .progress-fill{height:100%;background:var(--accent);border-radius:2px;width:0%;transition:width .1s linear}
|
||||
|
||||
/* SETTINGS PANEL */
|
||||
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:300;display:none;align-items:flex-end;justify-content:center}
|
||||
.settings-overlay.open{display:flex}
|
||||
.settings-panel{background:var(--surface);border-radius:20px 20px 0 0;padding:28px 24px 40px;width:100%;max-width:500px;max-height:80vh;overflow-y:auto;transform:translateY(100%);transition:transform .3s}
|
||||
.settings-overlay.open .settings-panel{transform:translateY(0)}
|
||||
.settings-panel h2{font-size:22px;font-weight:700;margin-bottom:24px}
|
||||
.setting-group{margin-bottom:24px}
|
||||
.setting-group label{display:block;font-size:13px;color:var(--text-muted);margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.setting-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
select,input[type=range]{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:10px 14px;border-radius:10px;font-size:15px;cursor:pointer;outline:none}
|
||||
select:focus,input[type=range]:focus{border-color:var(--accent)}
|
||||
select{min-width:160px}
|
||||
input[type=range]{-webkit-appearance:none;appearance:none;height:6px;border-radius:3px;outline:none;padding:0}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;border-radius:50%;background:var(--accent);cursor:pointer}
|
||||
.speed-value{font-size:14px;color:var(--accent);font-weight:700;min-width:44px;text-align:center}
|
||||
.theme-toggle{display:flex;gap:8px}
|
||||
.theme-option{padding:8px 16px;border-radius:8px;border:1px solid var(--border);background:none;color:var(--text-muted);cursor:pointer;font-size:14px;transition:all .2s}
|
||||
.theme-option.active{border-color:var(--accent);color:var(--accent);background:var(--selection-bg)}
|
||||
.close-settings{position:absolute;top:16px;right:16px;background:none;border:none;color:var(--text-muted);font-size:24px;cursor:pointer}
|
||||
|
||||
/* PROGRESS INDICATOR */
|
||||
.read-progress{position:fixed;top:60px;left:0;right:0;height:3px;background:var(--border);z-index:99}
|
||||
.read-progress-bar{height:100%;background:var(--accent);width:0%;transition:width .2s}
|
||||
|
||||
/* TOAST */
|
||||
.toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%) translateY(20px);background:var(--surface);color:var(--text);padding:12px 24px;border-radius:12px;border:1px solid var(--border);font-size:14px;opacity:0;pointer-events:none;transition:all .3s;z-index:500}
|
||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media(max-width:640px){
|
||||
.welcome h1{font-size:32px}
|
||||
.welcome p{font-size:16px}
|
||||
.dropzone{padding:32px 20px}
|
||||
.reader{padding:24px 16px}
|
||||
.reader-content{font-size:17px;line-height:1.75}
|
||||
.header{padding:10px 14px}
|
||||
.header h1{font-size:15px}
|
||||
.playback-bar{padding:10px 14px;gap:10px}
|
||||
.floating-play{padding:8px 16px;font-size:14px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- WELCOME / IMPORT -->
|
||||
<div class="welcome" id="welcome">
|
||||
<div class="logo">🗣️</div>
|
||||
<h1>Shadow Reader</h1>
|
||||
<p>Import any text, select a passage, and practice speaking by shadowing natural speech synthesis.</p>
|
||||
<div class="dropzone" id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<span><strong>Click to upload</strong> or drag & drop a TXT file</span>
|
||||
<input type="file" id="fileInput" accept=".txt">
|
||||
</div>
|
||||
<button class="sample-btn" id="sampleBtn">Try with sample text</button>
|
||||
</div>
|
||||
|
||||
<!-- APP -->
|
||||
<div class="app" id="app">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="logo-sm">🗣️</span>
|
||||
<h1>Shadow Reader</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="icon-btn" id="themeToggle" title="Theme">🌓</button>
|
||||
<button class="icon-btn" id="settingsBtn" title="Settings">⚙️</button>
|
||||
<button class="icon-btn" id="importBtn" title="Import new">📂</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="read-progress"><div class="read-progress-bar" id="progressBar"></div></div>
|
||||
<main class="reader" id="reader">
|
||||
<div class="reader-content" id="readerContent"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- FLOATING PLAY -->
|
||||
<div class="floating-play" id="floatingPlay"><span class="play-icon">▶️</span> Play selected</div>
|
||||
|
||||
<!-- PLAYBACK BAR -->
|
||||
<div class="playback-bar" id="playbackBar">
|
||||
<div class="track-info" id="trackInfo">Select text to play</div>
|
||||
<div class="controls">
|
||||
<button class="ctrl-btn secondary" id="repeatBtn" title="Repeat loop">🔁</button>
|
||||
<button class="ctrl-btn" id="playPauseBtn" title="Play/Pause">▶️</button>
|
||||
<button class="ctrl-btn secondary" id="stopBtn" title="Stop">⏹️</button>
|
||||
</div>
|
||||
<div class="progress-track" id="progressTrack"><div class="progress-fill" id="progressFill"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS PANEL -->
|
||||
<div class="settings-overlay" id="settingsOverlay">
|
||||
<div class="settings-panel" style="position:relative">
|
||||
<button class="close-settings" id="closeSettings">✕</button>
|
||||
<h2>⚙️ Settings</h2>
|
||||
<div class="setting-group">
|
||||
<label>Voice</label>
|
||||
<div class="setting-row">
|
||||
<select id="voiceSelect"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>Speed</label>
|
||||
<div class="setting-row">
|
||||
<input type="range" id="speedRange" min="50" max="200" value="100" step="25">
|
||||
<span class="speed-value" id="speedValue">1.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<label>Theme</label>
|
||||
<div class="setting-row theme-toggle">
|
||||
<button class="theme-option active" data-theme="dark">🌙 Dark</button>
|
||||
<button class="theme-option" data-theme="light">☀️ Light</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
/* === STATE === */
|
||||
var state={
|
||||
text:'',selectedText:'',isPlaying:false,isPaused:false,
|
||||
voices:[],voice:null,speed:1,repeat:false,
|
||||
utterance:null,charIndex:0,theme:'dark',
|
||||
scrollPos:0
|
||||
};
|
||||
|
||||
/* === DOM === */
|
||||
var $=function(s){return document.querySelector(s)};
|
||||
var welcome=$('#welcome'),app=$('#app');
|
||||
var dropzone=$('#dropzone'),fileInput=$('#fileInput'),sampleBtn=$('#sampleBtn');
|
||||
var reader=$('#reader'),readerContent=$('#readerContent');
|
||||
var floatingPlay=$('#floatingPlay');
|
||||
var playbackBar=$('#playbackBar'),trackInfo=$('#trackInfo');
|
||||
var playPauseBtn=$('#playPauseBtn'),stopBtn=$('#stopBtn'),repeatBtn=$('#repeatBtn');
|
||||
var progressTrack=$('#progressTrack'),progressFill=$('#progressFill');
|
||||
var settingsBtn=$('#settingsBtn'),settingsOverlay=$('#settingsOverlay'),closeSettings=$('#closeSettings');
|
||||
var voiceSelect=$('#voiceSelect'),speedRange=$('#speedRange'),speedValue=$('#speedValue');
|
||||
var themeToggle=$('#themeToggle'),importBtn=$('#importBtn');
|
||||
var progressBar=$('#progressBar'),toast=$('#toast');
|
||||
|
||||
/* === SAMPLE TEXT === */
|
||||
var sampleText=[
|
||||
"The Art of Shadowing",
|
||||
"",
|
||||
"Shadowing is a language learning technique where you listen to a piece of audio and repeat it almost simultaneously, like an echo. The goal is not just to understand the words, but to mimic the rhythm, intonation, and natural flow of native speech.",
|
||||
"Unlike traditional study methods that focus on grammar rules and vocabulary lists, shadowing trains your mouth muscles to move the way native speakers do. It bridges the gap between understanding English and actually speaking it fluently.",
|
||||
"Here's how it works: choose a passage from a book, article, or any text you enjoy. Select the text and press Play. Listen carefully to how each sentence is pronounced — the pauses, the emphasis, the rising and falling pitch. Then repeat it aloud, trying to match the speaker as closely as possible.",
|
||||
"Don't worry about perfection at first. Your tongue will feel clumsy, and you'll stumble over words. That's completely normal. The key is consistency. Even ten minutes of focused shadowing every day will produce noticeable results within a few weeks.",
|
||||
"Start with slower speeds if needed. Many learners find that 0.75x or 0.8x speed gives them enough time to process and repeat. As you improve, gradually increase to normal speed, then even faster for an extra challenge.",
|
||||
"Choose content you genuinely find interesting. If you love science fiction, practice with sci-fi passages. If you're into business, use articles from your field. When the material engages you, practice stops feeling like work.",
|
||||
"Remember: every fluent speaker was once a beginner who kept showing up. Press play, open your mouth, and start shadowing."
|
||||
].join('\n\n');
|
||||
|
||||
/* === INIT VOICES === */
|
||||
function loadVoices(){
|
||||
var vs=speechSynthesis.getVoices();
|
||||
if(vs.length>0){
|
||||
state.voices=vs.filter(function(v){return v.lang.startsWith('en')});
|
||||
populateVoices();
|
||||
}
|
||||
}
|
||||
function populateVoices(){
|
||||
voiceSelect.innerHTML='';
|
||||
var preferred=null;
|
||||
state.voices.forEach(function(v,i){
|
||||
var o=document.createElement('option');
|
||||
o.value=i;o.textContent=v.name+' ('+v.lang+')';
|
||||
if(v.name.match(/Google|Samantha|Alex|Daniel|Karen|Microsoft/))preferred=i;
|
||||
voiceSelect.appendChild(o);
|
||||
});
|
||||
if(preferred!==null&&state.voices[preferred]){state.voice=state.voices[preferred];voiceSelect.value=preferred;}
|
||||
else if(state.voices.length>0&&!state.voice){state.voice=state.voices[0];}
|
||||
}
|
||||
speechSynthesis.onvoiceschanged=loadVoices;
|
||||
loadVoices();
|
||||
|
||||
/* === FILE IMPORT === */
|
||||
dropzone.addEventListener('click',function(){fileInput.click();});
|
||||
dropzone.addEventListener('dragover',function(e){e.preventDefault();dropzone.classList.add('dragover');});
|
||||
dropzone.addEventListener('dragleave',function(){dropzone.classList.remove('dragover');});
|
||||
dropzone.addEventListener('drop',function(e){
|
||||
e.preventDefault();dropzone.classList.remove('dragover');
|
||||
var f=e.dataTransfer.files[0];if(f)handleFile(f);
|
||||
});
|
||||
fileInput.addEventListener('change',function(e){if(e.target.files[0])handleFile(e.target.files[0]);});
|
||||
importBtn.addEventListener('click',function(){fileInput.click();});
|
||||
|
||||
function handleFile(f){
|
||||
var r=new FileReader();
|
||||
r.onload=function(){loadText(r.result,f.name.replace(/\.[^.]+$/,''));};
|
||||
r.readAsText(f);
|
||||
}
|
||||
|
||||
sampleBtn.addEventListener('click',function(){loadText(sampleText,'Sample Text');});
|
||||
|
||||
/* === LOAD TEXT INTO READER === */
|
||||
function loadText(text,title){
|
||||
state.text=text;
|
||||
state.selectedText='';
|
||||
var paras=text.split(/\n\s*\n/).filter(function(p){return p.trim().length>0;});
|
||||
var html='';
|
||||
paras.forEach(function(p){
|
||||
var clean=p.trim().replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
html+='<p>'+clean+'</p>';
|
||||
});
|
||||
readerContent.innerHTML=html;
|
||||
welcome.style.display='none';
|
||||
app.classList.add('active');
|
||||
document.title=title+' — Shadow Reader';
|
||||
showToast('Loaded: '+title);
|
||||
|
||||
var saved=localStorage.getItem('sr-scroll');
|
||||
if(saved){setTimeout(function(){reader.scrollTop=parseInt(saved,10);},100);}
|
||||
reader.addEventListener('scroll',saveScroll);
|
||||
}
|
||||
|
||||
function saveScroll(){localStorage.setItem('sr-scroll',reader.scrollTop);}
|
||||
|
||||
/* === TEXT SELECTION + FLOATING PLAY === */
|
||||
document.addEventListener('mouseup',function(e){
|
||||
setTimeout(function(){
|
||||
var sel=window.getSelection();
|
||||
var txt=sel.toString().trim();
|
||||
if(txt.length<2){hideFloatingPlay();return;}
|
||||
state.selectedText=txt;
|
||||
var rng=sel.getRangeAt(0);
|
||||
var rect=rng.getBoundingClientRect();
|
||||
floatingPlay.style.left=(rect.left+rect.width/2)+'px';
|
||||
floatingPlay.style.top=rect.top+'px';
|
||||
floatingPlay.classList.add('visible');
|
||||
},10);
|
||||
});
|
||||
|
||||
floatingPlay.addEventListener('click',function(){hideFloatingPlay();startPlayback(state.selectedText);});
|
||||
document.addEventListener('mousedown',function(e){
|
||||
if(!floatingPlay.contains(e.target))hideFloatingPlay();
|
||||
});
|
||||
|
||||
function hideFloatingPlay(){floatingPlay.classList.remove('visible');}
|
||||
|
||||
/* === TTS PLAYBACK === */
|
||||
function startPlayback(text){
|
||||
if(!text||!text.trim())return;
|
||||
speechSynthesis.cancel();
|
||||
state.isPlaying=true;state.isPaused=false;state.charIndex=0;
|
||||
|
||||
var u=new SpeechSynthesisUtterance(text);
|
||||
if(state.voice)u.voice=state.voice;
|
||||
u.rate=state.speed;
|
||||
u.pitch=1;
|
||||
u.lang=state.voice?state.voice.lang:'en-US';
|
||||
|
||||
u.onstart=function(){
|
||||
updatePlaybackUI(true);
|
||||
trackInfo.textContent=text.length>60?text.substring(0,57)+'...':text;
|
||||
};
|
||||
u.onend=function(){
|
||||
if(state.repeat&&!state.isPaused){
|
||||
setTimeout(function(){startPlayback(text);},800);
|
||||
return;
|
||||
}
|
||||
state.isPlaying=false;
|
||||
updatePlaybackUI(false);
|
||||
};
|
||||
u.onerror=function(){state.isPlaying=false;updatePlaybackUI(false);showToast('Playback error');};
|
||||
u.onboundary=function(e){
|
||||
if(e.name==='word'){state.charIndex=e.charIndex;updateProgress();}
|
||||
};
|
||||
|
||||
state.utterance=u;
|
||||
speechSynthesis.speak(u);
|
||||
}
|
||||
|
||||
function togglePause(){
|
||||
if(!state.utterance)return;
|
||||
if(state.isPaused){
|
||||
speechSynthesis.resume();
|
||||
state.isPaused=false;
|
||||
playPauseBtn.textContent='⏸️';
|
||||
}else{
|
||||
speechSynthesis.pause();
|
||||
state.isPaused=true;
|
||||
playPauseBtn.textContent='▶️';
|
||||
}
|
||||
}
|
||||
|
||||
function stopPlayback(){
|
||||
speechSynthesis.cancel();
|
||||
state.isPlaying=false;state.isPaused=false;
|
||||
updatePlaybackUI(false);
|
||||
}
|
||||
|
||||
function updatePlaybackUI(playing){
|
||||
playbackBar.classList.toggle('active',playing);
|
||||
playPauseBtn.textContent=playing?(state.isPaused?'▶️':'⏸️'):'▶️';
|
||||
progressFill.style.width=playing?'0%':'0%';
|
||||
if(!playing)trackInfo.textContent='Select text to play';
|
||||
}
|
||||
|
||||
function updateProgress(){
|
||||
if(!state.utterance)return;
|
||||
var total=state.utterance.text.length;
|
||||
var pct=Math.min(100,(state.charIndex/total)*100);
|
||||
progressFill.style.width=pct+'%';
|
||||
}
|
||||
|
||||
playPauseBtn.addEventListener('click',togglePause);
|
||||
stopBtn.addEventListener('click',stopPlayback);
|
||||
repeatBtn.addEventListener('click',function(){
|
||||
state.repeat=!state.repeat;
|
||||
repeatBtn.style.background=state.repeat?'var(--accent)':'var(--border)';
|
||||
repeatBtn.style.color=state.repeat?'var(--ink)':'var(--text-muted)';
|
||||
showToast(state.repeat?'Repeat ON':'Repeat OFF');
|
||||
});
|
||||
|
||||
/* === SETTINGS === */
|
||||
settingsBtn.addEventListener('click',function(){settingsOverlay.classList.add('open');});
|
||||
closeSettings.addEventListener('click',function(){settingsOverlay.classList.remove('open');});
|
||||
settingsOverlay.addEventListener('click',function(e){if(e.target===settingsOverlay)settingsOverlay.classList.remove('open');});
|
||||
|
||||
voiceSelect.addEventListener('change',function(){
|
||||
state.voice=state.voices[parseInt(this.value,10)]||null;
|
||||
localStorage.setItem('sr-voice',this.value);
|
||||
});
|
||||
|
||||
speedRange.addEventListener('input',function(){
|
||||
state.speed=this.value/100;
|
||||
speedValue.textContent=state.speed.toFixed(2)+'x';
|
||||
});
|
||||
speedRange.addEventListener('change',function(){
|
||||
localStorage.setItem('sr-speed',this.value);
|
||||
});
|
||||
|
||||
/* THEME */
|
||||
function setTheme(t){
|
||||
state.theme=t;
|
||||
document.documentElement.setAttribute('data-theme',t);
|
||||
localStorage.setItem('sr-theme',t);
|
||||
document.querySelectorAll('.theme-option').forEach(function(b){
|
||||
b.classList.toggle('active',b.dataset.theme===t);
|
||||
});
|
||||
themeToggle.textContent=t==='dark'?'☀️':'🌓';
|
||||
}
|
||||
themeToggle.addEventListener('click',function(){setTheme(state.theme==='dark'?'light':'dark');});
|
||||
document.querySelectorAll('.theme-option').forEach(function(b){
|
||||
b.addEventListener('click',function(){setTheme(b.dataset.theme);});
|
||||
});
|
||||
|
||||
/* === RESTORE SETTINGS === */
|
||||
(function restore(){
|
||||
var t=localStorage.getItem('sr-theme')||'dark';setTheme(t);
|
||||
var s=localStorage.getItem('sr-speed')||'100';speedRange.value=s;state.speed=s/100;speedValue.textContent=state.speed.toFixed(2)+'x');
|
||||
var v=localStorage.getItem('sr-voice');
|
||||
})();
|
||||
|
||||
/* === READ PROGRESS === */
|
||||
reader.addEventListener('scroll',function(){
|
||||
var pct=Math.round((reader.scrollTop/(reader.scrollHeight-reader.clientHeight))*100)||0;
|
||||
progressBar.style.width=pct+'%';
|
||||
});
|
||||
|
||||
/* === TOAST === */
|
||||
function showToast(msg){
|
||||
toast.textContent=msg;toast.classList.add('show');
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t=setTimeout(function(){toast.classList.remove('show');},2200);
|
||||
}
|
||||
|
||||
/* KEYBOARD SHORTCUTS */
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT')return;
|
||||
if(e.code==='Space'&&state.isPlaying){e.preventDefault();togglePause();}
|
||||
if(e.code==='Escape'){stopPlayback();settingsOverlay.classList.remove('open');}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user