diff --git a/.github/ASSETS/donaton_timer_csseditor.png b/.github/ASSETS/donaton_timer_csseditor.png new file mode 100644 index 0000000..2b02f99 Binary files /dev/null and b/.github/ASSETS/donaton_timer_csseditor.png differ diff --git a/.github/ASSETS/donaton_timer_mainpage.png b/.github/ASSETS/donaton_timer_mainpage.png new file mode 100644 index 0000000..4ae5247 Binary files /dev/null and b/.github/ASSETS/donaton_timer_mainpage.png differ diff --git a/.github/ASSETS/donaton_timer_settings.png b/.github/ASSETS/donaton_timer_settings.png new file mode 100644 index 0000000..8a0e675 Binary files /dev/null and b/.github/ASSETS/donaton_timer_settings.png differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa1db30 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# ── AI / Personal helper files ────────────────────────────────────────────── +PROJECT_CONTEXT.md +.gitignore +# ── Flutter / Dart ─────────────────────────────────────────────────────────── +# Build outputs +build/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ + +# Generated files +*.g.dart +*.freezed.dart +*.mocks.dart + +# Coverage +coverage/ + +# ── Windows ────────────────────────────────────────────────────────────────── +windows/flutter/generated_plugin_registrant.cc +windows/flutter/generated_plugin_registrant.h +windows/flutter/generated_plugins.cmake +windows/flutter/ephemeral/ + +# ── IDE ────────────────────────────────────────────────────────────────────── +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.suo +*.user +*.userosscache +*.sln.docstates + +# ── OS ─────────────────────────────────────────────────────────────────────── +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.DS_Store +.AppleDouble +.LSOverride + +# ── Logs ───────────────────────────────────────────────────────────────────── +*.log +logs/ +logs.txt + +# ── Installer and large binaries ────────────────────────────────────────────── +installer/ +*.exe diff --git a/README.md b/README.md index 0191157..58ee178 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,83 @@ -# 🎮 Таймер для донатона | Donathon Countdown Timer +# Таймер для донатона | Donathon Countdown Timer ![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/MjKey/DonatonTimer/total) ![GitHub Release](https://img.shields.io/github/v/release/MjKey/DonatonTimer) [![Stars](https://img.shields.io/github/stars/MjKey/DonatonTimer?style=flat&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTggLjI1YS43NS43NSAwIDAgMSAuNjczLjQxOGwxLjg4MiAzLjgxNSA0LjIxLjYxMmEuNzUuNzUgMCAwIDEgLjQxNiAxLjI3OWwtMy4wNDYgMi45Ny43MTkgNC4xOTJhLjc1MS43NTEgMCAwIDEtMS4wODguNzkxTDggMTIuMzQ3bC0zLjc2NiAxLjk4YS43NS43NSAwIDAgMS0xLjA4OC0uNzlsLjcyLTQuMTk0TC44MTggNi4zNzRhLjc1Ljc1IDAgMCAxIC40MTYtMS4yOGw0LjIxLS42MTFMNy4zMjcuNjY4QS43NS43NSAwIDAgMSA4IC4yNVoiIGZpbGw9IiNlYWM1NGYiLz48L3N2Zz4=&logoSize=auto&label=Stars&labelColor=666666&color=eac54f)](https://github.com/MjKey/DonatonTimer/) **DonatonTimer** — приложение для управления таймером донатона, которое интегрируется с несколькими донат-сервисами, позволяя отслеживать и управлять временем в зависимости от поступивших донатов. Также присутствует **оверлей таймера** для OBS с кастомизируемыми стилями! -> Это моя первая разработка приложения на Flutter, до этого писал только на Python, думаю, получилось неплохо, пользуйтесь! 😺 +> Это моя первая разработка приложения на Flutter, до этого писал только на Python, думаю, получилось неплохо, пользуйтесь! > > Будет полезно тем, кто хочет себе удобный и функциональный таймер для донатона! -**Автор:** [MjKey](https://mjkey.ru) +**Автор:** [MjKey](https://mjkey.ru) +**Поддержать проект:** [CloudTips](https://pay.cloudtips.ru/p/cf634f74) • [Dalink](https://dalink.to/mjk3y) -## 📋 Инструкция в Wiki +--- + +## Инструкция в Wiki + +✦ [RU Wiki](https://github.com/MjKey/DonatonTimer/wiki/Настройка-и-использование-%5BRU%5D) +✦ [EN Wiki](https://github.com/MjKey/DonatonTimer/wiki/Setting-and-using-%5BEN%5D) + +--- -✬ [RU](https://github.com/MjKey/DonatonTimer/wiki/Настройка-и-использование-%5BRU%5D) | [EN](https://github.com/MjKey/DonatonTimer/wiki/Setting-and-using-%5BEN%5D) (⸝⸝ᵕᴗᵕ⸝⸝) +## Скриншоты + + + + + + + +
+ Главный экран
+ Управление и статистика

+ Главный экран +
+ Настройки
+ Подключение донат-сервисов

+ Настройки +
+ CSS Генератор
+ Кастомизация оверлея

+ CSS Генератор +
-## 🍌 Поддержка сервисов +--- + +## Поддержка сервисов | Сервис | Статус | Комментарий | |:--------------:|:------:|:-------------:| -| DonationAlerts | ✅ | Работает | -| Donate.Stream | ✅ | Работает | -| DonatePay | ✅ | Работает | -| DonateX | ✅ | Работает | -| Donatty | ✅ | Работает (SSE) | -| Streamer.bot | ✅ | Работает (WebSocket) | -| iHAQ Donate | ❌ | В планах | -| StreamElements | ❌ | В планах | - -## ✨ Что нового в v3.0.5 - -- 🤖 **Streamer.bot** — интеграция через WebSocket с гибкой привязкой событий к суммам донатов -- 🎁 **Donatty** — поддержка нового сервиса через SSE (Server-Sent Events) -- 🔗 **DonateX** — поддержка нового сервиса донатов -- 🔌 **Новые порты** — 7575 (HTTP) и 3434 (WS) по умолчанию, чтобы избежать конфликтов со Streamer.bot -- ⚠️ **Предупреждение о старых портах** — автоматическая миграция с 8080/4040 -- ⏱️ **Фиксированное время** — режим фиксированного времени за каждый донат -- ➖ **Режим убавления** — таймер можно настроить на убавление времени от донатов - -## ✨ Что было в v3.0.0 - -- 🔗 **Мульти-сервис** — DonationAlerts, DonatePay, Donate.Stream одновременно -- 🎨 **CSS генератор** — кастомизируемый оверлей для OBS с Google Fonts -- 🎭 **Раздельные цвета** — часы, минуты, секунды могут быть разных цветов -- ✨ **Анимации** — pulse, glow, bounce, blink для текста и разделителей -- 📱 **Мобильное управление** — контроль таймера через QR код -- 🔊 **Звуковые уведомления** — оповещения о донатах -- 💾 **Автосохранение** — таймер сохраняется при закрытии -- 🎮 **Ретро UI** — стильный 8-bit интерфейс (nes_ui) -- 🔌 **Выбор сокета** — для DonationAlerts можно выбрать socket/socket1-5 -- 🔗 **Парсинг URL** — можно вставить ссылку виджета вместо токена - -## 🎯 Ключевые возможности +| DonationAlerts | Да | Работает | +| Donate.Stream | Да | Работает | +| DonatePay | Да | Работает | +| DonateX | Да | Работает | +| Donatty | Да | Работает (SSE) | +| CloudTips | Да | Работает (WebSocket) | +| Streamer.bot | Да | BETA VER | +| iHAQ Donate | Нет | В планах | +| StreamElements | Нет | В планах | + +## Что нового в v3.0.X + +- **Мульти-сервис** — DonationAlerts, DonatePay, Donate.Stream, DonateX, Donatty одновременно +- **CSS генератор** — кастомизируемый оверлей для OBS с Google Fonts +- **Раздельные цвета** — часы, минуты, секунды могут быть разных цветов +- **Анимации** — pulse, glow, bounce, blink для текста и разделителей +- **Мобильное управление** — контроль таймера через QR код +- **Звуковые уведомления** — оповещения о донатах +- **Автосохранение** — таймер сохраняется при закрытии +- **Ретро UI** — стильный 8-bit интерфейс (nes_ui) +- **Выбор сокета** — для DonationAlerts можно выбрать socket/socket1-5 +- **Парсинг URL** — можно вставить ссылку виджета вместо токена +- **Streamer.bot** — интеграция через WebSocket с гибкой привязкой событий к суммам донатов +- **Конвертация** — конвертация валют в RUB +- **Фиксированное время** — режим фиксированного времени за каждый донат +- **Режим убавления времени** — таймер можно настроить на убавление времени от донатов + +--- + +## Ключевые возможности ### Интерфейс программы под Windows - Ретро 8-bit стиль @@ -74,7 +101,7 @@ - Автоматическое прибавление времени от доната - Настройка — сколько рублей = 1 час - Поддержка нескольких сервисов одновременно -- Интеграция со Streamer.bot (события → суммы донатов) +- Интеграция со Streamer.bot (события -> суммы донатов) ### CSS генератор стилей - 14 готовых пресетов (Cyberpunk, Matrix, Kawaii и др.) @@ -85,7 +112,9 @@ ### Мини-версия для Dok-Панели OBS - Упрощённый интерфейс для использования в dok-панели OBS -## 🛠️ Установка и запуск +--- + +## Установка и запуск ### Установка релизов @@ -95,15 +124,18 @@ 2. **Запустите установочный файл:** - Дважды щелкните по скачанному файлу и следуйте инструкциям на экране -### Установка артифактов +### Установка артефактов (сырых версих / альфа версих если они есть) -1. **Скачайте последний артифакт:** +1. **Скачайте последний артефакт:** - Перейдите в раздел [Actions](https://github.com/MjKey/DonatonTimer/actions), выберите последний удавшийся билд (с галочкой) - - Снизу будет Artifacts → Latest — скачиваем, разархивируем в любую папку + - Снизу будет Artifacts -> Latest — скачиваем, разархивируем в любую папку + - **ТОЛЬКО ДЛЯ ТЕСТИРОВАНИЯ, ЭТО НЕ РЕЛИЗЫ!** 2. **Запустите таймер** -## 🚀 Использование +--- + +## Использование ### URL адреса (по умолчанию) @@ -113,30 +145,34 @@ | `http://localhost:7575/dashboard` | Веб-панель управления | | `http://localhost:7575/mini` | Мини-версия для dok-панели OBS | -> ⚠️ **Старые порты:** В версиях до v3.0.5 использовались порты 8080 (HTTP) и 4040 (WS). При первом запуске приложение предложит автоматически переключиться на новые порты 7575/3434. +> **Старые порты:** В версиях до v3.0.6 использовались порты 8080 (HTTP) и 4040 (WS). При первом запуске приложение предложит автоматически переключиться на новые порты 7575/3434. ### Настройка dok-панели OBS -В OBS Studio → Dok-панели (D) → Пользовательские dok-панели браузера (C) +В OBS Studio -> Dok-панели -> Пользовательские dok-панели браузера ### Настройка Streamer.bot Подробная инструкция в [Wiki](https://github.com/MjKey/DonatonTimer/wiki). Кратко: -1. В Streamer.bot включите **WebSocket Server** (Servers/Clients → WebSocket Server → Auto Start) -2. В DonatonTimer → Настройки → Streamer.bot укажите адрес WebSocket (по умолчанию `ws://127.0.0.1:8080/`) +1. В Streamer.bot включите **WebSocket Server** (Servers/Clients -> WebSocket Server -> Auto Start) +2. В DonatonTimer -> Настройки -> Streamer.bot укажите адрес WebSocket (по умолчанию `ws://127.0.0.1:8080/`) 3. Включите сервис и нажмите **Сохранить** 4. Добавьте привязки событий: укажите Source (например, `Twitch`), Type (например, `Sub`) и эквивалентную сумму доната -## 📁 Хранение данных +--- + +## Хранение данных Настройки хранятся в: ``` %APPDATA%\MerryJoyKeyStudio\DonatonTimer\data.json ``` -## 🔧 Порты по умолчанию +--- + +## Порты по умолчанию | Порт | Назначение | |------|------------| @@ -145,17 +181,19 @@ > Старые порты 8080/4040 были изменены для предотвращения конфликтов со Streamer.bot. -## 💬 Вопросы и поддержка +--- + +## Вопросы и поддержка Если у вас есть вопросы или вы столкнулись с проблемами, не стесняйтесь открыть issue на [GitHub](https://github.com/MjKey/DonatonTimer/issues). -## 📝 Лицензия +## Лицензия Этот проект лицензируется под лицензией MIT — см. [LICENSE](LICENSE) для подробностей. --- -## 🔨 Сборка из исходного кода +## Сборка из исходного кода ```bash # Клонировать репозиторий @@ -184,9 +222,9 @@ flutter build windows --- -**Обратный отсчёт для донатона** 🎮 +**Обратный отсчёт для донатона** --- -Made with ❤️ by [MjKey](https://mjkey.ru) -Буду рад любой финансовой поддержки! +Made by [MjKey](https://mjkey.ru) with ❤️ +Буду рад любой финансовой поддержке! diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..d2cb281 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,217 @@ +# DonatonTimer | Countdown Timer + +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/MjKey/DonatonTimer/total) ![GitHub Release](https://img.shields.io/github/v/release/MjKey/DonatonTimer) [![Stars](https://img.shields.io/github/stars/MjKey/DonatonTimer?style=flat&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEiIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTTggLjI1YS43NS43NSAwIDAgMSAuNjczLjQxOGwxLjg4MiAzLjgxNSA0LjIxLjYxMmEuNzUuNzUgMCAwIDEgLjQxNiAxLjI3OWwtMy4wNDYgMi45Ny43MTkgNC4xOTJhLjc1MS43NTEgMCAwIDEtMS4wODguNzkxTDggMTIuMzQ3bC0zLjc2NiAxLjk4YS43NS43NSAwIDAgMS-1LjA4OC0uNzlsLjcyLTQuMTk0TC44MTggNi4zNzRhLjc1Ljc1IDAgMCAxIC40MTYtMS4yOGw0LjIxLS42MTFMNy4zMjcuNjY4QS43NS43NSAwIDAgMSA4IC4yNVoiIGZpbGw9IiNlYWM1NGYiLz48L3N2Zz4=&logoSize=auto&label=Stars&labelColor=666666&color=eac54f)](https://github.com/MjKey/DonatonTimer/) + +**DonatonTimer** is an application for managing donathon countdowns. It integrates with multiple donation platforms to track and add time dynamically based on incoming donations. It also features a fully customizable **timer overlay** for OBS with rich style generator! + +> This is my first application developed on Flutter (previously I only coded in Python). I hope you find this tool helpful and convenient for your streams! + +**Author:** [MjKey](https://mjkey.ru) +**Support Project:** [CloudTips](https://pay.cloudtips.ru/p/cf634f74) • [Dalink](https://dalink.to/mjk3y) + +--- + +## Wiki Documentation + +✦ [RU Wiki](https://github.com/MjKey/DonatonTimer/wiki/Настройка-и-использование-%5BRU%5D) +✦ [EN Wiki](https://github.com/MjKey/DonatonTimer/wiki/Setting-and-using-%5BEN%5D) + +--- + +## Screenshots + + + + + + + +
+ Main Screen
+ Timer control and stats

+ Main Screen +
+ Settings
+ Donation services setup

+ Settings +
+ CSS Generator
+ OBS Overlay Customization

+ CSS Generator +
+ +--- + +## Supported Services + +| Service | Status | Comment | +| :---: | :---: | :--- | +| **DonationAlerts** | Yes | Working | +| **Donate.Stream** | Yes | Working | +| **DonatePay** | Yes | Working | +| **DonateX** | Yes | Working | +| **Donatty** | Yes | Working (SSE) | +| **CloudTips** | Yes | Working (WebSocket) | +| **Streamer.bot** | Yes | BETA VER | +| **iHAQ Donate** | No | Planned | +| **StreamElements** | No | Planned | + +--- + +## What's New in v3.0.X + +- **Multi-service** — run DonationAlerts, DonatePay, Donate.Stream, DonateX, and Donatty simultaneously +- **CSS Generator** — customizable OBS overlay with Google Fonts integration +- **Separate Colors** customise different colors for hours, minutes, and seconds +- **Animations** — pulse, glow, bounce, and blink effects for text and separators +- **Mobile Control** — manage your timer from a phone using a simple QR code +- **Sound Alerts** — sound notifications for incoming donations +- **Auto-save** — timer state is automatically saved upon closing +- **Retro UI** — stylish 8-bit aesthetic (nes_ui) +- **Socket Selection** — manual socket configuration for DonationAlerts (socket/socket1-5) +- **URL Parsing** — extract widget tokens automatically by pasting widget URLs +- **Streamer.bot** — WebSocket integration with flexible mapping of events to donation equivalents +- **Currency Conversion** — automatic conversion of USD, EUR, KZT, etc. into RUB using current Central Bank exchange rates +- **Fixed Time** — option to add fixed time per donation event +- **Time Subtraction Mode** — configure the timer to subtract time instead of adding it + +--- + +## Key Features + +### Desktop App Interface (Windows) +- Retro 8-bit styled theme +- Real-time switching between Light and Dark themes +- Intuitive control dashboard and connection status indicators +- Helpful tooltips on hover for all buttons + +### Web Panel Administrator Interface +- Remote Start/Stop and time adjustments +- Interactive feed with recent donations and top-donators +- Responsive mobile layout with fast QR-code connection + +### Donation Integrations +- Automatic time calculations based on customizable rate formulas +- Support for multiple donation platforms running at the same time +- Flexible mapper to translate Streamer.bot events into virtual currencies +- Built-in multi-source converter so you never lose international donations + +### OBS Style Generator +- 14 pre-designed layout presets (Cyberpunk, Matrix, Neon, Kawaii, etc.) +- Direct Google Fonts library integration +- Fine-grained controls for individual HH:MM:SS styling + +--- + +## Installation & Setup + +### Installing Official Releases + +1. **Download the installer:** + - Go to [Releases](https://github.com/MjKey/DonatonTimer/releases) and download the latest setup file: `DonatonTimer_vX.X.X_Setup.exe` + +2. **Run the setup:** + - Run the downloaded installer and follow the on-screen instructions. + +### Installing Artifacts (Bleeding-edge Alpha builds) + +1. **Download the latest artifact:** + - Go to [Actions](https://github.com/MjKey/DonatonTimer/actions), select the latest successful workflow run (indicated by green checkmark). + - Scroll down to *Artifacts* -> download *Latest*. Extract the ZIP archive to any directory. + - **ONLY FOR TESTING PURPOSES! THESE ARE NOT STABLE RELEASES!** + +2. **Run the Timer** + +--- + +## Usage + +### Local Server URLs (Default) + +| URL | Description | +| :--- | :--- | +| `http://localhost:7575/timer` | Timer overlay for OBS Browser Source | +| `http://localhost:7575/dashboard` | Web Administrator Panel | +| `http://localhost:7575/mini` | Compact mini-version optimized for OBS Dok Panels | + +> **Port Migration Notice:** Since version v3.0.6, the app uses port `7575` (HTTP) and `3434` (WS) by default to prevent port conflicts with Streamer.bot (which defaults to 8080). Upon launching, the app will automatically suggest migrating your old config to these ports. + +### OBS Studio Dock Panel Setup +In OBS Studio -> Docks -> Custom Browser Docks... -> Add name and paste `http://localhost:7575/mini`. + +### Streamer.bot Integration Guide +Detailed instructions are available in the [Wiki](https://github.com/MjKey/DonatonTimer/wiki). + +In short: +1. In Streamer.bot, enable the WebSocket server: `Servers/Clients` -> `WebSocket Server` -> Enable *Auto Start* and click *Start*. +2. In DonatonTimer -> Settings -> *Streamer.bot*, enable the integration and specify the WebSocket address (default is `ws://127.0.0.1:8080/`). +3. Add event mappings: specify the Event Source (e.g., `Twitch`), Event Type (e.g., `Sub`), and the virtual donation amount. + +--- + +## Data Storage + +Application configuration and local database are stored at: +``` +%APPDATA%\MerryJoyKeyStudio\DonatonTimer\data.json +``` + +--- + +## Default Network Ports + +| Port | Description | +|------|------------| +| 7575 | HTTP Server (OBS overlay and dashboard) | +| 3434 | WebSocket Server (for synchronisation and phone connection) | + +> Legacy ports 8080/4040 were migrated to avoid socket conflicts with Streamer.bot. + +--- + +## Feedback & Support + +If you have any questions or encounter bugs, please feel free to open an Issue on [GitHub](https://github.com/MjKey/DonatonTimer/issues). + +## License + +This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. + +--- + +## Compiling from Source + +To compile this project locally, you must have Flutter SDK installed. + +```bash +# 1. Clone the repository +git clone https://github.com/MjKey/DonatonTimer.git +cd DonatonTimer + +# 2. Get dependencies +flutter pub get + +# 3. Run under Windows in debug mode +flutter run -d windows + +# 4. Build release bundle +flutter build windows +``` + +### Building Installer (Inno Setup) +```bash +# Compile project +flutter build windows + +# Compile installer (requires ISCC and Inno Setup 6) +"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" setup.iss +``` + +--- + +**Countdown Timer for Streams** + +--- + +Made by [MjKey](https://mjkey.ru) with ❤️ +I will be highly grateful for any financial and informational support! diff --git a/lang/en.json b/lang/en.json index 4adfa71..4d3796f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -145,6 +145,10 @@ "reset_all_confirm": "Are you sure? All settings, statistics and timer will be reset!", "all_reset": "All data reset", "confirm": "Confirm", + "currency_converter": "Currency Converter", + "enable_currency_conversion": "Enable conversion to RUB", + "currency_converter_desc": "If enabled, donations in USD, EUR, KZT, etc. will be converted to RUB at the current exchange rate. If disabled, non-RUB donations will be ignored.", + "converter_source": "Exchange rate source", "logging": "Logging", "logging_enabled": "Enable logging", "logging_desc": "Writes events to logs.txt file next to the application", @@ -171,6 +175,7 @@ "seconds_color": "Seconds color", "or_widget_url": "or widget URL", "socket_server": "Socket server", + "api_server": "API server", "donatex_setup_note": "Paste full links here, not a raw token or widget ID.\n1. Open https://donatex.gg/streamer/donations and copy the recent donations widget link.\n2. Open https://donatex.gg/streamer/donation-widget and copy the donation widget link.", "donatex_recent_url_label": "Recent donations widget URL", "donatex_recent_url_hint": "https://donatex.gg/recent-donations?token=XXX&mode=normal", @@ -198,5 +203,7 @@ "tooltip_style_generator": "Style Generator", "tooltip_settings": "Settings", "tooltip_language": "Switch language", - "tooltip_theme": "Switch theme" + "tooltip_theme": "Switch theme", + "cloudtips_link_label": "CloudTips widget link", + "cloudtips_link_hint": "Paste your link from stream.cloudtips.ru/n/... — the token will be extracted automatically" } diff --git a/lang/ru.json b/lang/ru.json index bfd3de4..ce4a78f 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -145,6 +145,10 @@ "reset_all_confirm": "Вы уверены? Все настройки, статистика и таймер будут сброшены!", "all_reset": "Все данные сброшены", "confirm": "Подтверждение", + "currency_converter": "Конвертер валют", + "enable_currency_conversion": "Включить конвертацию в RUB", + "currency_converter_desc": "Если включено, донаты в USD, EUR, KZT и др. будут переводиться в рубли по текущему курсу для расчета времени. Если выключено - такие донаты будут игнорироваться.", + "converter_source": "Источник курсов", "logging": "Логирование", "logging_enabled": "Включить логирование", "logging_desc": "Записывает события в файл logs.txt рядом с приложением", @@ -171,6 +175,7 @@ "seconds_color": "Цвет секунд", "or_widget_url": "или ссылка виджета", "socket_server": "Сервер сокета", + "api_server": "API-сервер", "donatex_setup_note": "Сюда нужно вставлять именно полные ссылки, а не отдельно token или widget_id.\n1. Откройте https://donatex.gg/streamer/donations и скопируйте ссылку виджета последних донатов.\n2. Откройте https://donatex.gg/streamer/donation-widget и скопируйте ссылку виджета донатов.", "donatex_recent_url_label": "Ссылка на виджет recent donations", "donatex_recent_url_hint": "https://donatex.gg/recent-donations?token=XXX&mode=normal", @@ -198,5 +203,7 @@ "tooltip_style_generator": "Генератор стилей", "tooltip_settings": "Настройки", "tooltip_language": "Сменить язык", - "tooltip_theme": "Сменить тему" + "tooltip_theme": "Сменить тему", + "cloudtips_link_label": "Ссылка на виджет CloudTips", + "cloudtips_link_hint": "Скопируйте ссылку из stream.cloudtips.ru/n/... — токен будет извлечён автоматически" } diff --git a/lib/main.dart b/lib/main.dart index ff3a018..4788607 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'services/donate_pay_adapter.dart'; import 'services/donate_stream_adapter.dart'; import 'services/donatex_adapter.dart'; import 'services/donatty_adapter.dart'; +import 'services/cloudtips_adapter.dart'; import 'services/streamer_bot_adapter.dart'; import 'services/web_server_service.dart'; import 'services/sound_service.dart'; @@ -25,7 +26,7 @@ void main() async { // Initialize logging await LogManager.init(); - LogManager.info('Запуск приложения DonatonTimer v3.0.5 by MjKey'); + LogManager.info('Запуск приложения DonatonTimer v3.0.6 by MjKey'); // Initialize window manager for desktop await windowManager.ensureInitialized(); @@ -38,7 +39,7 @@ void main() async { backgroundColor: Colors.transparent, skipTaskbar: false, titleBarStyle: TitleBarStyle.normal, - title: 'DonatonTimer v3.0.5 by MjKey', + title: 'DonatonTimer v3.0.6 by MjKey', ); windowManager.waitUntilReadyToShow(windowOptions, () async { @@ -86,6 +87,8 @@ void main() async { LogManager.info('Адаптер DonateX зарегистрирован'); donationService.registerAdapter(DonattyAdapter()); LogManager.info('Адаптер Donatty зарегистрирован'); + donationService.registerAdapter(CloudTipsAdapter()); + LogManager.info('Адаптер CloudTips зарегистрирован'); donationService.registerAdapter(StreamerBotAdapter()); LogManager.info('Адаптер StreamerBot зарегистрирован'); diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 2dfebc5..3cf0569 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -15,6 +15,8 @@ class AppSettings { final bool isFixedTimeMode; final int fixedTimeMinutes; final bool isSubtractionMode; + final bool enableCurrencyConversion; + final String currencyConverterSource; const AppSettings({ this.minutesPerAmount = 600.0, @@ -30,6 +32,8 @@ class AppSettings { this.isFixedTimeMode = false, this.fixedTimeMinutes = 1, this.isSubtractionMode = false, + this.enableCurrencyConversion = false, + this.currencyConverterSource = 'cbr-xml', }); /// Creates a copy with updated values. @@ -47,6 +51,8 @@ class AppSettings { bool? isFixedTimeMode, int? fixedTimeMinutes, bool? isSubtractionMode, + bool? enableCurrencyConversion, + String? currencyConverterSource, }) { return AppSettings( minutesPerAmount: minutesPerAmount ?? this.minutesPerAmount, @@ -62,6 +68,8 @@ class AppSettings { isFixedTimeMode: isFixedTimeMode ?? this.isFixedTimeMode, fixedTimeMinutes: fixedTimeMinutes ?? this.fixedTimeMinutes, isSubtractionMode: isSubtractionMode ?? this.isSubtractionMode, + enableCurrencyConversion: enableCurrencyConversion ?? this.enableCurrencyConversion, + currencyConverterSource: currencyConverterSource ?? this.currencyConverterSource, ); } @@ -104,6 +112,8 @@ class AppSettings { isFixedTimeMode: json['isFixedTimeMode'] as bool? ?? false, fixedTimeMinutes: json['fixedTimeMinutes'] as int? ?? 1, isSubtractionMode: json['isSubtractionMode'] as bool? ?? false, + enableCurrencyConversion: json['enableCurrencyConversion'] as bool? ?? false, + currencyConverterSource: json['currencyConverterSource'] as String? ?? 'cbr-xml', ); } @@ -122,6 +132,8 @@ class AppSettings { 'isFixedTimeMode': isFixedTimeMode, 'fixedTimeMinutes': fixedTimeMinutes, 'isSubtractionMode': isSubtractionMode, + 'enableCurrencyConversion': enableCurrencyConversion, + 'currencyConverterSource': currencyConverterSource, 'serviceConfigs': serviceConfigs.map( (key, value) => MapEntry(key, value.toJson()), ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 8be27a2..e93931b 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -431,7 +431,7 @@ class _MainScreenState extends State { ), const SizedBox(height: 8), Text( - '${localization.tr('version')}: 3.0.5', + '${localization.tr('version')}: 3.0.6', style: const TextStyle(fontSize: 12), ), const SizedBox(height: 24), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6138ce6..eb5f0e4 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -163,6 +163,31 @@ class _ServicesSettingsTabState extends State { final _donattyTokenController = TextEditingController(); bool _donattyEnabled = false; bool _donattyTokenVisible = false; + String _donattyApiServer = 'api-014'; + + static const List _donattyApiServers = [ + 'api-014', + 'api', + 'api-015', + 'api-013', + 'api-010', + 'api-011', + 'api-012', + 'api-007', + 'api-009', + 'api-006', + 'api-001', + 'api-002', + 'api-003', + 'api-004', + 'api-005', + 'api-008', + ]; + + // CloudTips controllers + final _ctTokenController = TextEditingController(); + bool _ctEnabled = false; + bool _ctTokenVisible = false; // StreamerBot controllers final _sbWsUrlController = TextEditingController(); @@ -231,6 +256,14 @@ class _ServicesSettingsTabState extends State { if (donattyConfig != null) { _donattyEnabled = donattyConfig.enabled; _donattyTokenController.text = donattyConfig.getCredential('token') ?? ''; + _donattyApiServer = donattyConfig.getCredential('apiServer') ?? 'api-014'; + } + + // CloudTips + final ctConfig = settings.getServiceConfig('CloudTips'); + if (ctConfig != null) { + _ctEnabled = ctConfig.enabled; + _ctTokenController.text = ctConfig.getCredential('token') ?? ''; } // StreamerBot @@ -265,6 +298,7 @@ class _ServicesSettingsTabState extends State { _dxWidgetUrlController.dispose(); _dxGroupUrlController.dispose(); _donattyTokenController.dispose(); + _ctTokenController.dispose(); _sbWsUrlController.dispose(); _sbSourceController.dispose(); _sbTypeController.dispose(); @@ -380,6 +414,10 @@ class _ServicesSettingsTabState extends State { _buildDonattySection(localization), const SizedBox(height: 16), + // CloudTips + _buildCloudTipsSection(localization), + const SizedBox(height: 16), + // StreamerBot _buildStreamerBotSection(localization), ], @@ -813,11 +851,105 @@ class _ServicesSettingsTabState extends State { ), ), const SizedBox(height: 16), + + // API server dropdown + Text('${localization.tr('api_server')}:'), + const SizedBox(height: 8), + DropdownButton( + value: _donattyApiServer, + isExpanded: true, + items: _donattyApiServers.map((server) { + return DropdownMenuItem( + value: server, + child: Text('$server.donatty.com'), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() => _donattyApiServer = value); + } + }, + ), + const SizedBox(height: 4), + Text( + 'По умолчанию api-014. Измените, если донаты не приходят.', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + const SizedBox(height: 16), + NesButton.text( type: NesButtonType.success, text: localization.tr('save'), onPressed: () => _saveServiceConfig('Donatty', _donattyEnabled, { 'token': _donattyTokenController.text, + 'apiServer': _donattyApiServer, + }), + ), + ], + ), + ), + ); + } + + Widget _buildCloudTipsSection(LocalizationProvider localization) { + final status = _getAdapterStatus('CloudTips'); + return NesContainer( + label: 'CloudTips', + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + NesCheckBox( + value: _ctEnabled, + onChange: (value) => setState(() => _ctEnabled = value), + ), + const SizedBox(width: 12), + Text( + _ctEnabled + ? localization.tr('enabled') + : localization.tr('disabled'), + ), + const Spacer(), + _buildStatusIndicator(status), + const SizedBox(width: 8), + ], + ), + const SizedBox(height: 16), + Text('${localization.tr('cloudtips_link_label')}:'), + const SizedBox(height: 8), + TextField( + controller: _ctTokenController, + obscureText: !_ctTokenVisible, + decoration: InputDecoration( + hintText: 'https://stream.cloudtips.ru/n/...', + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + suffixIcon: IconButton( + icon: Icon( + _ctTokenVisible ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => + setState(() => _ctTokenVisible = !_ctTokenVisible), + ), + ), + ), + const SizedBox(height: 8), + Text( + localization.tr('cloudtips_link_hint'), + style: const TextStyle(fontSize: 10, color: Colors.grey), + ), + const SizedBox(height: 16), + NesButton.text( + type: NesButtonType.success, + text: localization.tr('save'), + onPressed: () => _saveServiceConfig('CloudTips', _ctEnabled, { + 'token': _ctTokenController.text.trim(), }), ), ], @@ -1542,6 +1674,8 @@ class DataSettingsTab extends StatefulWidget { class _DataSettingsTabState extends State { bool _loggingEnabled = true; + bool _enableCurrencyConversion = false; + String _currencyConverterSource = 'er-api'; @override void initState() { @@ -1553,18 +1687,22 @@ class _DataSettingsTabState extends State { final donationService = context.read(); if (donationService != null) { _loggingEnabled = donationService.settings.loggingEnabled; + _enableCurrencyConversion = donationService.settings.enableCurrencyConversion; + _currencyConverterSource = donationService.settings.currencyConverterSource; } // Also sync with LogManager _loggingEnabled = LogManager.enabled; setState(() {}); } - Future _saveLoggingSettings() async { + Future _saveDataSettings() async { final donationService = context.read(); if (donationService == null) return; final newSettings = donationService.settings.copyWith( loggingEnabled: _loggingEnabled, + enableCurrencyConversion: _enableCurrencyConversion, + currencyConverterSource: _currencyConverterSource, ); await donationService.updateSettings(newSettings); @@ -1640,6 +1778,60 @@ class _DataSettingsTabState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Currency Converter settings + NesContainer( + label: localization.tr('currency_converter'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + NesCheckBox( + value: _enableCurrencyConversion, + onChange: (value) => setState(() => _enableCurrencyConversion = value), + ), + const SizedBox(width: 12), + Expanded(child: Text(localization.tr('enable_currency_conversion'))), + ], + ), + const SizedBox(height: 8), + Text( + localization.tr('currency_converter_desc'), + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + const SizedBox(height: 16), + Text('${localization.tr('converter_source')}:'), + const SizedBox(height: 8), + DropdownButton( + value: _currencyConverterSource, + isExpanded: true, + items: const [ + DropdownMenuItem(value: 'cbr-xml', child: Text('ЦБ РФ (XML, по умолчанию)')), + DropdownMenuItem(value: 'cbr-json', child: Text('ЦБ РФ (JSON)')), + DropdownMenuItem(value: 'ratata', child: Text('Ratata Money (ratata.money)')), + DropdownMenuItem(value: 'er-api', child: Text('ER-API (open.er-api.com)')), + DropdownMenuItem(value: 'frankfurter', child: Text('Frankfurter (api.frankfurter.app)')), + ], + onChanged: (value) { + if (value != null) { + setState(() => _currencyConverterSource = value); + } + }, + ), + const SizedBox(height: 16), + NesButton.text( + type: NesButtonType.normal, + text: localization.tr('save'), + onPressed: _saveDataSettings, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + // Logging settings NesContainer( label: localization.tr('logging'), @@ -1670,7 +1862,7 @@ class _DataSettingsTabState extends State { NesButton.text( type: NesButtonType.normal, text: localization.tr('save'), - onPressed: _saveLoggingSettings, + onPressed: _saveDataSettings, ), ], ), diff --git a/lib/services/cloudtips_adapter.dart b/lib/services/cloudtips_adapter.dart new file mode 100644 index 0000000..7e250ef --- /dev/null +++ b/lib/services/cloudtips_adapter.dart @@ -0,0 +1,308 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:logging/logging.dart'; +import '../models/donation.dart'; +import 'donation_service_adapter.dart'; +import 'log_manager.dart'; + +/// Адаптер для CloudTips. +/// Использует WebSocket (протокол SignalR/JSON) для получения донатов. +/// +/// Протокол: +/// 1. GET /notification/{token}/init -> {"connectionToken": "..."} +/// 2. GET /connection?connectionToken=...&authType=Notifications -> {"status":"Ok"} +/// 3. WSS /notification/socket?connectionToken=... +/// -> Отправить: {"protocol":"json","version":1}\x1e +/// -> Слушать type=1, target="SendNotification" +/// -> Ping/pong: на {"type":6} отвечаем {"type":6}\x1e +class CloudTipsAdapter extends BaseDonationServiceAdapter { + final Logger _logger = Logger('CloudTipsAdapter'); + + WebSocket? _webSocket; + Timer? _reconnectTimer; + Timer? _pingTimer; + + int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 10; + static const Duration _reconnectDelay = Duration(seconds: 5); + + /// Токен из URL ссылки (последний сегмент пути) + String? _linkToken; + + /// Токен подключения, полученный от API + String? _connectionToken; + + static const String _apiBase = 'streamers-api.cloudtips.ru'; + static const String _wsBase = 'wss://streamers-api.cloudtips.ru:3030'; + + /// Разделитель сообщений SignalR (Record Separator, 0x1e) + static const String _signalrTerminator = '\x1e'; + + @override + String get serviceName => 'CloudTips'; + + /// Маскирует токен, оставляя только первые 5 символов + String _maskToken(String? token) { + if (token == null || token.isEmpty) return 'null'; + if (token.length <= 5) return '***'; + return '${token.substring(0, 5)}***'; + } + + /// Извлекает токен из ссылки вида: + /// https://stream.cloudtips.ru/n/AAAL4ItkOPY2iphRhUCY + static String? extractLinkToken(String input) { + input = input.trim(); + // Пробуем разобрать как URL + final uri = Uri.tryParse(input); + if (uri != null && uri.pathSegments.isNotEmpty) { + // Берём последний сегмент пути + final last = uri.pathSegments.last; + if (last.isNotEmpty) return last; + } + // Если это просто голый токен без URL + if (!input.contains('/') && !input.contains(' ')) { + return input; + } + return null; + } + + @override + Future connect(Map config) async { + await disconnect(); + + final input = config['token'] as String?; + if (input == null || input.isEmpty) { + LogManager.warning('CloudTips: токен или ссылка не указаны'); + updateStatus(ConnectionStatus.error); + return; + } + + _linkToken = extractLinkToken(input); + if (_linkToken == null) { + LogManager.warning('CloudTips: не удалось извлечь токен из "$input"'); + updateStatus(ConnectionStatus.error); + return; + } + + LogManager.info('CloudTips: токен ${_maskToken(_linkToken)}, инициализация...'); + updateStatus(ConnectionStatus.connecting); + await _initConnection(); + } + + Future _initConnection() async { + try { + final httpClient = HttpClient() + ..connectionTimeout = const Duration(seconds: 15); + + // 1. Получаем connectionToken + final initUrl = Uri.https(_apiBase, '/notification/$_linkToken/init'); + final initReq = await httpClient.getUrl(initUrl); + final initResp = await initReq.close(); + + if (initResp.statusCode != 200) { + throw Exception('init failed: HTTP ${initResp.statusCode}'); + } + + final initBody = await initResp.transform(utf8.decoder).join(); + final initJson = json.decode(initBody) as Map; + _connectionToken = initJson['connectionToken'] as String?; + httpClient.close(); + + if (_connectionToken == null || _connectionToken!.isEmpty) { + throw Exception('connectionToken not found in response'); + } + + LogManager.info('CloudTips: connectionToken получен (${_maskToken(_connectionToken)}), подключение к WS...'); + + // 2. Подключаемся к WebSocket + final wsUrl = '$_wsBase/notification/socket?connectionToken=${Uri.encodeComponent(_connectionToken!)}'; + _webSocket = await WebSocket.connect(wsUrl); + + LogManager.info('CloudTips: WebSocket подключён, отправка handshake...'); + updateStatus(ConnectionStatus.connected); + _reconnectAttempts = 0; + + // 3. SignalR handshake + _webSocket!.add('${json.encode({"protocol": "json", "version": 1})}$_signalrTerminator'); + + // 4. Слушаем входящие сообщения + _webSocket!.listen( + _handleMessage, + onError: (e) { + _logger.severe('WebSocket error: $e'); + LogManager.error('CloudTips: ошибка WS - $e'); + _handleDisconnect(); + }, + onDone: () { + LogManager.warning('CloudTips: WS соединение закрыто'); + _handleDisconnect(); + }, + ); + + // 5. Periodic ping каждые 15 секунд (на случай если сервер не пингует) + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(const Duration(seconds: 15), (_) { + if (_webSocket?.readyState == WebSocket.open) { + _webSocket!.add('${json.encode({"type": 6})}$_signalrTerminator'); + } + }); + } catch (e, st) { + _logger.severe('CloudTips connect error: $e\n$st'); + LogManager.error('CloudTips: ошибка подключения - $e'); + _handleDisconnect(); + } + } + + void _handleMessage(dynamic raw) { + final text = raw.toString(); + // SignalR разделяет сообщения символом \x1e + final parts = text.split(_signalrTerminator); + for (final part in parts) { + final trimmed = part.trim(); + if (trimmed.isEmpty) continue; + try { + final msg = json.decode(trimmed) as Map; + _processMessage(msg); + } catch (_) { + // Игнорируем нераспознанные сообщения + } + } + } + + void _processMessage(Map msg) { + final type = msg['type'] as int?; + + // Ping - отвечаем pong + if (type == 6) { + if (_webSocket?.readyState == WebSocket.open) { + _webSocket!.add('${json.encode({"type": 6})}$_signalrTerminator'); + } + return; + } + + // type=1 — это вызов метода на клиенте + if (type == 1) { + final target = msg['target'] as String?; + if (target != 'SendNotification') return; + + final arguments = msg['arguments'] as List?; + if (arguments == null || arguments.isEmpty) return; + + final payload = arguments[0] as Map?; + if (payload == null) return; + + _processDonation(payload); + } + } + + void _processDonation(Map payload) { + try { + // Сумму берём ТОЛЬКО из поля title по шаблону "... N₽." + // Защита: ищем последнее вхождение числа перед знаком ₽ + final title = payload['title'] as String? ?? ''; + final amount = _extractAmountFromTitle(title); + if (amount == null || amount <= 0) { + LogManager.warning('CloudTips: не удалось извлечь сумму из "$title"'); + return; + } + + // Извлекаем имя из title: всё до " отправил" + final username = _extractUsernameFromTitle(title); + + final comment = payload['comment'] as String?; + final id = payload['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(); + + final donation = Donation( + id: '${serviceName}_$id', + serviceName: serviceName, + username: username, + amount: amount, + currency: 'RUB', + message: comment, + timestamp: DateTime.now(), + ); + + _logger.info('Donation: $username - $amount RUB'); + LogManager.info('CloudTips: донат от $username - $amount RUB'); + emitDonation(donation); + } catch (e, st) { + _logger.severe('Error processing CloudTips donation: $e\n$st'); + LogManager.error('CloudTips: ошибка обработки доната - $e'); + } + } + + /// Извлекает сумму из строки вида "Имя отправил 49₽." + /// Берём ПОСЛЕДНЕЕ число непосредственно перед знаком ₽. + /// Это защищает от случаев, когда пользователь вписывает число/₽ в имя. + double? _extractAmountFromTitle(String title) { + // Ищем: последовательность цифр (возможно с запятой/точкой), стоящая прямо перед "₽" + final matches = RegExp(r'(\d[\d\s]*)₽').allMatches(title); + if (matches.isEmpty) return null; + + // Берём ПОСЛЕДНЕЕ совпадение + final last = matches.last; + final numberStr = last.group(1)?.replaceAll(RegExp(r'\s'), '').trim(); + if (numberStr == null || numberStr.isEmpty) return null; + return double.tryParse(numberStr.replaceAll(',', '.')); + } + + /// Извлекает имя из строки "Имя отправил 49₽." + /// Обрезаем по последнему вхождению " отправил " + String _extractUsernameFromTitle(String title) { + final idx = title.lastIndexOf(' отправил '); + if (idx > 0) { + return title.substring(0, idx).trim(); + } + return title; + } + + void _handleDisconnect() { + _pingTimer?.cancel(); + _pingTimer = null; + if (status != ConnectionStatus.disconnected) { + updateStatus(ConnectionStatus.error); + _scheduleReconnect(); + } + } + + void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + _logger.severe('Max reconnect attempts reached'); + LogManager.error('CloudTips: превышено число попыток переподключения'); + updateStatus(ConnectionStatus.error); + return; + } + + _reconnectTimer?.cancel(); + final delay = _reconnectDelay * (_reconnectAttempts + 1); + _reconnectTimer = Timer(delay, () { + _reconnectAttempts++; + LogManager.info('CloudTips: попытка переподключения #$_reconnectAttempts...'); + updateStatus(ConnectionStatus.reconnecting); + _initConnection(); + }); + } + + @override + Future disconnect() async { + LogManager.info('CloudTips: отключение...'); + updateStatus(ConnectionStatus.disconnected); + + _reconnectTimer?.cancel(); + _pingTimer?.cancel(); + _reconnectTimer = null; + _pingTimer = null; + + await _webSocket?.close(); + _webSocket = null; + + LogManager.info('CloudTips: отключено'); + } + + @override + Future dispose() async { + await disconnect(); + await super.dispose(); + } +} diff --git a/lib/services/currency_converter_service.dart b/lib/services/currency_converter_service.dart new file mode 100644 index 0000000..a59c2af --- /dev/null +++ b/lib/services/currency_converter_service.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:logging/logging.dart'; +import 'log_manager.dart'; + +class CurrencyConverterService { + static final CurrencyConverterService _instance = CurrencyConverterService._internal(); + factory CurrencyConverterService() => _instance; + CurrencyConverterService._internal(); + + final _logger = Logger('CurrencyConverterService'); + + Map _rates = {}; // Currency -> Rate (relative to base) + DateTime? _lastUpdate; + + bool get hasRates => _rates.isNotEmpty && _rates.containsKey('RUB'); + DateTime? get lastUpdate => _lastUpdate; + + Future fetchRates(String source) async { + try { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + + if (source == 'er-api') { + final url = Uri.parse('https://open.er-api.com/v6/latest/USD'); + final request = await client.getUrl(url); + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = json.decode(body) as Map; + final ratesData = data['rates'] as Map; + + _rates.clear(); + ratesData.forEach((key, value) { + _rates[key] = (value as num).toDouble(); + }); + _lastUpdate = DateTime.now(); + LogManager.info('Конвертер валют обновлен (ER-API). База: USD, RUB: ${_rates['RUB']}'); + } else { + throw Exception('ER-API returned ${response.statusCode}'); + } + } else if (source == 'frankfurter') { + // У Frankfurter API по умолчанию база EUR + final url = Uri.parse('https://api.frankfurter.app/latest'); + final request = await client.getUrl(url); + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = json.decode(body) as Map; + final ratesData = data['rates'] as Map; + + _rates.clear(); + ratesData.forEach((key, value) { + _rates[key] = (value as num).toDouble(); + }); + final base = data['base'] as String? ?? 'EUR'; + _rates[base] = 1.0; + _lastUpdate = DateTime.now(); + LogManager.info('Конвертер валют обновлен (Frankfurter). База: $base, RUB: ${_rates['RUB']}'); + } else { + throw Exception('Frankfurter returned ${response.statusCode}'); + } + } else if (source == 'ratata') { + final url = Uri.parse('https://ratata.money/api/v1/rates/latest?base=RUB'); + final request = await client.getUrl(url); + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = json.decode(body) as Map; + final ratesData = data['rates'] as Map; + + _rates.clear(); + ratesData.forEach((key, value) { + _rates[key] = (value as num).toDouble(); + }); + _rates['RUB'] = 1.0; + _lastUpdate = DateTime.now(); + LogManager.info('Конвертер валют обновлен (Ratata). База: RUB, USD: ${_rates['USD']}'); + } else { + throw Exception('Ratata returned ${response.statusCode}'); + } + } else if (source == 'cbr-json') { + final url = Uri.parse('https://www.cbr-xml-daily.ru/latest.js'); + final request = await client.getUrl(url); + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final data = json.decode(body) as Map; + final ratesData = data['rates'] as Map; + + _rates.clear(); + ratesData.forEach((key, value) { + _rates[key] = (value as num).toDouble(); + }); + _rates['RUB'] = 1.0; + _lastUpdate = DateTime.now(); + LogManager.info('Конвертер валют обновлен (CBR-JSON). База: RUB, USD: ${_rates['USD']}'); + } else { + throw Exception('CBR JSON returned ${response.statusCode}'); + } + } else if (source == 'cbr-xml') { + final url = Uri.parse('https://www.cbr.ru/scripts/XML_daily.asp'); + final request = await client.getUrl(url); + final response = await request.close(); + + if (response.statusCode == 200) { + // CBR returns windows-1251, so we decode as latin1/ascii to safely extract English tags and digits + final bytes = await response.expand((b) => b).toList(); + final body = String.fromCharCodes(bytes); + + final regex = RegExp(r'([A-Z]+)<\/CharCode>.*?(\d+)<\/Nominal>.*?([\d,]+)<\/Value>'); + final matches = regex.allMatches(body); + + _rates.clear(); + for (final match in matches) { + final code = match.group(1)!; + final nominal = double.parse(match.group(2)!); + final valueStr = match.group(3)!.replaceAll(',', '.'); + final value = double.parse(valueStr); + + // value is how many RUB for `nominal` amount of `code` + // Example: 1 USD = 90 RUB. _rates['USD'] should be 1 / 90. + _rates[code] = nominal / value; + } + _rates['RUB'] = 1.0; + _lastUpdate = DateTime.now(); + LogManager.info('Конвертер валют обновлен (CBR-XML). База: RUB, USD: ${_rates['USD']}'); + } else { + throw Exception('CBR XML returned ${response.statusCode}'); + } + } + } catch (e) { + _logger.warning('Ошибка обновления курсов: $e'); + LogManager.error('Конвертер: ошибка загрузки курсов ($source) - $e'); + } + } + + /// Converts an amount from [fromCurrency] to RUB. + /// Returns null if conversion is impossible (e.g. unknown currency or no rates). + double? convertToRub(double amount, String fromCurrency) { + if (fromCurrency.toUpperCase() == 'RUB') return amount; + if (_rates.isEmpty || !_rates.containsKey('RUB')) return null; + + final cur = fromCurrency.toUpperCase(); + if (!_rates.containsKey(cur)) return null; + + final rateToRub = _rates['RUB']! / _rates[cur]!; + return amount * rateToRub; + } +} diff --git a/lib/services/donate_pay_adapter.dart b/lib/services/donate_pay_adapter.dart index ae9ed6e..8307e3a 100644 --- a/lib/services/donate_pay_adapter.dart +++ b/lib/services/donate_pay_adapter.dart @@ -241,11 +241,7 @@ class DonatePayAdapter extends BaseDonationServiceAdapter { final currency = transaction?['currency'] as String? ?? 'RUB'; final donationMessage = vars['comment'] as String?; - // Only process RUB donations - if (currency != 'RUB') { - LogManager.info('DonatePay: пропуск не-RUB доната ($currency)'); - return; - } + // Валюта больше не фильтруется здесь, это делает DonationService final donation = Donation( id: '${serviceName}_$id', diff --git a/lib/services/donate_stream_adapter.dart b/lib/services/donate_stream_adapter.dart index 3ee03c3..3720df4 100644 --- a/lib/services/donate_stream_adapter.dart +++ b/lib/services/donate_stream_adapter.dart @@ -173,11 +173,7 @@ class DonateStreamAdapter extends BaseDonationServiceAdapter { final currency = alertData['currency'] as String? ?? 'RUB'; final message = alertData['message'] as String?; - // Only process RUB donations - if (currency != 'RUB') { - _logger.info('Skipping non-RUB donation: currency=$currency'); - return; - } + // Валюта больше не фильтруется здесь, это делает DonationService final donation = Donation( id: '${serviceName}_$id', diff --git a/lib/services/donation_alerts_adapter.dart b/lib/services/donation_alerts_adapter.dart index 6641235..831e769 100644 --- a/lib/services/donation_alerts_adapter.dart +++ b/lib/services/donation_alerts_adapter.dart @@ -210,15 +210,11 @@ class DonationAlertsAdapter extends BaseDonationServiceAdapter { return; } - // Only process RUB donations - if (currency != 'RUB') { - _logger.info('Skipping non-RUB donation: $currency'); - return; - } + // Валюта больше не фильтруется здесь, это делает DonationService final id = donationData['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(); final username = donationData['username'] as String? ?? 'Anonymous'; - final amount = _parseDoubleField(donationData['amount_main']); + final amount = _parseDoubleField(donationData['amount']); final donationMessage = donationData['message'] as String?; final donation = Donation( diff --git a/lib/services/donation_service.dart b/lib/services/donation_service.dart index 1cde3d5..cc0654a 100644 --- a/lib/services/donation_service.dart +++ b/lib/services/donation_service.dart @@ -8,6 +8,7 @@ import '../models/statistics.dart'; import 'donation_service_adapter.dart'; import 'storage_service.dart'; import 'log_manager.dart'; +import 'currency_converter_service.dart'; /// Сервис для управления адаптерами донат-сервисов и обработки донатов. class DonationService extends ChangeNotifier { @@ -74,6 +75,9 @@ class DonationService extends ChangeNotifier { _rate = savedSettings.minutesPerAmount; } + // Загружаем курсы валют + CurrencyConverterService().fetchRates(_settings.currencyConverterSource); + // Load statistics final savedStats = _storageService.loadStatistics(); if (savedStats != null) { @@ -85,9 +89,16 @@ class DonationService extends ChangeNotifier { /// Updates the application settings. Future updateSettings(AppSettings newSettings) async { + final oldSource = _settings.currencyConverterSource; _settings = newSettings; _rate = newSettings.minutesPerAmount; await _storageService.saveSettings(newSettings); + + // Обновляем курсы, если источник изменился или если они еще не загружены + if (oldSource != newSettings.currencyConverterSource || !CurrencyConverterService().hasRates) { + CurrencyConverterService().fetchRates(newSettings.currencyConverterSource); + } + notifyListeners(); } @@ -193,12 +204,38 @@ class DonationService extends ChangeNotifier { } } + // Конвертация валют + double effectiveAmount = donation.amount; + String effectiveCurrency = donation.currency.toUpperCase(); + + if (effectiveCurrency != 'RUB') { + if (!_settings.enableCurrencyConversion) { + LogManager.info('Пропуск доната не в RUB ($effectiveCurrency), т.к. конвертация выключена: ${donation.username}'); + return; + } + + final converter = CurrencyConverterService(); + final converted = converter.convertToRub(donation.amount, effectiveCurrency); + + if (converted == null) { + LogManager.warning('Не удалось конвертировать ${donation.amount} $effectiveCurrency в RUB. Донат пропущен.'); + return; + } + + LogManager.info('Конвертация: ${donation.amount} $effectiveCurrency -> ${converted.toStringAsFixed(2)} RUB'); + effectiveAmount = converted; + } + // Рассчитываем время для добавления int secondsToAdd; if (_settings.isFixedTimeMode) { secondsToAdd = _settings.fixedTimeMinutes * 60; } else { - secondsToAdd = donation.calculateSeconds(_rate, _settings.timePerAmountMinutes); + if (_rate <= 0) { + secondsToAdd = 0; + } else { + secondsToAdd = ((effectiveAmount / _rate) * _settings.timePerAmountMinutes * 60).round(); + } } if (_settings.isSubtractionMode) { diff --git a/lib/services/donatty_adapter.dart b/lib/services/donatty_adapter.dart index 823f244..dcb19a8 100644 --- a/lib/services/donatty_adapter.dart +++ b/lib/services/donatty_adapter.dart @@ -24,10 +24,18 @@ class DonattyAdapter extends BaseDonationServiceAdapter { String? _groupId; String? _linkToken; String? _accessToken; + String _apiServer = 'api-014'; @override String get serviceName => 'Donatty'; + /// Маскирует токен, оставляя только первые 5 символов + String _maskToken(String? token) { + if (token == null || token.isEmpty) return 'null'; + if (token.length <= 5) return '***'; + return '${token.substring(0, 5)}***'; + } + /// Extracts tokens from the URL. /// Supports: https://widgets.donatty.com/group/?ref={group_id}&token={link_token} static Map? extractTokens(String input) { @@ -46,6 +54,9 @@ class DonattyAdapter extends BaseDonationServiceAdapter { @override Future connect(Map config) async { + // Отключаемся от предыдущего соединения, если оно было + await disconnect(); + final urlOrTokens = config['token'] as String?; if (urlOrTokens == null || urlOrTokens.isEmpty) { @@ -59,6 +70,7 @@ class DonattyAdapter extends BaseDonationServiceAdapter { if (tokens != null) { _groupId = tokens['group_id']; _linkToken = tokens['link_token']; + LogManager.info('Donatty: URL parsed, groupId=${_maskToken(_groupId)}, linkToken=${_maskToken(_linkToken)}'); } else { // It might be a token configuration, but we strictly need both group_id and link_token or a full link _logger.warning('Invalid Donatty link format. Ensure it contains ref and token.'); @@ -67,9 +79,11 @@ class DonattyAdapter extends BaseDonationServiceAdapter { return; } + _apiServer = config['apiServer'] as String? ?? 'api-014'; + updateStatus(ConnectionStatus.connecting); - _logger.info('Connecting to Donatty...'); - LogManager.info('Donatty: получение access_token...'); + _logger.info('Connecting to Donatty via $_apiServer...'); + LogManager.info('Donatty: получение access_token (сервер: $_apiServer)...'); await _initConnection(); } @@ -80,7 +94,7 @@ class DonattyAdapter extends BaseDonationServiceAdapter { ..connectionTimeout = const Duration(seconds: 15); // 1. Get access token - final authUrl = Uri.parse('https://api-014.donatty.com/auth/tokens/$_linkToken'); + final authUrl = Uri.parse('https://$_apiServer.donatty.com/auth/tokens/$_linkToken'); final authRequest = await _httpClient!.getUrl(authUrl); final authResponse = await authRequest.close(); @@ -97,10 +111,10 @@ class DonattyAdapter extends BaseDonationServiceAdapter { throw Exception('Access token not found in response'); } - LogManager.info('Donatty: access_token получен, подключение к SSE...'); + LogManager.info('Donatty: access_token получен (${_maskToken(_accessToken)}), подключение к SSE...'); // 2. Connect to SSE - final sseUrl = Uri.parse('https://api-014.donatty.com/widgets/$_groupId/sse?jwt=$_accessToken'); + final sseUrl = Uri.parse('https://$_apiServer.donatty.com/widgets/$_groupId/sse?jwt=$_accessToken'); _request = await _httpClient!.getUrl(sseUrl); _request!.headers.set('Accept', 'text/event-stream'); _request!.headers.set('Cache-Control', 'no-cache'); @@ -163,6 +177,14 @@ class DonattyAdapter extends BaseDonationServiceAdapter { } catch (e) { _logger.warning('Error parsing SSE line: $e\nLine was: $line'); } + } else if (line.startsWith('{')) { + // Возможно, они шлют чистый JSON без префикса data: + try { + final data = json.decode(line) as Map; + _handleEventData(data); + } catch (e) { + _logger.warning('Error parsing pure JSON line: $e\nLine was: $line'); + } } } @@ -193,12 +215,21 @@ class DonattyAdapter extends BaseDonationServiceAdapter { } } } + } else if (action == 'DATA') { + // Donatty присылает данные напрямую с action "DATA" + final eventData = data['data'] as Map?; + if (eventData != null) { + _processDonation(eventData); + } } } void _processDonation(Map eventData) { final eventType = eventData['streamEventType'] as String?; - if (eventType != 'DONATTY_DONATION') return; + + if (eventType != 'DONATTY_DONATION') { + return; + } try { final subscriber = eventData['subscriber'] as String? ?? 'Anonymous'; diff --git a/lib/services/services.dart b/lib/services/services.dart index 4d47c41..40d552d 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -13,4 +13,5 @@ export 'donate_pay_adapter.dart'; export 'donate_stream_adapter.dart'; export 'donatex_adapter.dart'; export 'donatty_adapter.dart'; +export 'cloudtips_adapter.dart'; export 'streamer_bot_adapter.dart'; diff --git a/setup.iss b/setup.iss index fe44095..5214c1b 100644 --- a/setup.iss +++ b/setup.iss @@ -1,10 +1,10 @@ -; DonatonTimer v3.0.5 Setup Script +; DonatonTimer v3.0.6 Setup Script ; Автор: MjKey (https://mjkey.ru) ; Проект: https://github.com/MjKey/DonatonTimer ; Inno Setup 6.2+ #define MyAppName "DonatonTimer" -#define MyAppVersion "3.0.5" +#define MyAppVersion "3.0.6" #define MyAppPublisher "MjKey" #define MyAppURL "https://github.com/MjKey/DonatonTimer" #define MyAppExeName "donaton_timer.exe" diff --git a/streamerbot.md b/streamerbot.md index 0de7a8e..cef19bd 100644 --- a/streamerbot.md +++ b/streamerbot.md @@ -7,13 +7,15 @@ --- -## ⚙️ Шаг 1. Настройка Streamer.bot +## Шаг 1. Настройка Streamer.bot 1. Откройте **Streamer.bot**. 2. Перейдите во вкладку `Servers/Clients` -> `WebSocket Server`. 3. Убедитесь, что стоит галочка **Auto Start**, а сам сервер запущен (статус *Started*). 4. Запомните **IP** и **Port**. По умолчанию это `127.0.0.1` и `8080`. -## ⚙️ Шаг 2. Подключение в Donaton Timer +--- + +## Шаг 2. Подключение в Donaton Timer 1. Откройте **Donaton Timer** и нажмите на иконку шестерёнки (Настройки). 2. Найдите секцию **Streamer.bot**. 3. Включите галочку активации интеграции. @@ -21,7 +23,7 @@ --- -## 🎯 Шаг 3. Привязка событий (Маппинги) +## Шаг 3. Привязка событий (Маппинги) Чтобы таймер реагировал на события, нужно создать для них "Привязки" (Event -> Сумма). Нажмите **+ Добавить событие** в настройках Donaton Timer. Перед вами 3 поля. Обратите внимание, что Streamer.bot **чувствителен к регистру**! Пишите `Source` и `Type` точь-в-точь как в таблицах ниже. @@ -35,7 +37,7 @@ ### Примеры популярных привязок для Twitch | Что хотите отслеживать | Source | Type | Сумма | -| :--- | :--- | :--- | :--- | +| :--- | :---: | :---: | :---: | | **Обычная Сабка (Sub)** | `Twitch` | `Sub` | `130` *(фиксированная цена)* | | **Ресаб (ReSub)** | `Twitch` | `ReSub` | `130` *(фиксированная цена)* | | **Подарочная сабка (GiftSub)** | `Twitch` | `GiftSub` | `130` *(фиксированная цена)* | @@ -43,6 +45,8 @@ > **Заметка**: Если вам нужны награды за баллы канала, можете использовать `Source: Twitch`, `Type: PointRedemption`, и указать нужную фиксированную цену (эквивалент в рублях). +--- + ### Как передать кастомные донаты (Например, из C# или макросов) Если вы генерируете собственные кастомные события внутри Streamer.bot (например, интеграция со сторонними сервисами через код), вы можете ловить их в Donaton Timer. @@ -50,4 +54,4 @@ * Source: `General` * Type: `Custom` * Сумма: `0` (чтобы передавать динамическую сумму из кода) -2. В Streamer.bot убедитесь, что при трансляции события (Broadcasting) в объекте `data` вы отправляете поле `amount` (сумма доната) и, желательно, поле `user` или `name` (имя зрителя). Таймер автоматически подхватит эти данные. +2. В Streamer.bot убедитесь, что при трансляции события (Broadcasting) в объект `data` вы отправляете поле `amount` (сумма доната) и, желательно, поле `user` или `name` (имя зрителя). Таймер автоматически подхватит эти данные. diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 1cf6726..c4c199e 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -93,7 +93,7 @@ BEGIN VALUE "FileDescription", "DonatonTimer - Timer for donation marathons" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "DonatonTimer" "\0" - VALUE "LegalCopyright", "Copyright (C) 2025 MjKey (https://mjkey.ru). All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 MjKey (https://mjkey.ru). All rights reserved." "\0" VALUE "OriginalFilename", "DonatonTimer.exe" "\0" VALUE "ProductName", "DonatonTimer" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index dbbc964..318a34b 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"DonatonTimer v3.0.5 by MjKey", origin, size)) { + if (!window.Create(L"DonatonTimer v3.0.6 by MjKey", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);