diff --git a/.github/workflows/agent.yml b/.github/workflows/agent.yml index e1a5dc9e8..c10525450 100644 --- a/.github/workflows/agent.yml +++ b/.github/workflows/agent.yml @@ -1,87 +1,87 @@ -name: Build and Deploy Agent - -on: - push: - paths: - - "agent/**" - branches: - - main - pull_request: - paths: - - "agent/**" - branches: - - 'main' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Log in to the Container registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v3 - with: - images: ghcr.io/muxable/rtchat-agent - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - - - name: Build and push Docker image - uses: docker/build-push-action@v2 - with: - context: ./agent - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - deploy: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - needs: build - concurrency: - group: deploy - cancel-in-progress: true - environment: - name: agent - url: https://console.cloud.google.com/compute/instanceGroups/details/us-central1-c/agent-small-us-central1?project=rtchat-47692 - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: google-github-actions/auth@v1 - with: - workload_identity_provider: 'projects/832669896677/locations/global/workloadIdentityPools/github-actions/providers/github-actions' - service_account: 'github-action-356868763@rtchat-47692.iam.gserviceaccount.com' - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v0 - - - name: Deploy to GCE - id: deploy - run: | - gcloud compute instance-groups managed rolling-action start-update agent-small-us-central1 --project=rtchat-47692 --type='proactive' --max-surge=1 --max-unavailable=1 --minimal-action='replace' --replacement-method='substitute' --version=template=https://www.googleapis.com/compute/beta/projects/rtchat-47692/global/instanceTemplates/agent-e2-small --zone=us-central1-c +#name: Build and Deploy Agent +# +#on: +# push: +# paths: +# - "agent/**" +# branches: +# - main +# pull_request: +# paths: +# - "agent/**" +# branches: +# - 'main' +# workflow_dispatch: +# +#jobs: +# build: +# runs-on: ubuntu-latest +# permissions: +# contents: read +# packages: write +# +# steps: +# - name: Checkout repository +# uses: actions/checkout@v4 +# +# - name: Set up Docker Buildx +# uses: docker/setup-buildx-action@v1 +# +# - name: Log in to the Container registry +# uses: docker/login-action@v1 +# with: +# registry: ghcr.io +# username: ${{ github.actor }} +# password: ${{ secrets.GITHUB_TOKEN }} +# +# - name: Extract metadata (tags, labels) for Docker +# id: meta +# uses: docker/metadata-action@v3 +# with: +# images: ghcr.io/muxable/rtchat-agent +# tags: | +# type=ref,event=branch +# type=ref,event=pr +# type=semver,pattern={{version}} +# type=semver,pattern={{major}}.{{minor}} +# +# - name: Build and push Docker image +# uses: docker/build-push-action@v2 +# with: +# context: ./agent +# push: true +# tags: ${{ steps.meta.outputs.tags }} +# labels: ${{ steps.meta.outputs.labels }} +# cache-from: type=gha +# cache-to: type=gha,mode=max +# +# deploy: +# if: github.ref == 'refs/heads/main' +# runs-on: ubuntu-latest +# permissions: +# id-token: write +# contents: read +# needs: build +# concurrency: +# group: deploy +# cancel-in-progress: true +# environment: +# name: agent +# url: https://console.cloud.google.com/compute/instanceGroups/details/us-central1-c/agent-small-us-central1?project=rtchat-47692 +# +# steps: +# - name: Checkout repository +# uses: actions/checkout@v4 +# +# - uses: google-github-actions/auth@v1 +# with: +# workload_identity_provider: 'projects/832669896677/locations/global/workloadIdentityPools/github-actions/providers/github-actions' +# service_account: 'github-action-356868763@rtchat-47692.iam.gserviceaccount.com' +# +# - name: Set up Cloud SDK +# uses: google-github-actions/setup-gcloud@v0 +# +# - name: Deploy to GCE +# id: deploy +# run: | +# gcloud compute instance-groups managed rolling-action start-update agent-small-us-central1 --project=rtchat-47692 --type='proactive' --max-surge=1 --max-unavailable=1 --minimal-action='replace' --replacement-method='substitute' --version=template=https://www.googleapis.com/compute/beta/projects/rtchat-47692/global/instanceTemplates/agent-e2-small --zone=us-central1-c diff --git a/.github/workflows/auxiliary.yml b/.github/workflows/auxiliary.yml index 0368b650b..4e5460ffc 100644 --- a/.github/workflows/auxiliary.yml +++ b/.github/workflows/auxiliary.yml @@ -1,80 +1,80 @@ -name: Build and Deploy Auxiliary - -on: - push: - paths: - - "auxiliary/**" - branches: - - main - pull_request: - paths: - - "auxiliary/**" - branches: - - 'main' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Log in to the Container registry - uses: docker/login-action@v1 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v3 - with: - images: ghcr.io/muxable/rtchat-auxiliary - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - - - name: Build and push Docker image - uses: docker/build-push-action@v2 - with: - context: ./auxiliary - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - deploy: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - needs: build - concurrency: - group: deploy - cancel-in-progress: true - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: google-github-actions/auth@v0 - with: - credentials_json: ${{ secrets.SERVICE_ACCOUNT_JSON }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v0 - - - name: Deploy to GCE - id: deploy - run: | - gcloud compute instance-groups managed rolling-action start-update auxiliary-us-central1 --version=template=auxiliary-e2-micro --zone=us-central1-a +#name: Build and Deploy Auxiliary +# +#on: +# push: +# paths: +# - "auxiliary/**" +# branches: +# - main +# pull_request: +# paths: +# - "auxiliary/**" +# branches: +# - 'main' +# workflow_dispatch: +# +#jobs: +# build: +# runs-on: ubuntu-latest +# permissions: +# contents: read +# packages: write +# +# steps: +# - name: Checkout repository +# uses: actions/checkout@v4 +# +# - name: Set up Docker Buildx +# uses: docker/setup-buildx-action@v1 +# +# - name: Log in to the Container registry +# uses: docker/login-action@v1 +# with: +# registry: ghcr.io +# username: ${{ github.actor }} +# password: ${{ secrets.GITHUB_TOKEN }} +# +# - name: Extract metadata (tags, labels) for Docker +# id: meta +# uses: docker/metadata-action@v3 +# with: +# images: ghcr.io/muxable/rtchat-auxiliary +# tags: | +# type=ref,event=branch +# type=ref,event=pr +# type=semver,pattern={{version}} +# type=semver,pattern={{major}}.{{minor}} +# +# - name: Build and push Docker image +# uses: docker/build-push-action@v2 +# with: +# context: ./auxiliary +# push: true +# tags: ${{ steps.meta.outputs.tags }} +# labels: ${{ steps.meta.outputs.labels }} +# cache-from: type=gha +# cache-to: type=gha,mode=max +# +# deploy: +# if: github.ref == 'refs/heads/main' +# runs-on: ubuntu-latest +# needs: build +# concurrency: +# group: deploy +# cancel-in-progress: true +# +# steps: +# - name: Checkout repository +# uses: actions/checkout@v4 +# +# - uses: google-github-actions/auth@v0 +# with: +# credentials_json: ${{ secrets.SERVICE_ACCOUNT_JSON }} +# +# - name: Set up Cloud SDK +# uses: google-github-actions/setup-gcloud@v0 +# +# - name: Deploy to GCE +# id: deploy +# run: | +# gcloud compute instance-groups managed rolling-action start-update auxiliary-us-central1 --version=template=auxiliary-e2-micro --zone=us-central1-a diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 000000000..f3b44c50a --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,68 @@ +name: Build Signed APK + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-apk: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + + - name: Install dependencies + run: flutter pub get + + # Build debug APK for external PRs (forks) + - name: Build Debug APK + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + run: flutter build apk --debug + + # Decode keystore: Runs on push, dispatch, OR internal PRs + - name: Decode keystore + if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + env: + UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} + run: printf '%s' "$UPLOAD_KEYSTORE" | base64 --decode > "$RUNNER_TEMP/upload-keystore.jks" + + # Build signed APK: Runs on push, dispatch, OR internal PRs + - name: Build signed APK + if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + env: + KEY_ALIAS: upload + KEY_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }} + STORE_FILE: ${{ runner.temp }}/upload-keystore.jks + STORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }} + run: flutter build apk --release + + - name: Upload Debug APK + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: build/app/outputs/flutter-apk/app-debug.apk + + - name: Upload signed APK + if: github.event_name != 'pull_request' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) + uses: actions/upload-artifact@v4 + with: + name: signed-apk + path: build/app/outputs/flutter-apk/app-release.apk diff --git a/.github/workflows/flutter-analyze.yml b/.github/workflows/flutter-analyze.yml index b2430258c..fd706bd82 100644 --- a/.github/workflows/flutter-analyze.yml +++ b/.github/workflows/flutter-analyze.yml @@ -20,3 +20,5 @@ jobs: - run: flutter pub get - name: Run flutter analyze uses: invertase/github-action-dart-analyzer@v3 + with: + fatal-warnings: false diff --git a/.github/workflows/periodic-restart.yml b/.github/workflows/periodic-restart.yml index 505bd12e8..68bb57a6e 100644 --- a/.github/workflows/periodic-restart.yml +++ b/.github/workflows/periodic-restart.yml @@ -1,45 +1,45 @@ -name: Periodically Restart Agent - -# This action periodically restarts the agent. -# Twitch likes to expire tokens if the agent runs for too long, so every couple of days we restart -# a random instance to ensure there's always a fresh agent available. - -on: - schedule: - - cron: "0 12 * * 1,3,5" # every Monday, Wednesday, and Friday at noon UTC - workflow_dispatch: - -jobs: - restart: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - uses: google-github-actions/auth@v1 - with: - workload_identity_provider: "projects/832669896677/locations/global/workloadIdentityPools/github-actions/providers/github-actions" - service_account: "github-action-356868763@rtchat-47692.iam.gserviceaccount.com" - - - uses: "google-github-actions/setup-gcloud@v1" - - - run: | - INSTANCES=$(gcloud compute instance-groups managed list-instances agent-small-us-west1 --region=us-west1) - NUM_HEALTHY_INSTANCES=$(echo "$INSTANCES" | grep "HEALTHY" | wc -l) - - echo "$INSTANCES" - - echo "Number of healthy instances: $NUM_HEALTHY_INSTANCES" - - # if there are fewer than three healthy instances, don't do anything - if [ "$NUM_HEALTHY_INSTANCES" -lt 3 ]; then - echo "Not enough healthy instances, not restarting" - exit 0 - fi - - # otherwise, pick an instance at random - INSTANCE=$(echo "$INSTANCES" | tail -n +2 | shuf -n 1 | cut -d ' ' -f 1) - echo "Restarting instance: $INSTANCE" - - # restart the instance - gcloud compute instance-groups managed recreate-instances agent-small-us-west1 --region=us-west1 --instances=$INSTANCE +#name: Periodically Restart Agent +# +## This action periodically restarts the agent. +## Twitch likes to expire tokens if the agent runs for too long, so every couple of days we restart +## a random instance to ensure there's always a fresh agent available. +# +#on: +# schedule: +# - cron: "0 12 * * 1,3,5" # every Monday, Wednesday, and Friday at noon UTC +# workflow_dispatch: +# +#jobs: +# restart: +# runs-on: ubuntu-latest +# permissions: +# contents: read +# id-token: write +# steps: +# - uses: google-github-actions/auth@v1 +# with: +# workload_identity_provider: "projects/832669896677/locations/global/workloadIdentityPools/github-actions/providers/github-actions" +# service_account: "github-action-356868763@rtchat-47692.iam.gserviceaccount.com" +# +# - uses: "google-github-actions/setup-gcloud@v1" +# +# - run: | +# INSTANCES=$(gcloud compute instance-groups managed list-instances agent-small-us-west1 --region=us-west1) +# NUM_HEALTHY_INSTANCES=$(echo "$INSTANCES" | grep "HEALTHY" | wc -l) +# +# echo "$INSTANCES" +# +# echo "Number of healthy instances: $NUM_HEALTHY_INSTANCES" +# +# # if there are fewer than three healthy instances, don't do anything +# if [ "$NUM_HEALTHY_INSTANCES" -lt 3 ]; then +# echo "Not enough healthy instances, not restarting" +# exit 0 +# fi +# +# # otherwise, pick an instance at random +# INSTANCE=$(echo "$INSTANCES" | tail -n +2 | shuf -n 1 | cut -d ' ' -f 1) +# echo "Restarting instance: $INSTANCE" +# +# # restart the instance +# gcloud compute instance-groups managed recreate-instances agent-small-us-west1 --region=us-west1 --instances=$INSTANCE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..ed7d7b7b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release APK + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag format + run: | + TAG="${GITHUB_REF#refs/tags/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Tag '$TAG' does not match expected format vX.Y.Z (e.g. v1.0.0, v1.2.3-beta.1)" + exit 1 + fi + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "VERSION=${TAG#v}" >> "$GITHUB_ENV" + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + + - name: Install dependencies + run: flutter pub get + + - name: Decode keystore + env: + UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} + run: printf '%s' "$UPLOAD_KEYSTORE" | base64 --decode > "$RUNNER_TEMP/upload-keystore.jks" + + - name: Build signed APK + env: + KEY_ALIAS: upload + KEY_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }} + STORE_FILE: ${{ runner.temp }}/upload-keystore.jks + STORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }} + run: flutter build apk --release --build-name="${{ env.VERSION }}" + + - name: Rename APK + run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/rtchat-${{ env.TAG }}.apk + + - name: Generate release notes + id: release_notes + run: | + # Find the previous tag + PREVIOUS_TAG=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | sed -n '2p') + # Escape for GitHub Actions multiline output + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + if [ -z "$PREVIOUS_TAG" ]; then + echo "notes<<$EOF" >> "$GITHUB_OUTPUT" + echo "Initial release 🎉" >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + else + NOTES=$(git log --pretty=format:"- %s (%h)" "$PREVIOUS_TAG"..HEAD -- ':!.github') + echo "notes<<$EOF" >> "$GITHUB_OUTPUT" + echo "## What's Changed" >> "$GITHUB_OUTPUT" + echo "" >> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "" >> "$GITHUB_OUTPUT" + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${{ env.TAG }}" >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + fi + + - name: Create draft release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG }} + name: ${{ env.TAG }} + body: ${{ steps.release_notes.outputs.notes }} + draft: true + prerelease: ${{ contains(env.TAG, '-') }} + files: build/app/outputs/flutter-apk/rtchat-${{ env.TAG }}.apk diff --git a/.github/workflows/rtchat.yml b/.github/workflows/rtchat.yml deleted file mode 100644 index e95c97cd0..000000000 --- a/.github/workflows/rtchat.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: RealtimeChat App - -on: - push: - branches: - - main - pull_request: - branches: - - main - release: - types: [released] - workflow_dispatch: - -jobs: - ios: - uses: muxable/.github/.github/workflows/flutter-ios.yml@main - concurrency: - group: ios-${{ github.ref }} - with: - environment-name: ios-${{ github.event_name == 'release' && 'app-store' || 'testflight' }} - environment-url: https://appstoreconnect.apple.com/apps/1567720948/${{ github.event_name == 'release' && 'appstore' || 'testflight' }} - publish: ${{ github.event_name != 'pull_request' }} - secrets: - APPSTORE_API_PRIVATE_KEY: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} - APPLE_CERTIFICATE_PRIVATE_KEY: ${{ secrets.APPLE_CERTIFICATE_PRIVATE_KEY }} - android: - uses: muxable/.github/.github/workflows/flutter-android.yml@main - concurrency: - group: android-${{ github.ref }} - with: - environment-name: android-${{ github.event_name == 'release' && 'production' || 'internal' }} - environment-url: https://play.google.com/console/developers/8168733962061318282/app/4973471021781937122/tracks/${{ github.event_name == 'release' && 'production' || 'internal-testing' }} - package-name: com.rtirl.chat - publish: ${{ github.event_name != 'pull_request' }} - secrets: - UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} - UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }} - GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} diff --git a/lib/models/messages/twitch/message.dart b/lib/models/messages/twitch/message.dart index 6a566bd6e..641427855 100644 --- a/lib/models/messages/twitch/message.dart +++ b/lib/models/messages/twitch/message.dart @@ -265,6 +265,14 @@ class TwitchMessageModel extends MessageModel { return tags['badges']['moderator'] != null; } + bool get isSubscriber { + final badgesRaw = tags['badges-raw'] as String?; + if (badgesRaw == null || badgesRaw.isEmpty) { + return false; + } + return badgesRaw.contains('subscriber/'); + } + List get tokenized => _tokenized ??= tokenize(); List? _tokenized; List tokenize() { diff --git a/lib/models/tts.dart b/lib/models/tts.dart index b92706b77..fd98be579 100644 --- a/lib/models/tts.dart +++ b/lib/models/tts.dart @@ -35,6 +35,17 @@ import 'package:rtchat/models/user.dart'; enum TtsMode { disabled, alertsOnly, enabled } class TtsModel extends ChangeNotifier { + static final _punctuation1 = RegExp(r'[\u0020-\u0026\u0028-\u002f]'); + static final _punctuation2 = RegExp(r'[\u003a-\u0040]'); + static final _punctuation3 = RegExp(r'[\u005b-\u0060]'); + static final _punctuation4 = RegExp(r'[\u007b-\u007e]'); + static final _punctuation5 = RegExp(r'[\u00a0-\u00bf]'); + static final _diacritics = RegExp(r'[\u0300-\u036f]'); + static final _repeatedChars = RegExp(r'(.)\1{2,}'); + static final _boxDrawing = RegExp(r'[\u2500-\u257f]'); + static final _braille = RegExp(r'[\u2800-\u28ff]'); + static final _whitespace = RegExp(r'\s+'); + var _isCloudTtsEnabled = false; final _tts = FlutterTts() ..setSharedInstance(true) @@ -54,6 +65,8 @@ class TtsModel extends ChangeNotifier { var _isBotMuted = false; var _isEmoteMuted = false; var _isPreludeMuted = false; + var _isUnderscoreReplacementEnabled = true; + var _isTtsCommandEncouraged = false; var _speed = Platform.isAndroid ? 0.8 : 0.395; var _pitch = 1.0; var _mode = TtsMode.disabled; @@ -63,6 +76,7 @@ class TtsModel extends ChangeNotifier { var _lastMessageTime = DateTime.now(); MessageModel? _activeMessage; var _isSubscribersOnly = false; + var _isTextSimplificationEnabled = false; @override void dispose() { @@ -124,7 +138,7 @@ class TtsModel extends ChangeNotifier { String getVocalization(AppLocalizations l10n, MessageModel model, {bool includeAuthorPrelude = false}) { if (model is TwitchMessageModel) { - final text = model.tokenized + var text = model.tokenized .where((token) => token is TextToken || (!_isEmoteMuted && token is EmoteToken) || @@ -141,10 +155,44 @@ class TtsModel extends ChangeNotifier { return token.url.host; } }).join(""); + + if (text.toLowerCase().startsWith("!v ")) { + debugPrint("Message starts with TTS command prefix: $text"); + text = text.substring("!v".length).trim(); + } + + if (_isTextSimplificationEnabled) { + text = text.replaceAll(_punctuation1, ' '); // replace punctuation with space except for apostrophes + text = text.replaceAll(_punctuation2, ' '); // replace punctuation with space + text = text.replaceAll(_punctuation3, ' '); // replace punctuation with space + text = text.replaceAll(_punctuation4, ' '); // replace punctuation with space + text = text.replaceAll(_punctuation5, ' '); // replace punctuation with space + text = text.replaceAll(_diacritics, ''); // remove diacritics + text = text.replaceAllMapped(_repeatedChars, (match) { + // replace repeated characters with a single instance + return match.group(1)!; + }); + text = text.replaceAll(_boxDrawing, ''); // remove box drawing characters + text = text.replaceAll(_braille, ''); // remove braille patterns + + // Remove doubles spaces that may have been introduced and trim the text + text = text.replaceAll(_whitespace, ' ').trim(); + } + + if (!includeAuthorPrelude || isPreludeMuted) { + return text; + } + if (text.trim().isEmpty) { return ""; } - final author = model.author.displayName ?? model.author.login; + var author = model.author.displayName ?? model.author.login; + if (_isUnderscoreReplacementEnabled) { + author = author + .replaceAll("_", " ") + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } if (!includeAuthorPrelude || isPreludeMuted) { return text; } @@ -206,6 +254,15 @@ class TtsModel extends ChangeNotifier { }); } + bool get isTextSimplificationEnabled { + return _isTextSimplificationEnabled; + } + + set isTextSimplificationEnabled(bool value) { + _isTextSimplificationEnabled = value; + notifyListeners(); + } + set newTtsEnabled(bool value) { if (value == _isNewTTsEnabled) { return; @@ -328,6 +385,24 @@ class TtsModel extends ChangeNotifier { notifyListeners(); } + bool get isTtsCommandEncouraged { + return _isTtsCommandEncouraged; + } + + set isTtsCommandEncouraged(bool value) { + _isTtsCommandEncouraged = value; + notifyListeners(); + } + + bool get isUnderscoreReplacementEnabled { + return _isUnderscoreReplacementEnabled; + } + + set isUnderscoreReplacementEnabled(bool value) { + _isUnderscoreReplacementEnabled = value; + notifyListeners(); + } + bool get isCloudTtsEnabled { return _isCloudTtsEnabled; } @@ -384,7 +459,13 @@ class TtsModel extends ChangeNotifier { } if (model is TwitchMessageModel) { - if (_isSubscribersOnly && (model.tags['badges']?['subscriber'] == null)) { + if (_isSubscribersOnly && !model.isSubscriber) { + return; + } + + if (_isTtsCommandEncouraged && + !model.message.toLowerCase().startsWith("!v ")) { + debugPrint("Message does not start with TTS command prefix: ${model.message}"); return; } @@ -394,7 +475,11 @@ class TtsModel extends ChangeNotifier { return; } - if ((_isBotMuted && model.author.isBot) || model.isCommand) { + if (_isBotMuted && model.author.isBot) { + return; + } + + if(model.isCommand && !model.message.toLowerCase().startsWith("!v")) { return; } } @@ -497,6 +582,12 @@ class TtsModel extends ChangeNotifier { if (json['isBotMuted'] != null) { _isBotMuted = json['isBotMuted']; } + if (json['isTtsCommandEncouraged'] != null) { + _isTtsCommandEncouraged = json['isTtsCommandEncouraged']; + } + if (json['isTextSimplificationEnabled'] != null) { + _isTextSimplificationEnabled = json['isTextSimplificationEnabled']; + } if (json['pitch'] != null) { _pitch = json['pitch']; } @@ -509,6 +600,9 @@ class TtsModel extends ChangeNotifier { if (json['isPreludeMuted'] != null) { _isPreludeMuted = json['isPreludeMuted']; } + if (json['isUnderscoreReplacementEnabled'] != null) { + _isUnderscoreReplacementEnabled = json['isUnderscoreReplacementEnabled']; + } if (json['isRandomVoiceEnabled'] != null) { _isRandomVoiceEnabled = json['isRandomVoiceEnabled']; } @@ -533,7 +627,10 @@ class TtsModel extends ChangeNotifier { Map toJson() => { "isBotMuted": isBotMuted, "isEmoteMuted": isEmoteMuted, + "isTextSimplificationEnabled": isTextSimplificationEnabled, "isPreludeMuted": isPreludeMuted, + "isUnderscoreReplacementEnabled": isUnderscoreReplacementEnabled, + "isTtsCommandEncouraged": isTtsCommandEncouraged, "isRandomVoiceEnabled": isRandomVoiceEnabled, "language": language.languageCode, "pitch": pitch, diff --git a/lib/screens/settings/tts.dart b/lib/screens/settings/tts.dart index 0f7e75381..2766797d2 100644 --- a/lib/screens/settings/tts.dart +++ b/lib/screens/settings/tts.dart @@ -264,6 +264,21 @@ class TextToSpeechScreen extends StatelessWidget { model.isPreludeMuted = value; }, ), + SwitchListTile.adaptive( + title: const Text("Replace underscores with spaces in viewer names"), + value: model.isUnderscoreReplacementEnabled, + onChanged: (value) { + model.isUnderscoreReplacementEnabled = value; + }, + ), + SwitchListTile.adaptive( + title: const Text("Simplify messages"), + subtitle: const Text("Simplify punctuation, symbols, and whitespace (including muting repetitive punctuation and unsupported characters)"), + value: model.isTextSimplificationEnabled, + onChanged: (value) { + model.isTextSimplificationEnabled = value; + }, + ), SwitchListTile.adaptive( title: const Text("Subscribers only"), value: model.isSubscribersOnly, @@ -271,6 +286,13 @@ class TextToSpeechScreen extends StatelessWidget { model.isSubscribersOnly = value; }, ), + SwitchListTile.adaptive( + title: const Text("Only say messages starting with !v"), + value: model.isTtsCommandEncouraged, + onChanged: (value) { + model.isTtsCommandEncouraged = value; + }, + ), ], ); }),