From 56e44f852bbe1aab28c586d553490b67148f17d1 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 2 Nov 2025 09:42:23 +0100 Subject: [PATCH 01/46] pop up mode and chat buble toggle --- README.md | 17 +- backend/modules/settings_defaults.json | 11 + backend/version.py | 2 +- .../settings/AudioFiltersSettings.jsx | 4 +- .../settings/AvatarConfigurationTabs.jsx | 18 +- .../components/settings/AvatarManagement.jsx | 2 +- .../settings/AvatarPlacementSettings.jsx | 172 +++- .../settings/ChatBubbleSettings.jsx | 243 ++++++ .../settings/ExportImportSettings.jsx | 12 - .../components/settings/GeneralSettings.jsx | 3 +- .../settings/GlowEffectSettings.jsx | 31 +- .../components/settings/MessageFiltering.jsx | 4 +- .../components/settings/TwitchIntegration.jsx | 6 +- .../settings/YouTubeIntegration.jsx | 3 - frontend/src/pages/SettingsPage.jsx | 20 +- frontend/src/pages/YappersPage.jsx | 760 ++++++++++++++++-- 16 files changed, 1135 insertions(+), 173 deletions(-) create mode 100644 frontend/src/components/settings/ChatBubbleSettings.jsx diff --git a/README.md b/README.md index 6e89a3a..3f6a680 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ chat-yapper/ ## Changelog +### v1.2.0 (Latest) +- chat bubbles +- pop-up mode + ### v1.1.2 (Latest) - Stabiltiy fixes - msi installation for windows for easier support @@ -204,14 +208,14 @@ chat-yapper/ --- -## TODO - -### In Testing +## Beta features - import/export feature - Youtube Integration - Factory Reset +- chat bubbles +- pop-up mode -### Features +## TODO - Discord integration - Better placement of avatars in UI - Allow mapping of voices to avatars @@ -223,13 +227,12 @@ chat-yapper/ - Statistics - More TTS options - Select scenes - - Waveform visualization in settings UI - Better error recovery for TTS provider failure, network issues, and db corruption - Memory Management -### Bugs -- None lmao +## Bugs +- audio crackling sometimes ## Acknowledgments diff --git a/backend/modules/settings_defaults.json b/backend/modules/settings_defaults.json index c7e781e..b4ce130 100644 --- a/backend/modules/settings_defaults.json +++ b/backend/modules/settings_defaults.json @@ -3,6 +3,10 @@ }, "audioFormat": "mp3", "volume": 1.0, +"avatarMode": "grid", +"popupDirection": "bottom", +"popupFixedEdge": false, +"popupRotateToDirection": false, "avatarRows": 3, "avatarRowConfig": [4, 4, 4], "avatarSize": 200, @@ -13,6 +17,13 @@ "avatarGlowColor": "#ffffff", "avatarGlowOpacity": 0.9, "avatarGlowSize": 20, +"chatBubblesEnabled": true, +"bubbleFontFamily": "Arial, sans-serif", +"bubbleFontSize": 14, +"bubbleFontColor": "#ffffff", +"bubbleBackgroundColor": "#000000", +"bubbleOpacity": 0.85, +"bubbleRounded": true, "tts": { "monstertts": { "apiKey": "" diff --git a/backend/version.py b/backend/version.py index 4b24afa..eaf3d68 100644 --- a/backend/version.py +++ b/backend/version.py @@ -3,4 +3,4 @@ This file is automatically updated during CI/CD builds """ -__version__ = "1.1.1" +__version__ = "1.2.0" diff --git a/frontend/src/components/settings/AudioFiltersSettings.jsx b/frontend/src/components/settings/AudioFiltersSettings.jsx index cb8a2dd..4bbf9c3 100644 --- a/frontend/src/components/settings/AudioFiltersSettings.jsx +++ b/frontend/src/components/settings/AudioFiltersSettings.jsx @@ -54,9 +54,7 @@ export default function AudioFiltersSettings({ settings, updateSettings }) {
-

- Apply audio effects to all TTS messages -

+
- + Placement + + + Chat Bubbles + Glow Effect @@ -38,6 +43,15 @@ function AvatarConfigurationTabs({ settings, updateSettings, apiUrl }) {
+ +
+ +
+
+
Avatar Management - Upload and manage avatar images (voices are randomly selected) + Upload and manage avatar images {/* Upload Configuration */} diff --git a/frontend/src/components/settings/AvatarPlacementSettings.jsx b/frontend/src/components/settings/AvatarPlacementSettings.jsx index d751c5b..8566159 100644 --- a/frontend/src/components/settings/AvatarPlacementSettings.jsx +++ b/frontend/src/components/settings/AvatarPlacementSettings.jsx @@ -2,13 +2,112 @@ import React from 'react' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Button } from '../ui/button' +import { Switch } from '../ui/switch' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' +import { LayoutGrid } from 'lucide-react' import logger from '../../utils/logger' function AvatarPlacementSettings({ settings, updateSettings, apiUrl }) { + const avatarMode = settings.avatarMode || 'grid' + return ( -
+ + + + + Avatar Placement + + + Configure avatar size, layout mode, and positioning + + +
- + + updateSettings({ avatarSize: parseInt(e.target.value) || 60 })} + /> +
+
+ +
+ + +
+ +
+ + {avatarMode === 'popup' && ( +
+
+ + +

+ Direction from which avatars will appear +

+
+ +
+
+ +

+ Should the avatar appear at the edge or at a random position +

+
+ updateSettings({ popupFixedEdge: checked })} + /> +
+ +
+
+ +

+ Rotate avatars to face the direction they're coming from +

+
+ updateSettings({ popupRotateToDirection: checked })} + /> +
+
+ )} + + {avatarMode === 'grid' && ( + <> +
+
-
- - updateSettings({ avatarSize: parseInt(e.target.value) || 60 })} - /> -
-
@@ -93,34 +180,37 @@ function AvatarPlacementSettings({ settings, updateSettings, apiUrl }) {
-
-
-
- -

Randomly reassign avatars to different positions

+
+
+
+ +

Randomly reassign avatars to different positions

+
+ +
- -
-
-
+ + )} +
+
) } diff --git a/frontend/src/components/settings/ChatBubbleSettings.jsx b/frontend/src/components/settings/ChatBubbleSettings.jsx new file mode 100644 index 0000000..116785c --- /dev/null +++ b/frontend/src/components/settings/ChatBubbleSettings.jsx @@ -0,0 +1,243 @@ +import React from 'react' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import { Slider } from '../ui/slider' +import { Input } from '../ui/input' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' +import { MessageSquare } from 'lucide-react' + +const FONT_OPTIONS = [ + { value: 'Arial, sans-serif', label: 'Arial' }, + { value: '"Helvetica Neue", Helvetica, sans-serif', label: 'Helvetica' }, + { value: '"Segoe UI", Tahoma, sans-serif', label: 'Segoe UI' }, + { value: 'Verdana, sans-serif', label: 'Verdana' }, + { value: '"Trebuchet MS", sans-serif', label: 'Trebuchet MS' }, + { value: 'Georgia, serif', label: 'Georgia' }, + { value: '"Times New Roman", Times, serif', label: 'Times New Roman' }, + { value: '"Courier New", Courier, monospace', label: 'Courier New' }, + { value: '"Comic Sans MS", cursive', label: 'Comic Sans MS' }, + { value: 'Impact, fantasy', label: 'Impact' }, + { value: '"Brush Script MT", cursive', label: 'Brush Script' } +] + +function ChatBubbleSettings({ settings, onUpdate }) { + const chatBubblesEnabled = settings?.chatBubblesEnabled ?? true + const bubbleFontFamily = settings?.bubbleFontFamily ?? 'Arial, sans-serif' + const bubbleFontSize = settings?.bubbleFontSize ?? 14 + const bubbleFontColor = settings?.bubbleFontColor ?? '#ffffff' + const bubbleBackgroundColor = settings?.bubbleBackgroundColor ?? '#000000' + const bubbleOpacity = settings?.bubbleOpacity ?? 0.85 + const bubbleRounded = settings?.bubbleRounded ?? true + + const handleToggle = (enabled) => { + onUpdate({ chatBubblesEnabled: enabled }) + } + + const handleFontFamilyChange = (e) => { + onUpdate({ bubbleFontFamily: e.target.value }) + } + + const handleFontSizeChange = (value) => { + onUpdate({ bubbleFontSize: value[0] }) + } + + const handleFontColorChange = (e) => { + onUpdate({ bubbleFontColor: e.target.value }) + } + + const handleBackgroundColorChange = (e) => { + onUpdate({ bubbleBackgroundColor: e.target.value }) + } + + const handleOpacityChange = (value) => { + onUpdate({ bubbleOpacity: value[0] }) + } + + const handleRoundedToggle = (rounded) => { + onUpdate({ bubbleRounded: rounded }) + } + + return ( + + + + + Chat Bubbles + + + Display chat messages in speech bubbles above avatars + + + +
+
+ +
+ +
+ + {chatBubblesEnabled && ( + <> + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+
+ + {bubbleFontSize}px +
+ +
+ + {/* Font Color */} +
+ +
+ + +
+
+ + {/* Background Color */} +
+ +
+ + +
+
+ + {/* Opacity */} +
+
+ + {Math.round(bubbleOpacity * 100)}% +
+ +
+ + {/* Shape Toggle */} +
+
+ +
+ +
+ + )} + + {/* Preview */} +
+
+ {/* Example avatar */} +
+ +
+ {/* Example chat bubble */} + {chatBubblesEnabled && ( +
+ Hello, World! +
+
+ )} +
+
+ + + + ) +} + +export default ChatBubbleSettings diff --git a/frontend/src/components/settings/ExportImportSettings.jsx b/frontend/src/components/settings/ExportImportSettings.jsx index b3469d3..1a73285 100644 --- a/frontend/src/components/settings/ExportImportSettings.jsx +++ b/frontend/src/components/settings/ExportImportSettings.jsx @@ -236,9 +236,6 @@ export default function ExportImportSettings({ apiUrl = '' }) {
-

- Download a complete backup of your settings, voices, and avatar images as a ZIP file -

{/* Opacity Slider */} @@ -86,9 +93,7 @@ export default function GlowEffectSettings({ settings, onUpdate }) { disabled={!glowEnabled} className="w-full" /> -

- Adjust the transparency of the glow effect (0% = invisible, 100% = fully opaque) -

+
{/* Size Slider */} @@ -109,9 +114,6 @@ export default function GlowEffectSettings({ settings, onUpdate }) { disabled={!glowEnabled} className="w-full" /> -

- Adjust the spread/blur radius of the glow effect -

{/* Preview */} @@ -132,6 +134,7 @@ export default function GlowEffectSettings({ settings, onUpdate }) { />
- + + ) } diff --git a/frontend/src/components/settings/MessageFiltering.jsx b/frontend/src/components/settings/MessageFiltering.jsx index c20bbe7..da90b89 100644 --- a/frontend/src/components/settings/MessageFiltering.jsx +++ b/frontend/src/components/settings/MessageFiltering.jsx @@ -300,7 +300,6 @@ function MessageFiltering({ settings, updateSettings, apiUrl }) {
-

Filter messages before TTS processing

- When a user's message is currently playing TTS, ignore any new messages from that same user until the current message finishes. This prevents interrupting or queueing multiple messages from one person. + A user will only have one message be read at a time. Otherwise one user can spam multiple messages to be read.

@@ -595,7 +594,6 @@ function MessageFiltering({ settings, updateSettings, apiUrl }) { })} placeholder="beep" /> -

Text to replace filtered words with

diff --git a/frontend/src/components/settings/TwitchIntegration.jsx b/frontend/src/components/settings/TwitchIntegration.jsx index b84dbdc..8d65e25 100644 --- a/frontend/src/components/settings/TwitchIntegration.jsx +++ b/frontend/src/components/settings/TwitchIntegration.jsx @@ -49,7 +49,7 @@ function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) { const error = urlParams.get('error'); if (error === 'twitch_not_configured') { - alert('⚠️ Twitch integration not configured!\n\nThe developer needs to set up Twitch OAuth credentials.\nSee TWITCH_SETUP.md for instructions.'); + alert('Twitch integration not configured!\n\nThe developer needs to set up Twitch OAuth credentials.\nSee TWITCH_SETUP.md for instructions.'); // Clear error from URL window.history.replaceState({}, document.title, window.location.pathname); } @@ -153,9 +153,7 @@ function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) {
-

- Start reading chat messages from your Twitch channel -

+
-

- Start reading live chat messages from your YouTube streams -

Moderation Event Tester - - Test Twitch CLEARCHAT events (bans/timeouts) to verify TTS cancellation works correctly - +
@@ -471,9 +469,7 @@ function ClearChatTester({ onTest }) { value={targetUser} onChange={e => setTargetUser(e.target.value)} /> -

- User whose TTS will be cancelled -

+
@@ -509,17 +505,7 @@ function ClearChatTester({ onTest }) {

)} - -
-

What this tests:

-
    -
  • Cancels any active TTS for the target user
  • -
  • Removes queued messages from the target user
  • -
  • Releases avatar slot if user is currently speaking
  • -
  • Simulates real Twitch moderation events
  • -
-
- +
@@ -1223,14 +1224,12 @@ export default function YappersPage() { const bubbleOffset = 10 // Distance from avatar const bgColor = settings?.bubbleBackgroundColor || '#000000' const opacity = settings?.bubbleOpacity ?? 0.85 - const hexOpacity = Math.round(opacity * 255).toString(16).padStart(2, '0') - const baseStyle = { position: 'absolute', minWidth: '120px', maxWidth: '300px', padding: '8px 12px', - background: `${bgColor}${hexOpacity}`, + background: hexColorWithOpacity(bgColor, opacity), color: settings?.bubbleFontColor || '#ffffff', borderRadius: (settings?.bubbleRounded ?? true) ? '12px' : '4px', fontFamily: settings?.bubbleFontFamily || 'Arial, sans-serif', @@ -1292,8 +1291,7 @@ export default function YappersPage() { const getChatBubbleTail = () => { const bgColor = settings?.bubbleBackgroundColor || '#000000' const opacity = settings?.bubbleOpacity ?? 0.85 - const hexOpacity = Math.round(opacity * 255).toString(16).padStart(2, '0') - const tailColor = `${bgColor}${hexOpacity}` + const tailColor = hexColorWithOpacity(bgColor, opacity) switch (popupDirection) { case 'bottom': diff --git a/frontend/src/utils/colorUtils.js b/frontend/src/utils/colorUtils.js new file mode 100644 index 0000000..f9a1d10 --- /dev/null +++ b/frontend/src/utils/colorUtils.js @@ -0,0 +1,16 @@ +/** + * Color utility functions for Chat Yapper frontend + */ + +/** + * Converts a hex color and opacity value to a hex color with opacity + * @param {string} color - Hex color code (e.g., '#000000') + * @param {number} opacity - Opacity value between 0 and 1 + * @returns {string} Hex color with opacity (e.g., '#000000D9') + */ +export function hexColorWithOpacity(color, opacity) { + const hexOpacity = Math.round(opacity * 255).toString(16).padStart(2, '0'); + return `${color}${hexOpacity}`; +} + +export default { hexColorWithOpacity }; From 730dc888dd15c22c1f4f01efe1a481008642457f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 10:32:14 +0000 Subject: [PATCH 23/46] Fix audio error handlers to clean up tracking references Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> --- frontend/src/pages/YappersPage.jsx | 54 ++++++++++++++---------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/frontend/src/pages/YappersPage.jsx b/frontend/src/pages/YappersPage.jsx index a3c3518..eacf9d3 100644 --- a/frontend/src/pages/YappersPage.jsx +++ b/frontend/src/pages/YappersPage.jsx @@ -666,6 +666,17 @@ export default function YappersPage() { } } + // Reusable cleanup function for audio tracking references + const cleanupAudioTracking = () => { + const username = msg.user?.toLowerCase() + if (username && activeAudioRef.current.get(username) === audio) { + activeAudioRef.current.delete(username) + } + if (audio) { + allActiveAudioRef.current.delete(audio) + } + } + // Check current avatar mode const currentAvatarMode = settingsRef.current?.avatarMode || 'grid' @@ -695,13 +706,7 @@ export default function YappersPage() { } // Clean up audio tracking - const username = msg.user?.toLowerCase() - if (username && activeAudioRef.current.get(username) === audio) { - activeAudioRef.current.delete(username) - } - if (audio) { - allActiveAudioRef.current.delete(audio) - } + cleanupAudioTracking() } audio.addEventListener('ended', end) audio.addEventListener('pause', end) @@ -735,13 +740,7 @@ export default function YappersPage() { notifySlotEnded(targetSlot.id) // Clean up audio tracking - const username = msg.user?.toLowerCase() - if (username && activeAudioRef.current.get(username) === audio) { - activeAudioRef.current.delete(username) - } - if (audio) { - allActiveAudioRef.current.delete(audio) - } + cleanupAudioTracking() } audio.addEventListener('ended', end) audio.addEventListener('pause', end) @@ -781,7 +780,11 @@ export default function YappersPage() { .catch(error => { console.error('Failed to load Web Speech instructions:', error) // CRITICAL: Clean up on fetch failure to prevent backend thinking slot is occupied - end() + if (targetSlot) { + notifySlotError(targetSlot.id, error.message || 'Failed to load Web Speech instructions') + deactivateSlot(targetSlot.id) + notifySlotEnded(targetSlot.id) + } }) } else { // Handle regular audio file @@ -794,7 +797,11 @@ export default function YappersPage() { // CRITICAL: Clean up on error to prevent backend thinking slot is occupied if (targetSlot) { notifySlotError(targetSlot.id, audio.error?.message || 'Audio loading error') + deactivateSlot(targetSlot.id) + notifySlotEnded(targetSlot.id) } + // Clean up audio tracking to prevent memory leaks + cleanupAudioTracking() }) // Wait for audio to be ready before playing to prevent crackling from premature playback @@ -810,19 +817,10 @@ export default function YappersPage() { // For popup mode, use the cleanup from the earlier event listeners // The 'error' event listener will handle cleanup } else if (targetSlot) { - const end = () => { - logger.info('Audio ended (from play error) - deactivating avatar:', targetSlot.id) - deactivateSlot(targetSlot.id) - notifySlotEnded(targetSlot.id) - const username = msg.user?.toLowerCase() - if (username && activeAudioRef.current.get(username) === audio) { - activeAudioRef.current.delete(username) - } - if (audio) { - allActiveAudioRef.current.delete(audio) - } - } - end() + logger.info('Audio ended (from play error) - deactivating avatar:', targetSlot.id) + deactivateSlot(targetSlot.id) + notifySlotEnded(targetSlot.id) + cleanupAudioTracking() } }) } From a0b1f82fdb6d56067c5e99120d05ab318c79e0b5 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 2 Nov 2025 12:21:14 +0100 Subject: [PATCH 24/46] update flow for builds --- .github/workflows/build-and-release.yml | 106 ++++++++++++++++++------ 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 8649896..c8a18e3 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -243,28 +243,86 @@ jobs: body: comment }); - - name: Download Docker artifacts + - name: Upload Windows artifacts for release if: steps.version.outputs.is_release == 'true' + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + dist/ChatYapper.exe + dist/msi/*.msi + retention-days: 90 + + create-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [build, linux-build, docker-build] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version from backend/version.py + id: get_version + run: | + if [ -f "backend/version.py" ]; then + version=$(grep -oP '__version__\s*=\s*["\047]\K[^"\047]+' backend/version.py || echo "1.0.0") + else + version="1.0.0" + fi + echo "app_version=$version" >> $GITHUB_OUTPUT + echo "version=v$version" >> $GITHUB_OUTPUT + + - name: Download Windows build artifacts uses: actions/download-artifact@v4 with: - name: docker-release-files - path: ./docker-release + name: windows-build + path: ./windows-build - - name: Download Linux build - if: steps.version.outputs.is_release == 'true' + - name: Download Linux build artifacts uses: actions/download-artifact@v4 with: name: linux-build path: ./linux-build - - name: Create Release (on merge to main) - if: steps.version.outputs.is_release == 'true' + - name: Download Docker artifacts + if: needs.docker-build.result == 'success' + uses: actions/download-artifact@v4 + with: + name: docker-release-files + path: ./docker-release + continue-on-error: true + + - name: Get MSI filename + id: msi_info + run: | + msi_file=$(ls windows-build/dist/msi/*.msi 2>/dev/null | head -n1 || echo "") + if [ -n "$msi_file" ]; then + msi_name=$(basename "$msi_file") + echo "msi_name=$msi_name" >> $GITHUB_OUTPUT + else + echo "msi_name=ChatYapper.msi" >> $GITHUB_OUTPUT + fi + + - name: Check Docker build status + id: docker_status + run: | + if [ "${{ needs.docker-build.result }}" == "success" ]; then + echo "docker_success=true" >> $GITHUB_OUTPUT + echo "Docker build succeeded" + else + echo "docker_success=false" >> $GITHUB_OUTPUT + echo "Docker build failed or was skipped - release will not include Docker files" + fi + + - name: Create Release uses: softprops/action-gh-release@v1 with: - tag_name: ${{ steps.version.outputs.version }} - name: Chat Yapper ${{ steps.version.outputs.version }} + tag_name: ${{ steps.get_version.outputs.version }} + name: Chat Yapper ${{ steps.get_version.outputs.version }} body: | - ## Chat Yapper ${{ steps.version.outputs.version }} + ## Chat Yapper ${{ steps.get_version.outputs.version }} ### What's Changed This release was automatically created from the latest merge to main. @@ -328,22 +386,19 @@ jobs: - **Issues:** https://github.com/${{ github.repository }}/issues - **Discussions:** https://github.com/${{ github.repository }}/discussions files: | - dist/ChatYapper.exe - dist/msi/*.msi + windows-build/dist/ChatYapper.exe + windows-build/dist/msi/*.msi linux-build/*.tar.gz - docker-release/docker-compose-release.yml - docker-release/.env.example - docker-release/DOCKER_INSTALL.md + docker-release/* draft: false prerelease: false + fail_on_unmatched_files: false - name: Notify release created - if: steps.version.outputs.is_release == 'true' run: | - Write-Host "Release created successfully!" - Write-Host "Version: ${{ steps.version.outputs.version }}" - Write-Host "View at: https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.version }}" - shell: powershell + echo "Release created successfully!" + echo "Version: ${{ steps.get_version.outputs.version }}" + echo "View at: https://github.com/${{ github.repository }}/releases/tag/${{ steps.get_version.outputs.version }}" linux-build: name: Build Linux Executable @@ -571,6 +626,7 @@ jobs: docker-build: name: Build and Push Docker Image runs-on: ubuntu-latest + continue-on-error: true needs: build # Run after Windows build succeeds steps: @@ -937,9 +993,9 @@ jobs: echo "Cannot merge to main until build succeeds" exit 1 fi - if [ "${{ needs.docker-build.result }}" != "success" ]; then - echo "[FAIL] Docker build failed or was cancelled" - echo "Cannot merge to main until build succeeds" - exit 1 + if [ "${{ needs.docker-build.result }}" == "success" ]; then + echo "[PASS] Docker build succeeded" + elif [ "${{ needs.docker-build.result }}" == "failure" ]; then + echo "[WARN] Docker build failed - continuing anyway (Docker build is optional)" fi - echo "[PASS] All builds succeeded - safe to merge" + echo "[PASS] Required builds succeeded - safe to merge" From 6adf5b1d062403541e0a97e3d04c96397d1fcbb4 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 2 Nov 2025 12:30:01 +0100 Subject: [PATCH 25/46] better build and release file --- .github/workflows/build-and-release.yml | 574 +----------------------- 1 file changed, 1 insertion(+), 573 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index f26b545..a869c5a 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -973,578 +973,6 @@ jobs: DOCKER_INSTALL.md retention-days: 90 - linux-build: - name: Build Linux Executable - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python 3.11 - uses: actions/setup-python@v4 - with: - python-version: '3.11' - cache: 'pip' - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y ffmpeg - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - # Use PyInstaller 5.13.2 to avoid wheel aliasing issues in v6.x on Linux - pip install pyinstaller==5.13.2 pillow python-dotenv - - - name: Get version from backend/version.py - id: get_version - run: | - if [ -f "backend/version.py" ]; then - version=$(grep -oP '__version__\s*=\s*["\047]\K[^"\047]+' backend/version.py || echo "1.0.0") - echo "Found version in backend/version.py: $version" - else - version="1.0.0" - echo "backend/version.py not found, using default: $version" - fi - - echo "app_version=$version" >> $GITHUB_OUTPUT - echo "VITE_APP_VERSION=$version" >> $GITHUB_ENV - - # Update backend/version.py - echo "__version__ = \"$version\"" > backend/version.py - - - name: Create .env file for build - run: | - echo "TWITCH_CLIENT_ID=${{ secrets.TWITCH_CLIENT_ID }}" > .env - echo "TWITCH_CLIENT_SECRET=${{ secrets.TWITCH_CLIENT_SECRET }}" >> .env - echo "YOUTUBE_CLIENT_ID=${{ secrets.YOUTUBE_CLIENT_ID }}" >> .env - echo "YOUTUBE_CLIENT_SECRET=${{ secrets.YOUTUBE_CLIENT_SECRET }}" >> .env - echo "VITE_APP_VERSION=${{ steps.get_version.outputs.app_version }}" >> .env - - - name: Build frontend - working-directory: ./frontend - run: | - npm install - npm run build - env: - VITE_APP_VERSION: ${{ steps.get_version.outputs.app_version }} - - - name: Run build script - run: python deployment/build.py - env: - TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} - TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} - YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }} - YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} - VITE_APP_VERSION: ${{ steps.get_version.outputs.app_version }} - - - name: Verify executable exists - run: | - if [ ! -f "dist/ChatYapper" ]; then - echo "Build failed: ChatYapper executable not found in dist directory" - exit 1 - fi - echo "[SUCCESS] Build successful: ChatYapper found" - size=$(du -h dist/ChatYapper | cut -f1) - echo "Executable size: $size" - - # Make executable - chmod +x dist/ChatYapper - - - name: Create launcher script - run: | - cat > dist/chatyapper.sh << 'EOFSH' - #!/bin/bash - # Chat Yapper Linux Launcher - - # Get the directory where this script is located - SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - - # Change to script directory - cd "$SCRIPT_DIR" - - # Check if ffmpeg is installed - if ! command -v ffmpeg &> /dev/null; then - echo "Warning: ffmpeg is not installed. Audio filters will not work." - echo "Install with: sudo apt-get install ffmpeg" - fi - - # Run the application - ./ChatYapper "$@" - EOFSH - - chmod +x dist/chatyapper.sh - - echo "Created launcher script: chatyapper.sh" - - - name: Create README for Linux build - run: | - cat > dist/README-LINUX.txt << 'EOFREADME' - # Chat Yapper - Linux Build - - ## Quick Start - - 1. Extract this archive to a directory of your choice - 2. Open a terminal in that directory - 3. Run: ./chatyapper.sh - 4. Open http://localhost:8008 in your browser - - ## Requirements - - - Linux x86_64 (64-bit) - - ffmpeg (optional, for audio filters) - Install with: sudo apt-get install ffmpeg - - ## Running - - Option 1 - Using the launcher script (recommended): - ./chatyapper.sh - - Option 2 - Direct execution: - ./ChatYapper - - The application will start on http://localhost:8008 - - ## First Run - - On first run, the application will: - - Create a database at ~/.chatyapper/app.db - - Create audio directory at ~/.chatyapper/audio - - Start the web server on port 8008 - - ## Configuration - - All settings are managed through the web interface at: - http://localhost:8008/settings - - ## Troubleshooting - - If you get "permission denied": - chmod +x ChatYapper - chmod +x chatyapper.sh - - If port 8008 is already in use: - PORT=8009 ./chatyapper.sh - - ## System Requirements - - - Linux kernel 3.10 or later - - 512MB RAM minimum, 1GB recommended - - 500MB disk space - - ## Support - - - Issues: https://github.com/${{ github.repository }}/issues - - Discussions: https://github.com/${{ github.repository }}/discussions - EOFREADME - - - name: Create tarball - run: | - cd dist - tar -czf ChatYapper-linux-x64-v${{ steps.get_version.outputs.app_version }}.tar.gz \ - ChatYapper \ - chatyapper.sh \ - README-LINUX.txt - - echo "Created tarball: ChatYapper-linux-x64-v${{ steps.get_version.outputs.app_version }}.tar.gz" - ls -lh ChatYapper-linux-x64-v${{ steps.get_version.outputs.app_version }}.tar.gz - - - name: Get version info - id: version - run: | - app_version="${{ steps.get_version.outputs.app_version }}" - short_sha=$(echo "${{ github.sha }}" | cut -c1-7) - - if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then - version="v$app_version" - echo "version=$version" >> $GITHUB_OUTPUT - echo "is_release=true" >> $GITHUB_OUTPUT - echo "Release version: $version" - else - date=$(date +%Y.%m.%d) - version="build-v$app_version-$date-$short_sha" - echo "version=$version" >> $GITHUB_OUTPUT - echo "is_release=false" >> $GITHUB_OUTPUT - echo "PR build version: $version" - fi - - - name: Upload artifact for PR - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 - with: - name: ChatYapper-Linux-${{ steps.version.outputs.version }} - path: dist/ChatYapper-linux-x64-*.tar.gz - retention-days: 7 - - - name: Upload Linux build for release - if: steps.version.outputs.is_release == 'true' - uses: actions/upload-artifact@v4 - with: - name: linux-build - path: dist/ChatYapper-linux-x64-*.tar.gz - retention-days: 90 - - docker-build: - name: Build and Push Docker Image - runs-on: ubuntu-latest - needs: build # Run after Windows build succeeds - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Get version from backend/version.py - id: docker_version - run: | - # Extract version from backend/version.py - if [ -f "backend/version.py" ]; then - version=$(grep -oP '__version__\s*=\s*["\047]\K[^"\047]+' backend/version.py || echo "1.0.0") - echo "Found version in backend/version.py: $version" - else - version="1.0.0" - echo "backend/version.py not found, using default: $version" - fi - - echo "app_version=$version" >> $GITHUB_OUTPUT - - # Determine if this is a release or PR build - if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "is_release=true" >> $GITHUB_OUTPUT - echo "docker_tag=v$version" >> $GITHUB_OUTPUT - else - short_sha=$(echo "${{ github.sha }}" | cut -c1-7) - echo "is_release=false" >> $GITHUB_OUTPUT - echo "docker_tag=pr-$short_sha" >> $GITHUB_OUTPUT - fi - - - name: Extract metadata for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository_owner }}/chat-yapper - tags: | - type=raw,value=${{ steps.docker_version.outputs.docker_tag }} - type=raw,value=latest,enable=${{ steps.docker_version.outputs.is_release == 'true' }} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64,linux/arm64 - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - APP_VERSION=${{ steps.docker_version.outputs.app_version }} - - - name: Generate docker-compose.yml for release - if: steps.docker_version.outputs.is_release == 'true' - run: | - cat > docker-compose-release.yml << 'EOF' - # Chat Yapper - Docker Compose Configuration - # Version: ${{ steps.docker_version.outputs.app_version }} - # - # Quick Start: - # 1. Create a .env file with your configuration (see .env.example) - # 2. Run: docker-compose up -d - # 3. Access at: http://localhost:8069 - - services: - chat-yapper: - image: ghcr.io/${{ github.repository_owner }}/chat-yapper:${{ steps.docker_version.outputs.docker_tag }} - container_name: chat-yapper - restart: unless-stopped - - ports: - - "8069:8008" - - volumes: - - chat-yapper-data:/data - - ./audio:/app/audio - - environment: - - HOST=0.0.0.0 - - PORT=8008 - - DEBUG=false - - DB_PATH=/data/app.db - - AUDIO_DIR=/app/audio - - TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID:-} - - TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET:-} - - YOUTUBE_CLIENT_ID=${YOUTUBE_CLIENT_ID:-} - - YOUTUBE_CLIENT_SECRET=${YOUTUBE_CLIENT_SECRET:-} - - FRONTEND_PORT=5173 - - env_file: - - .env - - network_mode: bridge - - volumes: - chat-yapper-data: - driver: local - EOF - - cat > .env.example << 'EOF' - # Chat Yapper Environment Configuration - # Copy this file to .env and fill in your values - - # Twitch OAuth Configuration - # Get credentials from: https://dev.twitch.tv/console/apps - TWITCH_CLIENT_ID=your_twitch_client_id_here - TWITCH_CLIENT_SECRET=your_twitch_client_secret_here - - # YouTube OAuth Configuration - # Get credentials from: https://console.cloud.google.com/apis/credentials - YOUTUBE_CLIENT_ID=your_youtube_client_id_here - YOUTUBE_CLIENT_SECRET=your_youtube_client_secret_here - - # Optional: TTS Provider API Keys - # MonsterTTS API Key (if using MonsterTTS) - # MONSTERTTS_API_KEY=your_monstertts_api_key_here - - # Google Cloud TTS API Key (if using Google TTS) - # GOOGLE_TTS_API_KEY=your_google_api_key_here - - # AWS Polly Credentials (if using AWS Polly) - # AWS_ACCESS_KEY_ID=your_aws_access_key - # AWS_SECRET_ACCESS_KEY=your_aws_secret_key - # AWS_REGION=us-east-1 - EOF - - - name: Create Docker installation guide - if: steps.docker_version.outputs.is_release == 'true' - run: | - cat > DOCKER_INSTALL.md << 'EOF' - # Chat Yapper - Docker Installation Guide - - ## Prerequisites - - Docker Desktop (Windows/Mac) or Docker Engine (Linux) - - That's it! No files needed for basic usage. - - ## Quick Start - - ### Option 1: Simple Docker Run (Recommended) - - **One command to get started:** - - ```bash - docker run -d \ - --name chat-yapper \ - -p 8069:8008 \ - -v chat-yapper-data:/data \ - -e TWITCH_CLIENT_ID=your_client_id \ - -e TWITCH_CLIENT_SECRET=your_client_secret \ - -e YOUTUBE_CLIENT_ID=your_youtube_id \ - -e YOUTUBE_CLIENT_SECRET=your_youtube_secret \ - --restart unless-stopped \ - ghcr.io/${{ github.repository_owner }}/chat-yapper:latest - ``` - - Replace the credentials with your actual values and you're done! - - **Access:** Open http://localhost:8069 in your browser - - **Optional audio volume:** Add `-v $(pwd)/audio:/app/audio` to access audio files on host - - ### Option 2: Using Docker Compose (For easier management) - - If you prefer using Docker Compose for easier configuration management: - - 1. **Download the release files:** - - `docker-compose-release.yml` - - `.env.example` - - 2. **Create your environment file:** - ```bash - cp .env.example .env - # Edit .env with your credentials using your preferred editor - ``` - - 3. **Start the application:** - ```bash - docker-compose -f docker-compose-release.yml up -d - ``` - - 4. **Access:** http://localhost:8069 - - ## Configuration - - ### Environment Variables - - | Variable | Description | Required | - |----------|-------------|----------| - | `TWITCH_CLIENT_ID` | Twitch OAuth Client ID | Yes (if using Twitch) | - | `TWITCH_CLIENT_SECRET` | Twitch OAuth Client Secret | Yes (if using Twitch) | - | `YOUTUBE_CLIENT_ID` | YouTube OAuth Client ID | Yes (if using YouTube) | - | `YOUTUBE_CLIENT_SECRET` | YouTube OAuth Client Secret | Yes (if using YouTube) | - | `DB_PATH` | Database file path | No (default: /data/app.db) | - | `AUDIO_DIR` | Audio files directory | No (default: /app/audio) | - | `HOST` | Server host | No (default: 0.0.0.0) | - | `PORT` | Server port | No (default: 8008) | - | `DEBUG` | Debug mode | No (default: false) | - - ### Persistent Data - - The Docker image stores data in two locations: - - **Database & Settings:** `/data` volume (persistent) - - **Audio Files:** `/app/audio` volume (can be mounted to host) - - ### Port Configuration - - The container exposes port 8008 internally, mapped to 8069 on the host by default. - To change the host port, modify the docker-compose file: - - ```yaml - ports: - - "YOUR_PORT:8008" - ``` - - ## Managing the Container - - ### View Logs - ```bash - docker logs -f chat-yapper - ``` - - ### Stop the Container - ```bash - docker-compose -f docker-compose-release.yml down - ``` - - ### Update to Latest Version - ```bash - # Pull the latest image - docker-compose -f docker-compose-release.yml pull - - # Restart with new image - docker-compose -f docker-compose-release.yml up -d - ``` - - ### Backup Data - ```bash - # Backup the data volume - docker run --rm \ - -v chat-yapper-data:/data \ - -v $(pwd):/backup \ - alpine tar czf /backup/chat-yapper-backup.tar.gz /data - ``` - - ### Restore Data - ```bash - # Restore from backup - docker run --rm \ - -v chat-yapper-data:/data \ - -v $(pwd):/backup \ - alpine tar xzf /backup/chat-yapper-backup.tar.gz -C / - ``` - - ## Troubleshooting - - ### Container won't start - - Check Docker Desktop is running - - Verify ports are not already in use: `netstat -ano | findstr :8069` - - Check logs: `docker logs chat-yapper` - - ### Can't access the application - - Ensure firewall allows port 8069 - - Try accessing http://127.0.0.1:8069 instead of localhost - - Check container status: `docker ps` - - ### Permission errors - - On Linux, ensure the mounted volumes have correct permissions - - Try running with sudo or adjust volume ownership - - ## Multi-Architecture Support - - This image supports both: - - **linux/amd64** (x86_64) - - **linux/arm64** (ARM64/Apple Silicon) - - Docker will automatically pull the correct architecture for your system. - - ## Support - - For issues, questions, or feature requests: - - GitHub Issues: https://github.com/${{ github.repository }}/issues - - GitHub Discussions: https://github.com/${{ github.repository }}/discussions - EOF - - - name: Comment PR with Docker build status - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const shortSha = '${{ github.sha }}'.substring(0, 7); - const dockerTag = `pr-${shortSha}`; - const dockerImage = `ghcr.io/${{ github.repository_owner }}/chat-yapper:${dockerTag}`; - - const comment = `## 🐳 Docker Image Built Successfully - - **Image:** \`${dockerImage}\` - **Tag:** \`${dockerTag}\` - - ### Test this PR with Docker: - - \`\`\`bash - docker pull ${dockerImage} - - docker run -d \\ - --name chat-yapper-pr \\ - -p 8069:8008 \\ - -e TWITCH_CLIENT_ID=your_id \\ - -e TWITCH_CLIENT_SECRET=your_secret \\ - ${dockerImage} - \`\`\` - - Access at: http://localhost:8069 - - The Docker image will be published to the GitHub Container Registry when merged to \`main\`.`; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: comment - }); - - - name: Upload Docker artifacts for release - if: steps.docker_version.outputs.is_release == 'true' - uses: actions/upload-artifact@v4 - with: - name: docker-release-files - path: | - docker-compose-release.yml - .env.example - DOCKER_INSTALL.md - retention-days: 90 - # This job runs after build and prevents merge if build failed build-status-check: name: Build Status Check @@ -1570,4 +998,4 @@ jobs: elif [ "${{ needs.docker-build.result }}" == "failure" ]; then echo "[WARN] Docker build failed - continuing anyway (Docker build is optional)" fi - echo "[PASS] Required builds succeeded - safe to merge" + echo "[PASS] Required builds succeeded - safe to merge" \ No newline at end of file From 57df8ef7b99d2457ee4ae688e13a2b54470da9b8 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 2 Nov 2025 13:32:28 +0100 Subject: [PATCH 26/46] workflow fix --- .github/workflows/build-and-release.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index a869c5a..ab3f017 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -297,12 +297,19 @@ jobs: - name: Get MSI filename id: msi_info run: | - msi_file=$(ls windows-build/dist/msi/*.msi 2>/dev/null | head -n1 || echo "") + # List all files to debug artifact structure + echo "Windows build artifact contents:" + ls -la windows-build/ || echo "windows-build directory not found" + + # Try to find MSI file + msi_file=$(ls windows-build/*.msi 2>/dev/null | head -n1 || echo "") if [ -n "$msi_file" ]; then msi_name=$(basename "$msi_file") echo "msi_name=$msi_name" >> $GITHUB_OUTPUT + echo "Found MSI: $msi_name" else echo "msi_name=ChatYapper.msi" >> $GITHUB_OUTPUT + echo "MSI file not found, using default name" fi - name: Check Docker build status @@ -386,8 +393,8 @@ jobs: - **Issues:** https://github.com/${{ github.repository }}/issues - **Discussions:** https://github.com/${{ github.repository }}/discussions files: | - windows-build/dist/ChatYapper.exe - windows-build/dist/msi/*.msi + windows-build/ChatYapper.exe + windows-build/*.msi linux-build/*.tar.gz docker-release/* draft: false From 76725205100bea2f3a25ffbeab851e74cc9397e4 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 2 Nov 2025 14:25:52 +0100 Subject: [PATCH 27/46] build fix --- .github/workflows/build-and-release.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index ab3f017..951c44c 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -297,12 +297,8 @@ jobs: - name: Get MSI filename id: msi_info run: | - # List all files to debug artifact structure - echo "Windows build artifact contents:" - ls -la windows-build/ || echo "windows-build directory not found" - - # Try to find MSI file - msi_file=$(ls windows-build/*.msi 2>/dev/null | head -n1 || echo "") + # Try to find MSI file in msi subdirectory + msi_file=$(ls windows-build/msi/*.msi 2>/dev/null | head -n1 || echo "") if [ -n "$msi_file" ]; then msi_name=$(basename "$msi_file") echo "msi_name=$msi_name" >> $GITHUB_OUTPUT @@ -394,9 +390,8 @@ jobs: - **Discussions:** https://github.com/${{ github.repository }}/discussions files: | windows-build/ChatYapper.exe - windows-build/*.msi + windows-build/msi/*.msi linux-build/*.tar.gz - docker-release/* draft: false prerelease: false fail_on_unmatched_files: false From f0cb45599493fd81b08177fd75d51ead1fc7cd33 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Thu, 6 Nov 2025 14:15:44 +0100 Subject: [PATCH 28/46] usernames in chat bubbles --- README.md | 25 ++-- backend/modules/settings_defaults.json | 2 + backend/version.py | 2 +- .../settings/ChatBubbleSettings.jsx | 103 ++++++++++++-- frontend/src/pages/YappersPage.jsx | 126 +++++++++++++----- 5 files changed, 198 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 58737df..fed0b2f 100644 --- a/README.md +++ b/README.md @@ -225,19 +225,20 @@ chat-yapper/ ## Changelog -### v1.2.0 (Latest) +### v1.2.1 (Latest) - **New Features:** - - Chat bubbles above avatars - - Pop-up mode for avatars - - Linux standalone build (x64) - - Improved audio quality (reduced crackling) - - Docker multi-architecture support (amd64, arm64) - -- **Improvements:** - - Better audio preloading and buffering - - High-quality ffmpeg audio processing - - GitHub Container Registry (GHCR) for Docker images - - Automated cross-platform builds via GitHub Actions + - usernames for chatbubbles + +### v1.2.0 +- Chat bubbles above avatars +- Pop-up mode for avatars +- Linux standalone build (x64) +- Improved audio quality (reduced crackling) +- Docker multi-architecture support (amd64, arm64) +- Better audio preloading and buffering +- High-quality ffmpeg audio processing +- GitHub Container Registry (GHCR) for Docker images +- Automated cross-platform builds via GitHub Actions ### v1.1.2 - Stability fixes diff --git a/backend/modules/settings_defaults.json b/backend/modules/settings_defaults.json index b4ce130..a31f077 100644 --- a/backend/modules/settings_defaults.json +++ b/backend/modules/settings_defaults.json @@ -24,6 +24,8 @@ "bubbleBackgroundColor": "#000000", "bubbleOpacity": 0.85, "bubbleRounded": true, +"bubbleShowUsername": true, +"bubbleUsernameColor": "#ffffff", "tts": { "monstertts": { "apiKey": "" diff --git a/backend/version.py b/backend/version.py index eaf3d68..21b74af 100644 --- a/backend/version.py +++ b/backend/version.py @@ -3,4 +3,4 @@ This file is automatically updated during CI/CD builds """ -__version__ = "1.2.0" +__version__ = "1.2.1" diff --git a/frontend/src/components/settings/ChatBubbleSettings.jsx b/frontend/src/components/settings/ChatBubbleSettings.jsx index 5e8edb8..7b4f2f5 100644 --- a/frontend/src/components/settings/ChatBubbleSettings.jsx +++ b/frontend/src/components/settings/ChatBubbleSettings.jsx @@ -29,6 +29,8 @@ function ChatBubbleSettings({ settings, onUpdate }) { const bubbleBackgroundColor = settings?.bubbleBackgroundColor ?? '#000000' const bubbleOpacity = settings?.bubbleOpacity ?? 0.85 const bubbleRounded = settings?.bubbleRounded ?? true + const bubbleShowUsername = settings?.bubbleShowUsername ?? true + const bubbleUsernameColor = settings?.bubbleUsernameColor ?? '#ffffff' const handleToggle = (enabled) => { onUpdate({ chatBubblesEnabled: enabled }) @@ -58,6 +60,14 @@ function ChatBubbleSettings({ settings, onUpdate }) { onUpdate({ bubbleRounded: rounded }) } + const handleShowUsernameToggle = (show) => { + onUpdate({ bubbleShowUsername: show }) + } + + const handleUsernameColorChange = (e) => { + onUpdate({ bubbleUsernameColor: e.target.value }) + } + return ( @@ -145,6 +155,48 @@ function ChatBubbleSettings({ settings, onUpdate }) {
+ {/* Show Username Toggle */} +
+
+ +

+ Display the user's name above the message +

+
+ +
+ + {/* Username Color */} + {bubbleShowUsername && ( +
+ +
+ + +
+
+ )} + {/* Background Color */}
+ + + + )} @@ -212,25 +268,46 @@ function ChatBubbleSettings({ settings, onUpdate }) { {/* Example chat bubble */} {chatBubblesEnabled && (
- Hello, World! + {bubbleShowUsername && ( +
+ Username +
+ )}
+ > + Hello, World! +
+
)}
diff --git a/frontend/src/pages/YappersPage.jsx b/frontend/src/pages/YappersPage.jsx index 0113401..21fa85a 100644 --- a/frontend/src/pages/YappersPage.jsx +++ b/frontend/src/pages/YappersPage.jsx @@ -173,7 +173,7 @@ export default function YappersPage() { const popupIdCounter = useRef(0) // Track chat messages for both grid and popup modes - const [chatMessages, setChatMessages] = useState({}) // slotId/popupId -> message text + const [chatMessages, setChatMessages] = useState({}) // slotId/popupId -> { message: string, username: string } // Helper function to check if two positions overlap const checkOverlap = useCallback((pos1, pos2, avatarSize) => { @@ -283,7 +283,7 @@ export default function YappersPage() { // Helper function to get random avatar from available avatars // Helper function to create a new popup avatar instance - const createPopupAvatar = useCallback((audioElement, avatarData, message) => { + const createPopupAvatar = useCallback((audioElement, avatarData, message, username) => { const id = `popup_${popupIdCounter.current++}` // Get existing avatar positions to avoid overlaps @@ -308,9 +308,9 @@ export default function YappersPage() { audio: audioElement } - // Store the chat message + // Store the chat message with username if (message) { - setChatMessages(prev => ({ ...prev, [id]: message })) + setChatMessages(prev => ({ ...prev, [id]: { message, username: username || 'Unknown' } })) } // Activate after a brief delay to trigger animation @@ -348,10 +348,10 @@ export default function YappersPage() { }, []) // Helper function to activate slot with random position for GRID mode only - const activateSlot = useCallback((slotId, message) => { + const activateSlot = useCallback((slotId, message, username) => { setActiveSlots(slots => ({...slots, [slotId]: true})) if (message) { - setChatMessages(prev => ({ ...prev, [slotId]: message })) + setChatMessages(prev => ({ ...prev, [slotId]: { message, username: username || 'Unknown' } })) } logger.info(`Activated slot ${slotId}`) }, []) @@ -400,7 +400,7 @@ export default function YappersPage() { if (nextSpeech.targetSlot) { utterance.addEventListener('start', () => { logger.info('Web Speech started - activating avatar:', nextSpeech.targetSlot.id) - activateSlot(nextSpeech.targetSlot.id, nextSpeech.data.text) + activateSlot(nextSpeech.targetSlot.id, nextSpeech.data.text, nextSpeech.data.user) }) const end = () => { @@ -688,7 +688,7 @@ export default function YappersPage() { // Create popup avatar immediately to avoid race condition // This ensures popupId is always set before any cleanup handlers run - const popupId = createPopupAvatar(audio, selectedAvatar, msg.message) + const popupId = createPopupAvatar(audio, selectedAvatar, msg.message, msg.user) logger.info('Created popup avatar with ID:', popupId) let cleanedUp = false @@ -719,7 +719,7 @@ export default function YappersPage() { audio.addEventListener('play', () => { logger.info('Audio started playing - activating avatar:', targetSlot.id) - activateSlot(targetSlot.id, msg.message) + activateSlot(targetSlot.id, msg.message, msg.user) }) let cleanedUp = false @@ -1030,37 +1030,65 @@ export default function YappersPage() { transform: 'translate(-50%, -100%)', minWidth: '120px', maxWidth: '300px', - padding: '8px 12px', - background: hexColorWithOpacity(settings?.bubbleBackgroundColor || '#000000', settings?.bubbleOpacity ?? 0.85), - color: settings?.bubbleFontColor || '#ffffff', - borderRadius: (settings?.bubbleRounded ?? true) ? '12px' : '4px', - fontFamily: settings?.bubbleFontFamily || 'Arial, sans-serif', - fontSize: `${settings?.bubbleFontSize ?? 14}px`, - lineHeight: '1.4', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', pointerEvents: 'none', zIndex: 1, opacity: activeSlots[slot.id] ? 1 : 0, - transition: 'opacity 300ms ease-out', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)' + transition: 'opacity 300ms ease-out' }} > - {chatMessages[slot.id]} - {/* Speech bubble tail */} + {/* Username label */} + {(settings?.bubbleShowUsername ?? true) && chatMessages[slot.id].username && ( +
+ {chatMessages[slot.id].username} +
+ )} + + {/* Message bubble */}
+ > + {chatMessages[slot.id].message} + {/* Speech bubble tail */} +
+
)} @@ -1359,8 +1387,38 @@ export default function YappersPage() { > {/* Chat bubble for popup mode */} {settings?.chatBubblesEnabled !== false && chatMessages[id] && ( -
- {chatMessages[id]} +
+ {/* Username label */} + {(settings?.bubbleShowUsername ?? true) && chatMessages[id].username && ( +
+ {chatMessages[id].username} +
+ )} + {/* Message text */} +
+ {chatMessages[id].message} +
{/* Speech bubble tail */}
Date: Thu, 6 Nov 2025 15:35:26 +0100 Subject: [PATCH 29/46] twitch authentifcation --- README.md | 3 + backend/app.py | 387 ++++++++++++++++-- backend/modules/settings_defaults.json | 7 +- backend/modules/twitch_listener.py | 9 + backend/routers/auth.py | 262 +++++++++++- backend/test_auto_refresh.py | 58 +++ .../components/settings/GeneralSettings.jsx | 23 ++ .../settings/PlatformIntegration.jsx | 7 +- .../components/settings/TwitchIntegration.jsx | 303 +++++++++++++- frontend/src/index.css | 27 ++ frontend/src/pages/SettingsPage.jsx | 6 + 11 files changed, 1027 insertions(+), 65 deletions(-) create mode 100644 backend/test_auto_refresh.py diff --git a/README.md b/README.md index fed0b2f..22d0030 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ chat-yapper/ ### v1.2.1 (Latest) - **New Features:** - usernames for chatbubbles + - text size adjustment + - Toggle for only allowing redeem messages for twitch + - twitch token refresh (should fix auth issues) ### v1.2.0 - Chat bubbles above avatars diff --git a/backend/app.py b/backend/app.py index 7c2e934..15daf77 100644 --- a/backend/app.py +++ b/backend/app.py @@ -232,6 +232,11 @@ async def log_requests(request: Request, call_next): else: logger.info("Static files directory not found") +# ---------- Global State ---------- +twitch_auth_error = None +# Track if we've attempted a token refresh in this session to prevent infinite retries +twitch_refresh_attempted = False + # ---------- WebSocket Hub ---------- class Hub: def __init__(self): @@ -291,6 +296,12 @@ async def ws_endpoint(ws: WebSocket): await hub.connect(ws) logger.info(f"WebSocket connected successfully. Total clients: {len(hub.clients)}") + # Reset refresh attempt tracking on new WebSocket connection (indicates page refresh) + global twitch_refresh_attempted + if twitch_refresh_attempted: + logger.info("Resetting token refresh attempt tracking due to new WebSocket connection (page refresh)") + twitch_refresh_attempted = False + # Send a welcome message to confirm connection welcome_msg = { "type": "connection", @@ -300,6 +311,12 @@ async def ws_endpoint(ws: WebSocket): await ws.send_text(json.dumps(welcome_msg)) logger.info(f"Sent welcome message to WebSocket client {client_info}") + # Send any pending auth error to the new client + global twitch_auth_error + if twitch_auth_error: + logger.info(f"Sending pending Twitch auth error to new client {client_info}") + await ws.send_text(json.dumps(twitch_auth_error)) + while True: # Handle messages from frontend (avatar slot status updates, etc.) message = await ws.receive_text() @@ -496,6 +513,14 @@ async def restart_twitch_if_needed(settings: Dict[str, Any]): twitch_config = settings.get("twitch", {}) channel = twitch_config.get("channel") or token_info["username"] + # Test Twitch connection first to detect auth issues early + connection_test_passed = await test_twitch_connection(token_info) + + if not connection_test_passed: + logger.warning("Twitch connection test failed during restart - not starting bot") + TwitchTask = None + return + # Event router to handle different event types async def route_twitch_event(e): event_type = e.get("type", "") @@ -505,13 +530,13 @@ async def route_twitch_event(e): # Default to chat event handler await handle_event(e) - TwitchTask = asyncio.create_task(run_twitch_bot( - token=token_info["token"], - nick=token_info["username"], + # Create Twitch bot task with shared error handling + TwitchTask = await create_twitch_bot_task( + token_info=token_info, channel=channel, - on_event=lambda e: asyncio.create_task(route_twitch_event(e)) - )) - logger.info("Twitch bot restarted") + route_twitch_event=route_twitch_event, + context_name="restart" + ) else: TwitchTask = None logger.info("Twitch bot disabled") @@ -520,27 +545,285 @@ async def route_twitch_event(e): -async def get_twitch_token_for_bot(): - """Get current Twitch token for bot connection""" - try: - auth = get_auth() - if auth: - # Check if token needs refresh (if expires_at is set and in the past) - if auth.expires_at: - expires_at = datetime.fromisoformat(auth.expires_at) - if expires_at <= datetime.now(): - logger.info("Twitch token expired, attempting refresh...") - # TODO: Implement token refresh +def create_twitch_task_exception_handler(context_name: str): + """Create a Twitch task exception handler with consistent error handling logic""" + def handle_twitch_task_exception(task): + logger.info(f"=== TWITCH TASK EXCEPTION HANDLER CALLED ({context_name.upper()}) ===") + try: + result = task.result() + logger.info("Task completed successfully") + except asyncio.CancelledError: + logger.info("Twitch bot task was cancelled") + except Exception as e: + logger.error(f"Twitch bot task failed: {e}", exc_info=True) + logger.info(f"ERROR: Twitch bot task failed: {e}") + logger.info(f"Exception type: {type(e).__name__}") + logger.info(f"Exception class: {e.__class__.__name__}") + + # Check if this is an authentication error + error_str = str(e).lower() + is_auth_error = ( + "authentication" in error_str or + "unauthorized" in error_str or + "invalid" in error_str or + "access token" in error_str or + e.__class__.__name__ == "AuthenticationError" + ) + + logger.info(f"Is auth error check: {is_auth_error}") + + if is_auth_error: + # Attempt automatic token refresh before showing error + async def handle_auth_error_with_refresh(): + global twitch_refresh_attempted, twitch_auth_error - return { - "token": auth.access_token, - "username": auth.username, - "user_id": auth.twitch_user_id + logger.warning(f"=== AUTHENTICATION ERROR DETECTED IN {context_name.upper()} ===") + + # Only attempt refresh once per session to prevent infinite retries + if not twitch_refresh_attempted: + logger.info("Attempting automatic token refresh...") + twitch_refresh_attempted = True + + try: + # Import here to avoid circular imports + from routers.auth import get_auth, refresh_twitch_token, get_twitch_user_info, store_twitch_auth + + auth = get_auth() + if auth and auth.refresh_token: + logger.info("Refresh token available, attempting refresh...") + refreshed_token_data = await refresh_twitch_token(auth.refresh_token) + + if refreshed_token_data: + # Get updated user info to ensure account is still valid + user_info = await get_twitch_user_info(refreshed_token_data["access_token"]) + if user_info: + # Store the refreshed token + await store_twitch_auth(user_info, refreshed_token_data) + logger.info("Successfully refreshed token, attempting to restart Twitch bot...") + + # Clear any existing auth error since we have a fresh token + twitch_auth_error = None + + # Restart the Twitch bot with new token + settings = get_settings() + await restart_twitch_if_needed(settings) + logger.info("Twitch bot restarted after token refresh") + return # Success! Don't show error + else: + logger.error("Failed to get user info after token refresh") + else: + logger.error("Token refresh failed") + else: + logger.warning("No refresh token available for automatic refresh") + except Exception as refresh_error: + logger.error(f"Error during automatic token refresh: {refresh_error}") + else: + logger.warning("Token refresh already attempted in this session, skipping automatic retry") + + # If we reach here, refresh failed or was already attempted - show error + logger.info(f"WebSocket clients available: {len(hub.clients)}") + + # Store the error globally so new WebSocket connections can be notified + twitch_auth_error = { + "type": "twitch_auth_error", + "message": "Twitch authentication failed. Please reconnect your account.", + "error": str(e) + } + logger.info(f"=== STORED GLOBAL AUTH ERROR ({context_name.upper()}) ===") + logger.info(f"Auth error message: {twitch_auth_error['message']}") + + # Try to broadcast the error + try: + await hub.broadcast(twitch_auth_error) + logger.info(f"Auth error broadcast completed ({context_name})") + except Exception as broadcast_error: + logger.error(f"Failed to broadcast auth error: {broadcast_error}") + + # Schedule the auth error handling + try: + loop = asyncio.get_running_loop() + if loop.is_running(): + asyncio.create_task(handle_auth_error_with_refresh()) + logger.info(f"Scheduled auth error handling with refresh ({context_name})") + except Exception as loop_error: + logger.warning(f"Could not schedule auth error handling: {loop_error}") + + return handle_twitch_task_exception + +async def handle_twitch_task_creation_error(create_error: Exception, context_name: str): + """Handle errors that occur during Twitch task creation with consistent logic""" + logger.error(f"Failed to create Twitch task during {context_name}: {create_error}") + logger.info(f"ERROR: Failed to create Twitch task during {context_name}: {create_error}") + + # Check if the creation error itself is an auth error + error_str = str(create_error).lower() + is_auth_error = ( + "authentication" in error_str or + "unauthorized" in error_str or + "invalid" in error_str or + "access token" in error_str + ) + + if is_auth_error: + logger.warning(f"=== AUTHENTICATION ERROR DURING TASK CREATION ({context_name.upper()}) ===") + + # Attempt automatic token refresh before showing error + global twitch_refresh_attempted, twitch_auth_error + + # Only attempt refresh once per session to prevent infinite retries + if not twitch_refresh_attempted: + logger.info("Attempting automatic token refresh during task creation...") + twitch_refresh_attempted = True + + try: + # Import here to avoid circular imports + from routers.auth import get_auth, refresh_twitch_token, get_twitch_user_info, store_twitch_auth + + auth = get_auth() + if auth and auth.refresh_token: + logger.info("Refresh token available, attempting refresh...") + refreshed_token_data = await refresh_twitch_token(auth.refresh_token) + + if refreshed_token_data: + # Get updated user info to ensure account is still valid + user_info = await get_twitch_user_info(refreshed_token_data["access_token"]) + if user_info: + # Store the refreshed token + await store_twitch_auth(user_info, refreshed_token_data) + logger.info("Successfully refreshed token during task creation, will retry bot startup") + + # Clear any existing auth error since we have a fresh token + twitch_auth_error = None + return # Success! Let the caller retry + else: + logger.error("Failed to get user info after token refresh during task creation") + else: + logger.error("Token refresh failed during task creation") + else: + logger.warning("No refresh token available for automatic refresh during task creation") + except Exception as refresh_error: + logger.error(f"Error during automatic token refresh in task creation: {refresh_error}") + else: + logger.warning("Token refresh already attempted in this session, showing error for task creation") + + # If we reach here, refresh failed or was already attempted - store and broadcast error + twitch_auth_error = { + "type": "twitch_auth_error", + "message": "Twitch authentication failed. Please reconnect your account.", + "error": str(create_error) + } + + # Try to broadcast immediately + try: + await hub.broadcast(twitch_auth_error) + logger.info(f"Auth error broadcast completed ({context_name} creation error)") + except Exception as broadcast_error: + logger.error(f"Failed to broadcast auth error during {context_name}: {broadcast_error}") + +async def test_twitch_connection(token_info: dict): + """Test Twitch connection without starting the full bot to detect auth issues early""" + logger.info("Testing Twitch connection...") + + try: + # Import TwitchIO for connection testing + import twitchio + from twitchio.ext import commands + + # Create a minimal test bot that just connects and disconnects + class TestBot(commands.Bot): + def __init__(self, token, nick): + super().__init__(token=token, nick=nick, prefix='!', initial_channels=[]) + self.connection_successful = False + + async def event_ready(self): + logger.info(f"Twitch connection test successful for user: {self.nick}") + self.connection_successful = True + # Disconnect immediately after successful connection + await self.close() + + # Create test bot instance + test_bot = TestBot(token=token_info["token"], nick=token_info["username"]) + + # Run the test with a timeout + try: + await asyncio.wait_for(test_bot.start(), timeout=10.0) + + if test_bot.connection_successful: + logger.info("Twitch connection test passed") + return True + else: + logger.warning("Twitch connection test failed - no ready event received") + return False + + except asyncio.TimeoutError: + logger.warning("Twitch connection test timed out") + await test_bot.close() + return False + + except Exception as e: + logger.error(f"Twitch connection test failed: {e}") + logger.info(f"Connection test error type: {type(e).__name__}") + + # Check if this is an authentication error + error_str = str(e).lower() + is_auth_error = ( + "authentication" in error_str or + "unauthorized" in error_str or + "invalid" in error_str or + "access token" in error_str or + e.__class__.__name__ == "AuthenticationError" + ) + + if is_auth_error: + logger.warning("=== AUTHENTICATION ERROR DETECTED IN CONNECTION TEST ===") + + # Store and broadcast auth error immediately + global twitch_auth_error + twitch_auth_error = { + "type": "twitch_auth_error", + "message": "Twitch authentication failed. Please reconnect your account.", + "error": str(e) } + + # Broadcast the error to connected clients + try: + await hub.broadcast(twitch_auth_error) + logger.info("Auth error broadcast completed (connection test)") + except Exception as broadcast_error: + logger.error(f"Failed to broadcast auth error during connection test: {broadcast_error}") + + return False + +async def create_twitch_bot_task(token_info: dict, channel: str, route_twitch_event, context_name: str): + """Create a Twitch bot task with consistent error handling""" + try: + # Create the task and monitor it for auth errors immediately + task = asyncio.create_task(run_twitch_bot( + token=token_info["token"], + nick=token_info["username"], + channel=channel, + on_event=lambda e: asyncio.create_task(route_twitch_event(e)) + )) + + # Attach the error handler + task.add_done_callback(create_twitch_task_exception_handler(context_name)) + logger.info(f"Twitch bot started with comprehensive error handling ({context_name})") + + return task + + except Exception as create_error: + await handle_twitch_task_creation_error(create_error, context_name) + return None + +async def get_twitch_token_for_bot(): + """Get current Twitch token for bot connection with automatic refresh""" + try: + # Import here to avoid circular imports + from routers.auth import get_twitch_token_for_bot as auth_get_token + return await auth_get_token() except Exception as e: logger.error(f"Error getting Twitch token: {e}") - - return None + return None async def restart_youtube_if_needed(settings: Dict[str, Any]): """Restart YouTube bot when settings change""" @@ -734,6 +1017,29 @@ def should_process_message(text: str, settings: Dict[str, Any], username: str = if not filtering.get("enabled", True): return True, text + # Check Twitch channel point redeem filter + twitch_settings = settings.get("twitch", {}) + redeem_filter = twitch_settings.get("redeemFilter", {}) + if redeem_filter.get("enabled", False): + allowed_redeem_names = redeem_filter.get("allowedRedeemNames", []) + if allowed_redeem_names: + # Check if message has a msg-param-reward-name tag (the redeem name) + # Also check custom-reward-id to confirm it's a redeem + custom_reward_id = tags.get("custom-reward-id", "") if tags else "" + reward_name = tags.get("msg-param-reward-name", "") if tags else "" + + if not custom_reward_id: + # No redeem ID means this is a regular message, not a channel point redeem + logger.info(f"Skipping message from {username} - not from a channel point redeem") + return False, text + + # Check if the redeem name is in the allowed list (case-insensitive) + if not any(reward_name.lower() == allowed_name.lower() for allowed_name in allowed_redeem_names): + logger.info(f"Skipping message from {username} - redeem name '{reward_name}' not in allowed list") + return False, text + + logger.info(f"Processing message from {username} - redeem name '{reward_name}' is allowed") + # Skip ignored users if username and filtering.get("ignoredUsers"): ignored_users = filtering.get("ignoredUsers", []) @@ -1336,6 +1642,15 @@ async def startup(): logger.info(f"Twitch config: channel={channel}, nick={token_info['username']}, token={'***' if token_info['token'] else 'None'}") + # Test Twitch connection first to detect auth issues early + connection_test_passed = await test_twitch_connection(token_info) + + if not connection_test_passed: + logger.warning("Twitch connection test failed - not starting full bot") + # Don't return here, let the auth error be handled by the test function + # The error will already be broadcast to clients + return + # Event router to handle different event types async def route_twitch_event(e): event_type = e.get("type", "") @@ -1346,26 +1661,14 @@ async def route_twitch_event(e): await handle_event(e) global TwitchTask - t = asyncio.create_task(run_twitch_bot( - token=token_info["token"], - nick=token_info["username"], - channel=channel, - on_event=lambda e: asyncio.create_task(route_twitch_event(e)) - )) - - # Add error handler for the Twitch task - def handle_twitch_task_exception(task): - try: - task.result() - except asyncio.CancelledError: - logger.info("Twitch bot task was cancelled") - except Exception as e: - logger.error(f"Twitch bot task failed: {e}", exc_info=True) - logger.info(f"ERROR: Twitch bot task failed: {e}") - t.add_done_callback(handle_twitch_task_exception) - TwitchTask = t - logger.info("Twitch bot task created") + # Create Twitch bot task with shared error handling + TwitchTask = await create_twitch_bot_task( + token_info=token_info, + channel=channel, + route_twitch_event=route_twitch_event, + context_name="startup" + ) else: if not run_twitch_bot: logger.warning("Twitch bot not available (import failed)") diff --git a/backend/modules/settings_defaults.json b/backend/modules/settings_defaults.json index a31f077..d0c93a6 100644 --- a/backend/modules/settings_defaults.json +++ b/backend/modules/settings_defaults.json @@ -3,6 +3,7 @@ }, "audioFormat": "mp3", "volume": 1.0, +"textSize": "normal", "avatarMode": "grid", "popupDirection": "bottom", "popupFixedEdge": false, @@ -43,7 +44,11 @@ "enabled": false, "channel": "", "token": "", -"nick": "" +"nick": "", +"redeemFilter": { +"enabled": false, +"allowedRedeemNames": [] +} }, "youtube": { "enabled": false, diff --git a/backend/modules/twitch_listener.py b/backend/modules/twitch_listener.py index a7e5b51..9c4f596 100644 --- a/backend/modules/twitch_listener.py +++ b/backend/modules/twitch_listener.py @@ -339,4 +339,13 @@ async def run_twitch_bot(token: str, nick: str, channel: str, on_event: Callable bot_logger.error(f"Twitch bot startup failed: {e}", exc_info=True) except Exception: pass + + # Check if this is an authentication error + error_str = str(e).lower() + if "authentication" in error_str or "unauthorized" in error_str or "invalid" in error_str: + # Re-raise with a more specific error type that we can catch + from twitchio.errors import AuthenticationError + if isinstance(e, AuthenticationError) or "access token" in error_str: + raise AuthenticationError("Invalid or unauthorized Access Token") from e + raise diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 96448c6..85e46c8 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -5,6 +5,7 @@ import secrets import time import urllib.parse +from datetime import datetime, timedelta from typing import Dict, Any import aiohttp @@ -120,6 +121,12 @@ async def twitch_auth_status(): try: auth = get_auth() if auth: + # Clear any pending auth error since we're now connected + import app + if hasattr(app, 'twitch_auth_error') and app.twitch_auth_error: + logger.info("Clearing Twitch auth error - user is now connected") + app.twitch_auth_error = None + return { "connected": True, "username": auth.username, @@ -173,6 +180,180 @@ async def api_test_twitch(): logger.error(f"Twitch test failed: {e}", exc_info=True) return {"success": False, "error": str(e)} +@router.post("/api/twitch/test-auth-error") +async def test_auth_error(): + """Test endpoint to simulate an auth error for debugging""" + try: + # Import here to avoid circular imports + from app import hub, twitch_auth_error + + # Set the global auth error + import app + app.twitch_auth_error = { + "type": "twitch_auth_error", + "message": "Test authentication error. Please reconnect your account.", + "error": "Simulated authentication failure" + } + + # Broadcast to all connected clients + await hub.broadcast(app.twitch_auth_error) + + logger.info(f"Test auth error sent to {len(hub.clients)} clients") + return {"success": True, "message": f"Test auth error sent to {len(hub.clients)} clients"} + except Exception as e: + logger.error(f"Failed to send test auth error: {e}", exc_info=True) + return {"success": False, "error": str(e)} + +@router.get("/api/twitch/auth-error") +async def get_auth_error(): + """Get any pending authentication error""" + logger.info("=== AUTH ERROR ENDPOINT CALLED ===") + try: + import app + logger.info(f"App module imported, checking for twitch_auth_error attribute") + logger.info(f"Has twitch_auth_error attribute: {hasattr(app, 'twitch_auth_error')}") + + if hasattr(app, 'twitch_auth_error'): + logger.info(f"twitch_auth_error value: {app.twitch_auth_error}") + + if hasattr(app, 'twitch_auth_error') and app.twitch_auth_error: + error = app.twitch_auth_error.copy() + # Don't clear the error automatically - let frontend decide when to clear it + logger.info("=== RETURNING AUTH ERROR TO FRONTEND ===") + logger.info(f"Error: {error}") + return {"has_error": True, "error": error} + + logger.info("No auth error found, returning has_error: False") + return {"has_error": False} + except Exception as e: + logger.error(f"Error checking auth error: {e}", exc_info=True) + return {"has_error": False, "error": str(e)} + +@router.delete("/api/twitch/auth-error") +async def clear_auth_error(): + """Clear any pending authentication error""" + try: + import app + if hasattr(app, 'twitch_auth_error') and app.twitch_auth_error: + app.twitch_auth_error = None + logger.info("Cleared pending auth error") + return {"success": True, "message": "Auth error cleared"} + return {"success": True, "message": "No auth error to clear"} + except Exception as e: + logger.error(f"Error clearing auth error: {e}", exc_info=True) + return {"success": False, "error": str(e)} + +@router.post("/api/twitch/refresh-token") +async def refresh_token_endpoint(): + """Manually refresh the Twitch access token""" + try: + auth = get_auth() + if not auth: + return {"success": False, "error": "No Twitch account connected"} + + if not auth.refresh_token: + return {"success": False, "error": "No refresh token available - please reconnect your account"} + + logger.info("Manual token refresh requested") + refreshed_token_data = await refresh_twitch_token(auth.refresh_token) + + if refreshed_token_data: + # Get updated user info + user_info = await get_twitch_user_info(refreshed_token_data["access_token"]) + if user_info: + # Store the refreshed token + await store_twitch_auth(user_info, refreshed_token_data) + logger.info("Successfully refreshed Twitch token manually") + + # Clear any auth errors since we have a fresh token + import app + if hasattr(app, 'twitch_auth_error') and app.twitch_auth_error: + app.twitch_auth_error = None + logger.info("Cleared auth error after manual token refresh") + + return { + "success": True, + "message": f"Token refreshed successfully for {user_info['login']}" + } + else: + return {"success": False, "error": "Failed to get user info after token refresh"} + else: + return {"success": False, "error": "Failed to refresh token - may need to reconnect your account"} + + except Exception as e: + logger.error(f"Error refreshing token: {e}", exc_info=True) + return {"success": False, "error": str(e)} + +@router.post("/api/twitch/test-connection") +async def test_twitch_connection_endpoint(): + """Test Twitch connection to detect auth issues proactively""" + try: + logger.info("Testing Twitch connection via API endpoint...") + + # Get current settings and token (this will auto-refresh if needed) + settings = get_settings() + if not settings.get("twitch", {}).get("enabled"): + return {"success": False, "error": "Twitch integration is disabled"} + + token_info = await get_twitch_token_for_bot() + if not token_info: + return {"success": False, "error": "No Twitch account connected"} + + # Import the test function from app + from app import test_twitch_connection + + # Run the connection test + connection_successful = await test_twitch_connection(token_info) + + if connection_successful: + # Clear any existing auth error since connection is working + import app + if hasattr(app, 'twitch_auth_error') and app.twitch_auth_error: + app.twitch_auth_error = None + logger.info("Connection test passed - cleared any existing auth errors") + + return { + "success": True, + "message": f"Twitch connection successful for {token_info['username']}" + } + else: + return { + "success": False, + "error": "Twitch connection test failed - check logs for details" + } + + except Exception as e: + logger.error(f"Error testing Twitch connection: {e}", exc_info=True) + return {"success": False, "error": str(e)} + +@router.post("/api/twitch/test-auto-refresh") +async def test_auto_refresh(): + """Test the automatic token refresh functionality""" + try: + # Reset the refresh attempt tracking to allow testing + import app + if hasattr(app, 'twitch_refresh_attempted'): + old_value = app.twitch_refresh_attempted + app.twitch_refresh_attempted = False + logger.info(f"Reset twitch_refresh_attempted from {old_value} to False for testing") + + # Simulate an auth error to trigger the refresh logic + from app import handle_twitch_task_creation_error + test_error = Exception("Authentication failed: invalid access token") + + # This will attempt the automatic refresh + await handle_twitch_task_creation_error(test_error, "manual_test") + + return { + "success": True, + "message": "Auto-refresh test completed - check logs for details", + "refresh_attempted": getattr(app, 'twitch_refresh_attempted', False) + } + + except Exception as e: + logger.error(f"Error testing auto-refresh: {e}", exc_info=True) + return {"success": False, "error": str(e)} + # Helper functions for OAuth async def exchange_code_for_token(code: str) -> Dict[str, Any]: @@ -231,14 +412,87 @@ async def store_twitch_auth(user_info: Dict[str, Any], token_data: Dict[str, Any logger.error(f"Error storing Twitch auth: {e}") raise +async def refresh_twitch_token(refresh_token: str) -> Dict[str, Any]: + """Refresh an expired Twitch access token""" + try: + data = { + "client_id": TWITCH_CLIENT_ID, + "client_secret": TWITCH_CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": refresh_token + } + + async with aiohttp.ClientSession() as session: + async with session.post("https://id.twitch.tv/oauth2/token", data=data) as response: + if response.status == 200: + result = await response.json() + logger.info("Successfully refreshed Twitch token") + return result + else: + error_text = await response.text() + logger.error(f"Token refresh failed: {response.status} - {error_text}") + return None + except Exception as e: + logger.error(f"Error refreshing Twitch token: {e}") + return None + async def get_twitch_token_for_bot(): - """Get current Twitch token for bot connection""" + """Get current Twitch token for bot connection with automatic refresh""" try: - return get_twitch_token() + auth = get_auth() + if not auth: + return None + + # Check if token needs refresh (if expires_at is set and in the past) + needs_refresh = False + if auth.expires_at: + try: + expires_at = datetime.fromisoformat(auth.expires_at) + # Add 5-minute buffer to refresh before actual expiration + buffer_time = expires_at - timedelta(minutes=5) + if datetime.now() >= buffer_time: + needs_refresh = True + logger.info(f"Twitch token expires at {expires_at}, refreshing with 5-minute buffer") + except ValueError as e: + logger.warning(f"Invalid expires_at format: {auth.expires_at}, will attempt refresh: {e}") + needs_refresh = True + + # Attempt token refresh if needed and refresh token is available + if needs_refresh and auth.refresh_token: + logger.info("Attempting to refresh Twitch token...") + + refreshed_token_data = await refresh_twitch_token(auth.refresh_token) + if refreshed_token_data: + # Get updated user info to ensure account is still valid + user_info = await get_twitch_user_info(refreshed_token_data["access_token"]) + if user_info: + # Store the refreshed token + await store_twitch_auth(user_info, refreshed_token_data) + logger.info("Successfully refreshed and stored new Twitch token") + + # Return the new token info + return { + "token": refreshed_token_data["access_token"], + "username": user_info["login"], + "user_id": user_info["id"] + } + else: + logger.error("Failed to get user info after token refresh") + else: + logger.error("Failed to refresh Twitch token - may need to re-authenticate") + # Could set a flag here to notify frontend that re-auth is needed + return None + + # Return current token if no refresh needed or no refresh token available + return { + "token": auth.access_token, + "username": auth.username, + "user_id": auth.twitch_user_id + } + except Exception as e: logger.error(f"Error getting Twitch token: {e}") - - return None + return None # YouTube OAuth Endpoints diff --git a/backend/test_auto_refresh.py b/backend/test_auto_refresh.py new file mode 100644 index 0000000..73c77d9 --- /dev/null +++ b/backend/test_auto_refresh.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify automatic token refresh functionality +""" + +import asyncio +import requests +from datetime import datetime + +async def test_auto_refresh(): + """Test the automatic refresh functionality""" + base_url = "http://localhost:8000" + + print("Testing Automatic Token Refresh Functionality") + print("=" * 50) + + try: + # Test 1: Check if backend is running + print("1. Checking backend connection...") + response = requests.get(f"{base_url}/api/twitch/status") + if response.status_code != 200: + print("Backend not running or Twitch not configured") + return + print("Backend connected") + + # Test 2: Test the auto-refresh endpoint + print("\n2. Testing auto-refresh endpoint...") + response = requests.post(f"{base_url}/api/twitch/test-auto-refresh") + result = response.json() + + if result.get("success"): + print("Auto-refresh test completed successfully") + print(f" Message: {result.get('message')}") + print(f" Refresh attempted: {result.get('refresh_attempted')}") + else: + print("Auto-refresh test completed with warnings") + print(f" Error: {result.get('error')}") + + # Test 3: Check current Twitch status + print("\n3. Checking final Twitch status...") + response = requests.get(f"{base_url}/api/twitch/status") + status = response.json() + + if status.get("connected"): + print("Twitch connection active") + print(f" User: {status.get('display_name')} (@{status.get('username')})") + else: + print("Twitch not connected") + + print("\nTest completed! Check backend logs for detailed refresh process.") + + except requests.exceptions.ConnectionError: + print("Cannot connect to backend. Make sure it's running on port 8000") + except Exception as e: + print(f"Test failed: {e}") + +if __name__ == "__main__": + asyncio.run(test_auto_refresh()) \ No newline at end of file diff --git a/frontend/src/components/settings/GeneralSettings.jsx b/frontend/src/components/settings/GeneralSettings.jsx index 12d7033..8061226 100644 --- a/frontend/src/components/settings/GeneralSettings.jsx +++ b/frontend/src/components/settings/GeneralSettings.jsx @@ -62,6 +62,29 @@ function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) {
+
+
+ +
+ +
+
diff --git a/frontend/src/components/settings/PlatformIntegration.jsx b/frontend/src/components/settings/PlatformIntegration.jsx index 8e96749..9ade530 100644 --- a/frontend/src/components/settings/PlatformIntegration.jsx +++ b/frontend/src/components/settings/PlatformIntegration.jsx @@ -1,7 +1,7 @@ import React from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' -import { TwitchIntegration, SpecialEventVoices } from './TwitchIntegration' +import { TwitchIntegration } from './TwitchIntegration' import YouTubeIntegration from './YouTubeIntegration' import { Zap, Youtube } from 'lucide-react' @@ -33,11 +33,6 @@ function PlatformIntegration({ settings, updateSettings, allVoices, apiUrl }) { updateSettings={updateSettings} apiUrl={apiUrl} /> - diff --git a/frontend/src/components/settings/TwitchIntegration.jsx b/frontend/src/components/settings/TwitchIntegration.jsx index 8d65e25..001393e 100644 --- a/frontend/src/components/settings/TwitchIntegration.jsx +++ b/frontend/src/components/settings/TwitchIntegration.jsx @@ -5,21 +5,149 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Switch } from '../ui/switch' import { Button } from '../ui/button' +import { useWebSocket } from '../../WebSocketContext' import { Zap, - CheckCircle2 + CheckCircle2, + AlertTriangle } from 'lucide-react' +function RedeemNamesManager({ redeemNames, onUpdate }) { + const [newRedeem, setNewRedeem] = useState('') + + const addRedeem = () => { + const redeemName = newRedeem.trim() + if (!redeemName) return + + if (redeemNames.some(name => name.toLowerCase() === redeemName.toLowerCase())) { + alert('This redeem name is already in the list') + return + } + + onUpdate([...redeemNames, redeemName]) + setNewRedeem('') + } + + const removeRedeem = (redeemToRemove) => { + onUpdate(redeemNames.filter(name => name !== redeemToRemove)) + } + + const clearAllRedeems = () => { + if (redeemNames.length === 0) return + if (confirm(`Are you sure you want to remove all ${redeemNames.length} redeem names?`)) { + onUpdate([]) + } + } + + return ( +
+
+ setNewRedeem(e.target.value)} + onKeyPress={e => e.key === 'Enter' && addRedeem()} + /> + +
+ + {redeemNames.length > 0 && ( +
+
+ + Allowed Redeems ({redeemNames.length}) + + +
+ +
+ {redeemNames.map((name, index) => ( +
+ {name} + +
+ ))} +
+
+ )} + + {redeemNames.length === 0 && ( +

+ No redeem names added yet +

+ )} +
+ ) +} + function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) { const [twitchStatus, setTwitchStatus] = useState(null); const [loading, setLoading] = useState(false); const [channelInput, setChannelInput] = useState(''); + const [authError, setAuthError] = useState(null); + const { addListener } = useWebSocket(); - // Check Twitch connection status on component mount + // Check Twitch connection status and auth errors on component mount useEffect(() => { checkTwitchStatus(); + checkAuthError(); }, []); + const checkAuthError = async () => { + try { + logger.info('=== CHECKING FOR AUTH ERRORS ==='); + const response = await fetch(`${apiUrl}/api/twitch/auth-error`); + const result = await response.json(); + logger.info('Auth error check result:', result); + + if (result.has_error && result.error) { + logger.error('=== PENDING AUTH ERROR FOUND ==='); + logger.error('Auth error details:', result.error); + setAuthError(result.error.message); + setTwitchStatus({ connected: false }); + } else { + logger.info('No auth error found'); + } + } catch (error) { + logger.error('Failed to check for auth errors:', error); + } + }; + + // Listen for WebSocket messages including auth errors + useEffect(() => { + const removeListener = addListener((data) => { + logger.info('WebSocket message received in TwitchIntegration:', data.type); + if (data.type === 'twitch_auth_error') { + logger.error('Twitch authentication error received:', data.message); + setAuthError(data.message); + // Also update the status to disconnected + setTwitchStatus({ connected: false }); + } + }); + + return removeListener; + }, [addListener]); + // Sync channel input with settings useEffect(() => { if (settings.twitch?.channel !== undefined) { @@ -32,6 +160,51 @@ function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) { const response = await fetch(`${apiUrl}/api/twitch/status`); const status = await response.json(); setTwitchStatus(status); + + // If connected, test the actual connection to detect auth issues + if (status.connected && settings.twitch?.enabled) { + logger.info('Twitch shows connected, testing actual connection...'); + try { + const testResponse = await fetch(`${apiUrl}/api/twitch/test-connection`, { + method: 'POST' + }); + const testResult = await testResponse.json(); + + if (!testResult.success) { + logger.warning('Twitch connection test failed:', testResult.error); + // Don't override the status here, let the test function handle auth errors + // The WebSocket listener will catch any auth errors that are broadcast + } else { + logger.info('Twitch connection test passed'); + // Clear any existing auth errors since connection is working + if (authError) { + setAuthError(null); + try { + await fetch(`${apiUrl}/api/twitch/auth-error`, { method: 'DELETE' }); + } catch (error) { + logger.error('Failed to clear auth error on backend:', error); + } + } + } + } catch (testError) { + logger.error('Failed to test Twitch connection:', testError); + } + } + + // Clear auth error if we're now connected + else if (status.connected && authError) { + setAuthError(null); + // Also clear it on the backend + try { + await fetch(`${apiUrl}/api/twitch/auth-error`, { method: 'DELETE' }); + } catch (error) { + logger.error('Failed to clear auth error on backend:', error); + } + } + // If not connected, also check for auth errors + else if (!status.connected) { + await checkAuthError(); + } } catch (error) { logger.error('Failed to check Twitch status:', error); setTwitchStatus({ connected: false }); @@ -105,6 +278,51 @@ function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) { + {/* Authentication Error Display */} + {authError && ( +
+ +
+

Authentication Failed

+

{authError}

+
+
+ + +
+
+ )} + {!twitchStatus?.connected ? (
@@ -158,14 +376,27 @@ function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) { updateSettings({ - twitch: { - ...settings.twitch, - enabled: checked, - // Set the connected username as the channel by default - channel: checked && !settings.twitch?.channel ? twitchStatus.username : settings.twitch?.channel - } - })} + onCheckedChange={async (checked) => { + // Update settings first + await updateSettings({ + twitch: { + ...settings.twitch, + enabled: checked, + // Set the connected username as the channel by default + channel: checked && !settings.twitch?.channel ? twitchStatus.username : settings.twitch?.channel + } + }); + + // If enabling, check for auth errors after a short delay to let backend start + if (checked) { + setTimeout(async () => { + await checkAuthError(); + }, 2000); // Give the backend time to attempt connection and fail + } else { + // If disabling, clear any existing auth error + setAuthError(null); + } + }} />
@@ -195,6 +426,54 @@ function TwitchIntegration({ settings, updateSettings, apiUrl = '' }) { Usually your own channel: {twitchStatus.username}

+ + {/* Channel Point Redeem Filter */} +
+
+
+ +
+ updateSettings({ + twitch: { + ...settings.twitch, + redeemFilter: { + ...settings.twitch?.redeemFilter, + enabled: checked + } + } + })} + /> +
+ + {settings.twitch?.redeemFilter?.enabled && ( +
+ + updateSettings({ + twitch: { + ...settings.twitch, + redeemFilter: { + ...settings.twitch?.redeemFilter, + allowedRedeemNames: names + } + } + })} + /> +

+ Enter the exact names of your channel point rewards. Names are case-insensitive. + You can find reward names in your Twitch Dashboard under Channel Points. +

+
+ )} +
)}
@@ -251,13 +530,13 @@ function SpecialEventVoices({ settings, updateSettings, allVoices }) { } // Export both components individually for flexibility, and a combined component as default -export { TwitchIntegration, SpecialEventVoices } +export { TwitchIntegration, SpecialEventVoices, RedeemNamesManager } export default function TwitchIntegrationTab({ settings, updateSettings, allVoices, apiUrl }) { return (
- + {/* */}
) } \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 2c33d4e..e4395ed 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -24,6 +24,17 @@ --input: 0 0% 89.8%; --ring: 0 0% 3.9%; --radius: 0.5rem; + + /* Text size scaling variables */ + --text-scale: 1; + --text-xs: calc(0.75rem * var(--text-scale)); + --text-sm: calc(0.875rem * var(--text-scale)); + --text-base: calc(1rem * var(--text-scale)); + --text-lg: calc(1.125rem * var(--text-scale)); + --text-xl: calc(1.25rem * var(--text-scale)); + --text-2xl: calc(1.5rem * var(--text-scale)); + --text-3xl: calc(1.875rem * var(--text-scale)); + --text-4xl: calc(2.25rem * var(--text-scale)); } .dark { @@ -47,6 +58,11 @@ --input: 0 0% 14.9%; --ring: 0 0% 83.1%; } + + /* Large text mode */ + .text-large { + --text-scale: 1.25; + } } @layer base { @@ -55,9 +71,20 @@ } body { @apply bg-background text-foreground; + font-size: var(--text-base); } } +/* Override Tailwind text sizes to use our CSS variables */ +.text-xs { font-size: var(--text-xs) !important; } +.text-sm { font-size: var(--text-sm) !important; } +.text-base { font-size: var(--text-base) !important; } +.text-lg { font-size: var(--text-lg) !important; } +.text-xl { font-size: var(--text-xl) !important; } +.text-2xl { font-size: var(--text-2xl) !important; } +.text-3xl { font-size: var(--text-3xl) !important; } +.text-4xl { font-size: var(--text-4xl) !important; } + html, body, #root { height: 100%; } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 0175fb7..29b20fe 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -79,6 +79,12 @@ export default function SettingsPage() { // Load settings fetch(`${apiUrl}/api/settings`).then(r => r.json()).then(data => { setSettings(data) + // Apply text size setting globally on load + if (data.textSize === 'large') { + document.documentElement.classList.add('text-large') + } else { + document.documentElement.classList.remove('text-large') + } }) // Load voices from database From 979accaaedfd05ccbf2e7e06984832ef20eba8b7 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sat, 22 Nov 2025 18:36:25 +0100 Subject: [PATCH 30/46] tts limit --- backend/app.py | 460 ++++++++++++++---- backend/modules/persistent_data.py | 22 +- backend/modules/settings_defaults.json | 2 + backend/modules/youtube_listener.py | 64 ++- backend/routers/auth.py | 128 ++++- backend/routers/system.py | 133 ++++- backend/test_auto_refresh.py | 58 --- .../components/settings/GeneralSettings.jsx | 160 ++++-- frontend/src/pages/SettingsPage.jsx | 47 +- 9 files changed, 849 insertions(+), 225 deletions(-) delete mode 100644 backend/test_auto_refresh.py diff --git a/backend/app.py b/backend/app.py index 15daf77..f476d54 100644 --- a/backend/app.py +++ b/backend/app.py @@ -47,9 +47,36 @@ # 1. Global TTS stop button (stops all) # 2. Individual user ban/timeout (stops only that user) # 3. New TTS from same user is ignored if their previous TTS is still playing -# Track active TTS tasks for cancellation only +# Track active TTS tasks for cancellation only (simplified - no timers) # username -> {"task": asyncio.Task, "message": str} active_tts_jobs = {} +total_active_tts_count = 0 # Total count of active TTS jobs (for parallel limiting) +parallel_message_queue = [] # Queue for messages when parallel limit is reached + +def increment_tts_count(): + """Increment the TTS count for parallel limiting""" + global total_active_tts_count + total_active_tts_count += 1 + +def decrement_tts_count(): + """Safely decrement the TTS count, preventing negative values""" + global total_active_tts_count + if total_active_tts_count > 0: + total_active_tts_count -= 1 + +# sync_tts_count function removed - using simple audio duration-based limiting + +def force_reset_tts_counter(): + """Force reset TTS counter to 0 and clear active jobs (emergency reset)""" + global total_active_tts_count + old_count = total_active_tts_count + old_jobs = len(active_tts_jobs) + + total_active_tts_count = 0 + active_tts_jobs.clear() + + print(f"FORCE RESET: TTS counter {old_count}→0, cleared {old_jobs} active jobs") + logger.warning(f"FORCE RESET: TTS counter {old_count}→0, cleared {old_jobs} active jobs") # Global TTS control tts_enabled = True # Global flag to control TTS processing @@ -88,6 +115,67 @@ def queue_avatar_message(message_data): }) logger.info(f"Queued message for {message_data.get('user')} (queue length: {len(avatar_message_queue)})") +def queue_parallel_message(message_data): + """Add a message to the parallel queue when limit is reached""" + global parallel_message_queue + + parallel_message_queue.append({ + "message_data": message_data, + "queued_time": time.time() + }) + logger.info(f"Queued message for {message_data.get('user')} (parallel queue length: {len(parallel_message_queue)})") + +def process_parallel_message_queue(): + """Process queued messages if parallel slots become available""" + global parallel_message_queue, total_active_tts_count + + if not parallel_message_queue: + return + + settings = app_get_settings() + parallel_limit = settings.get("parallelMessageLimit", 5) + + # Check if we're under the limit now (or if there's no limit) + if parallel_limit is None or not isinstance(parallel_limit, (int, float)) or parallel_limit <= 0 or total_active_tts_count < parallel_limit: + # Try to process the oldest queued message + queued_item = parallel_message_queue[0] + message_data = queued_item["message_data"] + + # Check if message is too old (ignore messages older than 120 seconds) + if time.time() - queued_item["queued_time"] > 120: + parallel_message_queue.pop(0) + logger.info(f"Discarded old queued parallel message for {message_data.get('user')}") + # Try to process next message + if parallel_message_queue: + process_parallel_message_queue() + return + + # Remove from queue and process + parallel_message_queue.pop(0) + + # Reserve the slot by incrementing counter (check if replacing existing job) + username = message_data.get('user', 'unknown') + username_lower = username.lower() + replacing_existing = username_lower in active_tts_jobs + + if not replacing_existing: + total_active_tts_count += 1 + + limit_text = "unlimited" if not parallel_limit or not isinstance(parallel_limit, (int, float)) or parallel_limit <= 0 else str(int(parallel_limit)) + logger.info(f"Processing queued parallel message for {username} (active: {total_active_tts_count}/{limit_text}, replacing={replacing_existing})") + + # Process the queued message + async def process_queued(): + try: + await process_tts_message(message_data) + except Exception as e: + # If processing fails, we need to decrement the counter + if not replacing_existing: + decrement_tts_count() + logger.error(f"Failed to process queued TTS message for {username}: {e}") + + asyncio.create_task(process_queued()) + def process_avatar_message_queue(): """Process queued messages if slots become available""" global avatar_message_queue @@ -234,8 +322,10 @@ async def log_requests(request: Request, call_next): # ---------- Global State ---------- twitch_auth_error = None +youtube_auth_error = None # Track if we've attempted a token refresh in this session to prevent infinite retries twitch_refresh_attempted = False +youtube_refresh_attempted = False # ---------- WebSocket Hub ---------- class Hub: @@ -301,6 +391,7 @@ async def ws_endpoint(ws: WebSocket): if twitch_refresh_attempted: logger.info("Resetting token refresh attempt tracking due to new WebSocket connection (page refresh)") twitch_refresh_attempted = False + youtube_refresh_attempted = False # Send a welcome message to confirm connection welcome_msg = { @@ -312,10 +403,13 @@ async def ws_endpoint(ws: WebSocket): logger.info(f"Sent welcome message to WebSocket client {client_info}") # Send any pending auth error to the new client - global twitch_auth_error + global twitch_auth_error, youtube_auth_error if twitch_auth_error: logger.info(f"Sending pending Twitch auth error to new client {client_info}") await ws.send_text(json.dumps(twitch_auth_error)) + if youtube_auth_error: + logger.info(f"Sending pending YouTube auth error to new client {client_info}") + await ws.send_text(json.dumps(youtube_auth_error)) while True: # Handle messages from frontend (avatar slot status updates, etc.) @@ -349,7 +443,11 @@ async def handle_websocket_message(data: Dict[str, Any]): if message_type == "avatar_slot_ended": # Frontend reports that an avatar slot has finished playing slot_id = data.get("slot_id") + logger.info(f"Avatar slot ended: slot_id={slot_id}") if slot_id: + # DISABLED: WebSocket decrement to avoid conflict with auto-decrement + # The auto-decrement based on audio duration is more reliable + release_avatar_slot(slot_id) process_avatar_message_queue() logger.info(f"Avatar slot {slot_id} released by frontend") @@ -357,7 +455,10 @@ async def handle_websocket_message(data: Dict[str, Any]): elif message_type == "avatar_slot_error": # Frontend reports an error with avatar slot playback slot_id = data.get("slot_id") + logger.info(f"Avatar slot error: slot_id={slot_id}") if slot_id: + # DISABLED: WebSocket decrement to avoid conflict with auto-decrement + release_avatar_slot(slot_id) process_avatar_message_queue() logger.info(f"Avatar slot {slot_id} released due to frontend error") @@ -720,6 +821,61 @@ async def handle_twitch_task_creation_error(create_error: Exception, context_nam except Exception as broadcast_error: logger.error(f"Failed to broadcast auth error during {context_name}: {broadcast_error}") +async def handle_youtube_auth_error(): + """Handle YouTube authentication errors with automatic token refresh""" + logger.warning("=== YOUTUBE AUTHENTICATION ERROR ===") + + global youtube_refresh_attempted, youtube_auth_error + + try: + # Import here to avoid circular imports + from routers.auth import get_youtube_auth, refresh_youtube_token, get_youtube_channel_info, store_youtube_auth + + auth = get_youtube_auth() + if auth and auth.refresh_token: + logger.info("YouTube refresh token available, attempting refresh...") + refreshed_token_data = await refresh_youtube_token(auth.refresh_token) + + if refreshed_token_data: + # Get updated channel info to ensure account is still valid + channel_info = await get_youtube_channel_info(refreshed_token_data["access_token"]) + if channel_info: + # Store the refreshed token + await store_youtube_auth(channel_info, refreshed_token_data) + logger.info("Successfully refreshed YouTube token, attempting to restart YouTube bot...") + + # Clear any existing auth error since we have a fresh token + youtube_auth_error = None + + # Try to restart the YouTube bot with current settings + from modules.persistent_data import get_settings + current_settings = get_settings() + await restart_youtube_if_needed(current_settings) + logger.info("YouTube bot restarted after token refresh") + return + else: + logger.error("Failed to get channel info after YouTube token refresh") + else: + logger.error("YouTube token refresh failed") + else: + logger.warning("No YouTube refresh token available for automatic refresh") + except Exception as refresh_error: + logger.error(f"Error during automatic YouTube token refresh: {refresh_error}") + + # If we reach here, refresh failed - store and broadcast error + youtube_auth_error = { + "type": "youtube_auth_error", + "message": "YouTube authentication failed. Please reconnect your YouTube account.", + "action": "reconnect" + } + + # Try to broadcast immediately + try: + await hub.broadcast(youtube_auth_error) + logger.info("YouTube auth error broadcast completed") + except Exception as broadcast_error: + logger.error(f"Failed to broadcast YouTube auth error: {broadcast_error}") + async def test_twitch_connection(token_info: dict): """Test Twitch connection without starting the full bot to detect auth issues early""" logger.info("Testing Twitch connection...") @@ -914,8 +1070,6 @@ def cancel_user_tts(username: str): """ Cancel any active TTS for a specific user. """ - global active_tts_jobs - username_lower = username.lower() logger.info(f"Attempting to cancel TTS for user: {username}") @@ -926,6 +1080,10 @@ def cancel_user_tts(username: str): job_info["task"].cancel() logger.info(f"Cancelled active TTS for user: {username} (message: {job_info['message'][:50]}...)") del active_tts_jobs[username_lower] + # Note: Counter will be decremented by the cancelled task's exception handler + + # Process any queued parallel messages now that a slot is free + process_parallel_message_queue() else: logger.info(f"No active TTS found for user: {username}") @@ -939,11 +1097,11 @@ def cancel_user_tts(username: str): def stop_all_tts(): """ - Stop all active TTS jobs + Stop all TTS jobs """ - global active_tts_jobs, tts_enabled + global active_tts_jobs, tts_enabled, total_active_tts_count - logger.info(f"Stopping all TTS - {len(active_tts_jobs)} active jobs") + logger.info(f"Stopping all TTS - {total_active_tts_count} active jobs") # Cancel all active TTS jobs cancelled_count = 0 @@ -955,6 +1113,8 @@ def stop_all_tts(): # Clear all data structures active_tts_jobs.clear() + parallel_message_queue.clear() # Also clear parallel message queue + total_active_tts_count = 0 # Disable TTS processing tts_enabled = False @@ -1211,94 +1371,167 @@ def should_process_message(text: str, settings: Dict[str, Any], username: str = # ---------- TTS Pipeline ---------- async def handle_test_voice_event(evt: Dict[str, Any]): - """Handle test voice events - similar to handle_event but uses the provided test voice""" + """Handle test voice events - bypasses parallel limits for testing""" logger.info(f"Handling test voice event: {evt}") - settings = app_get_settings() - audio_format = settings.get("audioFormat", "mp3") - test_voice_data = evt.get("testVoice") - if not test_voice_data: - logger.info("No test voice data provided") + # Check if TTS is globally enabled + if not tts_enabled: + logger.info(f"TTS is disabled - skipping test voice message from {evt.get('user', 'unknown')}") return - # Create a temporary voice object for testing - class TestVoice: - def __init__(self, data): - self.id = "test" - self.name = data.get("name", "Test Voice") - self.provider = data.get("provider", "unknown") - self.voice_id = data.get("voice_id", "") - self.avatar_image = None - self.enabled = True + # Test voices bypass parallel limits (they're just for testing) - selected_voice = TestVoice(test_voice_data) - logger.info(f"Test voice: {selected_voice.name} ({selected_voice.provider})") - - # Get TTS configuration - Use hybrid provider that handles all providers - tts_config = settings.get("tts", {}) - - # Get TTS provider configurations - monstertts_config = tts_config.get("monstertts", {}) - monster_api_key = monstertts_config.get("apiKey", "") + # After parallel limiting check passes, set up variables for TTS processing + username = evt.get('user', 'unknown') + username_lower = username.lower() + settings = app_get_settings() - google_config = tts_config.get("google", {}) - google_api_key = google_config.get("apiKey", "") + # Track this job for cancellation + task = asyncio.current_task() - polly_config = tts_config.get("polly", {}) + # Check if user already has an active job and cancel it + if username_lower in active_tts_jobs: + old_task = active_tts_jobs[username_lower].get("task") + if old_task and not old_task.done(): + old_task.cancel() + logger.info(f"Cancelled previous TTS for test user {username}") - # Use hybrid provider - provider = await get_hybrid_provider( - monster_api_key=monster_api_key if monster_api_key else None, - monster_voice_id=selected_voice.voice_id if selected_voice.provider == "monstertts" else None, - edge_voice_id=selected_voice.voice_id if selected_voice.provider == "edge" else None, - fallback_voices=[selected_voice], # Use test voice as fallback - google_api_key=google_api_key if google_api_key else None, - polly_config=polly_config if polly_config.get("accessKey") and polly_config.get("secretKey") else None - ) + active_tts_jobs[username_lower] = { + "task": task, + "message": evt.get('text', '') + } - # Create TTS job with the test voice - job = TTSJob(text=evt.get('text', '').strip(), voice=selected_voice.voice_id, audio_format=audio_format) - logger.info(f"Test TTS Job: text='{job.text}', voice='{selected_voice.name}' ({selected_voice.provider}:{selected_voice.voice_id}), format='{job.audio_format}'") + try: + audio_format = settings.get("audioFormat", "mp3") + + test_voice_data = evt.get("testVoice") + if not test_voice_data: + logger.info("No test voice data provided") + return + + # Create a temporary voice object for testing + class TestVoice: + def __init__(self, data): + self.id = "test" + self.name = data.get("name", "Test Voice") + self.provider = data.get("provider", "unknown") + self.voice_id = data.get("voice_id", "") + self.avatar_image = None + self.enabled = True + + selected_voice = TestVoice(test_voice_data) + logger.info(f"Test voice: {selected_voice.name} ({selected_voice.provider})") + + # Get TTS configuration + tts_config = settings.get("tts", {}) + monstertts_config = tts_config.get("monstertts", {}) + monster_api_key = monstertts_config.get("apiKey", "") + google_config = tts_config.get("google", {}) + google_api_key = google_config.get("apiKey", "") + polly_config = tts_config.get("polly", {}) + + # Use hybrid provider + provider = await get_hybrid_provider( + monster_api_key=monster_api_key if monster_api_key else None, + monster_voice_id=selected_voice.voice_id if selected_voice.provider == "monstertts" else None, + edge_voice_id=selected_voice.voice_id if selected_voice.provider == "edge" else None, + fallback_voices=[selected_voice], + google_api_key=google_api_key if google_api_key else None, + polly_config=polly_config if polly_config.get("accessKey") and polly_config.get("secretKey") else None + ) + + # Create and process TTS job + job = TTSJob(text=evt.get('text', '').strip(), voice=selected_voice.voice_id, audio_format=audio_format) + logger.info(f"Test TTS Job: text='{job.text}', voice='{selected_voice.name}' ({selected_voice.provider}:{selected_voice.voice_id})") - # Fire-and-forget to allow overlap - async def _run(): - try: - logger.info(f"Starting test TTS synthesis...") - path = await provider.synth(job) - logger.info(f"Test TTS generated: {path}") - - # Broadcast to clients to play - voice_info = { - "id": selected_voice.id, - "name": selected_voice.name, - "provider": selected_voice.provider, - "avatar": selected_voice.avatar_image - } - payload = { - "type": "play", - "user": evt.get("user"), - "message": evt.get("text"), - "eventType": evt.get("eventType", "chat"), - "voice": voice_info, - "audioUrl": f"/audio/{os.path.basename(path)}" - } - logger.info(f"Broadcasting test voice to {len(hub.clients)} clients: {payload}") - await hub.broadcast(payload) - except Exception as e: - logger.info(f"Test TTS synthesis error: {e}") + logger.info(f"Starting test TTS synthesis...") + path = await provider.synth(job) + logger.info(f"Test TTS generated: {path}") + + # Broadcast to clients + voice_info = { + "id": selected_voice.id, + "name": selected_voice.name, + "provider": selected_voice.provider, + "avatar": selected_voice.avatar_image + } + payload = { + "type": "play", + "user": evt.get("user"), + "message": evt.get("text"), + "eventType": evt.get("eventType", "chat"), + "voice": voice_info, + "audioUrl": f"/audio/{os.path.basename(path)}" + } + logger.info(f"Broadcasting test voice to {len(hub.clients)} clients") + await hub.broadcast(payload) + + # Clean up TTS job tracking (test voices don't affect counter) + if username_lower in active_tts_jobs: + del active_tts_jobs[username_lower] + logger.info(f"Test TTS complete. Counter unaffected: {total_active_tts_count}") + + except asyncio.CancelledError: + logger.info(f"Test TTS cancelled for user: {evt.get('user')}") + if username_lower in active_tts_jobs: + del active_tts_jobs[username_lower] + logger.info(f"Cleaned up cancelled test job. Counter unaffected: {total_active_tts_count}") + raise + except Exception as e: + logger.error(f"Test TTS error for {username_lower}: {e}", exc_info=True) + if username_lower in active_tts_jobs: + del active_tts_jobs[username_lower] + logger.info(f"Cleaned up failed test job. Counter unaffected: {total_active_tts_count}") + # Test voices don't affect parallel limit counter - asyncio.create_task(_run()) +async def check_parallel_limits_and_process(evt: Dict[str, Any], is_test_voice: bool = False): + """ + Audio duration-based parallel limiting - simple and reliable. + No WebSocket dependencies, no job tracking complexity. + """ + global total_active_tts_count + + username = evt.get('user', 'unknown') + settings = app_get_settings() + parallel_limit = settings.get("parallelMessageLimit", 5) + queue_overflow = settings.get("queueOverflowMessages", True) + current_active = total_active_tts_count + + # Check if we have a limit and if it's exceeded + if parallel_limit is not None and isinstance(parallel_limit, (int, float)) and parallel_limit > 0 and current_active >= parallel_limit: + logger.info(f"Parallel limit reached ({current_active}/{parallel_limit}) for {username}") + + if queue_overflow and not is_test_voice: # Don't queue test voices + queue_parallel_message(evt) + logger.info(f"Message queued due to parallel limit (queue size: {len(parallel_message_queue)})") + else: + logger.info(f"Message from {username} ignored due to parallel limit") + return False + + # Accept message - increment counter and process + increment_tts_count() + + try: + await process_tts_message(evt) + return True + except Exception as e: + # If processing failed, decrement counter + decrement_tts_count() + logger.error(f"TTS processing failed for {username}: {e}", exc_info=True) + return False async def handle_event(evt: Dict[str, Any]): + """Handle regular chat events with message filtering and parallel limiting""" + print("*** HANDLE_EVENT CALLED ***") logger.info(f"Handling event: {evt}") # Check if TTS is globally enabled if not tts_enabled: + print("*** TTS DISABLED - EXITING ***") logger.info(f"TTS is disabled - skipping message from {evt.get('user', 'unknown')}") return - + settings = app_get_settings() - # Apply message filtering original_text = evt.get('text', '').strip() username = evt.get('user', '') @@ -1317,27 +1550,29 @@ async def handle_event(evt: Dict[str, Any]): evt_filtered = evt.copy() evt_filtered['text'] = filtered_text - # Process immediately - no queuing - username = evt.get('user', 'unknown') - logger.info(f"Message received from {username}: processing immediately") + # Check parallel limits and process if allowed (this handles the entire processing) + await check_parallel_limits_and_process(evt_filtered, is_test_voice=False) + if filtered_text != original_text: logger.info(f"Text after filtering: '{filtered_text}'") - await process_tts_message(evt_filtered) + raise return async def process_tts_message(evt: Dict[str, Any]): - """Process TTS message - voice selection, synthesis, and broadcast""" + """Process TTS message with simple audio duration-based limiting""" username = evt.get('user', 'unknown') username_lower = username.lower() - # Skip TTS if there's no text to speak (e.g., subscription notifications without messages) + # Skip TTS if there's no text to speak text = evt.get("text", "").strip() if not text: event_type = evt.get("eventType", "chat") logger.info(f"Skipping TTS for {username} - no text to speak (eventType: {event_type})") + # Counter was already incremented, so decrement it + decrement_tts_count() return - - # Track this job for cancellation + + # Track this task for cancellation (simple - just task and message) task = asyncio.current_task() active_tts_jobs[username_lower] = { "task": task, @@ -1542,23 +1777,42 @@ async def process_tts_message(evt: Dict[str, Any]): } await hub.broadcast(queue_notification) - # Clean up TTS job tracking + # Simple audio duration-based parallel limiting + # Schedule decrement after audio finishes (based on duration) + decrement_delay = audio_duration + 0.5 if audio_duration and audio_duration > 0 else 5.0 + + async def decrement_after_audio(): + await asyncio.sleep(decrement_delay) + decrement_tts_count() + # Process any queued messages now that a slot is free + process_parallel_message_queue() + + # Start the decrement timer (fire and forget - no tracking needed) + asyncio.create_task(decrement_after_audio()) + + # Clean up job tracking (we only needed it for potential cancellation during processing) if username_lower in active_tts_jobs: del active_tts_jobs[username_lower] - logger.info(f"TTS processing complete. Active TTS jobs: {list(active_tts_jobs.keys())}") + + logger.info(f"TTS generation complete for {username}. Counter: {total_active_tts_count}") except asyncio.CancelledError: - logger.info(f"TTS synthesis cancelled for user: {evt.get('user')}") + logger.info(f"TTS synthesis cancelled for user: {username}") + # Clean up job tracking if username_lower in active_tts_jobs: del active_tts_jobs[username_lower] - logger.info(f"Cleaned up cancelled job. Remaining jobs: {len(active_tts_jobs)}") + # Counter was already incremented, so decrement it on cancellation + decrement_tts_count() raise # Re-raise to properly handle cancellation except Exception as e: - logger.info(f"TTS Error: {e}") - logger.error(f"TTS synthesis error for {username_lower}: {e}", exc_info=True) + logger.error(f"TTS synthesis error for {username}: {e}", exc_info=True) + # Clean up job tracking if username_lower in active_tts_jobs: del active_tts_jobs[username_lower] - logger.info(f"Cleaned up failed job. Remaining jobs: {len(active_tts_jobs)}") + # Counter was already incremented, so decrement it on error + decrement_tts_count() + # Process any queued parallel messages now that a slot is free + process_parallel_message_queue() # ---------- Simulate messages (for local testing) ---------- @@ -1716,6 +1970,32 @@ def handle_youtube_task_exception(task): except Exception as e: logger.error(f"YouTube bot task failed: {e}", exc_info=True) logger.info(f"ERROR: YouTube bot task failed: {e}") + + # Handle authentication errors + error_message = str(e).lower() + if "401" in error_message or "unauthorized" in error_message or "credential" in error_message: + global youtube_auth_error, youtube_refresh_attempted + + # Check if we should attempt automatic token refresh + if not youtube_refresh_attempted: + youtube_refresh_attempted = True + logger.info("YouTube authentication error detected, attempting automatic token refresh...") + + # Attempt token refresh in a separate task + asyncio.create_task(handle_youtube_auth_error()) + else: + logger.warning("YouTube token refresh already attempted, skipping automatic retry") + + # Set auth error for frontend notification + youtube_auth_error = { + "type": "youtube_auth_error", + "message": "YouTube authentication failed. Please reconnect your YouTube account.", + "action": "reconnect" + } + + # Broadcast to connected clients + if hub: + asyncio.create_task(hub.broadcast(youtube_auth_error)) yt.add_done_callback(handle_youtube_task_exception) YouTubeTask = yt diff --git a/backend/modules/persistent_data.py b/backend/modules/persistent_data.py index e360c35..c511337 100644 --- a/backend/modules/persistent_data.py +++ b/backend/modules/persistent_data.py @@ -308,19 +308,10 @@ def save_twitch_auth(user_info: dict, token_data: dict): logger.info(f"Stored Twitch auth for user: {user_info['login']}") def get_twitch_token(): - """Get current Twitch token for bot connection""" - from datetime import datetime - + """Get current Twitch token for bot connection (use get_twitch_token_for_bot for auto-refresh)""" with Session(engine) as session: auth = session.exec(select(TwitchAuth)).first() if auth: - # Check if token needs refresh (if expires_at is set and in the past) - if auth.expires_at: - expires_at = datetime.fromisoformat(auth.expires_at) - if expires_at <= datetime.now(): - logger.info("Twitch token expired, attempting refresh...") - # TODO: Implement token refresh - return { "token": auth.access_token, "username": auth.username, @@ -390,19 +381,10 @@ def save_youtube_auth(channel_info: dict, token_data: dict): logger.info(f"Stored YouTube auth for channel: {channel_info.get('snippet', {}).get('title', 'Unknown')}") def get_youtube_token(): - """Get current YouTube token for bot connection""" - from datetime import datetime - + """Get current YouTube token for bot connection (use get_youtube_token_for_bot for auto-refresh)""" with Session(engine) as session: auth = session.exec(select(YouTubeAuth)).first() if auth: - # Check if token needs refresh (if expires_at is set and in the past) - if auth.expires_at: - expires_at = datetime.fromisoformat(auth.expires_at) - if expires_at <= datetime.now(): - logger.info("YouTube token expired, attempting refresh...") - # TODO: Implement token refresh - return { "access_token": auth.access_token, "refresh_token": auth.refresh_token, diff --git a/backend/modules/settings_defaults.json b/backend/modules/settings_defaults.json index d0c93a6..da1757d 100644 --- a/backend/modules/settings_defaults.json +++ b/backend/modules/settings_defaults.json @@ -4,6 +4,8 @@ "audioFormat": "mp3", "volume": 1.0, "textSize": "normal", +"parallelMessageLimit": 5, +"queueOverflowMessages": true, "avatarMode": "grid", "popupDirection": "bottom", "popupFixedEdge": false, diff --git a/backend/modules/youtube_listener.py b/backend/modules/youtube_listener.py index be53078..2243fab 100644 --- a/backend/modules/youtube_listener.py +++ b/backend/modules/youtube_listener.py @@ -83,7 +83,11 @@ async def find_active_stream(self) -> bool: logger.warning("No active live stream found for this channel") return False except HttpError as e: - logger.error(f"YouTube API error finding active stream: {e}") + status_code = getattr(e, 'resp', {}).get('status', 0) + if status_code == 401: + logger.error("YouTube API authentication error while finding active stream - token may be expired") + else: + logger.error(f"YouTube API error finding active stream: {e}") return False except Exception as e: logger.error(f"Error finding active stream: {e}", exc_info=True) @@ -119,7 +123,11 @@ async def get_live_chat_id(self) -> Optional[str]: logger.error(f"Video {self.video_id} not found") return None except HttpError as e: - logger.error(f"YouTube API error getting live chat ID: {e}") + status_code = getattr(e, 'resp', {}).get('status', 0) + if status_code == 401: + logger.error("YouTube API authentication error while getting live chat ID - token may be expired") + else: + logger.error(f"YouTube API error getting live chat ID: {e}") return None except Exception as e: logger.error(f"Error getting live chat ID: {e}", exc_info=True) @@ -289,17 +297,47 @@ async def listen_to_chat(self, on_event: Callable[[Dict[str, Any]], None]): error_reason = getattr(e, 'reason', 'Unknown error') status_code = getattr(e, 'resp', {}).get('status', 0) - if status_code == 403: - logger.error("⚠️ YouTube API quota exceeded or access forbidden") - logger.error(" The YouTube Data API has strict quota limits:") - logger.error(" - Default quota: 10,000 units/day") - logger.error(" - Each chat poll costs ~5 units") - logger.error(" - This allows ~2,000 polls/day (~83/hour or ~1.4/minute)") - logger.error(" Pausing for 5 minutes to avoid further quota usage...") - await asyncio.sleep(300) # Wait 5 minutes on quota errors - # After quota error, slow down significantly - self.polling_interval = self.max_polling_interval - self.consecutive_empty_polls = 10 # Force slow polling + if status_code == 401: + logger.warning("YouTube API authentication error - token may be expired") + logger.info("Attempting to refresh credentials and rebuild YouTube client...") + + # Try to refresh credentials using the auth router function + try: + from routers.auth import get_youtube_token_for_bot + token_info = await get_youtube_token_for_bot() + if token_info and token_info.get('credentials'): + # Rebuild YouTube client with refreshed credentials + from googleapiclient.discovery import build + self.credentials = token_info['credentials'] + self.youtube = build('youtube', 'v3', credentials=self.credentials) + logger.info("Successfully refreshed YouTube credentials and rebuilt client") + continue # Retry the request + else: + logger.error("Failed to refresh YouTube credentials - stopping listener") + self.running = False + break + except Exception as refresh_error: + logger.error(f"Error during YouTube credential refresh: {refresh_error}") + await asyncio.sleep(30) # Wait before retrying + + elif status_code == 403: + error_details = str(e) + if "quotaExceeded" in error_details or "quota" in error_reason.lower(): + logger.error("⚠️ YouTube API quota exceeded") + logger.error(" The YouTube Data API has strict quota limits:") + logger.error(" - Default quota: 10,000 units/day") + logger.error(" - Each chat poll costs ~5 units") + logger.error(" - This allows ~2,000 polls/day (~83/hour or ~1.4/minute)") + logger.error(" Pausing for 5 minutes to avoid further quota usage...") + await asyncio.sleep(300) # Wait 5 minutes on quota errors + # After quota error, slow down significantly + self.polling_interval = self.max_polling_interval + self.consecutive_empty_polls = 10 # Force slow polling + else: + logger.error("⚠️ YouTube API access forbidden - check permissions") + logger.error(f" Error details: {error_reason}") + await asyncio.sleep(60) # Wait 1 minute for permission errors + elif status_code == 404: logger.error("Live chat not found - stream may have ended") self.running = False diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 85e46c8..c3129ca 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -604,6 +604,41 @@ async def youtube_auth_status(): logger.error(f"Error checking YouTube status: {e}") return {"connected": False, "error": str(e)} +@router.post("/api/youtube/refresh-token") +async def refresh_youtube_token_endpoint(): + """Manually refresh the YouTube access token""" + try: + auth = get_youtube_auth() + if not auth: + return {"success": False, "error": "No YouTube account connected"} + + if not auth.refresh_token: + return {"success": False, "error": "No refresh token available - please reconnect your account"} + + logger.info("Manual YouTube token refresh requested") + refreshed_token_data = await refresh_youtube_token(auth.refresh_token) + + if refreshed_token_data: + # Get updated channel info + channel_info = await get_youtube_channel_info(refreshed_token_data["access_token"]) + if channel_info: + # Store the refreshed token + await store_youtube_auth(channel_info, refreshed_token_data) + logger.info("Successfully refreshed YouTube token manually") + + return { + "success": True, + "message": f"Token refreshed successfully for {channel_info.get('snippet', {}).get('title', 'Unknown')}" + } + else: + return {"success": False, "error": "Failed to get channel info after token refresh"} + else: + return {"success": False, "error": "Failed to refresh token - may need to reconnect your account"} + + except Exception as e: + logger.error(f"Error refreshing YouTube token: {e}", exc_info=True) + return {"success": False, "error": str(e)} + @router.delete("/api/youtube/disconnect") async def youtube_disconnect(): """Disconnect YouTube account""" @@ -681,11 +716,96 @@ async def store_youtube_auth(channel_info: Dict[str, Any], token_data: Dict[str, logger.error(f"Error storing YouTube auth: {e}") raise +async def refresh_youtube_token(refresh_token: str) -> Dict[str, Any]: + """Refresh an expired YouTube access token""" + try: + data = { + "client_id": YOUTUBE_CLIENT_ID, + "client_secret": YOUTUBE_CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": refresh_token + } + + async with aiohttp.ClientSession() as session: + async with session.post("https://oauth2.googleapis.com/token", data=data) as response: + if response.status == 200: + result = await response.json() + logger.info("Successfully refreshed YouTube token") + return result + else: + error_text = await response.text() + logger.error(f"YouTube token refresh failed: {response.status} - {error_text}") + return None + except Exception as e: + logger.error(f"Error refreshing YouTube token: {e}") + return None + async def get_youtube_token_for_bot(): - """Get current YouTube token for bot connection""" + """Get current YouTube token for bot connection with automatic refresh""" try: - return get_youtube_token() + from google.oauth2.credentials import Credentials + + auth = get_youtube_auth() + if not auth: + return None + + # Check if token needs refresh (if expires_at is set and in the past) + needs_refresh = False + current_token = auth.access_token + current_refresh_token = auth.refresh_token + + if auth.expires_at: + try: + expires_at = datetime.fromisoformat(auth.expires_at) + # Add 5-minute buffer to refresh before actual expiration + buffer_time = expires_at - timedelta(minutes=5) + if datetime.now() >= buffer_time: + needs_refresh = True + logger.info(f"YouTube token expires at {expires_at}, refreshing with 5-minute buffer") + except ValueError as e: + logger.warning(f"Invalid expires_at format: {auth.expires_at}, will attempt refresh: {e}") + needs_refresh = True + + # Attempt token refresh if needed and refresh token is available + if needs_refresh and auth.refresh_token: + logger.info("Attempting to refresh YouTube token...") + + refreshed_token_data = await refresh_youtube_token(auth.refresh_token) + if refreshed_token_data: + # Get updated channel info to ensure account is still valid + channel_info = await get_youtube_channel_info(refreshed_token_data["access_token"]) + if channel_info: + # Store the refreshed token + await store_youtube_auth(channel_info, refreshed_token_data) + logger.info("Successfully refreshed and stored new YouTube token") + + # Use refreshed token data + current_token = refreshed_token_data["access_token"] + current_refresh_token = refreshed_token_data.get("refresh_token", auth.refresh_token) + else: + logger.error("Failed to get channel info after YouTube token refresh") + else: + logger.error("Failed to refresh YouTube token - may need to re-authenticate") + return None + + # Create Google OAuth2 credentials object + credentials = Credentials( + token=current_token, + refresh_token=current_refresh_token, + token_uri="https://oauth2.googleapis.com/token", + client_id=YOUTUBE_CLIENT_ID, + client_secret=YOUTUBE_CLIENT_SECRET + ) + + # Return token info with credentials + return { + "access_token": current_token, + "refresh_token": current_refresh_token, + "channel_id": auth.channel_id, + "channel_name": auth.channel_name, + "credentials": credentials + } + except Exception as e: logger.error(f"Error getting YouTube token: {e}") - - return None \ No newline at end of file + return None \ No newline at end of file diff --git a/backend/routers/system.py b/backend/routers/system.py index 1597496..1da5eb9 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -49,6 +49,67 @@ async def api_get_status(): logger.info(f"API: Returning status: {status}") return status +@router.get("/api/debug/tts-state") +async def api_debug_tts_state(): + """Debug endpoint to check TTS counter state""" + from app import total_active_tts_count, active_tts_jobs + from modules.avatars import get_active_avatar_slots + + active_slots = get_active_avatar_slots() + + state = { + "total_active_tts_count": total_active_tts_count, + "active_tts_jobs_count": len(active_tts_jobs), + "active_tts_jobs_users": list(active_tts_jobs.keys()), + "active_avatar_slots_count": len(active_slots), + "active_avatar_slots": list(active_slots.keys()), + "mismatch": total_active_tts_count != len(active_tts_jobs) + } + + logger.info(f"DEBUG TTS State: {state}") + print(f"DEBUG TTS State: {state}") + return state + +@router.post("/api/debug/reset-tts-counter") +async def api_debug_reset_tts_counter(): + """Debug endpoint to reset TTS counter to match active jobs""" + from app import total_active_tts_count, active_tts_jobs, sync_tts_count + + old_count = total_active_tts_count + sync_tts_count() + new_count = total_active_tts_count + + result = { + "old_count": old_count, + "new_count": new_count, + "active_jobs": len(active_tts_jobs), + "reset": True + } + + logger.info(f"DEBUG: Reset TTS counter from {old_count} to {new_count}") + print(f"DEBUG: Reset TTS counter from {old_count} to {new_count}") + return result + +@router.post("/api/debug/force-reset-tts") +async def api_debug_force_reset_tts(): + """Debug endpoint to force reset TTS counter to 0 (emergency reset)""" + from app import force_reset_tts_counter, total_active_tts_count, active_tts_jobs + + old_count = total_active_tts_count + old_jobs = len(active_tts_jobs) + + force_reset_tts_counter() + + result = { + "old_count": old_count, + "old_jobs": old_jobs, + "new_count": 0, + "new_jobs": 0, + "force_reset": True + } + + return result + @router.get("/api/test") async def api_test(): """Simple test endpoint for debugging""" @@ -124,7 +185,7 @@ async def api_debug_per_user_queuing(): """Debug endpoint to check per-user queuing setting""" try: # Import global variables when needed - from app import active_tts_jobs + from app import active_tts_jobs, total_active_tts_count settings = get_settings() filtering = settings.get("messageFiltering", {}) @@ -134,7 +195,7 @@ async def api_debug_per_user_queuing(): "ignoreIfUserSpeaking": ignore_if_user_speaking, "messageFiltering": filtering, "activeJobsByUser": list(active_tts_jobs.keys()), - "totalActiveJobs": len(active_tts_jobs) + "totalActiveJobs": total_active_tts_count } except Exception as e: return {"error": str(e)} @@ -284,4 +345,72 @@ async def api_test_clearchat(payload: Dict[str, Any]): } except Exception as e: logger.error(f"Failed to simulate CLEARCHAT: {e}", exc_info=True) + return {"success": False, "error": str(e)} + +@router.post("/api/test-parallel-limit") +async def test_parallel_limit(): + """Test parallel message limiting by sending multiple messages rapidly""" + try: + import asyncio + import time + from app import handle_event + + # Get current settings to check limits + settings = get_settings() + parallel_limit = settings.get("parallelMessageLimit", 5) + queue_overflow = settings.get("queueOverflowMessages", True) + + logger.info(f"Testing parallel limit: {parallel_limit} messages, queue overflow: {queue_overflow}") + + # Create test messages to exceed the parallel limit (if there is one) + test_messages = [] + if parallel_limit and parallel_limit > 0: + num_messages = parallel_limit + 3 # Send 3 more than the limit + else: + num_messages = 8 # Send 8 messages for unlimited test + + for i in range(num_messages): + test_messages.append({ + "type": "chat", + "user": f"TestUser{i + 1}", + "text": f"This is test message number {i + 1} to test parallel limiting.", + "eventType": "chat", + "tags": {} + }) + + # Send all messages rapidly + start_time = time.time() + tasks = [] + for msg in test_messages: + task = asyncio.create_task(handle_event(msg)) + tasks.append(task) + + # Wait for all tasks to complete (or be queued/ignored) + await asyncio.gather(*tasks, return_exceptions=True) + + duration = time.time() - start_time + + # Import queue info + from app import active_tts_jobs, parallel_message_queue, total_active_tts_count + + result = { + "success": True, + "message": f"Sent {num_messages} messages in {duration:.2f}s", + "config": { + "parallelLimit": parallel_limit, + "queueOverflowMessages": queue_overflow + }, + "results": { + "messagesProcessed": total_active_tts_count, + "messagesQueued": len(parallel_message_queue), + "activeTtsJobs": list(active_tts_jobs.keys()), + "queuedUsers": [item["message_data"]["user"] for item in parallel_message_queue] + } + } + + logger.info(f"Parallel limit test completed: {result['results']}") + return result + + except Exception as e: + logger.error(f"Failed to test parallel limit: {e}", exc_info=True) return {"success": False, "error": str(e)} \ No newline at end of file diff --git a/backend/test_auto_refresh.py b/backend/test_auto_refresh.py deleted file mode 100644 index 73c77d9..0000000 --- a/backend/test_auto_refresh.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -""" -Simple test script to verify automatic token refresh functionality -""" - -import asyncio -import requests -from datetime import datetime - -async def test_auto_refresh(): - """Test the automatic refresh functionality""" - base_url = "http://localhost:8000" - - print("Testing Automatic Token Refresh Functionality") - print("=" * 50) - - try: - # Test 1: Check if backend is running - print("1. Checking backend connection...") - response = requests.get(f"{base_url}/api/twitch/status") - if response.status_code != 200: - print("Backend not running or Twitch not configured") - return - print("Backend connected") - - # Test 2: Test the auto-refresh endpoint - print("\n2. Testing auto-refresh endpoint...") - response = requests.post(f"{base_url}/api/twitch/test-auto-refresh") - result = response.json() - - if result.get("success"): - print("Auto-refresh test completed successfully") - print(f" Message: {result.get('message')}") - print(f" Refresh attempted: {result.get('refresh_attempted')}") - else: - print("Auto-refresh test completed with warnings") - print(f" Error: {result.get('error')}") - - # Test 3: Check current Twitch status - print("\n3. Checking final Twitch status...") - response = requests.get(f"{base_url}/api/twitch/status") - status = response.json() - - if status.get("connected"): - print("Twitch connection active") - print(f" User: {status.get('display_name')} (@{status.get('username')})") - else: - print("Twitch not connected") - - print("\nTest completed! Check backend logs for detailed refresh process.") - - except requests.exceptions.ConnectionError: - print("Cannot connect to backend. Make sure it's running on port 8000") - except Exception as e: - print(f"Test failed: {e}") - -if __name__ == "__main__": - asyncio.run(test_auto_refresh()) \ No newline at end of file diff --git a/frontend/src/components/settings/GeneralSettings.jsx b/frontend/src/components/settings/GeneralSettings.jsx index 8061226..ba9da40 100644 --- a/frontend/src/components/settings/GeneralSettings.jsx +++ b/frontend/src/components/settings/GeneralSettings.jsx @@ -3,11 +3,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui import { Input } from '../ui/input' import { Label } from '../ui/label' import { Button } from '../ui/button' -import { Settings, Sun, Moon } from 'lucide-react' +import { Switch } from '../ui/switch' +import { Settings, Sun, Moon, Plus, Minus, Infinity } from 'lucide-react' import logger from '../../utils/logger' function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) { const [tempVolume, setTempVolume] = useState(Math.round((settings.volume !== undefined ? settings.volume : 1.0) * 100)) + const [customLimit, setCustomLimit] = useState('') + const [isCustomMode, setIsCustomMode] = useState(false) const [theme, setTheme] = useState(() => { // Get theme from localStorage or default to 'dark' return localStorage.getItem('theme') || 'dark' @@ -24,6 +27,20 @@ function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) { localStorage.setItem('theme', theme) }, [theme]) + useEffect(() => { + // Sync custom mode state with current settings + const currentLimit = settings.parallelMessageLimit + const presetValues = [1, 2, 3, 4, 5, 6, 8, 10, 15, 20, null] + + if (currentLimit !== null && !presetValues.includes(currentLimit)) { + setIsCustomMode(true) + setCustomLimit(currentLimit.toString()) + } else { + setIsCustomMode(false) + setCustomLimit('') + } + }, [settings.parallelMessageLimit]) + const toggleTheme = () => { setTheme(prevTheme => prevTheme === 'dark' ? 'light' : 'dark') } @@ -35,7 +52,7 @@ function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) { General Settings - Configure TTS control and audio volume + Configure TTS control, audio volume, and message limits
@@ -85,36 +102,7 @@ function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) {
-
-
- -

Stop all TTS and prevent new messages from being spoken

-
- -
+
@@ -157,6 +145,114 @@ function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) {
+ +
+ + +

+ Set the maximum number of messages that can be spoken simultaneously +

+ +
+ {/* No Limit Toggle */} + + + {/* Button Group with Input and +/- Controls */} +
+ { + const value = e.target.value + if (value === '' || value === '0') { + // Don't update on empty or zero + return + } + const numValue = parseInt(value) + if (numValue >= 1 && numValue <= 999) { + updateSettings({ parallelMessageLimit: numValue }) + logger.info(`Parallel message limit changed to ${numValue}`) + } + }} + placeholder="Inf" + className="!w-20 font-mono text-center h-10 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]" + disabled={settings.parallelMessageLimit === null} + /> + + + + +
+
+ + + + +
+ +
+
+ +

+ When limit is reached, queue messages instead of ignoring them +

+
+ { + updateSettings({ queueOverflowMessages: checked }) + logger.info(`Queue overflow messages: ${checked ? 'enabled' : 'disabled'}`) + }} + /> +
) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 29b20fe..ac53d66 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -34,7 +34,8 @@ import { Shield, Database, Grid3x3, - Sparkles + Sparkles, + Square } from 'lucide-react' export default function SettingsPage() { @@ -170,11 +171,45 @@ export default function SettingsPage() { />
-

- Chat Yapper - Chat Yapper Settings -

-

Configure your voice avatar TTS system

+
+
+

+ Chat Yapper + Chat Yapper Settings +

+

Configure your voice avatar TTS system

+
+ + {/* Global Stop TTS Button */} + {settings && ( + + )} +
From 008f0e0101a03e1f44a3ffa66b799087c33176db Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sat, 22 Nov 2025 19:54:54 +0100 Subject: [PATCH 31/46] notifcation that user needs to click page, quick status --- backend/app.py | 91 ++++++++++++++- backend/modules/twitch_listener.py | 8 ++ backend/tests/conftest.py | 25 ++++ deployment/build.py | 31 ++++- frontend/src/components/VoiceManager.jsx | 4 +- .../components/settings/QuickStatusView.jsx | 109 ++++++++++++++++++ frontend/src/components/ui/badge.jsx | 36 ++++++ frontend/src/components/ui/toast.jsx | 63 ++++++++++ frontend/src/hooks/useToast.js | 51 ++++++++ frontend/src/pages/SettingsPage.jsx | 37 +++++- frontend/src/pages/YappersPage.jsx | 98 +++++++++++++++- 11 files changed, 540 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/settings/QuickStatusView.jsx create mode 100644 frontend/src/components/ui/badge.jsx create mode 100644 frontend/src/components/ui/toast.jsx create mode 100644 frontend/src/hooks/useToast.js diff --git a/backend/app.py b/backend/app.py index f476d54..f6924d3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -884,11 +884,88 @@ async def test_twitch_connection(token_info: dict): # Import TwitchIO for connection testing import twitchio from twitchio.ext import commands + from modules.twitch_listener import _ti_major # Create a minimal test bot that just connects and disconnects class TestBot(commands.Bot): def __init__(self, token, nick): - super().__init__(token=token, nick=nick, prefix='!', initial_channels=[]) + # Handle both access-token and oauth: formats + if token and not token.startswith("oauth:"): + token = f"oauth:{token}" + + major_version = _ti_major() + + # Build constructor kwargs compatible with 1.x, 2.x, and 3.x + try: + if major_version >= 3: + # TwitchIO 3.x requires client_id, client_secret, and bot_id + try: + from modules.persistent_data import TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET + client_id = TWITCH_CLIENT_ID or "" + client_secret = TWITCH_CLIENT_SECRET or "" + except ImportError: + # Fallback for embedded builds + try: + import embedded_config + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') + client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') + except ImportError: + client_id = "" + client_secret = "" + + # Validate that we have required credentials for TwitchIO 3.x + if not client_id or not client_secret: + raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") + + bot_id = nick + + super().__init__( + token=token, + client_id=client_id, + client_secret=client_secret, + bot_id=bot_id, + prefix='!', + initial_channels=[] + ) + elif major_version >= 2: + # TwitchIO 2.x + super().__init__(token=token, prefix='!', initial_channels=[]) + else: + # TwitchIO 1.x expects irc_token + nick + super().__init__(irc_token=token, nick=nick, prefix='!', initial_channels=[]) + except TypeError as e: + # If we still get a TypeError, it might be version detection issue + # Try the 3.x format as fallback + if "client_id" in str(e) or "client_secret" in str(e) or "bot_id" in str(e): + try: + from modules.persistent_data import TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET + client_id = TWITCH_CLIENT_ID or "" + client_secret = TWITCH_CLIENT_SECRET or "" + except ImportError: + try: + import embedded_config + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') + client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') + except ImportError: + client_id = "" + client_secret = "" + + # Validate that we have required credentials for TwitchIO 3.x + if not client_id or not client_secret: + raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") + + bot_id = nick + super().__init__( + token=token, + client_id=client_id, + client_secret=client_secret, + bot_id=bot_id, + prefix='!', + initial_channels=[] + ) + else: + raise + self.connection_successful = False async def event_ready(self): @@ -920,6 +997,13 @@ async def event_ready(self): logger.error(f"Twitch connection test failed: {e}") logger.info(f"Connection test error type: {type(e).__name__}") + # Check if this is a TwitchIO 3.x configuration error + if "client_id" in str(e) or "client_secret" in str(e) or "bot_id" in str(e): + logger.error("*** TwitchIO 3.x CONFIGURATION ERROR ***") + logger.error("This error indicates TwitchIO 3.x is installed but TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET are not configured.") + logger.error("This can happen when the application is built on a different PC without the proper .env file.") + logger.error("To fix this, ensure TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are properly configured in the build environment.") + # Check if this is an authentication error error_str = str(e).lower() is_auth_error = ( @@ -927,7 +1011,10 @@ async def event_ready(self): "unauthorized" in error_str or "invalid" in error_str or "access token" in error_str or - e.__class__.__name__ == "AuthenticationError" + e.__class__.__name__ == "AuthenticationError" or + "client_id" in error_str or + "client_secret" in error_str or + "bot_id" in error_str ) if is_auth_error: diff --git a/backend/modules/twitch_listener.py b/backend/modules/twitch_listener.py index 9c4f596..daea82c 100644 --- a/backend/modules/twitch_listener.py +++ b/backend/modules/twitch_listener.py @@ -77,6 +77,10 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict client_id = "" client_secret = "" + # Validate that we have required credentials for TwitchIO 3.x + if not client_id or not client_secret: + raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") + bot_id = nick super().__init__( @@ -110,6 +114,10 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict client_id = "" client_secret = "" + # Validate that we have required credentials for TwitchIO 3.x + if not client_id or not client_secret: + raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") + bot_id = nick super().__init__( token=token, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1d1c491..6fefe07 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -42,6 +42,31 @@ def test_audio_dir(tmp_path): return audio_dir +@pytest.fixture(autouse=True) +def mock_twitch_credentials(): + """Mock Twitch OAuth credentials for TwitchIO 3.x compatibility""" + original_values = {} + + # Set required credentials for TwitchIO 3.x + env_vars = { + 'TWITCH_CLIENT_ID': 'test_client_id_12345', + 'TWITCH_CLIENT_SECRET': 'test_client_secret_67890' + } + + for key, value in env_vars.items(): + original_values[key] = os.environ.get(key) + os.environ[key] = value + + yield + + # Restore original values + for key, original_value in original_values.items(): + if original_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = original_value + + @pytest.fixture def test_settings(): """Provide default test settings""" diff --git a/deployment/build.py b/deployment/build.py index 9b9b530..6ab7638 100644 --- a/deployment/build.py +++ b/deployment/build.py @@ -165,6 +165,31 @@ def create_embedded_env_config(): logger.info(f"Found YouTube Client ID: {'Yes' if youtube_client_id else 'No'}") logger.info(f"Found YouTube Client Secret: {'Yes' if youtube_client_secret else 'No'}") + # Check TwitchIO version compatibility + try: + import twitchio + version_str = getattr(twitchio, "__version__", "unknown") + major_version = int(version_str.split('.')[0]) if version_str != "unknown" and version_str.split('.')[0].isdigit() else 0 + + logger.info(f"Detected TwitchIO version: {version_str} (major: {major_version})") + + # Warn if TwitchIO 3.x is used without credentials + if major_version >= 3 and (not twitch_client_id or not twitch_client_secret): + logger.warning("*** IMPORTANT BUILD WARNING ***") + logger.warning("TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET") + logger.warning("But these credentials are missing from your .env file!") + logger.warning("The built executable may fail to connect to Twitch.") + logger.warning("Please add TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to your .env file.") + print("\n*** BUILD WARNING ***") + print("TwitchIO 3.x detected but Twitch credentials missing from .env file!") + print("The built executable may fail to connect to Twitch.") + print("Please add TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to your .env file.\n") + + except ImportError: + logger.warning("TwitchIO not found during build - this may cause runtime issues") + except Exception as e: + logger.warning(f"Could not check TwitchIO version: {e}") + # Create embedded config Python file config_content = f'''""" Embedded environment configuration for Chat Yapper executable. @@ -780,8 +805,8 @@ def generate_checksums(): f.write(f"{checksum} ChatYapper.exe\n") logger.info(f"SHA256 checksum: {checksum}") - print(f"[✓] SHA256: {checksum}") - print(f"[✓] Checksum saved to: {checksum_file}") + print(f"[OK] SHA256: {checksum}") + print(f"[OK] Checksum saved to: {checksum_file}") # Also create a checksums.txt with metadata metadata_file = Path("dist/CHECKSUMS.txt") @@ -797,7 +822,7 @@ def generate_checksums(): f.write(" Linux: sha256sum ChatYapper.exe\n") f.write(" Mac: shasum -a 256 ChatYapper.exe\n") - print(f"[✓] Verification file saved to: {metadata_file}") + print(f"[OK] Verification file saved to: {metadata_file}") logger.info(f"Checksum files generated successfully") return True diff --git a/frontend/src/components/VoiceManager.jsx b/frontend/src/components/VoiceManager.jsx index 16aaa61..20bddc1 100644 --- a/frontend/src/components/VoiceManager.jsx +++ b/frontend/src/components/VoiceManager.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react' import logger from '../utils/logger' -export default function VoiceManager({ managedAvatars, apiUrl }) { +export default function VoiceManager({ managedAvatars, apiUrl, onVoicesChange }) { const [voices, setVoices] = useState([]) const [availableVoices, setAvailableVoices] = useState({ monstertts: [], google: [], polly: [], edge: [] }) const [showAddForm, setShowAddForm] = useState(false) @@ -192,6 +192,7 @@ export default function VoiceManager({ managedAvatars, apiUrl }) { if (response.ok) { loadVoices() + onVoicesChange?.() // Notify parent component setShowAddForm(false) setSelectedVoice('') } @@ -221,6 +222,7 @@ export default function VoiceManager({ managedAvatars, apiUrl }) { method: 'DELETE' }) loadVoices() + onVoicesChange?.() // Notify parent component } catch (error) { console.error('Failed to delete voice:', error) } diff --git a/frontend/src/components/settings/QuickStatusView.jsx b/frontend/src/components/settings/QuickStatusView.jsx new file mode 100644 index 0000000..530d1ab --- /dev/null +++ b/frontend/src/components/settings/QuickStatusView.jsx @@ -0,0 +1,109 @@ +import React from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' +import { Badge } from '../ui/badge' +import { + Volume2, + VolumeX, + Mic, + MicOff, + Music, + Waves, + Zap, + Youtube, + Shield, + ShieldX, + Users, + CheckCircle2, + XCircle +} from 'lucide-react' + +function QuickStatusView({ settings, allVoices }) { + if (!settings) return null + + // Calculate status values + const ttsEnabled = settings.ttsControl?.enabled !== false + const volume = Math.round((settings.volume || 1.0) * 100) + const audioFiltersEnabled = settings.audioFilters?.enabled || false + const twitchEnabled = settings.twitch?.enabled || false + const youtubeEnabled = settings.youtube?.enabled || false + const filtersEnabled = settings.messageFiltering?.enabled ?? true + + // Count configured voices + const voiceCount = allVoices?.length || 0 + + const StatusItem = ({ icon: Icon, label, value, status, variant = 'secondary' }) => ( +
+
+ + {label} +
+ + {value} + +
+ ) + + return ( + + + + + 0 ? Volume2 : VolumeX} + label="Volume" + value={`${volume}%`} + status={volume > 0} + variant={volume > 0 ? 'default' : 'secondary'} + /> + + 0} + variant={voiceCount > 0 ? 'default' : 'secondary'} + /> + + + + + + + + + + + ) +} + +export default QuickStatusView \ No newline at end of file diff --git a/frontend/src/components/ui/badge.jsx b/frontend/src/components/ui/badge.jsx new file mode 100644 index 0000000..a1dd34e --- /dev/null +++ b/frontend/src/components/ui/badge.jsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva } from "class-variance-authority" + +import { cn } from "../../lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + ...props +}) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/frontend/src/components/ui/toast.jsx b/frontend/src/components/ui/toast.jsx new file mode 100644 index 0000000..38d5e4e --- /dev/null +++ b/frontend/src/components/ui/toast.jsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from 'react' +import { X, Volume2, VolumeX, MousePointer2 } from 'lucide-react' + +const Toast = ({ message, type = 'info', onClose, autoClose = true, duration = 8000 }) => { + useEffect(() => { + if (autoClose) { + const timer = setTimeout(onClose, duration) + return () => clearTimeout(timer) + } + }, [autoClose, duration, onClose]) + + const getIcon = () => { + switch (type) { + case 'error': + return + case 'warning': + return + case 'info': + default: + return + } + } + + const getTypeClasses = () => { + switch (type) { + case 'error': + return 'bg-red-600 border-red-500 text-white' + case 'warning': + return 'bg-yellow-600 border-yellow-500 text-white' + case 'info': + default: + return 'bg-blue-600 border-blue-500 text-white' + } + } + + return ( +
+
+ {getIcon()} +
+ {typeof message === 'string' ?
{message}
: message} +
+ +
+
+ ) +} + +export default Toast \ No newline at end of file diff --git a/frontend/src/hooks/useToast.js b/frontend/src/hooks/useToast.js new file mode 100644 index 0000000..3b3bc38 --- /dev/null +++ b/frontend/src/hooks/useToast.js @@ -0,0 +1,51 @@ +import { useState, useCallback } from 'react' + +export const useToast = () => { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((message, type = 'info', options = {}) => { + const id = Date.now() + Math.random() + const newToast = { + id, + message, + type, + ...options + } + + setToasts(prev => { + // Prevent duplicate messages of the same type + const isDuplicate = prev.some(toast => + toast.message === message && toast.type === type + ) + if (isDuplicate) { + return prev + } + return [...prev, newToast] + }) + return id + }, []) + + const hideToast = useCallback((id) => { + setToasts(prev => prev.filter(toast => toast.id !== id)) + }, []) + + const showAutoplayError = useCallback(() => { + return showToast( + '🔊 Audio blocked! Click anywhere to enable TTS.', + 'warning', + { autoClose: false } // Don't auto-close this important message + ) + }, [showToast]) + + const hideAllToasts = useCallback(() => { + setToasts([]) + }, []) + + return { + toasts, + showToast, + hideToast, + showAutoplayError, + hideAllToasts + } +} \ No newline at end of file diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index ac53d66..62f38ff 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -17,6 +17,7 @@ import MessageFiltering from '../components/settings/MessageFiltering' import AudioFiltersSettings from '../components/settings/AudioFiltersSettings' import MessageHistory from '../components/MessageHistory' import ExportImportSettings from '../components/settings/ExportImportSettings' +import QuickStatusView from '../components/settings/QuickStatusView' import chatYapperIcon from '../assets/icon.png' import backgroundImage from '../assets/background.png' import backgroundImage2 from '../assets/background2.png' @@ -89,9 +90,7 @@ export default function SettingsPage() { }) // Load voices from database - fetch(`${apiUrl}/api/voices`).then(r => r.json()).then(data => { - setAllVoices(data?.voices || []) - }) + loadVoices() // Load managed avatars fetch(`${apiUrl}/api/avatars/managed`).then(r => r.json()).then(data => { @@ -102,6 +101,16 @@ export default function SettingsPage() { // No need for WebSocket listener here - backend is source of truth // Settings will be refreshed from backend when needed + const loadVoices = async () => { + try { + const response = await fetch(`${apiUrl}/api/voices`) + const data = await response.json() + setAllVoices(data?.voices || []) + } catch (error) { + console.error('Failed to load voices:', error) + } + } + const updateSettings = async (partial) => { const next = { ...(settings || {}), ...partial } setSettings(next) @@ -212,8 +221,12 @@ export default function SettingsPage() {
- - + {/* Main content area with two-column layout on larger screens */} +
+ {/* Main settings area */} +
+ + General @@ -284,7 +297,7 @@ export default function SettingsPage() { - + @@ -313,6 +326,18 @@ export default function SettingsPage() { +
+ + {/* Status sidebar */} +
+
+ +
+
+
) diff --git a/frontend/src/pages/YappersPage.jsx b/frontend/src/pages/YappersPage.jsx index 21fa85a..7a8b8c7 100644 --- a/frontend/src/pages/YappersPage.jsx +++ b/frontend/src/pages/YappersPage.jsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import logger from '../utils/logger' import { hexColorWithOpacity } from '../utils/colorUtils' +import Toast from '../components/ui/toast' +import { useToast } from '../hooks/useToast' // Audio playback constants const AUDIO_READY_TIMEOUT_MS = 5000 // 5 seconds timeout for audio readiness check @@ -15,6 +17,11 @@ export default function YappersPage() { // Use ref to always have the latest settings value without waiting for React re-render // This solves timing issues where settings update via WebSocket but audio is created before re-render const settingsRef = useRef(null) + + // Toast notifications for user feedback + const { toasts, showToast, hideToast, showAutoplayError, hideAllToasts } = useToast() + const [hasShownAutoplayError, setHasShownAutoplayError] = useState(false) + const [userHasInteracted, setUserHasInteracted] = useState(false) // Track active audio objects for stopping // Supports parallel audio: multiple users can have TTS playing simultaneously @@ -110,6 +117,37 @@ export default function YappersPage() { } }, []) + // Track user interaction to know when we can show autoplay notifications + useEffect(() => { + const handleUserInteraction = () => { + if (!userHasInteracted) { + setUserHasInteracted(true) + logger.info('User has interacted with the page - audio should now be enabled') + + // Only show success notification if we had previously shown an error + if (hasShownAutoplayError) { + logger.info('Showing audio enabled notification after autoplay error was resolved') + hideAllToasts() + showToast('🎵 Audio enabled!', 'info', { duration: 2000 }) + setHasShownAutoplayError(false) // Reset after showing success + } else { + logger.info('User interacted but no autoplay error was shown, not showing success message') + } + } + } + + const events = ['click', 'touchstart', 'keydown', 'mousedown'] + events.forEach(event => { + document.addEventListener(event, handleUserInteraction, { once: false, passive: true }) + }) + + return () => { + events.forEach(event => { + document.removeEventListener(event, handleUserInteraction) + }) + } + }, [userHasInteracted, hasShownAutoplayError, hideAllToasts, showToast]) + // Backend-managed avatar slot assignments const [avatarSlots, setAvatarSlots] = useState([]) const [assignmentGeneration, setAssignmentGeneration] = useState(0) @@ -415,6 +453,14 @@ export default function YappersPage() { utterance.addEventListener('end', end) utterance.addEventListener('error', (e) => { console.error('Web Speech error:', e) + + // Check if this is related to user interaction requirement + if (e.error === 'not-allowed' && !userHasInteracted && !hasShownAutoplayError) { + logger.warn('Web Speech blocked - likely requires user interaction') + showAutoplayError() + setHasShownAutoplayError(true) + } + end() }) } else { @@ -427,6 +473,14 @@ export default function YappersPage() { utterance.addEventListener('end', end) utterance.addEventListener('error', (e) => { console.error('Web Speech error:', e) + + // Check if this is related to user interaction requirement + if (e.error === 'not-allowed' && !userHasInteracted && !hasShownAutoplayError) { + logger.warn('Web Speech blocked - likely requires user interaction') + showAutoplayError() + setHasShownAutoplayError(true) + } + end() }) } @@ -806,6 +860,24 @@ export default function YappersPage() { }) .catch((error) => { console.error(`Audio play() failed for ${msg.user}:`, error) + + // Check if this is an autoplay policy error + const isAutoplayError = ( + error.name === 'NotAllowedError' || + error.message.includes('autoplay') || + error.message.includes('user activation') || + error.message.includes('user gesture') || + error.message.includes('interact with the document first') + ) + + // Only show notification if it's an autoplay error, user hasn't interacted, + // and we haven't already shown the error + if (isAutoplayError && !userHasInteracted && !hasShownAutoplayError) { + logger.warn('Audio blocked by browser autoplay policy - showing user notification') + showAutoplayError() + setHasShownAutoplayError(true) + } + // CRITICAL: Clean up on play failure to prevent backend thinking slot is occupied if (currentAvatarMode === 'popup') { // Clean up audio tracking for popup mode @@ -940,12 +1012,36 @@ export default function YappersPage() { return (
- {/* Chat bubble fade-in animation */} + {/* Toast Notifications */} + {toasts.map(toast => ( + hideToast(toast.id)} + autoClose={toast.autoClose} + duration={toast.duration} + /> + ))} + + + + {/* Chat bubble fade-in animation & Toast animations */} {/* Voice Avatars Overlay */}
From c09bdcc69d2bf2a23741cd1aa48f6d229745885a Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sat, 22 Nov 2025 19:57:27 +0100 Subject: [PATCH 32/46] update readme, version --- README.md | 7 ++++++- backend/version.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 22d0030..fae153d 100644 --- a/README.md +++ b/README.md @@ -225,8 +225,13 @@ chat-yapper/ ## Changelog -### v1.2.1 (Latest) +### v1.2.2 (Latest) - **New Features:** + - Quick status view + - Limit concurrent TTS messages + - Some more twitch fixes and improved notifcations + +### v1.2.1 - usernames for chatbubbles - text size adjustment - Toggle for only allowing redeem messages for twitch diff --git a/backend/version.py b/backend/version.py index 21b74af..0b2e76e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -3,4 +3,4 @@ This file is automatically updated during CI/CD builds """ -__version__ = "1.2.1" +__version__ = "1.2.2" From ee60426558ae66f1f6aa1ab351ce68e62fb95e8d Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sat, 22 Nov 2025 21:18:07 +0100 Subject: [PATCH 33/46] emergency twitch fix --- backend/app.py | 32 +++++++++++++++++++----------- backend/modules/persistent_data.py | 3 ++- backend/modules/twitch_listener.py | 22 ++++++++++---------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/backend/app.py b/backend/app.py index f6924d3..267fd32 100644 --- a/backend/app.py +++ b/backend/app.py @@ -888,7 +888,7 @@ async def test_twitch_connection(token_info: dict): # Create a minimal test bot that just connects and disconnects class TestBot(commands.Bot): - def __init__(self, token, nick): + def __init__(self, token, nick, user_id=None): # Handle both access-token and oauth: formats if token and not token.startswith("oauth:"): token = f"oauth:{token}" @@ -904,20 +904,21 @@ def __init__(self, token, nick): client_id = TWITCH_CLIENT_ID or "" client_secret = TWITCH_CLIENT_SECRET or "" except ImportError: - # Fallback for embedded builds + # Fallback for embedded builds with fixed client ID try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "" + client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x if not client_id or not client_secret: raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") - bot_id = nick + # For TwitchIO 3.x, bot_id should be the user ID, not the username + bot_id = user_id or nick super().__init__( token=token, @@ -944,17 +945,18 @@ def __init__(self, token, nick): except ImportError: try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "" + client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x if not client_id or not client_secret: raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") - bot_id = nick + # For TwitchIO 3.x, bot_id should be the user ID, not the username + bot_id = user_id or nick super().__init__( token=token, client_id=client_id, @@ -974,8 +976,12 @@ async def event_ready(self): # Disconnect immediately after successful connection await self.close() - # Create test bot instance - test_bot = TestBot(token=token_info["token"], nick=token_info["username"]) + # Create test bot instance using authenticated username and user ID for compatibility + test_bot = TestBot( + token=token_info["token"], + nick=token_info["username"], + user_id=token_info.get("user_id") + ) # Run the test with a timeout try: @@ -1041,11 +1047,13 @@ async def create_twitch_bot_task(token_info: dict, channel: str, route_twitch_ev """Create a Twitch bot task with consistent error handling""" try: # Create the task and monitor it for auth errors immediately + # Use the authenticated username for TwitchIO compatibility, display name handled separately task = asyncio.create_task(run_twitch_bot( token=token_info["token"], nick=token_info["username"], channel=channel, - on_event=lambda e: asyncio.create_task(route_twitch_event(e)) + on_event=lambda e: asyncio.create_task(route_twitch_event(e)), + user_id=token_info.get("user_id") )) # Attach the error handler @@ -1981,7 +1989,7 @@ async def startup(): twitch_config = settings.get("twitch", {}) channel = twitch_config.get("channel") or token_info["username"] - logger.info(f"Twitch config: channel={channel}, nick={token_info['username']}, token={'***' if token_info['token'] else 'None'}") + logger.info(f"Twitch config: channel={channel}, nick={token_info['username']} (displayed as Chat Yapper), token={'***' if token_info['token'] else 'None'}") # Test Twitch connection first to detect auth issues early connection_test_passed = await test_twitch_connection(token_info) diff --git a/backend/modules/persistent_data.py b/backend/modules/persistent_data.py index c511337..5e00033 100644 --- a/backend/modules/persistent_data.py +++ b/backend/modules/persistent_data.py @@ -47,7 +47,8 @@ def get_user_data_dir(): os.makedirs(PERSISTENT_AVATARS_DIR, exist_ok=True) # Twitch OAuth Configuration - uses embedded config when running as executable -TWITCH_CLIENT_ID = get_env_var("TWITCH_CLIENT_ID", "") +# Fixed client ID for Chat Yapper bot +TWITCH_CLIENT_ID = get_env_var("TWITCH_CLIENT_ID", "pker88pnps6l8ku90u7ggwvt9dmz2f") TWITCH_CLIENT_SECRET = get_env_var("TWITCH_CLIENT_SECRET", "") TWITCH_REDIRECT_URI = f"http://localhost:{os.environ.get('PORT', 8000)}/auth/twitch/callback" TWITCH_SCOPE = "chat:read" diff --git a/backend/modules/twitch_listener.py b/backend/modules/twitch_listener.py index daea82c..da331c4 100644 --- a/backend/modules/twitch_listener.py +++ b/backend/modules/twitch_listener.py @@ -48,7 +48,7 @@ def _is_vip_from(message, tags: Dict[str, Any]) -> bool: return "vip/" in badges or badges == "vip" class TwitchBot(commands.Bot): - def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict[str, Any]], None]): + def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict[str, Any]], None], user_id: str = None): # Handle both access-token and oauth: formats if token and not token.startswith("oauth:"): token = f"oauth:{token}" @@ -68,20 +68,21 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict client_id = TWITCH_CLIENT_ID or "" client_secret = TWITCH_CLIENT_SECRET or "" except ImportError: - # Fallback for embedded builds + # Fallback for embedded builds with fixed client ID try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "" + client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x if not client_id or not client_secret: raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") - bot_id = nick + # For TwitchIO 3.x, bot_id should be the user ID, not the username + bot_id = user_id or nick super().__init__( token=token, @@ -108,17 +109,18 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict except ImportError: try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "" + client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x if not client_id or not client_secret: raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}") - bot_id = nick + # For TwitchIO 3.x, bot_id should be the user ID, not the username + bot_id = user_id or nick super().__init__( token=token, client_id=client_id, @@ -263,7 +265,7 @@ async def event_clearchat(self, channel, tags): self._emit_clearchat(channel, tags) -async def run_twitch_bot(token: str, nick: str, channel: str, on_event: Callable[[Dict[str, Any]], None]): +async def run_twitch_bot(token: str, nick: str, channel: str, on_event: Callable[[Dict[str, Any]], None], user_id: str = None): bot_logger = None bot = None try: @@ -282,7 +284,7 @@ async def run_twitch_bot(token: str, nick: str, channel: str, on_event: Callable pass try: - bot = TwitchBot(token, nick, channel, on_event) + bot = TwitchBot(token, nick, channel, on_event, user_id) if bot_logger: bot_logger.info("Twitch bot instance created, connecting...") From 1aa681380821eb22f689da31fdf65cf48880b7de Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sat, 22 Nov 2025 21:26:46 +0100 Subject: [PATCH 34/46] yep --- backend/app.py | 8 ++++---- backend/modules/twitch_listener.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/app.py b/backend/app.py index 267fd32..2e3053a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -907,10 +907,10 @@ def __init__(self, token, nick, user_id=None): # Fallback for embedded builds with fixed client ID try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" + client_id = "" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x @@ -945,10 +945,10 @@ def __init__(self, token, nick, user_id=None): except ImportError: try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" + client_id = "" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x diff --git a/backend/modules/twitch_listener.py b/backend/modules/twitch_listener.py index da331c4..7bae093 100644 --- a/backend/modules/twitch_listener.py +++ b/backend/modules/twitch_listener.py @@ -71,10 +71,10 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict # Fallback for embedded builds with fixed client ID try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" + client_id = "" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x @@ -109,10 +109,10 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict except ImportError: try: import embedded_config - client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', 'pker88pnps6l8ku90u7ggwvt9dmz2f') + client_id = getattr(embedded_config, 'TWITCH_CLIENT_ID', '') client_secret = getattr(embedded_config, 'TWITCH_CLIENT_SECRET', '') except ImportError: - client_id = "pker88pnps6l8ku90u7ggwvt9dmz2f" + client_id = "" client_secret = "" # Validate that we have required credentials for TwitchIO 3.x From 58440843bc5216309e4275ff67dcced4bbb75864 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sat, 22 Nov 2025 22:52:24 +0100 Subject: [PATCH 35/46] dang --- backend/app.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/backend/app.py b/backend/app.py index 2e3053a..bfb1d92 100644 --- a/backend/app.py +++ b/backend/app.py @@ -880,7 +880,16 @@ async def test_twitch_connection(token_info: dict): """Test Twitch connection without starting the full bot to detect auth issues early""" logger.info("Testing Twitch connection...") + # Save current directory and switch to a writable temp directory for TwitchIO token cache + import os + import tempfile + original_dir = os.getcwd() + temp_dir = tempfile.gettempdir() + try: + # Change to temp directory to avoid permission issues with .tio.tokens.json + os.chdir(temp_dir) + # Import TwitchIO for connection testing import twitchio from twitchio.ext import commands @@ -889,6 +898,9 @@ async def test_twitch_connection(token_info: dict): # Create a minimal test bot that just connects and disconnects class TestBot(commands.Bot): def __init__(self, token, nick, user_id=None): + # Store nick for logging + self._nick = nick + # Handle both access-token and oauth: formats if token and not token.startswith("oauth:"): token = f"oauth:{token}" @@ -971,7 +983,7 @@ def __init__(self, token, nick, user_id=None): self.connection_successful = False async def event_ready(self): - logger.info(f"Twitch connection test successful for user: {self.nick}") + logger.info(f"Twitch connection test successful for user: {self._nick}") self.connection_successful = True # Disconnect immediately after successful connection await self.close() @@ -1003,6 +1015,13 @@ async def event_ready(self): logger.error(f"Twitch connection test failed: {e}") logger.info(f"Connection test error type: {type(e).__name__}") + # Check if this is a file permission error (TwitchIO 3.x token cache) + if "Permission denied" in str(e) and ".tio.tokens.json" in str(e): + logger.warning("TwitchIO token cache permission error - this is non-critical for testing") + logger.info("Connection test will continue despite token cache error") + # Don't treat this as a critical auth error since it's just a cache write issue + return False + # Check if this is a TwitchIO 3.x configuration error if "client_id" in str(e) or "client_secret" in str(e) or "bot_id" in str(e): logger.error("*** TwitchIO 3.x CONFIGURATION ERROR ***") @@ -1042,6 +1061,13 @@ async def event_ready(self): logger.error(f"Failed to broadcast auth error during connection test: {broadcast_error}") return False + + finally: + # Restore original directory + try: + os.chdir(original_dir) + except Exception: + pass # Ignore errors when restoring directory async def create_twitch_bot_task(token_info: dict, channel: str, route_twitch_event, context_name: str): """Create a Twitch bot task with consistent error handling""" From 4a6a8e58251c020ed69ea69294d5ba031f33f968 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 23 Nov 2025 18:20:44 +0100 Subject: [PATCH 36/46] better twitch logging --- backend/app.py | 4 ++++ backend/modules/twitch_listener.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/app.py b/backend/app.py index bfb1d92..411b266 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1072,6 +1072,8 @@ async def event_ready(self): async def create_twitch_bot_task(token_info: dict, channel: str, route_twitch_event, context_name: str): """Create a Twitch bot task with consistent error handling""" try: + logger.info(f"Creating Twitch bot task ({context_name}) with user_id: {token_info.get('user_id')}") + # Create the task and monitor it for auth errors immediately # Use the authenticated username for TwitchIO compatibility, display name handled separately task = asyncio.create_task(run_twitch_bot( @@ -1082,6 +1084,8 @@ async def create_twitch_bot_task(token_info: dict, channel: str, route_twitch_ev user_id=token_info.get("user_id") )) + logger.info(f"Twitch bot task created, attaching error handler...") + # Attach the error handler task.add_done_callback(create_twitch_task_exception_handler(context_name)) logger.info(f"Twitch bot started with comprehensive error handling ({context_name})") diff --git a/backend/modules/twitch_listener.py b/backend/modules/twitch_listener.py index 7bae093..84c4a24 100644 --- a/backend/modules/twitch_listener.py +++ b/backend/modules/twitch_listener.py @@ -268,10 +268,14 @@ async def event_clearchat(self, channel, tags): async def run_twitch_bot(token: str, nick: str, channel: str, on_event: Callable[[Dict[str, Any]], None], user_id: str = None): bot_logger = None bot = None + + # Early logging to confirm function is called + print(f"[TWITCH DEBUG] run_twitch_bot called: nick={nick}, channel={channel}, user_id={user_id}") + try: import logging bot_logger = logging.getLogger("ChatYapper.Twitch") - bot_logger.info(f"Starting Twitch bot: nick={nick}, channel={channel}") + bot_logger.info(f"Starting Twitch bot: nick={nick}, channel={channel}, user_id={user_id}") # Log TwitchIO version for debugging try: @@ -280,10 +284,12 @@ async def run_twitch_bot(token: str, nick: str, channel: str, on_event: Callable bot_logger.info(f"TwitchIO version: {version}") except Exception: pass - except Exception: + except Exception as log_err: + print(f"[TWITCH DEBUG] Logging setup failed: {log_err}") pass try: + bot_logger.info(f"Creating TwitchBot instance with user_id={user_id}...") bot = TwitchBot(token, nick, channel, on_event, user_id) if bot_logger: bot_logger.info("Twitch bot instance created, connecting...") From 693bfbcb1f5bb0e2c947969d1e0a317eae60bbc2 Mon Sep 17 00:00:00 2001 From: pladisdev Date: Sun, 23 Nov 2025 22:31:41 +0100 Subject: [PATCH 37/46] animations and avatar layout editor --- backend/app.py | 55 +- backend/modules/avatars.py | 166 +-- backend/modules/db_migration.py | 23 + backend/modules/models.py | 15 +- backend/modules/persistent_data.py | 89 +- backend/modules/settings_defaults.json | 9 + backend/routers/avatars.py | 151 ++- .../settings/AvatarConfigurationTabs.jsx | 33 +- .../settings/AvatarLayoutEditor.jsx | 1012 +++++++++++++++++ .../settings/AvatarPlacementSettings.jsx | 251 ++-- .../settings/CrowdAnimationSettings.jsx | 296 +++++ frontend/src/pages/SettingsPage.jsx | 1 + frontend/src/pages/YappersPage.jsx | 279 ++++- 13 files changed, 2044 insertions(+), 336 deletions(-) create mode 100644 frontend/src/components/settings/AvatarLayoutEditor.jsx create mode 100644 frontend/src/components/settings/CrowdAnimationSettings.jsx diff --git a/backend/app.py b/backend/app.py index 411b266..76c9d34 100644 --- a/backend/app.py +++ b/backend/app.py @@ -230,9 +230,9 @@ async def process_queued_tts_message(message_data, target_slot): enriched_message.update({ "targetSlot": { "id": target_slot["id"], - "row": target_slot["row"], - "col": target_slot["col"], - "totalInRow": target_slot["totalInRow"] + "x_position": target_slot.get("x_position", 50), + "y_position": target_slot.get("y_position", 50), + "size": target_slot.get("size", 100) }, "avatarData": target_slot["avatarData"], "generationId": get_avatar_assignments_generation_id() @@ -1716,9 +1716,34 @@ async def process_tts_message(evt: Dict[str, Any]): return event_type = evt.get("eventType", "chat") - # Select voice: special mapping else random + + # First, find an available avatar slot (without voice filtering) + # This will be used to determine which voice to use + target_slot = find_available_slot_for_tts(voice_id=None, user=username) + + if not target_slot: + logger.info(f"No available avatar slots for {username}, message will be queued") + decrement_tts_count() + return + + # Now determine the voice to use based on priority: + # 1. Slot's assigned voice (if voice_id is set and valid) + # 2. Special event voice mapping + # 3. Random voice (avoiding last voice if no slot assignment) + selected_voice = None - if event_type in special: + slot_voice_id = target_slot.get("voice_id") + + # Check if slot has an assigned voice + if slot_voice_id is not None: + selected_voice = next((v for v in enabled_voices if v.id == slot_voice_id), None) + if selected_voice: + logger.info(f"Using slot-assigned voice: {selected_voice.name} ({selected_voice.provider}) for slot {target_slot['id']}") + else: + logger.warning(f"Slot {target_slot['id']} has voice_id {slot_voice_id} but voice not found in enabled voices, will select randomly") + + # If no slot voice, check special event mapping + if not selected_voice and event_type in special: vid = special[event_type].get("voiceId") # Validate vid is a proper integer/string ID, not a function name or corrupted value if vid and not str(vid).isdigit() and str(vid) not in ["get_by_id", "null", "undefined", ""]: @@ -1729,9 +1754,11 @@ async def process_tts_message(evt: Dict[str, Any]): selected_voice = next((v for v in enabled_voices if str(v.id) == str(vid)), None) if not selected_voice: logger.warning(f"Special event voice ID {vid} for {event_type} not found in enabled voices, will use random voice instead") + else: + logger.info(f"Special event voice selected: {selected_voice.name} ({selected_voice.provider})") + # If still no voice selected, choose randomly (avoiding last voice if possible) if not selected_voice: - # Random selection from enabled voices, avoiding last selected voice if possible global last_selected_voice_id # If we have more than 2 voices, avoid selecting the same voice as last time @@ -1749,11 +1776,8 @@ async def process_tts_message(evt: Dict[str, Any]): selected_voice = random.choice(enabled_voices) logger.info(f"Random voice selected: {selected_voice.name} ({selected_voice.provider})") - # Update last selected voice + # Update last selected voice only when randomly selected (not for slot-assigned or special event voices) last_selected_voice_id = selected_voice.id - else: - logger.info(f"Special event voice selected: {selected_voice.name} ({selected_voice.provider})") - # Don't update last_selected_voice_id for special events, so they don't affect the pattern # Track voice usage for distribution analysis global voice_usage_stats, voice_selection_count @@ -1830,9 +1854,8 @@ async def process_tts_message(evt: Dict[str, Any]): # Get audio duration for accurate slot timeout (no filters applied) audio_duration = get_audio_duration(path) - # Find available avatar slot for this TTS - voice_id = selected_voice.id - target_slot = find_available_slot_for_tts(voice_id, username) + # We already found the slot earlier (before voice selection) + # No need to find it again here audio_url = f"/audio/{os.path.basename(path)}" @@ -1870,9 +1893,9 @@ async def process_tts_message(evt: Dict[str, Any]): enhanced_payload.update({ "targetSlot": { "id": target_slot["id"], - "row": target_slot["row"], - "col": target_slot["col"], - "totalInRow": target_slot["totalInRow"] + "x_position": target_slot.get("x_position", 50), + "y_position": target_slot.get("y_position", 50), + "size": target_slot.get("size", 100) }, "avatarData": target_slot["avatarData"], "generationId": get_avatar_assignments_generation_id() diff --git a/backend/modules/avatars.py b/backend/modules/avatars.py index cbea887..6e91c80 100644 --- a/backend/modules/avatars.py +++ b/backend/modules/avatars.py @@ -103,95 +103,91 @@ def get_available_avatars(): ] def generate_avatar_slot_assignments(): - """Generate randomized avatar assignments for all slots based on settings""" + """Generate avatar assignments from configured slots in database""" global avatar_slot_assignments, avatar_assignments_generation_id - settings = get_settings() - avatar_rows = settings.get("avatarRows", 2) - avatar_row_config = settings.get("avatarRowConfig", [6, 6]) + from modules.persistent_data import get_avatar_slots - # Debug logging for settings - logger.info(f"Avatar settings from backend: avatarRows={avatar_rows}, avatarRowConfig={avatar_row_config}") + # Get configured slots from database + configured_slots = get_avatar_slots() + + if not configured_slots: + # No configured slots - return empty list + logger.info("No configured avatar slots found - avatar crowd will be empty") + avatar_slot_assignments = [] + avatar_assignments_generation_id += 1 + return avatar_slot_assignments + # Get available avatars available_avatars = get_available_avatars() if not available_avatars: logger.warning("No avatars available for assignment") avatar_slot_assignments = [] - return + avatar_assignments_generation_id += 1 + return avatar_slot_assignments - total_slots = sum(avatar_row_config[:avatar_rows]) - logger.info(f"Generating avatar assignments for {total_slots} slots with {len(available_avatars)} avatars") - logger.info(f"Available avatars: {[{'name': a['name'], 'defaultImage': a['defaultImage'][:50] + '...' if len(a['defaultImage']) > 50 else a['defaultImage']} for a in available_avatars]}") - - assignments = [] - - # Handle avatars with specific spawn positions first - slot_index = 0 - slots = [] - - # Create all slots first - for row_index in range(avatar_rows): - avatars_in_row = avatar_row_config[row_index] if row_index < len(avatar_row_config) else 6 - logger.debug(f"Creating row {row_index} with {avatars_in_row} avatars") - for col_index in range(avatars_in_row): - slots.append({ - "id": f"slot_{slot_index}", - "row": row_index, - "col": col_index, - "totalInRow": avatars_in_row, - "avatarData": None, # Will be assigned - "isActive": False - }) - slot_index += 1 + # Create avatar lookup by group_id matching frontend logic + # Frontend uses: avatar.avatar_group_id || `single_${avatar.id}` + from modules.persistent_data import get_enabled_avatars + raw_avatars = get_enabled_avatars() - logger.info(f"Total slots created: {len(slots)}") - - # First pass: Handle avatars with specific spawn positions - assigned_slots = set() - for avatar in available_avatars: - if avatar["spawn_position"] is not None: - spawn_pos = avatar["spawn_position"] - 1 # Convert to 0-based index - if 0 <= spawn_pos < len(slots) and spawn_pos not in assigned_slots: - slots[spawn_pos]["avatarData"] = avatar.copy() - assigned_slots.add(spawn_pos) - logger.info(f"Assigned {avatar['name']} to specific position {spawn_pos + 1}") + avatar_group_lookup = {} + for avatar_db in raw_avatars: + group_id = avatar_db.avatar_group_id or f"single_{avatar_db.id}" + if group_id not in avatar_group_lookup: + avatar_group_lookup[group_id] = { + "name": avatar_db.name, + "images": {}, + "voice_id": avatar_db.voice_id, + "spawn_position": avatar_db.spawn_position + } + # Ensure file path is properly formatted + file_path = avatar_db.file_path + if not file_path.startswith('http') and not file_path.startswith('/'): + file_path = f"/{file_path}" + avatar_group_lookup[group_id]["images"][avatar_db.avatar_type] = file_path - # Second pass: Randomly assign remaining avatars to unassigned slots - unassigned_slots = [i for i in range(len(slots)) if i not in assigned_slots] + # Convert to avatar data format + avatar_data_by_group = {} + for group_id, group_data in avatar_group_lookup.items(): + default_img = group_data["images"].get("default", group_data["images"].get("speaking")) + speaking_img = group_data["images"].get("speaking", group_data["images"].get("default")) + avatar_data_by_group[group_id] = { + "name": group_data["name"], + "defaultImage": default_img, + "speakingImage": speaking_img, + "isSingleImage": default_img == speaking_img or not (default_img and speaking_img), + "voice_id": group_data["voice_id"], + "spawn_position": group_data["spawn_position"] + } - # Create assignment pool - ensure each avatar appears at least once if we have enough slots - assignment_pool = [] - if len(unassigned_slots) >= len(available_avatars): - # Add each avatar at least once - assignment_pool.extend(available_avatars) - # Fill remaining with random avatars - remaining = len(unassigned_slots) - len(available_avatars) - for _ in range(remaining): - assignment_pool.append(random.choice(available_avatars)) - else: - # More avatars than slots, randomly select - for _ in range(len(unassigned_slots)): - assignment_pool.append(random.choice(available_avatars)) - - # Shuffle the assignment pool - random.shuffle(assignment_pool) + assignments = [] - # Assign to unassigned slots - for i, slot_idx in enumerate(unassigned_slots): - if i < len(assignment_pool): - slots[slot_idx]["avatarData"] = assignment_pool[i].copy() - logger.info(f"Randomly assigned {assignment_pool[i]['name']} to slot {slot_idx}") + for slot_config in configured_slots: + slot_data = { + "id": slot_config['id'], # Use database primary key for unique ID + "slot_index": slot_config['slot_index'], # Keep slot_index for ordering/display + "x_position": slot_config["x_position"], + "y_position": slot_config["y_position"], + "size": slot_config["size"], + "voice_id": slot_config.get("voice_id"), # Voice assignment for this slot (None = random) + "avatarData": None, + "isActive": False + } + + # Assign avatar if one is configured for this slot + if slot_config.get("avatar_group_id") and slot_config["avatar_group_id"] in avatar_data_by_group: + avatar_data = avatar_data_by_group[slot_config["avatar_group_id"]].copy() + slot_data["avatarData"] = avatar_data + logger.info(f"Assigned {avatar_data['name']} to slot {slot_config['slot_index']} at ({slot_config['x_position']}%, {slot_config['y_position']}%)") + + assignments.append(slot_data) - avatar_slot_assignments = slots + avatar_slot_assignments = assignments avatar_assignments_generation_id += 1 - logger.info(f"Generated {len(avatar_slot_assignments)} avatar slot assignments (gen #{avatar_assignments_generation_id})") + logger.info(f"Generated {len(avatar_slot_assignments)} avatar slot assignments from configured slots (gen #{avatar_assignments_generation_id})") - # Log a sample of what will be sent to frontend for debugging - if avatar_slot_assignments: - sample_slot = avatar_slot_assignments[0] - logger.info(f"Sample slot data being sent to frontend: {sample_slot}") - return avatar_slot_assignments def find_available_slot_for_tts(voice_id=None, user=None): @@ -219,22 +215,40 @@ def find_available_slot_for_tts(voice_id=None, user=None): logger.info(f"Cleaning up expired active slot: {slot_id}") del active_avatar_slots[slot_id] + # Get list of valid voice IDs for validation + from modules.persistent_data import get_voices + voices_data = get_voices() + voices_list = voices_data.get("voices", []) + valid_voice_ids = {voice["id"] for voice in voices_list if voice.get("enabled", False)} + # Find slots that match the voice_id if specified matching_slots = [] available_slots = [] for slot in avatar_slot_assignments: slot_id = slot["id"] - avatar_data = slot["avatarData"] + slot_voice_id = slot.get("voice_id") is_active = slot_id in active_avatar_slots if not is_active: available_slots.append(slot) - # Check if this avatar matches the voice_id - if voice_id and avatar_data and avatar_data.get("voice_id") == voice_id: - matching_slots.append(slot) + # Check if this slot matches the voice_id + # slot_voice_id can be: + # - None (random - matches any voice) + # - A valid voice ID (must match the requested voice) + # - An invalid/deleted voice ID (treated as random) + if voice_id: + if slot_voice_id is None: + # Random slot - matches any voice + matching_slots.append(slot) + elif slot_voice_id == voice_id and slot_voice_id in valid_voice_ids: + # Exact match with valid voice + matching_slots.append(slot) + elif slot_voice_id not in valid_voice_ids: + # Voice was deleted - treat as random + matching_slots.append(slot) # Prefer voice-matched slots if available if matching_slots: diff --git a/backend/modules/db_migration.py b/backend/modules/db_migration.py index 4fec6fb..9465276 100644 --- a/backend/modules/db_migration.py +++ b/backend/modules/db_migration.py @@ -102,6 +102,24 @@ def migrate_avatarimage_table(conn: sqlite3.Connection) -> None: logger.info("AvatarImage table schema is up to date") +def migrate_avatarslot_table(conn: sqlite3.Connection) -> None: + """Add new columns to AvatarSlot table if they don't exist""" + logger.info("Checking AvatarSlot table schema...") + + # List of migrations: (column_name, column_type, default_value) + migrations = [ + ("voice_id", "INTEGER", None), # Voice assignment for this slot (None = random) + ] + + changes_made = False + for col_name, col_type, default in migrations: + if add_column_if_missing(conn, "avatarslot", col_name, col_type, default): + changes_made = True + + if not changes_made: + logger.info("AvatarSlot table schema is up to date") + + def run_all_migrations(db_path: str) -> None: """ Run all database migrations. @@ -135,6 +153,11 @@ def run_all_migrations(db_path: str) -> None: else: logger.info("AvatarImage table doesn't exist yet - will be created") + if "avatarslot" in existing_tables: + migrate_avatarslot_table(conn) + else: + logger.info("AvatarSlot table doesn't exist yet - will be created") + conn.close() logger.info("Database migration check completed successfully") diff --git a/backend/modules/models.py b/backend/modules/models.py index 583545b..3e52bee 100644 --- a/backend/modules/models.py +++ b/backend/modules/models.py @@ -64,4 +64,17 @@ class ProviderVoiceCache(SQLModel, table=True): provider: str # Provider name (e.g., "polly", "monstertts", "google") voices_json: str # JSON string of voice list last_updated: str # ISO timestamp of when this was last fetched - credentials_hash: Optional[str] = Field(default=None) # Hash of credentials to detect changes \ No newline at end of file + credentials_hash: Optional[str] = Field(default=None) # Hash of credentials to detect changes + + +class AvatarSlot(SQLModel, table=True): + """Individual avatar slot configuration for precise positioning and control""" + id: Optional[int] = Field(default=None, primary_key=True) + slot_index: int # Unique index for this slot (0-based position in the list) + x_position: int # X position as percentage (0-100) + y_position: int # Y position as percentage (0-100) + size: int = Field(default=100) # Avatar size in pixels + avatar_group_id: Optional[str] = Field(default=None) # Which avatar is assigned to this slot (None = empty slot) + voice_id: Optional[int] = Field(default=None) # ID of the Voice to use for this slot (None = random) + created_at: Optional[str] = Field(default=None) # When this slot was created + updated_at: Optional[str] = Field(default=None) # When this slot was last modified \ No newline at end of file diff --git a/backend/modules/persistent_data.py b/backend/modules/persistent_data.py index 5e00033..fe2c74b 100644 --- a/backend/modules/persistent_data.py +++ b/backend/modules/persistent_data.py @@ -8,7 +8,7 @@ from modules import logger, get_env_var, log_important -from modules.models import Setting, Voice, TwitchAuth, YouTubeAuth, AvatarImage, ProviderVoiceCache +from modules.models import Setting, Voice, TwitchAuth, YouTubeAuth, AvatarImage, ProviderVoiceCache, AvatarSlot def find_project_root(): """Find the project root by looking for characteristic files""" @@ -569,4 +569,89 @@ def hash_credentials(*credentials: str) -> str: hash_credentials(access_key, secret_key) # For multiple credentials (Polly) """ combined = ":".join(credentials) - return hashlib.sha256(combined.encode()).hexdigest()[:16] \ No newline at end of file + return hashlib.sha256(combined.encode()).hexdigest()[:16] + + +# ============================================================================ +# Avatar Slot Management Functions +# ============================================================================ + +def get_avatar_slots(): + """Get all configured avatar slots ordered by slot_index""" + with Session(engine) as session: + slots = session.exec(select(AvatarSlot).order_by(AvatarSlot.slot_index)).all() + return [slot.model_dump() for slot in slots] + + +def get_avatar_slot(slot_id: int): + """Get a specific avatar slot by ID""" + with Session(engine) as session: + slot = session.exec(select(AvatarSlot).where(AvatarSlot.id == slot_id)).first() + return slot.model_dump() if slot else None + + +def create_avatar_slot(slot_index: int, x_position: int, y_position: int, + size: int = 60, avatar_group_id: str = None): + """Create a new avatar slot""" + with Session(engine) as session: + now = datetime.now().isoformat() + slot = AvatarSlot( + slot_index=slot_index, + x_position=x_position, + y_position=y_position, + size=size, + avatar_group_id=avatar_group_id, + created_at=now, + updated_at=now + ) + session.add(slot) + session.commit() + session.refresh(slot) + logger.info(f"Created avatar slot #{slot_index} at ({x_position}%, {y_position}%)") + return slot.model_dump() + + +def update_avatar_slot(slot_id: int, **kwargs): + """Update an avatar slot. Accepts: x_position, y_position, size, avatar_group_id, slot_index""" + with Session(engine) as session: + slot = session.exec(select(AvatarSlot).where(AvatarSlot.id == slot_id)).first() + if not slot: + logger.error(f"Avatar slot {slot_id} not found") + return None + + # Update fields + for key, value in kwargs.items(): + if hasattr(slot, key): + setattr(slot, key, value) + + slot.updated_at = datetime.now().isoformat() + session.add(slot) + session.commit() + session.refresh(slot) + logger.info(f"Updated avatar slot {slot_id}: {kwargs}") + return slot.model_dump() + + +def delete_avatar_slot(slot_id: int): + """Delete an avatar slot""" + with Session(engine) as session: + slot = session.exec(select(AvatarSlot).where(AvatarSlot.id == slot_id)).first() + if not slot: + logger.error(f"Avatar slot {slot_id} not found") + return False + + session.delete(slot) + session.commit() + logger.info(f"Deleted avatar slot {slot_id}") + return True + + +def delete_all_avatar_slots(): + """Delete all avatar slots""" + with Session(engine) as session: + slots = session.exec(select(AvatarSlot)).all() + for slot in slots: + session.delete(slot) + session.commit() + logger.info(f"Deleted all {len(slots)} avatar slots") + return len(slots) \ No newline at end of file diff --git a/backend/modules/settings_defaults.json b/backend/modules/settings_defaults.json index da1757d..82aaff3 100644 --- a/backend/modules/settings_defaults.json +++ b/backend/modules/settings_defaults.json @@ -16,6 +16,15 @@ "avatarSpacing": 150, "avatarSpacingX": 180, "avatarSpacingY": 100, +"crowdAnimationType": "bounce", +"crowdBounceHeight": 10, +"crowdAnimationDuration": 300, +"crowdAnimationDelay": 0, +"crowdAnimationCurve": "ease-out", +"crowdIdleAnimationType": "none", +"crowdIdleAnimationIntensity": 2, +"crowdIdleAnimationSpeed": 3000, +"crowdIdleAnimationSynced": false, "avatarGlowEnabled": true, "avatarGlowColor": "#ffffff", "avatarGlowOpacity": 0.9, diff --git a/backend/routers/avatars.py b/backend/routers/avatars.py index 1fe94cc..694950c 100644 --- a/backend/routers/avatars.py +++ b/backend/routers/avatars.py @@ -12,7 +12,9 @@ from modules.persistent_data import ( PUBLIC_DIR, PERSISTENT_AVATARS_DIR, delete_avatar, get_avatar, get_all_avatars, add_avatar, update_avatar, - delete_avatar_group, update_avatar_group_position, toggle_avatar_group_disabled + delete_avatar_group, update_avatar_group_position, toggle_avatar_group_disabled, + get_avatar_slots, get_avatar_slot, create_avatar_slot, update_avatar_slot, + delete_avatar_slot, delete_all_avatar_slots ) from modules.models import AvatarImage from modules import logger @@ -414,4 +416,149 @@ async def api_get_avatar_queue(): } except Exception as e: logger.error(f"Failed to get avatar queue: {e}") - return {"queue": [], "length": 0, "active_slots": 0, "total_slots": 0} \ No newline at end of file + return {"queue": [], "length": 0, "active_slots": 0, "total_slots": 0} + + +# ============================================================================ +# Avatar Slot Configuration Endpoints +# ============================================================================ + +@router.get("/api/avatar-slots/configured") +async def api_get_configured_slots(): + """Get all configured avatar slots""" + try: + slots = get_avatar_slots() + return {"success": True, "slots": slots} + except Exception as e: + logger.error(f"Failed to get configured avatar slots: {e}") + return {"success": False, "error": str(e), "slots": []} + + +@router.post("/api/avatar-slots/configured") +async def api_create_avatar_slot(slot_data: dict): + """Create a new avatar slot configuration""" + try: + slot = create_avatar_slot( + slot_index=slot_data["slot_index"], + x_position=slot_data["x_position"], + y_position=slot_data["y_position"], + size=slot_data.get("size", 60), + avatar_group_id=slot_data.get("avatar_group_id") + ) + + # Broadcast update to all clients + from app import hub, avatar_message_queue + from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, + get_avatar_slot_assignments, get_avatar_assignments_generation_id) + + # Regenerate avatar slot assignments + get_active_avatar_slots().clear() + avatar_message_queue.clear() + generate_avatar_slot_assignments() + + await hub.broadcast({ + "type": "avatar_slots_updated", + "slots": get_avatar_slot_assignments(), + "generationId": get_avatar_assignments_generation_id() + }) + + return {"success": True, "slot": slot} + except Exception as e: + logger.error(f"Failed to create avatar slot: {e}") + return {"success": False, "error": str(e)} + + +@router.put("/api/avatar-slots/configured/{slot_id}") +async def api_update_configured_slot(slot_id: int, slot_data: dict): + """Update an avatar slot configuration""" + try: + # Extract valid fields + update_fields = {} + for key in ["x_position", "y_position", "size", "avatar_group_id", "slot_index", "voice_id"]: + if key in slot_data: + update_fields[key] = slot_data[key] + + slot = update_avatar_slot(slot_id, **update_fields) + + if not slot: + return {"success": False, "error": "Slot not found"} + + # Broadcast update to all clients + from app import hub, avatar_message_queue + from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, + get_avatar_slot_assignments, get_avatar_assignments_generation_id) + + # Regenerate avatar slot assignments + get_active_avatar_slots().clear() + avatar_message_queue.clear() + generate_avatar_slot_assignments() + + await hub.broadcast({ + "type": "avatar_slots_updated", + "slots": get_avatar_slot_assignments(), + "generationId": get_avatar_assignments_generation_id() + }) + + return {"success": True, "slot": slot} + except Exception as e: + logger.error(f"Failed to update avatar slot: {e}") + return {"success": False, "error": str(e)} + + +@router.delete("/api/avatar-slots/configured/{slot_id}") +async def api_delete_configured_slot(slot_id: int): + """Delete an avatar slot configuration""" + try: + success = delete_avatar_slot(slot_id) + + if not success: + return {"success": False, "error": "Slot not found"} + + # Broadcast update to all clients + from app import hub, avatar_message_queue + from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, + get_avatar_slot_assignments, get_avatar_assignments_generation_id) + + # Regenerate avatar slot assignments + get_active_avatar_slots().clear() + avatar_message_queue.clear() + generate_avatar_slot_assignments() + + await hub.broadcast({ + "type": "avatar_slots_updated", + "slots": get_avatar_slot_assignments(), + "generationId": get_avatar_assignments_generation_id() + }) + + return {"success": True} + except Exception as e: + logger.error(f"Failed to delete avatar slot: {e}") + return {"success": False, "error": str(e)} + + +@router.delete("/api/avatar-slots/configured") +async def api_delete_all_configured_slots(): + """Delete all avatar slot configurations""" + try: + count = delete_all_avatar_slots() + + # Broadcast update to all clients + from app import hub, avatar_message_queue + from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, + get_avatar_slot_assignments, get_avatar_assignments_generation_id) + + # Regenerate avatar slot assignments (will be empty or use defaults) + get_active_avatar_slots().clear() + avatar_message_queue.clear() + generate_avatar_slot_assignments() + + await hub.broadcast({ + "type": "avatar_slots_updated", + "slots": get_avatar_slot_assignments(), + "generationId": get_avatar_assignments_generation_id() + }) + + return {"success": True, "deleted_count": count} + except Exception as e: + logger.error(f"Failed to delete all avatar slots: {e}") + return {"success": False, "error": str(e)} \ No newline at end of file diff --git a/frontend/src/components/settings/AvatarConfigurationTabs.jsx b/frontend/src/components/settings/AvatarConfigurationTabs.jsx index 7bda80a..f1ded2b 100644 --- a/frontend/src/components/settings/AvatarConfigurationTabs.jsx +++ b/frontend/src/components/settings/AvatarConfigurationTabs.jsx @@ -1,12 +1,16 @@ import React from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' -import { Image, Grid3x3, MessageSquare, Sparkles } from 'lucide-react' +import { Image, Grid3x3, MessageSquare, Sparkles, Zap } from 'lucide-react' +import AvatarLayoutEditor from './AvatarLayoutEditor' import AvatarPlacementSettings from './AvatarPlacementSettings' import ChatBubbleSettings from './ChatBubbleSettings' import GlowEffectSettings from './GlowEffectSettings' +import CrowdAnimationSettings from './CrowdAnimationSettings' -function AvatarConfigurationTabs({ settings, updateSettings, apiUrl }) { +function AvatarConfigurationTabs({ settings, updateSettings, apiUrl, managedAvatars }) { + const avatarMode = settings.avatarMode || 'grid' + return ( @@ -18,11 +22,15 @@ function AvatarConfigurationTabs({ settings, updateSettings, apiUrl }) { - + Placement + + + Animations + Chat Bubbles @@ -35,11 +43,29 @@ function AvatarConfigurationTabs({ settings, updateSettings, apiUrl }) {
+ {/* Mode selector and popup settings */} + + {/* Layout editor for grid mode */} + {avatarMode === 'grid' && ( + + )} +
+
+ + +
+
@@ -67,3 +93,4 @@ function AvatarConfigurationTabs({ settings, updateSettings, apiUrl }) { } export default AvatarConfigurationTabs + diff --git a/frontend/src/components/settings/AvatarLayoutEditor.jsx b/frontend/src/components/settings/AvatarLayoutEditor.jsx new file mode 100644 index 0000000..c1b5e57 --- /dev/null +++ b/frontend/src/components/settings/AvatarLayoutEditor.jsx @@ -0,0 +1,1012 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { LayoutGrid, Plus, Trash2, Save, ChevronUp, ChevronDown } from 'lucide-react' +import logger from '../../utils/logger' + +function AvatarLayoutEditor({ apiUrl, managedAvatars }) { + const [configuredSlots, setConfiguredSlots] = useState([]) + const [selectedSlot, setSelectedSlot] = useState(null) + const [selectedSlots, setSelectedSlots] = useState([]) // Multi-selection + const [dimensions] = useState({ width: 800, height: 600 }) + const [isDragging, setIsDragging] = useState(false) + const [draggedSlot, setDraggedSlot] = useState(null) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const [copiedSlot, setCopiedSlot] = useState(null) + const [voices, setVoices] = useState([]) + const canvasRef = useRef(null) + const sizeUpdateTimeoutRef = useRef(null) + const pendingSizeUpdateRef = useRef(null) + const draggedListItemRef = useRef(null) + const dragOverIndexRef = useRef(null) + + // Box selection state + const [isBoxSelecting, setIsBoxSelecting] = useState(false) + const [boxSelectStart, setBoxSelectStart] = useState({ x: 0, y: 0 }) + const [boxSelectEnd, setBoxSelectEnd] = useState({ x: 0, y: 0 }) + + // Track if we should clear multi-selection on mouse up (when clicking already-selected item without modifier) + const [pendingClearMultiSelect, setPendingClearMultiSelect] = useState(false) + + // Load configured slots and voices on mount + useEffect(() => { + loadConfiguredSlots() + loadVoices() + }, [apiUrl]) + + // Keyboard shortcuts for copy/paste/delete + useEffect(() => { + const handleKeyDown = (e) => { + // Only handle shortcuts when not typing in an input field + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') { + return + } + + // Ctrl+C or Cmd+C - Copy selected slot(s) + if ((e.ctrlKey || e.metaKey) && e.key === 'c' && (selectedSlot || selectedSlots.length > 0)) { + e.preventDefault() + if (selectedSlots.length > 0) { + setCopiedSlot({ multiple: true, slots: selectedSlots }) + logger.info(`Copied ${selectedSlots.length} slots`) + } else if (selectedSlot) { + setCopiedSlot(selectedSlot) + logger.info(`Copied slot #${selectedSlot.slot_index + 1}`) + } + } + + // Ctrl+V or Cmd+V - Paste copied slot(s) + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && copiedSlot) { + e.preventDefault() + handlePasteSlot() + } + + // Delete key - Delete selected slot(s) + if (e.key === 'Delete') { + e.preventDefault() + if (selectedSlots.length > 0) { + handleDeleteMultipleSlots() + } else if (selectedSlot) { + handleDeleteSlot(selectedSlot.id) + } + } + + // Escape key - Clear selection + if (e.key === 'Escape') { + setSelectedSlot(null) + setSelectedSlots([]) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedSlot, selectedSlots, copiedSlot]) + + // Handle wheel events for size adjustment during drag (with passive: false to allow preventDefault) + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const handleWheel = (e) => { + // Only adjust size when dragging an avatar + if (!isDragging || !draggedSlot) return + + e.preventDefault() + + // Determine size change (positive = zoom in, negative = zoom out) + const delta = e.deltaY < 0 ? 5 : -5 + const currentSize = draggedSlot.size + const newSize = Math.max(20, Math.min(1000, currentSize + delta)) + + if (newSize !== currentSize) { + // Update the dragged slot's size immediately in state for visual feedback + setDraggedSlot(prev => ({ ...prev, size: newSize })) + setConfiguredSlots(prev => prev.map(slot => + slot.id === draggedSlot.id + ? { ...slot, size: newSize } + : slot + )) + + // Update selected slot if it's the dragged one + if (selectedSlot?.id === draggedSlot.id) { + setSelectedSlot(prev => ({ ...prev, size: newSize })) + } + + // Store the pending update but don't save yet + pendingSizeUpdateRef.current = { slotId: draggedSlot.id, size: newSize } + + // Clear existing timeout and set a new one + if (sizeUpdateTimeoutRef.current) { + clearTimeout(sizeUpdateTimeoutRef.current) + } + + // Save to backend after user stops scrolling for 500ms + sizeUpdateTimeoutRef.current = setTimeout(async () => { + if (pendingSizeUpdateRef.current) { + await handleUpdateSlot(pendingSizeUpdateRef.current.slotId, { + size: pendingSizeUpdateRef.current.size + }) + pendingSizeUpdateRef.current = null + } + }, 500) + } + } + + canvas.addEventListener('wheel', handleWheel, { passive: false }) + return () => { + canvas.removeEventListener('wheel', handleWheel) + if (sizeUpdateTimeoutRef.current) { + clearTimeout(sizeUpdateTimeoutRef.current) + } + } + }, [isDragging, draggedSlot, selectedSlot]) + + const loadConfiguredSlots = async () => { + try { + const response = await fetch(`${apiUrl}/api/avatar-slots/configured`) + const data = await response.json() + if (data.success) { + setConfiguredSlots(data.slots || []) + logger.info(`Loaded ${data.slots?.length || 0} configured avatar slots`) + return data.slots || [] + } + return [] + } catch (error) { + logger.error('Failed to load configured avatar slots:', error) + return [] + } + } + + const loadVoices = async () => { + try { + const response = await fetch(`${apiUrl}/api/voices`) + const data = await response.json() + if (data.voices) { + setVoices(data.voices || []) + logger.info(`Loaded ${data.voices?.length || 0} voices`) + } + } catch (error) { + logger.error('Failed to load voices:', error) + } + } + + // Group managed avatars by group_id + const groupedAvatars = React.useMemo(() => { + const grouped = {} + managedAvatars.forEach(avatar => { + const key = avatar.avatar_group_id || `single_${avatar.id}` + if (!grouped[key]) { + grouped[key] = { + id: key, + name: avatar.name, + images: {}, + disabled: avatar.disabled + } + } + grouped[key].images[avatar.avatar_type] = avatar.file_path + }) + + return Object.values(grouped).map(group => ({ + id: group.id, + name: group.name, + defaultImage: group.images.default || group.images.speaking, + speakingImage: group.images.speaking || group.images.default, + disabled: group.disabled + })) + }, [managedAvatars]) + + const handleAddSlot = async () => { + try { + // Find next available slot index + const maxIndex = configuredSlots.reduce((max, slot) => Math.max(max, slot.slot_index), -1) + const newIndex = maxIndex + 1 + + // Place new slot in center with default size of 100px + const response = await fetch(`${apiUrl}/api/avatar-slots/configured`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slot_index: newIndex, + x_position: 50, + y_position: 50, + size: 100, + avatar_group_id: null + }) + }) + + const data = await response.json() + if (data.success) { + await loadConfiguredSlots() + logger.info('Added new avatar slot') + } + } catch (error) { + logger.error('Failed to add avatar slot:', error) + } + } + + const handleDeleteSlot = async (slotId) => { + try { + const response = await fetch(`${apiUrl}/api/avatar-slots/configured/${slotId}`, { + method: 'DELETE' + }) + + const data = await response.json() + if (data.success) { + await loadConfiguredSlots() + if (selectedSlot?.id === slotId) { + setSelectedSlot(null) + } + // Remove from multi-selection if present + setSelectedSlots(prev => prev.filter(s => s.id !== slotId)) + logger.info('Deleted avatar slot') + } + } catch (error) { + logger.error('Failed to delete avatar slot:', error) + } + } + + const handleDeleteMultipleSlots = async () => { + try { + const deletePromises = selectedSlots.map(slot => + fetch(`${apiUrl}/api/avatar-slots/configured/${slot.id}`, { method: 'DELETE' }) + ) + await Promise.all(deletePromises) + await loadConfiguredSlots() + setSelectedSlots([]) + setSelectedSlot(null) + logger.info(`Deleted ${selectedSlots.length} avatar slots`) + } catch (error) { + logger.error('Failed to delete multiple slots:', error) + } + } + + const handlePasteSlot = async () => { + if (!copiedSlot) return + + try { + if (copiedSlot.multiple) { + // Paste multiple slots + const maxIndex = configuredSlots.reduce((max, slot) => Math.max(max, slot.slot_index), -1) + const newSlotIndices = [] + + for (let i = 0; i < copiedSlot.slots.length; i++) { + const slot = copiedSlot.slots[i] + const newIndex = maxIndex + 1 + i + newSlotIndices.push(newIndex) + const newX = Math.min(100, slot.x_position + 5) + const newY = Math.min(100, slot.y_position + 5) + + const response = await fetch(`${apiUrl}/api/avatar-slots/configured`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slot_index: newIndex, + x_position: newX, + y_position: newY, + size: slot.size, + avatar_group_id: slot.avatar_group_id || null + }) + }) + + const data = await response.json() + if (!data.success) { + logger.error('Failed to create slot:', data) + } + } + + // Reload slots and get the updated list + const updatedSlots = await loadConfiguredSlots() + + // Select the newly created slots and clear the old selection + const newSlots = updatedSlots.filter(s => newSlotIndices.includes(s.slot_index)) + if (newSlots.length > 0) { + setSelectedSlots(newSlots) + setSelectedSlot(newSlots[0]) + logger.info(`Pasted and selected ${newSlots.length} slots`) + } + } else { + // Paste single slot + const maxIndex = configuredSlots.reduce((max, slot) => Math.max(max, slot.slot_index), -1) + const newIndex = maxIndex + 1 + + // Offset the position slightly so it doesn't overlap exactly + const newX = Math.min(100, copiedSlot.x_position + 5) + const newY = Math.min(100, copiedSlot.y_position + 5) + + const response = await fetch(`${apiUrl}/api/avatar-slots/configured`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slot_index: newIndex, + x_position: newX, + y_position: newY, + size: copiedSlot.size, + avatar_group_id: copiedSlot.avatar_group_id || null + }) + }) + + const data = await response.json() + if (data.success) { + // Reload slots and get the updated list + const updatedSlots = await loadConfiguredSlots() + + // Find and select the newly created slot, clear multi-selection + const newSlot = updatedSlots.find(s => s.slot_index === newIndex) + if (newSlot) { + setSelectedSlot(newSlot) + setSelectedSlots([]) // Clear multi-selection + logger.info(`Pasted and selected slot #${newIndex + 1}`) + } + } + } + } catch (error) { + logger.error('Error pasting slot:', error) + } + } + + const handleUpdateSlot = async (slotId, updates) => { + try { + const response = await fetch(`${apiUrl}/api/avatar-slots/configured/${slotId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }) + + const data = await response.json() + if (data.success) { + const updatedSlots = await loadConfiguredSlots() + // Update selectedSlot if it's the one that was modified + if (selectedSlot && selectedSlot.id === slotId) { + const updatedSlot = updatedSlots.find(s => s.id === slotId) + if (updatedSlot) { + setSelectedSlot(updatedSlot) + } + } + logger.info('Updated avatar slot') + } + } catch (error) { + logger.error('Failed to update avatar slot:', error) + } + } + + const handleMoveSlotUp = async (slot) => { + const sortedSlots = [...configuredSlots].sort((a, b) => b.slot_index - a.slot_index) + const currentIndex = sortedSlots.findIndex(s => s.id === slot.id) + + // Can't move up if already at the top + if (currentIndex === 0) return + + // Swap slot_index with the slot above it + const slotAbove = sortedSlots[currentIndex - 1] + const updates = [ + { id: slot.id, slot_index: slotAbove.slot_index }, + { id: slotAbove.id, slot_index: slot.slot_index } + ] + + // Optimistically update local state + const updatedSlots = configuredSlots.map(s => { + const update = updates.find(u => u.id === s.id) + return update ? { ...s, slot_index: update.slot_index } : s + }) + setConfiguredSlots(updatedSlots) + + // Save to backend + try { + await Promise.all(updates.map(update => + fetch(`${apiUrl}/api/avatar-slots/configured/${update.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slot_index: update.slot_index }) + }) + )) + logger.info('Moved slot up') + } catch (error) { + logger.error('Failed to move slot:', error) + await loadConfiguredSlots() + } + } + + const handleMoveSlotDown = async (slot) => { + const sortedSlots = [...configuredSlots].sort((a, b) => b.slot_index - a.slot_index) + const currentIndex = sortedSlots.findIndex(s => s.id === slot.id) + + // Can't move down if already at the bottom + if (currentIndex === sortedSlots.length - 1) return + + // Swap slot_index with the slot below it + const slotBelow = sortedSlots[currentIndex + 1] + const updates = [ + { id: slot.id, slot_index: slotBelow.slot_index }, + { id: slotBelow.id, slot_index: slot.slot_index } + ] + + // Optimistically update local state + const updatedSlots = configuredSlots.map(s => { + const update = updates.find(u => u.id === s.id) + return update ? { ...s, slot_index: update.slot_index } : s + }) + setConfiguredSlots(updatedSlots) + + // Save to backend + try { + await Promise.all(updates.map(update => + fetch(`${apiUrl}/api/avatar-slots/configured/${update.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slot_index: update.slot_index }) + }) + )) + logger.info('Moved slot down') + } catch (error) { + logger.error('Failed to move slot:', error) + await loadConfiguredSlots() + } + } + + const handleCanvasClick = (e) => { + // This is now handled by mouse down/up for box selection + } + + const handleCanvasMouseDown = (e) => { + if (e.target !== e.currentTarget) return // Only on canvas, not on slots + + const rect = e.currentTarget.getBoundingClientRect() + const x = Math.round(((e.clientX - rect.left) / rect.width) * 100) + const y = Math.round(((e.clientY - rect.top) / rect.height) * 100) + + // Start box selection + setIsBoxSelecting(true) + setBoxSelectStart({ x, y }) + setBoxSelectEnd({ x, y }) + + // Clear selection if not holding Ctrl/Shift + if (!e.ctrlKey && !e.shiftKey) { + setSelectedSlot(null) + setSelectedSlots([]) + setPendingClearMultiSelect(false) + } + } + + const handleMouseDown = (e, slot) => { + e.stopPropagation() + + // Multi-selection with Ctrl/Cmd or Shift + if (e.ctrlKey || e.metaKey || e.shiftKey) { + setPendingClearMultiSelect(false) + if (selectedSlots.find(s => s.id === slot.id)) { + // Deselect if already selected + setSelectedSlots(prev => prev.filter(s => s.id !== slot.id)) + if (selectedSlot?.id === slot.id) { + setSelectedSlot(null) + } + } else { + // Add to selection + // If transitioning from single selection to multi-selection, include the previously selected slot + if (selectedSlot && selectedSlots.length === 0) { + setSelectedSlots([selectedSlot, slot]) + } else { + setSelectedSlots(prev => [...prev, slot]) + } + setSelectedSlot(slot) + } + } else { + // Clicking without modifier keys + if (selectedSlots.length > 0 && selectedSlots.find(s => s.id === slot.id)) { + // Clicking on an already-selected item in multi-selection + // Don't clear selection yet - allow dragging, but mark for potential clear on mouse up + setPendingClearMultiSelect(true) + setIsDragging(true) + setDraggedSlot(slot) // Primary slot for drag offset calculation + setDragOffset({ x: slot.x_position, y: slot.y_position }) + } else { + // Single selection - clear multi-selection immediately + setPendingClearMultiSelect(false) + setSelectedSlot(slot) + setSelectedSlots([]) + setIsDragging(true) + setDraggedSlot(slot) + setDragOffset({ x: slot.x_position, y: slot.y_position }) + } + } + } + + const handleMouseMove = (e) => { + const rect = e.currentTarget.getBoundingClientRect() + const x = Math.max(0, Math.min(100, Math.round(((e.clientX - rect.left) / rect.width) * 100))) + const y = Math.max(0, Math.min(100, Math.round(((e.clientY - rect.top) / rect.height) * 100))) + + if (isBoxSelecting) { + // Update box selection area + setBoxSelectEnd({ x, y }) + + // Calculate which slots are in the box + const minX = Math.min(boxSelectStart.x, x) + const maxX = Math.max(boxSelectStart.x, x) + const minY = Math.min(boxSelectStart.y, y) + const maxY = Math.max(boxSelectStart.y, y) + + const slotsInBox = configuredSlots.filter(slot => { + return slot.x_position >= minX && slot.x_position <= maxX && + slot.y_position >= minY && slot.y_position <= maxY + }) + + setSelectedSlots(slotsInBox) + if (slotsInBox.length > 0) { + setSelectedSlot(slotsInBox[0]) + } else { + setSelectedSlot(null) + } + } else if (isDragging && draggedSlot) { + // User is actually dragging - clear the pending flag + if (pendingClearMultiSelect && (x !== draggedSlot.x_position || y !== draggedSlot.y_position)) { + setPendingClearMultiSelect(false) + } + // Update drag offset for visual feedback + setDragOffset({ x, y }) + } + } + + const handleMouseUp = async () => { + // Flush any pending size updates immediately when mouse is released + if (sizeUpdateTimeoutRef.current) { + clearTimeout(sizeUpdateTimeoutRef.current) + sizeUpdateTimeoutRef.current = null + } + if (pendingSizeUpdateRef.current) { + await handleUpdateSlot(pendingSizeUpdateRef.current.slotId, { + size: pendingSizeUpdateRef.current.size + }) + pendingSizeUpdateRef.current = null + } + + if (isBoxSelecting) { + // End box selection + setIsBoxSelecting(false) + setBoxSelectStart({ x: 0, y: 0 }) + setBoxSelectEnd({ x: 0, y: 0 }) + } else if (isDragging && draggedSlot) { + // Calculate movement delta + const deltaX = dragOffset.x - draggedSlot.x_position + const deltaY = dragOffset.y - draggedSlot.y_position + const didActuallyDrag = deltaX !== 0 || deltaY !== 0 + + // If we were pending a clear and user didn't actually drag, clear multi-selection now + if (pendingClearMultiSelect && !didActuallyDrag) { + setIsDragging(false) + setDraggedSlot(null) + setDragOffset({ x: 0, y: 0 }) + setPendingClearMultiSelect(false) + setSelectedSlots([]) + setSelectedSlot(draggedSlot) + } else if (selectedSlots.length > 0 && selectedSlots.find(s => s.id === draggedSlot.id)) { + // Move all selected slots + // Calculate updates based on the current configuredSlots to get accurate positions + const updates = selectedSlots.map(slot => { + const currentSlot = configuredSlots.find(s => s.id === slot.id) + return { + id: slot.id, + x_position: Math.max(0, Math.min(100, currentSlot.x_position + deltaX)), + y_position: Math.max(0, Math.min(100, currentSlot.y_position + deltaY)) + } + }) + + // Update ALL state in a single batched operation to prevent intermediate renders + // This ensures positions are updated atomically with drag state clearing + const updatedSlots = configuredSlots.map(slot => { + const update = updates.find(u => u.id === slot.id) + return update ? { ...slot, x_position: update.x_position, y_position: update.y_position } : slot + }) + + const updatedSelectedSlots = selectedSlots.map(slot => { + const update = updates.find(u => u.id === slot.id) + return update ? { ...slot, x_position: update.x_position, y_position: update.y_position } : slot + }) + + // Batch all state updates together - React 18 automatically batches these + setConfiguredSlots(updatedSlots) + setSelectedSlots(updatedSelectedSlots) + setIsDragging(false) + setDraggedSlot(null) + setDragOffset({ x: 0, y: 0 }) + setPendingClearMultiSelect(false) + + // Save all updates to backend in parallel, without reloading after each one + try { + await Promise.all(updates.map(update => + fetch(`${apiUrl}/api/avatar-slots/configured/${update.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + x_position: update.x_position, + y_position: update.y_position + }) + }) + )) + logger.info(`Moved ${selectedSlots.length} slots`) + } catch (error) { + logger.error('Failed to update multiple slots:', error) + // Reload on error to sync with backend + await loadConfiguredSlots() + } + } else { + // Move single slot + const newX = dragOffset.x + const newY = dragOffset.y + + // Clear drag state first + setIsDragging(false) + setDraggedSlot(null) + setDragOffset({ x: 0, y: 0 }) + setPendingClearMultiSelect(false) + + // Update positions + setConfiguredSlots(prev => prev.map(slot => + slot.id === draggedSlot.id + ? { ...slot, x_position: newX, y_position: newY } + : slot + )) + + // Update selected slot to reflect new position + if (selectedSlot?.id === draggedSlot.id) { + setSelectedSlot({ ...selectedSlot, x_position: newX, y_position: newY }) + } + + // Save to backend + await handleUpdateSlot(draggedSlot.id, { + x_position: newX, + y_position: newY + }) + } + } else { + // No dragging occurred, just clean up + setIsDragging(false) + setDraggedSlot(null) + setDragOffset({ x: 0, y: 0 }) + setPendingClearMultiSelect(false) + } + } + + return ( + + + + + Avatar Layout Editor + + + + {/* Canvas for visual editing */} +
+
+ {/* Grid background */} +
+ + {/* Box selection visual */} + {isBoxSelecting && ( +
+ )} + + {/* Render avatar slots */} + {configuredSlots.map(slot => { + const avatar = groupedAvatars.find(a => a.id === slot.avatar_group_id) + const isSelected = selectedSlot?.id === slot.id || selectedSlots.find(s => s.id === slot.id) + const isBeingDragged = isDragging && draggedSlot?.id === slot.id + const isInMultiDrag = isDragging && selectedSlots.find(s => s.id === slot.id) && selectedSlots.find(s => s.id === draggedSlot?.id) + + // Calculate display position + let displayX = slot.x_position + let displayY = slot.y_position + + if (isBeingDragged) { + displayX = dragOffset.x + displayY = dragOffset.y + } else if (isInMultiDrag && draggedSlot) { + // Apply same delta to all selected slots + const deltaX = dragOffset.x - draggedSlot.x_position + const deltaY = dragOffset.y - draggedSlot.y_position + displayX = Math.max(0, Math.min(100, slot.x_position + deltaX)) + displayY = Math.max(0, Math.min(100, slot.y_position + deltaY)) + } + + return ( +
handleMouseDown(e, slot)} + > + {avatar ? ( + {avatar.name} e.preventDefault()} + /> + ) : ( +
+ Empty +
+ )} + {isSelected && ( +
+ {slot.slot_index + 1} +
+ )} +
+ ) + })} +
+
+ + {/* Toolbar */} +
+ + {selectedSlot && ( + + )} +
+ + {/* Selected slot editor */} + {selectedSlot && ( +
+

+ Edit Slot #{selectedSlot.slot_index + 1} +

+ +
+
+ + { + const value = parseInt(e.target.value) + setConfiguredSlots(prev => prev.map(slot => + slot.id === selectedSlot.id + ? { ...slot, x_position: value } + : slot + )) + setSelectedSlot({ ...selectedSlot, x_position: value }) + }} + onBlur={() => handleUpdateSlot(selectedSlot.id, { x_position: selectedSlot.x_position })} + /> +
+ +
+ + { + const value = parseInt(e.target.value) + setConfiguredSlots(prev => prev.map(slot => + slot.id === selectedSlot.id + ? { ...slot, y_position: value } + : slot + )) + setSelectedSlot({ ...selectedSlot, y_position: value }) + }} + onBlur={() => handleUpdateSlot(selectedSlot.id, { y_position: selectedSlot.y_position })} + /> +
+
+ +
+ + { + const value = parseInt(e.target.value) || 60 + setConfiguredSlots(prev => prev.map(slot => + slot.id === selectedSlot.id + ? { ...slot, size: value } + : slot + )) + setSelectedSlot({ ...selectedSlot, size: value }) + }} + onBlur={() => handleUpdateSlot(selectedSlot.id, { size: selectedSlot.size })} + /> +
+ +
+ + +
+ +
+ + +
+
+ )} + + {configuredSlots.length === 0 && ( +
+ No avatar slots configured. Click "Add Slot" to create your first avatar position. +
+ )} + + {/* Avatar slots list view */} + {configuredSlots.length > 0 && ( +
+

Configured Avatar Slots ({configuredSlots.length})

+

Top items appear above bottom items

+
+ {configuredSlots + .sort((a, b) => b.slot_index - a.slot_index) + .map((slot, index) => { + const avatar = groupedAvatars.find(a => a.id === slot.avatar_group_id) + const isSelected = selectedSlot?.id === slot.id + const sortedSlots = [...configuredSlots].sort((a, b) => b.slot_index - a.slot_index) + const isFirst = index === 0 + const isLast = index === sortedSlots.length - 1 + + return ( +
+ {/* Move up/down buttons */} +
+ + +
+ + {/* Avatar preview */} +
setSelectedSlot(slot)} + > + {avatar ? ( + {avatar.name} + ) : ( +
Empty
+ )} +
+ + {/* Slot info */} +
setSelectedSlot(slot)} + > +
+ Slot #{slot.slot_index + 1} + {avatar && ` - ${avatar.name}`} +
+
+ Position: ({slot.x_position}%, {slot.y_position}%) • Size: {slot.size}px +
+
+ + {/* Delete button */} + +
+ ) + })} +
+
+ )} + + + ) +} + +export default AvatarLayoutEditor diff --git a/frontend/src/components/settings/AvatarPlacementSettings.jsx b/frontend/src/components/settings/AvatarPlacementSettings.jsx index a99f6fa..e2f32f5 100644 --- a/frontend/src/components/settings/AvatarPlacementSettings.jsx +++ b/frontend/src/components/settings/AvatarPlacementSettings.jsx @@ -1,11 +1,10 @@ -import React from 'react' +import React from 'react' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Button } from '../ui/button' import { Switch } from '../ui/switch' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' import { LayoutGrid } from 'lucide-react' -import logger from '../../utils/logger' function AvatarPlacementSettings({ settings, updateSettings, apiUrl }) { const avatarMode = settings.avatarMode || 'grid' @@ -15,200 +14,104 @@ function AvatarPlacementSettings({ settings, updateSettings, apiUrl }) { - Avatar Placement + Avatar Display Mode - Configure avatar size, layout mode, and positioning + Choose how avatars appear and configure mode-specific settings -
- - updateSettings({ avatarSize: parseInt(e.target.value) || 60 })} - /> -
-
- -
- - -
- -
- - {avatarMode === 'popup' && ( -
-
- - -

- Direction from which avatars will appear -

+ Crowd Mode + +
+

+ {avatarMode === 'grid' + ? 'Avatars appear in fixed positions on screen with individual size control' + : 'Avatars pop up individually when speaking'} +

+
-
-
- + {avatarMode === 'popup' && ( +
+

Pop-up Mode Settings

+ +
+ + updateSettings({ avatarSize: parseInt(e.target.value) || 100 })} + />

- Should the avatar appear at the edge or at a random position + Size for pop-up avatars

- updateSettings({ popupFixedEdge: checked })} - /> -
- -
-
- + +
+ +

- Rotate avatars to face the direction they're coming from + Direction from which avatars will appear

- updateSettings({ popupRotateToDirection: checked })} - /> -
-
- )} - - {avatarMode === 'grid' && ( - <> -
- - { - const newRows = parseInt(e.target.value) || 2 - const currentRowConfig = settings.avatarRowConfig || [6, 6] - let newRowConfig = [...currentRowConfig] - if (newRows > currentRowConfig.length) { - while (newRowConfig.length < newRows) { - newRowConfig.push(6) - } - } else if (newRows < currentRowConfig.length) { - newRowConfig = newRowConfig.slice(0, newRows) - } - updateSettings({ avatarRows: newRows, avatarRowConfig: newRowConfig }) - }} - /> -
-
- -
- {(settings.avatarRowConfig || [6, 6]).slice(0, settings.avatarRows || 2).map((avatarsInRow, rowIndex) => ( -
- - { - const newConfig = [...(settings.avatarRowConfig || [6, 6])] - newConfig[rowIndex] = parseInt(e.target.value) || 1 - updateSettings({ avatarRowConfig: newConfig }) - }} +
+
+ +

+ Should the avatar appear at the edge or at a random position +

+
+ updateSettings({ popupFixedEdge: checked })} />
- ))} -
-

- Total avatars: {(settings.avatarRowConfig || [6, 6]).slice(0, settings.avatarRows || 2).reduce((sum, count) => sum + count, 0)} -

-
- -
-
- - updateSettings({ avatarSpacingX: parseInt(e.target.value) || 50 })} - /> -
-
- - updateSettings({ avatarSpacingY: parseInt(e.target.value) || 50 })} - /> -
-
-
-
- -

Randomly reassign avatars to different positions

+
+ +

+ Rotate avatars to face the direction they're coming from +

- + updateSettings({ popupRotateToDirection: checked })} + />
- - )} + )} ) diff --git a/frontend/src/components/settings/CrowdAnimationSettings.jsx b/frontend/src/components/settings/CrowdAnimationSettings.jsx new file mode 100644 index 0000000..aeb7454 --- /dev/null +++ b/frontend/src/components/settings/CrowdAnimationSettings.jsx @@ -0,0 +1,296 @@ +import React, { useState } from 'react' +import { Input } from '../ui/input' +import { Label } from '../ui/label' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs' +import { Switch } from '../ui/switch' +import { Sparkles, Activity, Clock } from 'lucide-react' + +function CrowdAnimationSettings({ settings, onUpdate }) { + const animationSettings = { + animationType: settings?.crowdAnimationType || 'bounce', + bounceHeight: settings?.crowdBounceHeight ?? 10, + animationDuration: settings?.crowdAnimationDuration ?? 300, + animationDelay: settings?.crowdAnimationDelay ?? 0, + animationCurve: settings?.crowdAnimationCurve || 'ease-out', + // Idle animation settings + idleAnimationType: settings?.crowdIdleAnimationType || 'none', + idleAnimationIntensity: settings?.crowdIdleAnimationIntensity ?? 2, + idleAnimationSpeed: settings?.crowdIdleAnimationSpeed ?? 3000, + idleAnimationSynced: settings?.crowdIdleAnimationSynced ?? false + } + + return ( + + + + + Crowd Mode Animations + + + Customize how avatars animate in crowd mode + + + + + + + + Active Animations + + + + Idle Animations + + + + +
+ + +

+ The type of animation when an avatar is speaking +

+
+ + {(animationSettings.animationType === 'bounce' || + animationSettings.animationType === 'float' || + animationSettings.animationType === 'spin') && ( +
+
+ + {animationSettings.bounceHeight}px +
+ onUpdate({ crowdBounceHeight: parseFloat(e.target.value) })} + /> +

+ {animationSettings.animationType === 'spin' + ? 'How far up the avatar moves while spinning' + : animationSettings.animationType === 'bounce' + ? 'How far up the avatar bounces when active' + : 'Distance of the floating movement'} +

+
+ )} + + {animationSettings.animationType === 'pulse' && ( +
+
+ + {animationSettings.bounceHeight}% +
+ onUpdate({ crowdBounceHeight: parseFloat(e.target.value) })} + /> +

+ How much the avatar scales up when active (100% = no scaling) +

+
+ )} + + {animationSettings.animationType === 'bounce' && ( +
+ + +

+ The timing curve for the bounce animation +

+
+ )} + +
+
+ + {animationSettings.animationDuration}ms +
+ onUpdate({ crowdAnimationDuration: parseInt(e.target.value) })} + /> +

+ Speed of the animation transition +

+
+ +
+
+ + {animationSettings.animationDelay}ms +
+ onUpdate({ crowdAnimationDelay: parseInt(e.target.value) })} + /> +

+ Delay before the animation starts when avatar becomes active +

+
+ +
+

Active Animation Preview

+
+

• Type: {animationSettings.animationType}

+ {animationSettings.animationType !== 'none' && ( + <> +

• {animationSettings.animationType === 'pulse' ? 'Scale' : 'Height'}: {animationSettings.bounceHeight}{animationSettings.animationType === 'pulse' ? '%' : 'px'}

+ {animationSettings.animationType === 'bounce' && ( +

• Curve: {animationSettings.animationCurve}

+ )} +

• Duration: {animationSettings.animationDuration}ms

+

• Delay: {animationSettings.animationDelay}ms

+ + )} +
+
+
+ + +
+ + +

+ Animation for avatars when they are not speaking +

+
+ + {animationSettings.idleAnimationType !== 'none' && ( + <> +
+
+ + + {animationSettings.idleAnimationIntensity <= 3 ? 'Subtle' : + animationSettings.idleAnimationIntensity <= 6 ? 'Medium' : 'Strong'} + +
+ onUpdate({ crowdIdleAnimationIntensity: parseFloat(e.target.value) })} + /> +

+ {animationSettings.idleAnimationType === 'jitter' ? 'How much the avatar vibrates' : + animationSettings.idleAnimationType === 'pulse' ? 'How much the avatar scales' : + 'How far the avatar moves'} +

+
+ +
+
+ + {animationSettings.idleAnimationSpeed}ms +
+ onUpdate({ crowdIdleAnimationSpeed: parseInt(e.target.value) })} + /> +

+ Duration of one animation cycle (lower = faster) +

+
+ +
+
+ +

+ All avatars animate in sync vs. independently +

+
+ onUpdate({ crowdIdleAnimationSynced: checked })} + /> +
+ +
+

Idle Animation Preview

+
+

• Type: {animationSettings.idleAnimationType}

+

• Intensity: + {animationSettings.idleAnimationIntensity <= 3 ? 'Subtle' : + animationSettings.idleAnimationIntensity <= 6 ? 'Medium' : 'Strong'} +

+

• Speed: {animationSettings.idleAnimationSpeed}ms

+

• Sync: {animationSettings.idleAnimationSynced ? 'Synchronized' : 'Independent'}

+
+
+ + )} +
+
+
+
+ ) +} + +export default CrowdAnimationSettings diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 62f38ff..3f9f8f7 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -274,6 +274,7 @@ export default function SettingsPage() { settings={settings} updateSettings={updateSettings} apiUrl={apiUrl} + managedAvatars={managedAvatars} /> Audio object (one per user) const allActiveAudioRef = useRef(new Set()) // All Audio objects for global stop + // Store random animation delays for each slot to ensure consistent randomness + const idleAnimationDelaysRef = useRef(new Map()) + // Determine the correct API URL const apiUrl = location.hostname === 'localhost' && (location.port === '5173' || location.port === '5174') ? `http://localhost:${import.meta.env.VITE_BACKEND_PORT || 8008}` // Vite dev server connecting to backend @@ -60,7 +63,15 @@ export default function YappersPage() { avatarRows: 2, avatarRowConfig: [6, 6], avatarSize: 60, - avatarActiveOffset: 2.5, + crowdAnimationType: 'bounce', + crowdBounceHeight: 10, + crowdAnimationDuration: 300, + crowdAnimationDelay: 0, + crowdAnimationCurve: 'ease-out', + crowdIdleAnimationType: 'none', + crowdIdleAnimationIntensity: 2, + crowdIdleAnimationSpeed: 3000, + crowdIdleAnimationSynced: false, popupDirection: 'bottom', popupFixedEdge: false, popupRotateToDirection: false @@ -81,6 +92,14 @@ export default function YappersPage() { } }, [settings]) + // Clear random animation delays when sync setting changes to force new randomization + useEffect(() => { + if (settings?.crowdIdleAnimationSynced !== undefined) { + idleAnimationDelaysRef.current.clear() + logger.info('Idle animation delays cleared due to sync setting change') + } + }, [settings?.crowdIdleAnimationSynced]) + // Update volume for all currently playing audio when volume setting changes useEffect(() => { if (settings?.volume !== undefined) { @@ -1053,69 +1072,205 @@ export default function YappersPage() { )} {avatarMode === 'grid' ? ( - // Grid Mode - Original crowd formation + // Grid Mode - Fixed positions from configured slots avatarSlots.map((slot, index) => { - // Grid layout with individual row configuration - const baseSize = settings?.avatarSize || 60 - const spacingX = settings?.avatarSpacingX || settings?.avatarSpacing || 50 - const spacingY = settings?.avatarSpacingY || settings?.avatarSpacing || 50 - const baseX = 100 // Starting position from left - const baseY = 100 // Starting position from bottom + // Use exact positions from slot configuration + const x = slot.x_position || 50 // Percentage + const y = slot.y_position || 50 // Percentage + const slotSize = slot.size || settings?.avatarSize || 100 // Pixels - // Calculate centering offset for this row - const maxAvatarsInAnyRow = Math.max(...(settings?.avatarRowConfig || [6, 6])) - const avatarsInThisRow = slot.totalInRow - const maxRowWidth = (maxAvatarsInAnyRow - 1) * spacingX - const thisRowWidth = (avatarsInThisRow - 1) * spacingX - const centerOffset = (maxRowWidth - thisRowWidth) / 2 // Center shorter rows - - // Add honeycomb offset - create true honeycomb/brick pattern - // For proper honeycomb: alternate between offset and no offset, with shorter rows getting preference for offset - const isMaxWidthRow = avatarsInThisRow === maxAvatarsInAnyRow - const shouldOffset = isMaxWidthRow ? (slot.row % 2 === 1) : (slot.row % 2 === 0) // Alternate pattern, but flip for shorter rows - const honeycombOffset = shouldOffset ? spacingX / 2 : 0 // Offset by half spacing for honeycomb pattern - - // Calculate position based on row and column with centering and honeycomb offset - // Note: Calculate total rows to invert row positioning (bottom to top) - const totalRows = Math.max(...avatarSlots.map(s => s.row)) + 1 - const x = baseX + slot.col * spacingX + centerOffset + honeycombOffset - const y = baseY + (totalRows - 1 - slot.row) * spacingY // Invert row order for bottom-up positioning - - // Get glow effect settings - const glowEnabled = settings?.avatarGlowEnabled ?? true - const glowColor = settings?.avatarGlowColor ?? '#ffffff' - const glowOpacity = settings?.avatarGlowOpacity ?? 0.9 - const glowSize = settings?.avatarGlowSize ?? 20 - const activeOffset = settings?.avatarActiveOffset ?? 2.5 - - // Convert hex color to rgba for glow effect - const hexToRgba = (hex, opacity) => { - const r = parseInt(hex.slice(1, 3), 16) - const g = parseInt(hex.slice(3, 5), 16) - const b = parseInt(hex.slice(5, 7), 16) - return `rgba(${r},${g},${b},${opacity})` - } - - // Build filter based on glow settings - const activeFilter = glowEnabled - ? `brightness(1.25) drop-shadow(0 0 ${glowSize}px ${hexToRgba(glowColor, glowOpacity)})` - : 'brightness(1.25)' - const inactiveFilter = 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))' - - return ( -
+ // Convert pixel size to percentage based on a reference canvas (800x600) + // This matches the layout editor's rendering + const referenceWidth = 800 + const referenceHeight = 600 + const widthPercent = (slotSize / referenceWidth) * 100 + const heightPercent = (slotSize / referenceHeight) * 100 + + // Get glow effect settings + const glowEnabled = settings?.avatarGlowEnabled ?? true + const glowColor = settings?.avatarGlowColor ?? '#ffffff' + const glowOpacity = settings?.avatarGlowOpacity ?? 0.9 + const glowSize = settings?.avatarGlowSize ?? 20 + + // Get animation settings + const animationType = settings?.crowdAnimationType || 'bounce' + const bounceHeight = settings?.crowdBounceHeight ?? 10 + const animationDuration = settings?.crowdAnimationDuration ?? 300 + const animationDelay = settings?.crowdAnimationDelay ?? 0 + const animationCurve = settings?.crowdAnimationCurve || 'ease-out' + + // Get idle animation settings + const idleAnimationType = settings?.crowdIdleAnimationType || 'none' + const idleAnimationIntensity = settings?.crowdIdleAnimationIntensity ?? 2 + const idleAnimationSpeed = settings?.crowdIdleAnimationSpeed ?? 3000 + const idleAnimationSynced = settings?.crowdIdleAnimationSynced ?? false + + // Map curve names to CSS timing functions + const getCurveCss = (curve) => { + const curves = { + 'ease-out': 'ease-out', + 'ease-in': 'ease-in', + 'ease-in-out': 'ease-in-out', + 'linear': 'linear', + 'bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', + 'elastic': 'cubic-bezier(0.68, -0.6, 0.32, 1.6)' + } + return curves[curve] || 'ease-out' + } + + // Helper function to calculate transform based on animation type + const getTransform = (isActive) => { + const baseTransform = 'translate(-50%, -50%)' + + if (!isActive || animationType === 'none') { + return baseTransform + } + + switch (animationType) { + case 'bounce': + return `${baseTransform} translateY(-${bounceHeight}px)` + case 'spin': + return `${baseTransform} translateY(-${bounceHeight}px) rotate(${bounceHeight * 7.2}deg)` + case 'float': + return `${baseTransform} translateY(-${bounceHeight}px)` + case 'pulse': + return `${baseTransform} scale(${bounceHeight / 100})` + default: + return baseTransform + } + } + + // Convert hex color to rgba for glow effect + const hexToRgba = (hex, opacity) => { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `rgba(${r},${g},${b},${opacity})` + } + + // Build filter based on glow settings + const activeFilter = glowEnabled + ? `brightness(1.25) drop-shadow(0 0 ${glowSize}px ${hexToRgba(glowColor, glowOpacity)})` + : 'brightness(1.25)' + const inactiveFilter = 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))' + + // Skip slots without assigned avatars + if (!slot.avatarData) { + return null + } + + // For float animations, use CSS animation instead of transform + const useContinuousAnimation = animationType === 'float' && activeSlots[slot.id] + + // Determine if idle animation should be applied (when not active and idle animation is enabled) + const isIdle = !activeSlots[slot.id] + const useIdleAnimation = isIdle && idleAnimationType !== 'none' + + // Calculate idle animation parameters based on intensity + const getIdleAnimationParams = () => { + switch (idleAnimationType) { + case 'jitter': + return { distance: idleAnimationIntensity * 0.5 } + case 'pulse': + return { scale: 1 + (idleAnimationIntensity * 0.01) } + case 'sway-horizontal': + case 'sway-vertical': + return { distance: idleAnimationIntensity * 2 } + default: + return {} + } + } + const idleParams = getIdleAnimationParams() + + // For idle animations, use shared or unique animation name based on sync setting + const idleAnimationName = idleAnimationSynced + ? `crowd-idle-${idleAnimationType}` + : `crowd-idle-${idleAnimationType}-${slot.id}` + + // Generate a random delay for independent animations to prevent syncing + const getRandomDelay = () => { + if (idleAnimationSynced) return 0 + + // Generate or retrieve random delay for this slot + const delayKey = `${slot.id}-${idleAnimationType}` + if (!idleAnimationDelaysRef.current.has(delayKey)) { + // Generate truly random delay between 0 and -idleAnimationSpeed + const randomDelay = -1 * Math.random() * idleAnimationSpeed + idleAnimationDelaysRef.current.set(delayKey, randomDelay) + } + return idleAnimationDelaysRef.current.get(delayKey) + } + const idleAnimationRandomDelay = getRandomDelay() + + return ( +
+ {/* Inject keyframe animation for this specific slot when using continuous animations */} + {useContinuousAnimation && ( + + )} + {/* Inject keyframe animation for idle animations */} + {useIdleAnimation && idleAnimationType === 'jitter' && ( + + )} + {useIdleAnimation && idleAnimationType === 'pulse' && ( + + )} + {useIdleAnimation && idleAnimationType === 'sway-horizontal' && ( + + )} + {useIdleAnimation && idleAnimationType === 'sway-vertical' && ( + + )} {/* Chat bubble for grid mode */} {settings?.chatBubblesEnabled !== false && chatMessages[slot.id] && (
Date: Sun, 23 Nov 2025 22:48:32 +0100 Subject: [PATCH 38/46] fix spin animation --- README.md | 6 ++++++ backend/version.py | 2 +- .../settings/CrowdAnimationSettings.jsx | 6 +++--- frontend/src/pages/YappersPage.jsx | 18 ++++++++++++++---- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index fae153d..72c9d85 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,12 @@ chat-yapper/ ## Changelog +### v1.3.0 (Latest) +- **New Features:** + - Better control of avatar placement in Avatar Layout Editor + - Select and Adjust speaking animations for crowd mode + - Added idle animations for crowd mode + ### v1.2.2 (Latest) - **New Features:** - Quick status view diff --git a/backend/version.py b/backend/version.py index 0b2e76e..45b1079 100644 --- a/backend/version.py +++ b/backend/version.py @@ -3,4 +3,4 @@ This file is automatically updated during CI/CD builds """ -__version__ = "1.2.2" +__version__ = "1.3.0" diff --git a/frontend/src/components/settings/CrowdAnimationSettings.jsx b/frontend/src/components/settings/CrowdAnimationSettings.jsx index aeb7454..222e7df 100644 --- a/frontend/src/components/settings/CrowdAnimationSettings.jsx +++ b/frontend/src/components/settings/CrowdAnimationSettings.jsx @@ -82,7 +82,7 @@ function CrowdAnimationSettings({ settings, onUpdate }) { max="50" step="1" value={animationSettings.bounceHeight} - onChange={e => onUpdate({ crowdBounceHeight: parseFloat(e.target.value) })} + onChange={e => onUpdate({ crowdBounceHeight: parseInt(e.target.value) })} />

{animationSettings.animationType === 'spin' @@ -107,7 +107,7 @@ function CrowdAnimationSettings({ settings, onUpdate }) { max="150" step="5" value={animationSettings.bounceHeight} - onChange={e => onUpdate({ crowdBounceHeight: parseFloat(e.target.value) })} + onChange={e => onUpdate({ crowdBounceHeight: parseInt(e.target.value) })} />

How much the avatar scales up when active (100% = no scaling) @@ -230,7 +230,7 @@ function CrowdAnimationSettings({ settings, onUpdate }) { max="10" step="1" value={animationSettings.idleAnimationIntensity} - onChange={e => onUpdate({ crowdIdleAnimationIntensity: parseFloat(e.target.value) })} + onChange={e => onUpdate({ crowdIdleAnimationIntensity: parseInt(e.target.value) })} />

{animationSettings.idleAnimationType === 'jitter' ? 'How much the avatar vibrates' : diff --git a/frontend/src/pages/YappersPage.jsx b/frontend/src/pages/YappersPage.jsx index caf0192..f5f1a37 100644 --- a/frontend/src/pages/YappersPage.jsx +++ b/frontend/src/pages/YappersPage.jsx @@ -1159,8 +1159,8 @@ export default function YappersPage() { return null } - // For float animations, use CSS animation instead of transform - const useContinuousAnimation = animationType === 'float' && activeSlots[slot.id] + // For float and spin animations, use CSS animation instead of transform + const useContinuousAnimation = (animationType === 'float' || animationType === 'spin') && activeSlots[slot.id] // Determine if idle animation should be applied (when not active and idle animation is enabled) const isIdle = !activeSlots[slot.id] @@ -1214,7 +1214,9 @@ export default function YappersPage() { zIndex: 10 + index, transition: useContinuousAnimation || useIdleAnimation ? 'none' : `all ${animationDuration}ms ${getCurveCss(animationCurve)} ${animationDelay}ms`, animation: useContinuousAnimation - ? `crowd-float-${slot.id} ${animationDuration * 2}ms ease-in-out infinite ${animationDelay}ms` + ? animationType === 'float' + ? `crowd-float-${slot.id} ${animationDuration * 2}ms ease-in-out infinite ${animationDelay}ms` + : `crowd-spin-${slot.id} ${animationDuration}ms linear infinite ${animationDelay}ms` : useIdleAnimation ? `${idleAnimationName} ${idleAnimationSpeed}ms ease-in-out infinite ${idleAnimationRandomDelay}ms` : 'none', @@ -1222,7 +1224,7 @@ export default function YappersPage() { }} > {/* Inject keyframe animation for this specific slot when using continuous animations */} - {useContinuousAnimation && ( + {useContinuousAnimation && animationType === 'float' && ( )} + {useContinuousAnimation && animationType === 'spin' && ( + + )} {/* Inject keyframe animation for idle animations */} {useIdleAnimation && idleAnimationType === 'jitter' && (