diff --git a/.github/scripts/lzy_web.py b/.github/scripts/lzy_web.py new file mode 100644 index 000000000..5ce7b30d9 --- /dev/null +++ b/.github/scripts/lzy_web.py @@ -0,0 +1,98 @@ +import requests, os, datetime, sys + +# Cookie 中 phpdisk_info 的值 +cookie_phpdisk_info = os.environ.get('phpdisk_info') +# Cookie 中 ylogin 的值 +cookie_ylogin = os.environ.get('ylogin') + +# 请求头 +headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36 Edg/89.0.774.45', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Referer': 'https://pc.woozooo.com/account.php?action=login' +} + +# 小饼干 +cookie = { + 'ylogin': cookie_ylogin, + 'phpdisk_info': cookie_phpdisk_info +} + + +# 日志打印 +def log(msg): + utc_time = datetime.datetime.utcnow() + china_time = utc_time + datetime.timedelta(hours=8) + print(f"[{china_time.strftime('%Y.%m.%d %H:%M:%S')}] {msg}") + + +# 检查是否已登录 +def login_by_cookie(): + url_account = "https://pc.woozooo.com/account.php" + if cookie['phpdisk_info'] is None: + log('ERROR: 请指定 Cookie 中 phpdisk_info 的值!') + return False + if cookie['ylogin'] is None: + log('ERROR: 请指定 Cookie 中 ylogin 的值!') + return False + res = requests.get(url_account, headers=headers, cookies=cookie, verify=True) + if '网盘用户登录' in res.text: + log('ERROR: 登录失败,请更新Cookie') + return False + else: + log('登录成功') + return True + + +# 上传文件 +def upload_file(file_dir, folder_id): + file_name = os.path.basename(file_dir) + url_upload = "https://up.woozooo.com/fileup.php" + headers['Referer'] = f'https://up.woozooo.com/mydisk.php?item=files&action=index&u={cookie_ylogin}' + post_data = { + "task": "1", + "folder_id": folder_id, + "id": "WU_FILE_0", + "name": file_name, + } + files = {'upload_file': (file_name, open(file_dir, "rb"), 'application/octet-stream')} + res = requests.post(url_upload, data=post_data, files=files, headers=headers, cookies=cookie, timeout=120).json() + log(f"{file_dir} -> {res['info']}") + return res['zt'] == 1 + + +# 上传文件夹内的文件 +def upload_folder(folder_dir, folder_id): + file_list = sorted(os.listdir(folder_dir), reverse=True) + for file in file_list: + path = os.path.join(folder_dir, file) + if os.path.isfile(path): + upload_file(path, folder_id) + else: + upload_folder(path, folder_id) + + +# 上传 +def upload(dir, folder_id): + if dir is None: + log('ERROR: 请指定上传的文件路径') + return + if folder_id is None: + log('ERROR: 请指定蓝奏云的文件夹id') + return + if os.path.isfile(dir): + upload_file(dir, str(folder_id)) + else: + upload_folder(dir, str(folder_id)) + + +if __name__ == '__main__': + argv = sys.argv[1:] + if len(argv) != 2: + log('ERROR: 参数错误,请以这种格式重新尝试\npython lzy_web.py 需上传的路径 蓝奏云文件夹id') + # 需上传的路径 + upload_path = argv[0] + # 蓝奏云文件夹id + lzy_folder_id = argv[1] + if login_by_cookie(): + upload(upload_path, lzy_folder_id) \ No newline at end of file diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 000000000..9f991df64 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,29 @@ +name: Auto merge +on: + pull_request: + types: + - labeled + - unlabeled + - synchronize + - opened + - edited + - ready_for_review + - reopened + - unlocked + pull_request_review: + types: + - submitted + check_suite: + types: + - completed + status: {} +jobs: + automerge: + runs-on: ubuntu-latest + steps: + - id: automerge + name: automerge + uses: "pascalgn/automerge-action@v0.16.2" + env: + MERGE_FILTER_AUTHOR: "jing332" + GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 000000000..3bc960bdb --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,72 @@ +name: Build Debug + +on: + workflow_dispatch: + +jobs: + go-lib: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v4 + with: + go-version: 1.19.1 + cache-dependency-path: ${{ github.workspace }}/tts-server-lib + + - name: Build tts-server-lib + run: | + cd tts-server-lib + go install golang.org/x/mobile/cmd/gomobile@latest + gomobile init + go get golang.org/x/mobile/bind + gomobile bind -ldflags "-s -w" -v -androidapi=19 + cp -f *.aar $GITHUB_WORKSPACE/app/libs + + - uses: actions/upload-artifact@v3.1.0 + with: + name: tts-server-lib + path: tts-server-lib/*.aar + + build: + needs: go-lib + runs-on: ubuntu-latest + env: + outputs_dir: "${{ github.workspace }}/app/build/outputs" + + steps: + - uses: actions/checkout@v3 + + - uses: actions/download-artifact@v3 + with: + name: tts-server-lib + path: ${{ github.workspace }}/app/libs + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Init Signature + run: | + touch local.properties + echo ALIAS_NAME='${{ secrets.ALIAS_NAME }}' >> local.properties + echo ALIAS_PASSWORD='${{ secrets.ALIAS_PASSWORD }}' >> local.properties + echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}' >> local.properties + echo KEY_PATH='./key.jks' >> local.properties + # 从Secrets读取无换行符Base64解码, 然后保存到到app/key.jks + echo ${{ secrets.KEY_STORE }} | base64 --decode > $GITHUB_WORKSPACE/app/key.jks + + - name: Build with Gradle + run: | + chmod +x gradlew + ./gradlew assembleAppDebug --build-cache --parallel --daemon --warning-mode all + + - name: Upload APK To Artifact + uses: actions/upload-artifact@v3 + with: + name: "TTS-Server_debug" + path: ${{env.outputs_dir}}/apk/app/debug/*debug.apk diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6876be1fd..e4fe329d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,97 +2,108 @@ name: Build Release on: push: - branches: + branches: - "master" paths: - "CHANGELOG.md" workflow_dispatch: jobs: + golib: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-go@v4 + with: + go-version: 1.20.3 + cache-dependency-path: ${{ github.workspace }}/tts-server-lib + + - name: Build tts-server-lib + run: | + cd tts-server-lib + go install golang.org/x/mobile/cmd/gomobile + gomobile init + go get golang.org/x/mobile/bind + gomobile bind -ldflags "-s -w" -v -androidapi=21 + cp -f *.aar $GITHUB_WORKSPACE/app/libs + + - uses: actions/upload-artifact@v3.1.0 + with: + name: tts-server-lib + path: tts-server-lib/*.aar + build: + needs: golib + strategy: + matrix: + product: [ { name: "App原版", value: app } ] + + fail-fast: false runs-on: ubuntu-latest + env: + product: ${{ matrix.product.value }} + product_name: ${{matrix.product.value}} + outputs_dir: "${{ github.workspace }}/app/build/outputs" + ver_name: "" + steps: - - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: 1.19.1 - - - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Build Go Lib - run: | - cd tts-server-lib - go install golang.org/x/mobile/cmd/gomobile@latest - gomobile init - go get golang.org/x/mobile/bind - gomobile bind -ldflags "-s -w" -v -target="android/arm,android/arm64" -androidapi=19 - cp -f *.aar $GITHUB_WORKSPACE/app/libs - - - name: Upload to Artifact - uses: actions/upload-artifact@v3.1.0 - with: - name: tts-server-lib - path: | - tts-server-lib/*.aar - tts-server-lib/*.jar - - - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Init Sign - run: | - touch local.properties - echo ALIAS_NAME='${{ secrets.ALIAS_NAME }}' >> local.properties - echo ALIAS_PASSWORD='${{ secrets.ALIAS_PASSWORD }}' >> local.properties - echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}' >> local.properties - echo KEY_PATH='./key.jks' >> local.properties - # 从Secrets读取无换行符Base64解码, 然后保存到到app/key.jks - echo ${{ secrets.KEY_STORE }} | base64 --decode > $GITHUB_WORKSPACE/app/key.jks - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew assembleRelease --build-cache --parallel --daemon --warning-mode all - - - name: Organize the Files - run: | - mkdir -p ${{ github.workspace }}/apk/ - rm -f ${{ github.workspace }}/apk/* - cp ${{ github.workspace }}/app/build/outputs/apk/*/*.apk ${{ github.workspace }}/apk/ - cp ${{ github.workspace }}/app/build/outputs/apk/*/*.json ${{ github.workspace }}/apk/ - - - name: Get VerName - run: | - echo "ver_name=$(grep 'versionName' apk/output-metadata.json | cut -d\" -f4)" >> $GITHUB_ENV - - - name: Upload App To Artifact - uses: actions/upload-artifact@v3 - with: - name: TTS-Server_${{ env.ver_name }} - path: ${{ github.workspace }}/apk/*.apk - - - uses: softprops/action-gh-release@v0.1.14 - with: - name: ${{ env.ver_name }} - tag_name: ${{ env.ver_name }} - body_path: ${{ github.workspace }}/CHANGELOG.md - draft: false - prerelease: false - files: ${{ github.workspace }}/apk/*apk - env: - GITHUB_TOKEN: ${{ secrets.TOKEN }} + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v3 + with: + name: tts-server-lib + path: ${{ github.workspace }}/app/libs + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Init Signature + run: | + touch local.properties + echo ALIAS_NAME='${{ secrets.ALIAS_NAME }}' >> local.properties + echo ALIAS_PASSWORD='${{ secrets.ALIAS_PASSWORD }}' >> local.properties + echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}' >> local.properties + echo KEY_PATH='./key.jks' >> local.properties + # 从Secrets读取无换行符Base64解码, 然后保存到到app/key.jks + echo ${{ secrets.KEY_STORE }} | base64 --decode > $GITHUB_WORKSPACE/app/key.jks + + - name: Build with Gradle + run: | + chmod +x gradlew + ./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all + + - name: Init environment variable + run: | + echo "ver_name=$(grep -m 1 'versionName' ${{ env.outputs_dir }}/apk/${{ env.product }}/release/output-metadata.json | cut -d\" -f4)" >> $GITHUB_ENV + + - name: Upload Mappings to Artifact + uses: actions/upload-artifact@v3 + with: + name: mappings_${{ env.product }}_${{ env.ver_name }} + path: ${{ env.outputs_dir }}/mapping/*/*.txt + + - name: Upload APK To Artifact + uses: actions/upload-artifact@v3 + with: + name: "TTS-Server_${{ env.product }}_${{ env.ver_name }}" + path: ${{env.outputs_dir}}/apk/${{ env.product }}/release/*${{ env.ver_name }}.apk + + + - uses: softprops/action-gh-release@v0.1.15 + with: + name: ${{ env.ver_name }} + tag_name: ${{ env.ver_name }} + body_path: ${{ github.workspace }}/CHANGELOG.md + draft: false + prerelease: false + files: ${{env.outputs_dir}}/apk/${{ env.product }}/release/*.apk + env: + GITHUB_TOKEN: ${{ secrets.TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d88c958d1..04ad2e6e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,59 +4,72 @@ on: push: branches: - "master" + - "compose" paths-ignore: - - "README.md" - - "CHANGELOG.md" - + - "**.md" workflow_dispatch: jobs: - build: + go-lib: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up Go - uses: actions/setup-go@v3 + - uses: actions/setup-go@v4 with: - go-version: 1.19.1 + go-version: 1.20.3 + cache-dependency-path: ${{ github.workspace }}/tts-server-lib - - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Build Go Lib + - name: Build tts-server-lib run: | cd tts-server-lib - go install golang.org/x/mobile/cmd/gomobile@latest + go install golang.org/x/mobile/cmd/gomobile gomobile init go get golang.org/x/mobile/bind - gomobile bind -ldflags "-s -w" -v -target="android/arm,android/arm64" -androidapi=19 + gomobile bind -ldflags "-s -w" -v -androidapi=21 cp -f *.aar $GITHUB_WORKSPACE/app/libs - - name: Upload to Artifact - uses: actions/upload-artifact@v3.1.0 + - uses: actions/upload-artifact@v3.1.0 with: name: tts-server-lib - path: | - tts-server-lib/*.aar - tts-server-lib/*.jar + path: tts-server-lib/*.aar + + build: + needs: go-lib + strategy: + matrix: + product: [ { name: "App原版", value: app, lzyId: "9493563" }, { name: "Dev共存版", value: dev, lzyId: "7381570" } ] + + fail-fast: false + runs-on: ubuntu-latest + env: + product: ${{ matrix.product.value }} + product_name: ${{ matrix.product.value }} + lzy_folder_id: ${{ matrix.product.lzyId }} + ylogin: ${{ secrets.LANZOU_ID }} + phpdisk_info: ${{ secrets.LANZOU_PSD }} + outputs_dir: "${{ github.workspace }}/app/build/outputs" + ver_name: "" + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 - - uses: actions/cache@v3 + - uses: actions/download-artifact@v3 with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Init Sign + name: tts-server-lib + path: ${{ github.workspace }}/app/libs + + - uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Init Signature run: | touch local.properties echo ALIAS_NAME='${{ secrets.ALIAS_NAME }}' >> local.properties @@ -65,24 +78,40 @@ jobs: echo KEY_PATH='./key.jks' >> local.properties # 从Secrets读取无换行符Base64解码, 然后保存到到app/key.jks echo ${{ secrets.KEY_STORE }} | base64 --decode > $GITHUB_WORKSPACE/app/key.jks - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew assembleRelease --build-cache --parallel --daemon --warning-mode all - - name: Organize the Files + - name: Build with Gradle run: | - mkdir -p ${{ github.workspace }}/apk/ - rm -f ${{ github.workspace }}/apk/* - cp ${{ github.workspace }}/app/build/outputs/apk/*/*.apk ${{ github.workspace }}/apk/ - cp ${{ github.workspace }}/app/build/outputs/apk/*/*.json ${{ github.workspace }}/apk/ + chmod +x gradlew + ./gradlew assemble${{ env.product }}release --build-cache --parallel --daemon --warning-mode all - - name: Get VerName + - name: Set Version Name run: | - echo "ver_name=$(grep 'versionName' apk/output-metadata.json | cut -d\" -f4)" >> $GITHUB_ENV + echo "ver_name=$(grep -m 1 'versionName' ${{ env.outputs_dir }}/apk/${{ env.product }}/release/output-metadata.json | cut -d\" -f4)" >> $GITHUB_ENV + + # - name: Set APK Path + # run: | + # echo "apk_path=${{ env.outputs_dir }}/apk/${{ env.product }}/release/${{ env.product }}-release.apk" >> $GITHUB_ENV + + # - name: Upload Mappings to Artifact + # uses: actions/upload-artifact@v3 + # with: + # name: mappings_${{ env.product }}_${{ env.ver_name }} + # path: ${{ env.outputs_dir }}/mapping/*/*.txt - - name: Upload App To Artifact + - name: Upload APK To Artifact uses: actions/upload-artifact@v3 with: - name: TTS-Server_${{ env.ver_name }} - path: ${{ github.workspace }}/apk/*.apk \ No newline at end of file + name: "TTS-Server_${{ env.product }}_${{ env.ver_name }}" + path: ${{env.outputs_dir}}/apk/${{ env.product }}/release/*${{ env.ver_name }}.apk + + + - name: Upload APK To Lanzouyun + run: | + mkdir apk + cp ${{ env.outputs_dir }}/apk/${{ env.product }}/release/*${{ env.ver_name }}.apk "${{ github.workspace }}/apk" + path="${{ github.workspace }}/apk" + python3 ${{ github.workspace }}/.github/scripts/lzy_web.py $path "${{ env.lzy_folder_id }}" + +# echo "app: https://jing332.lanzn.com/b09jpjd2d" +# echo "dev: https://jing332.lanzn.com/b09ig9qla" +# echo "密码PWD: 1234" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 951e883e1..4852ad8e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,6 @@ *.iml +.idea .gradle -/local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml .DS_Store /build /captures diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521a..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index bdda35ad9..000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -TTS Server \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index fb7f4a8a4..000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 526b4c25c..000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 48719d61d..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f4..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f3b0611..07b1d312e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ -1. 🥰 新增Creation接口(来自微软Speech Studio Demo),本接口基本与Azure相同,且服务器在东南亚,延迟低、更稳定。 -打开网页版(Azure),开启测试按钮上方的 "使用Creation接口" 选项以使用。 -2. 根据日志等级上色。 -3. 新的桌面快捷方式图标。 -4. 长按快捷开关(Android7+)自动跳转到APP +- 有好的配置导入提示 +- 添加三种主题色 +- 新用户自动添加默认配置 +- 添加帮助文档,并自动显示 +- 朗读规则支持单条导出 +- 支持由调用者通过API指定发音配置 +- 修复朗读规则导出无拓展名 +- 修复本地TTS无法在编辑界面试听 +- 修复插件TTS附加数据不更新(解决Azure插件风格和角色变化问题) +- 修复Android8及以下版本的备份问题 \ No newline at end of file diff --git a/README.md b/README.md index 94a5a48f1..58d4f025a 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,145 @@ ![MIT](https://img.shields.io/badge/license-MIT-green) +[![Crowdin](https://img.shields.io/badge/Localization-Crowdin-blueviolet?logo=Crowdin)](https://crowdin.com/project/tts-server) + [![CI](https://github.com/jing332/tts-server-android/actions/workflows/release.yml/badge.svg)](https://github.com/jing332/tts-server-android/actions/workflows/release.yml) [![CI](https://github.com/jing332/tts-server-android/actions/workflows/test.yml/badge.svg)](https://github.com/jing332/tts-server-android/actions/workflows/test.yml) + +![GitHub release](https://img.shields.io/github/downloads/jing332/tts-server-android/total) ![GitHub release (latest by date)](https://img.shields.io/github/downloads/jing332/tts-server-android/latest/total) -# 介绍 -这是 [tts-server-go](https://github.com/jing332/tts-server-go) 的Android版本。使用Kotlin开发,通过 [Gomobile](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile) 将Go编译为Lib以供Android APP调用. 关于本项目的Go Lib,见 [tts-server-lib](./tts-server-lib). +# TTS Server [![](https://img.shields.io/badge/Q%E7%BE%A4-124841768-blue)](https://jq.qq.com/?_wv=1027&k=y7WCDjEA) + +本APP起初为阅读APP的网络朗读所用,在原有基础上,现已支持: +* 内置微软接口(Edge大声朗读、~~Azure演示API~~(已猝) ),可自定义HTTP请求,可导入其他本地TTS引擎,以及根据中文双引号的简单旁白/对话识别朗读 + ,还有自动重试,备用配置,文本替换等更多功能。
点击展开查看截图 - - ![Screenshot_20220917-102952972](https://user-images.githubusercontent.com/42014615/190837053-24550576-fddf-49a8-b3b6-f99743fe4f27.jpg) - + + + + + + +
+ +# Download + +* [Stable - 稳定版(Releases)](https://github.com/jing332/tts-server-android/releases) + +* [Dev - 开发版(Actions 需登陆Github账户)](https://github.com/jing332/tts-server-android/actions) + +## Actions mirror + +app: https://jing332.lanzn.com/b09jpjd2d + +dev: https://jing332.lanzn.com/b09ig9qla + +密码Password: 1234 + + +# JS + +#### 朗读规则 + +程序已内置旁白对话规则,通过 朗读规则管理 -> 加号 添加。 + +由用户制作的朗读规则: + +1. 可识别角色名的旁白对话规则: + 打开[此链接](https://www.gitlink.org.cn/geek/src/tree/master/ttsrv-speechRules-multiVoice.json), + 复制全部内容到剪贴板,然后在规则管理界面导入。 + +2. 5种语言检测: 复制 [此链接](https://jt12.de/SYV2_1/2023/04/16/10/08/08/1681610888643b588876c09.json), + 规则管理界面选择网络链接导入。 + +##### Small Example Js Rule: +```javascript +let SpeechRuleJS = { + name: "Fesgheli" , + id: "ir.masoudsoft.ttsfarsi.rr.fesgheli", + author: "Masoud Azizi", + telegram: "@ttsfarsi", + version: 1, + tags: {en: "English", fa: "Farsi"}, + + handleText(text) { + return text.split(/([a-zA-Z]+)/).map(part => ({ + text: part, + tag: /[a-zA-Z]+/.test(part) ? "en" : "fa" + })); + }, +}; +``` + +#### TTS插件 + +程序已内置Azure官方接口的TTS插件: 插件管理 -> 右上角添加 -> 保存 -> 设置变量 -> 填入Key与Region即可 + +讯飞WebAPI插件:复制 [此链接](https://jt12.de/SYV2_1/2023/04/16/10/25/17/1681611917643b5c8d61313.json), +插件管理界面选择网络链接导入,随后设置变量 AppId, ApiKey, ApiSecret即可。 + +# Grateful + +
+ 开源项目 + +| Application | Microsoft TTS | +|---------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| [gedoor/legado](https://github.com/gedoor/legado) | [wxxxcxx/ms-ra-forwarder](https://github.com/wxxxcxx/ms-ra-forwarder) | +| [ag2s20150909/TTS](https://github.com/ag2s20150909/TTS) | [litcc/tts-server](https://github.com/litcc/tts-server) | +| [benjaminwan/ChineseTtsTflite](https://github.com/benjaminwan/ChineseTtsTflite) | [asters1/tts](https://github.com/asters1/tts) | +| [yellowgreatsun/MXTtsEngine](https://github.com/yellowgreatsun/MXTtsEngine) | +| [2dust/v2rayNG](https://github.com/2dust/v2rayNG) | + +| Library | Description | +|-----------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [dromara/hutool](https://github.com/dromara/hutool/) | 🍬A set of tools that keep Java sweet. | +| [LouisCAD/Splitties](https://github.com/LouisCAD/Splitties) | A collection of hand-crafted extensions for your Kotlin projects. | +| [getactivity/logcat](https://github.com/getactivity/logcat) | Android 日志打印框架,在手机上可以直接看到 Logcat 日志啦 | +| [rosuH/AndroidFilePicker](https://github.com/rosuH/AndroidFilePicker) | FilePicker is a small and fast file selector library that is constantly evolving with the goal of rapid integration, high customization, and configurability~ | +| [androidbroadcast/ViewBindingPropertyDelegate](https://github.com/androidbroadcast/ViewBindingPropertyDelegate) | Make work with Android View Binding simpler | +| [zhanghai/AndroidFastScroll](https://github.com/zhanghai/AndroidFastScroll) | Fast scroll for Android RecyclerView and more | +| [Rosemoe/sora-editor](https://github.com/Rosemoe/sora-editor) | sora-editor is a cool and optimized code editor on Android platform | +| [gedoor/rhino-android](https://github.com/gedoor/rhino-android) | Give access to RhinoScriptEngine from the JSR223 interfaces on Android JRE. | +| [liangjingkanji/BRV](https://github.com/liangjingkanji/BRV) | Android上最好的RecyclerView框架, 比 BRVAH 更简单强大 | +| [liangjingkanji/Net](https://github.com/liangjingkanji/Net) | Android最好的网络请求工具, 比 Retrofit/OkGo 更简单易用 | +| [chibatching/kotpref](https://github.com/chibatching/kotpref) | Android SharedPreferences delegation library for Kotlin | +| [google/ExoPlayer](https://github.com/google/ExoPlayer) | An extensible media player for Android | +| [material-components-android](https://github.com/material-components/material-components-android) | Modular and customizable Material Design UI components for Android | +| [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization/) | Kotlin multiplatform / multi-format serialization | +| [kotlinx.coroutine](https://github.com/Kotlin/kotlinx.coroutines) | Library support for Kotlin coroutines | +
+ +其他资源: + +* [阿里巴巴IconFont](https://www.iconfont.cn/) + +* [酷安@沉默_9520](http://www.coolapk.com/u/25956307) 本APP图标作者 + +# Build + +### Android Studio: +在项目根目录下新建文件 `local.properties` 并写入如下内容: +``` +KEY_PATH=E\:\\Android\\key\\sign.jks (签名文件) +KEY_PASSWORD= 密码 +ALIAS_NAME= 别名 +ALIAS_PASSWORD= 别名密码 +``` + + + +### Github Actions: +> 详见 https://www.cnblogs.com/jing332/p/17452492.html + +使用 Git Bash 对签名文件进行无换行Base64编码: `openssl base64 < key.jks | tr -d '\r\n' | tee key.jks.base64.txt` + +分别添加如下四个安全变量 (Repository secrets): +> 前往以下链接:https://github.com/你的用户名/tts-server-android/settings/secrets/actions +* `ALIAS_NAME` 别名 +* `ALIAS_PASSWORD` 别名密码 +* `KEY_PASSWORD` 密码 +* `KEY_STORE` 前面生成的 sign.jks.base64.txt 内容 diff --git a/app/build.gradle b/app/build.gradle index 454e3b866..f0c3db791 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,99 +1,286 @@ plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -// id 'kotlin-kapt' + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlinx-serialization") + id("kotlin-kapt") + id("kotlin-parcelize") + id("com.google.devtools.ksp") + id("com.mikepenz.aboutlibraries.plugin") } +static def buildTime() { + def t = new Date().time / 1000 + return (long) t +} + +static def releaseTime() { + return new Date().format("yy.MMddHH", TimeZone.getTimeZone("GMT+8")) +} + +def version = "1." + releaseTime() +def gitCommits = Integer.parseInt('git rev-list HEAD --count'.execute().text.trim()) + android { - compileSdk 33 + compileSdk 34 + namespace 'com.github.jing332.tts_server_android' defaultConfig { applicationId 'com.github.jing332.tts_server_android' minSdk 21 - targetSdk 33 - versionCode 2 - versionName "0.1_${releaseTime()}" + targetSdk 34 + versionCode gitCommits + versionName version + + ksp { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + arg("room.incremental", "true") + arg("room.expandProjection", "true") + } + + // 读取strings.xml所在文件夹 获得应用支持的语言 + tasks.register('buildTranslationArray') { + def defaultCode = "zh-CN" + def foundLocales = new StringBuilder() + foundLocales.append("new String[]{") + + fileTree("src/main/res").visit { details -> + if (details.file.path.endsWith("strings.xml")) { + def path = details.file.parent.replaceAll('\\\\', "/") + def languageCode = path.tokenize('/').last().replaceAll('values-', '').replaceAll('-r', '-') + languageCode = (languageCode == "values") ? defaultCode : languageCode; + foundLocales.append("\"").append(languageCode).append("\"").append(",") + } + } + foundLocales.append("}") + def foundLocalesString = foundLocales.toString().replaceAll(',}', '}') + buildConfigField "String[]", "TRANSLATION_ARRAY", foundLocalesString + } + preBuild.dependsOn buildTranslationArray + buildConfigField "long", "BUILD_TIME", buildTime().toString() testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - signingConfigs{ - release{ - v1SigningEnabled true - v2SigningEnabled true - enableV3Signing = true - enableV4Signing = true + signingConfigs { + release { //签名文件 从local.properties取值 - Properties pro = new Properties() + Properties pro = new Properties() InputStream input = project.rootProject.file("local.properties").newDataInputStream() pro.load(input) storeFile file(pro.getProperty("KEY_PATH")) storePassword pro.getProperty("KEY_PASSWORD") keyAlias pro.getProperty("ALIAS_NAME") keyPassword pro.getProperty("ALIAS_PASSWORD") - } } buildTypes { release { - //签名apk - signingConfig signingConfigs.release + signingConfig signingConfigs.release minifyEnabled true - //资源缩减 -// shrinkResources true - // Zipalign优化 - zipAlignEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } + debug { + signingConfig signingConfigs.release + applicationIdSuffix ".debug" + versionNameSuffix "_debug" + splits.abi.enable = false + splits.density.enable = false + } + } + packagingOptions { + resources { + excludes += ['META-INF/INDEX.LIST', 'META-INF/*.md'] + } + } + + + // 分别打包APK 原版 和 dev共存版 + flavorDimensions += "version" + productFlavors { + app { + dimension = "version" + } + dev { + dimension = "version" + applicationIdSuffix ".dev" + } } + splits { + abi { + enable true + reset() + include 'x86_64', 'x86', 'armeabi-v7a', 'arm64-v8a' + universalApk true + } + } compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 -// sourceCompatibility JavaVersion.VERSION_1_8 -// targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '11' + jvmTarget = '17' } - android.applicationVariants.all { - variant -> - variant.outputs.all {//修改apk文件名 - outputFileName = "TTS-Server-v${variant.versionName}.apk" + kotlin { + jvmToolchain(17) + + /*sourceSets.all { + languageSettings { + languageVersion = "2.0" } + }*/ + } + + // 修改apk文件名 + android.applicationVariants.all { variant -> + variant.outputs.all { output -> + //noinspection GrDeprecatedAPIUsage + def abiName = output.getFilter(com.android.build.OutputFile.ABI) + if (abiName == null) + output.outputFileName = "TTS-Server-v${variant.versionName}.apk" + else + output.outputFileName = "TTS-Server-v${variant.versionName}_${abiName}.apk" + } + } + +// sourceSets { +// main { +// java { +// exclude 'tts_server_android/ui' +// } +// } +// } + + buildFeatures { + viewBinding true + dataBinding true + + compose true + buildConfig true + } + + composeOptions { + kotlinCompilerExtensionVersion = "$compose_compiler" } } dependencies { - coreLibraryDesugaring('com.android.tools:desugar_jdk_libs:1.1.6') + implementation 'androidx.activity:activity-ktx:1.8.2' + coreLibraryDesugaring('com.android.tools:desugar_jdk_libs:2.0.4') + implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs') - implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + //noinspection GradleDependency + implementation 'com.google.android.material:material:1.9.0-beta01' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0' + + def markwon_version = '4.6.2' + implementation "com.caverock:androidsvg:1.4" + implementation "io.noties.markwon:core:$markwon_version" + implementation "io.noties.markwon:image:$markwon_version" +// implementation "io.noties.markwon:html:$markwon_version" + implementation "io.noties.markwon:linkify:$markwon_version" + + // RecyclerView + implementation 'com.github.liangjingkanji:BRV:1.5.8' + implementation "androidx.recyclerview:recyclerview:1.3.2" + implementation 'me.zhanghai.android.fastscroll:library:1.2.0' + + // Code Editor + implementation 'io.github.Rosemoe.sora-editor:editor:0.21.1' + implementation 'io.github.Rosemoe.sora-editor:language-textmate:0.21.1' + + // Room + ksp("androidx.room:room-compiler:$room_version") + implementation("androidx.room:room-runtime:$room_version") + implementation("androidx.room:room-ktx:$room_version") + androidTestImplementation("androidx.room:room-testing:$room_version") + + // IO & NET + implementation 'com.squareup.okio:okio:3.3.0' + implementation 'com.squareup.okhttp3:okhttp:4.11.0' + implementation 'com.github.liangjingkanji:Net:3.6.4' + + implementation 'me.rosuh:AndroidFilePicker:1.0.1' + implementation 'com.louiscad.splitties:splitties-systemservices:3.0.0' + + implementation 'cn.hutool:hutool-crypto:5.8.19' + + implementation("com.hankcs:hanlp:portable-1.8.4") - //UI - implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + // Media + implementation("androidx.media3:media3-exoplayer:1.2.1") + implementation("androidx.media3:media3-ui:1.2.1") + // https://github.com/gyf-dev/ImmersionBar + implementation 'com.geyifeng.immersionbar:immersionbar:3.2.2' + implementation 'com.geyifeng.immersionbar:immersionbar-ktx:3.2.2' - implementation 'com.squareup.okhttp3:okhttp:4.10.0' -// implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") + // https://github.com/FunnySaltyFish/ComposeDataSaver + implementation "com.github.FunnySaltyFish.ComposeDataSaver:data-saver:v1.1.5" + implementation("com.mikepenz:aboutlibraries-core:${about_lib_version}") + implementation("com.mikepenz:aboutlibraries-compose:${about_lib_version}") -// kapt('com.squareup.moshi:moshi-kotlin-codegen:1.14.0') -// implementation('com.squareup.moshi:moshi:1.14.0') + def accompanistVersion = "0.33.0-alpha" + implementation("com.google.accompanist:accompanist-systemuicontroller:${accompanistVersion}") + implementation("com.google.accompanist:accompanist-navigation-animation:${accompanistVersion}") + implementation("com.google.accompanist:accompanist-webview:${accompanistVersion}") + implementation("com.google.accompanist:accompanist-permissions:${accompanistVersion}") -// implementation 'androidx.work:work-runtime-ktx:2.7.1' -// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9") + + + implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") + + implementation 'androidx.activity:activity-compose:1.8.2' + implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1") + implementation("androidx.navigation:navigation-compose:2.7.6") + + implementation("io.github.dokar3:sheets-m3:0.5.4") + + + + + def lifecycle_version = "2.7.0" + implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version") + implementation "androidx.lifecycle:lifecycle-livedata-ktx:${lifecycle_version}" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${lifecycle_version}" + +// def composeBom = platform('androidx.compose:compose-bom:2023.08.00') + def composeBom = platform("dev.chrisbanes.compose:compose-bom:2024.01.00-alpha01") + implementation composeBom + androidTestImplementation composeBom + + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.ui:ui") + + implementation 'androidx.compose.material:material-icons-core' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.compose.material3:material3-window-size-class' + + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + + // Logcat (Debug) +// debugImplementation 'com.github.getActivity:Logcat:11.2' testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.3' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } - -static def releaseTime() { - return new Date().format("yyyyMMddHHmm", TimeZone.getTimeZone("GMT+08:00")) -} \ No newline at end of file diff --git a/app/libs/rhino-1.7.13-1.jar b/app/libs/rhino-1.7.13-1.jar new file mode 100644 index 000000000..1f490006e Binary files /dev/null and b/app/libs/rhino-1.7.13-1.jar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index b7aca515c..075872eac 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,47 +1,31 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +-keepattributes SourceFile,LineNumberTable -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile - -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# Rhino +-keep class javax.script.** { *; } +-keep class com.sun.script.javascript.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class com.script.javascript.** { *; } + +# 插件相关 +-keep class com.github.jing332.tts_server_android.model.rhino.core.** { *; } +-keep class cn.hutool.crypto.** { *; } +-keep class com.hankcs.hanlp.** { *; } + +#-keep class cn.hutool.core.** { *; } + +-keepnames class * extends java.lang.Exception -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +# 判断SVG库是否存在 (io.noties.markwon.image.svg.SvgSupport) +-keepnames class com.caverock.androidsvg.SVG -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +# OKIO +-keep class okio.* { *; } + +# 保持 ViewBinding 实现类中的所有名称以 “inflate” 开头的方法不被混淆 +-keepclassmembers class * implements androidx.viewbinding.ViewBinding { + public static ** inflate(...); +} #-------------- 去掉所有打印 ------------- @@ -96,3 +80,235 @@ public *** println(...); public *** print(...); } + +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`. +# If you have any, uncomment and replace classes with those containing named companion objects. +#-keepattributes InnerClasses # Needed for `getDeclaredClasses`. +#-if @kotlinx.serialization.Serializable class +#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions. +#com.example.myapplication.HasNamedCompanion2 +#{ +# static **$* *; +#} +#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept. +# static <1>$$serializer INSTANCE; +#} + +-dontwarn com.bumptech.glide.Glide +-dontwarn com.bumptech.glide.RequestBuilder +-dontwarn com.bumptech.glide.RequestManager +-dontwarn com.bumptech.glide.request.BaseRequestOptions +-dontwarn com.bumptech.glide.request.target.ViewTarget +-dontwarn com.squareup.picasso.Picasso +-dontwarn com.squareup.picasso.RequestCreator +-dontwarn java.awt.AWTException +-dontwarn java.awt.AlphaComposite +-dontwarn java.awt.BasicStroke +-dontwarn java.awt.Color +-dontwarn java.awt.Composite +-dontwarn java.awt.Desktop +-dontwarn java.awt.Dimension +-dontwarn java.awt.Font +-dontwarn java.awt.FontFormatException +-dontwarn java.awt.FontMetrics +-dontwarn java.awt.Graphics2D +-dontwarn java.awt.Graphics +-dontwarn java.awt.GraphicsConfiguration +-dontwarn java.awt.GraphicsDevice +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.Image +-dontwarn java.awt.Point +-dontwarn java.awt.Rectangle +-dontwarn java.awt.RenderingHints$Key +-dontwarn java.awt.RenderingHints +-dontwarn java.awt.Robot +-dontwarn java.awt.Shape +-dontwarn java.awt.Stroke +-dontwarn java.awt.Toolkit +-dontwarn java.awt.color.ColorSpace +-dontwarn java.awt.datatransfer.Clipboard +-dontwarn java.awt.datatransfer.ClipboardOwner +-dontwarn java.awt.datatransfer.DataFlavor +-dontwarn java.awt.datatransfer.StringSelection +-dontwarn java.awt.datatransfer.Transferable +-dontwarn java.awt.datatransfer.UnsupportedFlavorException +-dontwarn java.awt.font.FontRenderContext +-dontwarn java.awt.geom.AffineTransform +-dontwarn java.awt.geom.Ellipse2D$Double +-dontwarn java.awt.geom.Rectangle2D +-dontwarn java.awt.geom.RoundRectangle2D$Double +-dontwarn java.awt.image.AffineTransformOp +-dontwarn java.awt.image.BufferedImage +-dontwarn java.awt.image.BufferedImageOp +-dontwarn java.awt.image.ColorConvertOp +-dontwarn java.awt.image.ColorModel +-dontwarn java.awt.image.CropImageFilter +-dontwarn java.awt.image.DataBuffer +-dontwarn java.awt.image.DataBufferByte +-dontwarn java.awt.image.DataBufferInt +-dontwarn java.awt.image.FilteredImageSource +-dontwarn java.awt.image.ImageFilter +-dontwarn java.awt.image.ImageObserver +-dontwarn java.awt.image.ImageProducer +-dontwarn java.awt.image.RenderedImage +-dontwarn java.awt.image.SampleModel +-dontwarn java.awt.image.WritableRaster +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.FeatureDescriptor +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor +-dontwarn java.beans.PropertyEditor +-dontwarn java.beans.PropertyEditorManager +-dontwarn java.beans.Transient +-dontwarn java.beans.XMLEncoder +-dontwarn java.lang.management.ManagementFactory +-dontwarn java.lang.management.RuntimeMXBean +-dontwarn javax.imageio.IIOImage +-dontwarn javax.imageio.ImageIO +-dontwarn javax.imageio.ImageReader +-dontwarn javax.imageio.ImageTypeSpecifier +-dontwarn javax.imageio.ImageWriteParam +-dontwarn javax.imageio.ImageWriter +-dontwarn javax.imageio.metadata.IIOMetadata +-dontwarn javax.imageio.stream.ImageInputStream +-dontwarn javax.imageio.stream.ImageOutputStream +-dontwarn javax.naming.InitialContext +-dontwarn javax.naming.NamingEnumeration +-dontwarn javax.naming.NamingException +-dontwarn javax.naming.directory.Attribute +-dontwarn javax.naming.directory.Attributes +-dontwarn javax.naming.directory.InitialDirContext +-dontwarn javax.swing.ImageIcon +-dontwarn javax.tools.DiagnosticCollector +-dontwarn javax.tools.DiagnosticListener +-dontwarn javax.tools.FileObject +-dontwarn javax.tools.ForwardingJavaFileManager +-dontwarn javax.tools.JavaCompiler$CompilationTask +-dontwarn javax.tools.JavaCompiler +-dontwarn javax.tools.JavaFileManager$Location +-dontwarn javax.tools.JavaFileManager +-dontwarn javax.tools.JavaFileObject$Kind +-dontwarn javax.tools.JavaFileObject +-dontwarn javax.tools.SimpleJavaFileObject +-dontwarn javax.tools.StandardJavaFileManager +-dontwarn javax.tools.StandardLocation +-dontwarn javax.tools.ToolProvider +-dontwarn javax.xml.bind.JAXBContext +-dontwarn javax.xml.bind.Marshaller +-dontwarn javax.xml.bind.Unmarshaller +-dontwarn org.bouncycastle.asn1.ASN1Encodable +-dontwarn org.bouncycastle.asn1.ASN1InputStream +-dontwarn org.bouncycastle.asn1.ASN1Object +-dontwarn org.bouncycastle.asn1.ASN1ObjectIdentifier +-dontwarn org.bouncycastle.asn1.ASN1Primitive +-dontwarn org.bouncycastle.asn1.ASN1Sequence +-dontwarn org.bouncycastle.asn1.BERSequence +-dontwarn org.bouncycastle.asn1.DERSequence +-dontwarn org.bouncycastle.asn1.DLSequence +-dontwarn org.bouncycastle.asn1.gm.GMNamedCurves +-dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo +-dontwarn org.bouncycastle.asn1.sec.ECPrivateKey +-dontwarn org.bouncycastle.asn1.util.ASN1Dump +-dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier +-dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +-dontwarn org.bouncycastle.asn1.x9.X9ECParameters +-dontwarn org.bouncycastle.asn1.x9.X9ObjectIdentifiers +-dontwarn org.bouncycastle.cert.X509CertificateHolder +-dontwarn org.bouncycastle.crypto.AlphabetMapper +-dontwarn org.bouncycastle.crypto.BlockCipher +-dontwarn org.bouncycastle.crypto.CipherParameters +-dontwarn org.bouncycastle.crypto.CryptoException +-dontwarn org.bouncycastle.crypto.Digest +-dontwarn org.bouncycastle.crypto.InvalidCipherTextException +-dontwarn org.bouncycastle.crypto.Mac +-dontwarn org.bouncycastle.crypto.digests.SM3Digest +-dontwarn org.bouncycastle.crypto.engines.SM2Engine$Mode +-dontwarn org.bouncycastle.crypto.engines.SM2Engine +-dontwarn org.bouncycastle.crypto.engines.SM4Engine +-dontwarn org.bouncycastle.crypto.macs.CBCBlockCipherMac +-dontwarn org.bouncycastle.crypto.macs.HMac +-dontwarn org.bouncycastle.crypto.params.AsymmetricKeyParameter +-dontwarn org.bouncycastle.crypto.params.ECDomainParameters +-dontwarn org.bouncycastle.crypto.params.ECPrivateKeyParameters +-dontwarn org.bouncycastle.crypto.params.ECPublicKeyParameters +-dontwarn org.bouncycastle.crypto.params.KeyParameter +-dontwarn org.bouncycastle.crypto.params.ParametersWithID +-dontwarn org.bouncycastle.crypto.params.ParametersWithIV +-dontwarn org.bouncycastle.crypto.params.ParametersWithRandom +-dontwarn org.bouncycastle.crypto.signers.DSAEncoding +-dontwarn org.bouncycastle.crypto.signers.PlainDSAEncoding +-dontwarn org.bouncycastle.crypto.signers.SM2Signer +-dontwarn org.bouncycastle.crypto.signers.StandardDSAEncoding +-dontwarn org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey +-dontwarn org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey +-dontwarn org.bouncycastle.jcajce.provider.asymmetric.util.EC5Util +-dontwarn org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil +-dontwarn org.bouncycastle.jcajce.spec.FPEParameterSpec +-dontwarn org.bouncycastle.jcajce.spec.OpenSSHPrivateKeySpec +-dontwarn org.bouncycastle.jcajce.spec.OpenSSHPublicKeySpec +-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider +-dontwarn org.bouncycastle.jce.spec.ECNamedCurveSpec +-dontwarn org.bouncycastle.jce.spec.ECParameterSpec +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.bouncycastle.math.ec.ECCurve +-dontwarn org.bouncycastle.math.ec.ECPoint +-dontwarn org.bouncycastle.math.ec.FixedPointCombMultiplier +-dontwarn org.bouncycastle.openssl.PEMDecryptorProvider +-dontwarn org.bouncycastle.openssl.PEMEncryptedKeyPair +-dontwarn org.bouncycastle.openssl.PEMException +-dontwarn org.bouncycastle.openssl.PEMKeyPair +-dontwarn org.bouncycastle.openssl.PEMParser +-dontwarn org.bouncycastle.openssl.X509TrustedCertificateBlock +-dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +-dontwarn org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder +-dontwarn org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder +-dontwarn org.bouncycastle.operator.InputDecryptorProvider +-dontwarn org.bouncycastle.operator.OperatorCreationException +-dontwarn org.bouncycastle.pkcs.PKCS10CertificationRequest +-dontwarn org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo +-dontwarn org.bouncycastle.pkcs.PKCSException +-dontwarn org.bouncycastle.util.Arrays +-dontwarn org.bouncycastle.util.BigIntegers +-dontwarn org.bouncycastle.util.encoders.Hex +-dontwarn org.bouncycastle.util.io.pem.PemObject +-dontwarn org.bouncycastle.util.io.pem.PemObjectGenerator +-dontwarn org.bouncycastle.util.io.pem.PemReader +-dontwarn org.bouncycastle.util.io.pem.PemWriter +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough +-dontwarn pl.droidsonroids.gif.GifDrawable \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/1.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/1.json new file mode 100644 index 000000000..89435780f --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/1.json @@ -0,0 +1,64 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "17b1a49245c8b41c456e06bbb7554b4c", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uiData` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `readAloudTarget` INTEGER NOT NULL, `msTtsProperty` TEXT, `httpTts` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiData", + "columnName": "uiData", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "msTtsProperty", + "columnName": "msTtsProperty", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "httpTts", + "columnName": "httpTts", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '17b1a49245c8b41c456e06bbb7554b4c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/10.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/10.json new file mode 100644 index 000000000..6fe062db1 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/10.json @@ -0,0 +1,263 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "5744e6d22a05829e0ca3956e8a0e2a3a", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5744e6d22a05829e0ca3956e8a0e2a3a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/11.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/11.json new file mode 100644 index 000000000..ee628bc40 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/11.json @@ -0,0 +1,270 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "5bcca51c0d3dd68d497ac129c7108864", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bcca51c0d3dd68d497ac129c7108864')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/12.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/12.json new file mode 100644 index 000000000..4fda9446d --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/12.json @@ -0,0 +1,270 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "5bcca51c0d3dd68d497ac129c7108864", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bcca51c0d3dd68d497ac129c7108864')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/13.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/13.json new file mode 100644 index 000000000..63164fbb8 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/13.json @@ -0,0 +1,270 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "5bcca51c0d3dd68d497ac129c7108864", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5bcca51c0d3dd68d497ac129c7108864')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/14.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/14.json new file mode 100644 index 000000000..9b16f87cb --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/14.json @@ -0,0 +1,277 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "37e1b6c08be1534864ab98afe56be74c", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37e1b6c08be1534864ab98afe56be74c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/15.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/15.json new file mode 100644 index 000000000..ab670236a --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/15.json @@ -0,0 +1,284 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "49bad10542127ac0a5d951d7f0f721fa", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '49bad10542127ac0a5d951d7f0f721fa')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/16.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/16.json new file mode 100644 index 000000000..e19d0c199 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/16.json @@ -0,0 +1,297 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "a41d35ae3e4adcc466d0bdf126b300f1", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a41d35ae3e4adcc466d0bdf126b300f1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/17.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/17.json new file mode 100644 index 000000000..f0b2ef6b5 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/17.json @@ -0,0 +1,367 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "d6b22ed6ea4951687d07aae4d63fe153", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd6b22ed6ea4951687d07aae4d63fe153')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/18.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/18.json new file mode 100644 index 000000000..344d87cb8 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/18.json @@ -0,0 +1,388 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "acf6946fd71ad7ebe884e2477066a1cf", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'acf6946fd71ad7ebe884e2477066a1cf')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/19.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/19.json new file mode 100644 index 000000000..e75ee4227 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/19.json @@ -0,0 +1,395 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "53e08c5e51449226ae36af316654bd77", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagName` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagName", + "columnName": "speechRule_tagName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '53e08c5e51449226ae36af316654bd77')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/2.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/2.json new file mode 100644 index 000000000..ebfbd026c --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/2.json @@ -0,0 +1,108 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "1af9d6ebe37f8debc406f7cfcb902e1a", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `displayName` TEXT, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1af9d6ebe37f8debc406f7cfcb902e1a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/20.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/20.json new file mode 100644 index 000000000..45a1a83d1 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/20.json @@ -0,0 +1,409 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "3b08d7569dae697e6efe84fd1413f2c5", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagName` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagName", + "columnName": "speechRule_tagName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `defVars` TEXT NOT NULL DEFAULT '{}', `userVars` TEXT NOT NULL DEFAULT '{}', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defVars", + "columnName": "defVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "userVars", + "columnName": "userVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b08d7569dae697e6efe84fd1413f2c5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/21.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/21.json new file mode 100644 index 000000000..2ba3990c6 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/21.json @@ -0,0 +1,409 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "3b08d7569dae697e6efe84fd1413f2c5", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagName` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagName", + "columnName": "speechRule_tagName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `defVars` TEXT NOT NULL DEFAULT '{}', `userVars` TEXT NOT NULL DEFAULT '{}', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defVars", + "columnName": "defVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "userVars", + "columnName": "userVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3b08d7569dae697e6efe84fd1413f2c5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/22.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/22.json new file mode 100644 index 000000000..452ee73e3 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/22.json @@ -0,0 +1,430 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "b4c51dd25c6bf4f625aebb151e6977c6", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagName` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagName", + "columnName": "speechRule_tagName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, `audioParams_speed` REAL NOT NULL DEFAULT 0.0, `audioParams_volume` REAL NOT NULL DEFAULT 0.0, `audioParams_pitch` REAL NOT NULL DEFAULT 0.0, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioParams.speed", + "columnName": "audioParams_speed", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "audioParams.volume", + "columnName": "audioParams_volume", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "audioParams.pitch", + "columnName": "audioParams_pitch", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `defVars` TEXT NOT NULL DEFAULT '{}', `userVars` TEXT NOT NULL DEFAULT '{}', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defVars", + "columnName": "defVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "userVars", + "columnName": "userVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4c51dd25c6bf4f625aebb151e6977c6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/23.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/23.json new file mode 100644 index 000000000..728b32587 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/23.json @@ -0,0 +1,437 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "cdd66e8dd75632e2933b30c9a90f77d6", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagName` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagName", + "columnName": "speechRule_tagName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, `audioParams_speed` REAL NOT NULL DEFAULT 0.0, `audioParams_volume` REAL NOT NULL DEFAULT 0.0, `audioParams_pitch` REAL NOT NULL DEFAULT 0.0, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioParams.speed", + "columnName": "audioParams_speed", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "audioParams.volume", + "columnName": "audioParams_volume", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "audioParams.pitch", + "columnName": "audioParams_pitch", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, `onExecution` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onExecution", + "columnName": "onExecution", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `defVars` TEXT NOT NULL DEFAULT '{}', `userVars` TEXT NOT NULL DEFAULT '{}', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defVars", + "columnName": "defVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "userVars", + "columnName": "userVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cdd66e8dd75632e2933b30c9a90f77d6')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/24.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/24.json new file mode 100644 index 000000000..48b9942ff --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/24.json @@ -0,0 +1,444 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "78fcaa494796c10ba20d2f89ecc40f05", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `tts` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `speechRule_target` INTEGER NOT NULL, `speechRule_isStandby` INTEGER NOT NULL, `speechRule_tag` TEXT NOT NULL DEFAULT '', `speechRule_tagRuleId` TEXT NOT NULL DEFAULT '', `speechRule_tagName` TEXT NOT NULL DEFAULT '', `speechRule_tagData` TEXT NOT NULL DEFAULT '', `speechRule_configId` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "speechRule.target", + "columnName": "speechRule_target", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.isStandby", + "columnName": "speechRule_isStandby", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speechRule.tag", + "columnName": "speechRule_tag", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagRuleId", + "columnName": "speechRule_tagRuleId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagName", + "columnName": "speechRule_tagName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.tagData", + "columnName": "speechRule_tagData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "speechRule.configId", + "columnName": "speechRule_configId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, `audioParams_speed` REAL NOT NULL DEFAULT 0.0, `audioParams_volume` REAL NOT NULL DEFAULT 0.0, `audioParams_pitch` REAL NOT NULL DEFAULT 0.0, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "audioParams.speed", + "columnName": "audioParams_speed", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "audioParams.volume", + "columnName": "audioParams_volume", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "audioParams.pitch", + "columnName": "audioParams_pitch", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `sampleText` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sampleText", + "columnName": "sampleText", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, `onExecution` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "onExecution", + "columnName": "onExecution", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `version` INTEGER NOT NULL DEFAULT 0, `name` TEXT NOT NULL, `pluginId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `defVars` TEXT NOT NULL DEFAULT '{}', `userVars` TEXT NOT NULL DEFAULT '{}', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defVars", + "columnName": "defVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "userVars", + "columnName": "userVars", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "speech_rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` INTEGER NOT NULL, `ruleId` TEXT NOT NULL, `author` TEXT NOT NULL, `code` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `tagsData` TEXT NOT NULL DEFAULT '', `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "tagsData", + "columnName": "tagsData", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '78fcaa494796c10ba20d2f89ecc40f05')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/3.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/3.json new file mode 100644 index 000000000..996a79371 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/3.json @@ -0,0 +1,108 @@ +{ + "formatVersion": 2, + "database": { + "version": 3, + "identityHash": "1af9d6ebe37f8debc406f7cfcb902e1a", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isEnabled` INTEGER NOT NULL, `displayName` TEXT, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1af9d6ebe37f8debc406f7cfcb902e1a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/4.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/4.json new file mode 100644 index 000000000..9e77492cc --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/4.json @@ -0,0 +1,147 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "1ff434b373036cebb5cdc5d67ab18b7a", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isExpanded` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ff434b373036cebb5cdc5d67ab18b7a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/5.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/5.json new file mode 100644 index 000000000..46ba14ed1 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/5.json @@ -0,0 +1,147 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "1ff434b373036cebb5cdc5d67ab18b7a", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isExpanded` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1ff434b373036cebb5cdc5d67ab18b7a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/6.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/6.json new file mode 100644 index 000000000..59a920469 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/6.json @@ -0,0 +1,154 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "499b92014ee3e6b853be6ab2729f75d7", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isExpanded` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '499b92014ee3e6b853be6ab2729f75d7')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/7.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/7.json new file mode 100644 index 000000000..e40a27f13 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/7.json @@ -0,0 +1,161 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "b7c0690e81519f7f69dc49b5b442da2e", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b7c0690e81519f7f69dc49b5b442da2e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/8.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/8.json new file mode 100644 index 000000000..233571aa5 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/8.json @@ -0,0 +1,168 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "4424315f39a367dfb9b638642ede84ce", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "groupId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4424315f39a367dfb9b638642ede84ce')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/9.json b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/9.json new file mode 100644 index 000000000..e1d8f81d7 --- /dev/null +++ b/app/schemas/com.github.jing332.tts_server_android.data.AppDatabase/9.json @@ -0,0 +1,213 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "30fa567a608c45f832845582ee80184e", + "entities": [ + { + "tableName": "sysTts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `displayName` TEXT, `isEnabled` INTEGER NOT NULL, `isStandby` INTEGER NOT NULL DEFAULT 0, `readAloudTarget` INTEGER NOT NULL, `tts` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStandby", + "columnName": "isStandby", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "readAloudTarget", + "columnName": "readAloudTarget", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tts", + "columnName": "tts", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SystemTtsGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`groupId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "groupId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL DEFAULT 1, `name` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `isRegex` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `replacement` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRegex", + "columnName": "isRegex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replacement", + "columnName": "replacement", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "replaceRuleGroup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `order` INTEGER NOT NULL, `isExpanded` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExpanded", + "columnName": "isExpanded", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '30fa567a608c45f832845582ee80184e')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/jing332/tts_server_android/DirectUploadEngineTest.kt b/app/src/androidTest/java/com/github/jing332/tts_server_android/DirectUploadEngineTest.kt new file mode 100644 index 000000000..7276c8176 --- /dev/null +++ b/app/src/androidTest/java/com/github/jing332/tts_server_android/DirectUploadEngineTest.kt @@ -0,0 +1,59 @@ +package com.github.jing332.tts_server_android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.jing332.tts_server_android.model.rhino.core.ext.JsExtensions +import com.github.jing332.tts_server_android.model.rhino.direct_link_upload.DirectUploadEngine +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DirectUploadEngineTest { + + @Test + fun catBox() { + val ext = JsExtensions(app, "") + + val form = mutableMapOf() + + form["reqtype"] = "fileupload" + form["fileToUpload"] = mutableMapOf().also { + it["fileName"] = "ccc.json" + it["body"] = """ {"1":"1", "2":"2"} """ + it["contentType"] = "application/json" + } +// form["file"] = mutableMapOf().apply { +// put("file", mutableMapOf().apply { +// put("fileToUpload", """ {"1":"1", "2":"2"} """) +// }) +// put("fileName", "config.json") +// put("contentType", "application/json") +// } + + val resp = ext.httpPostMultipart( + "https://catbox.moe/user/api.php", + form + ) + println(resp.body?.string()) + } + + @Test + fun testJS() { + val code = """ + let DirectUploadJS = { + "XX网盘(永久有效)": function(config){ + println("from js: " + config) + return {'url':'https://xxx.com/111.json', 'summary':'永久有效'} + }, + } + """.trimIndent() + val engine = DirectUploadEngine(context = app, code = code) + val list = engine.obtainFunctionList() + println(list) + list.forEach { + it.invoke("jsonsjosnsjkosnsojsn").apply { +// println(keys) + } + } + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/jing332/tts_server_android/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/github/jing332/tts_server_android/ExampleInstrumentedTest.kt deleted file mode 100644 index e4a5cb5e0..000000000 --- a/app/src/androidTest/java/com/github/jing332/tts_server_android/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.jing332.tts_server_android - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.github.jing332.testgomobile", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/jing332/tts_server_android/GoLibTest.kt b/app/src/androidTest/java/com/github/jing332/tts_server_android/GoLibTest.kt new file mode 100644 index 000000000..85f0c09a6 --- /dev/null +++ b/app/src/androidTest/java/com/github/jing332/tts_server_android/GoLibTest.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GoLibTest { + @Test + fun goLib() { + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/jing332/tts_server_android/HttpTtsUrlTest.kt b/app/src/androidTest/java/com/github/jing332/tts_server_android/HttpTtsUrlTest.kt new file mode 100644 index 000000000..fa6cf05d5 --- /dev/null +++ b/app/src/androidTest/java/com/github/jing332/tts_server_android/HttpTtsUrlTest.kt @@ -0,0 +1,32 @@ +package com.github.jing332.tts_server_android + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.model.AnalyzeUrl +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class HttpTtsUrlTest { + @Test + fun test() { + // Context of the app under test. +// val appContext = InstrumentationRegistry.getInstrumentation().targetContext + + // "http://tsn.baidu.com/text2audio,{\"method\": \"POST\", \"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=4114&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=220&vol=5&aue=6&pit=5&res_tag=audio\"}" + val url = + """ http://192.168.0.109:1233/api/ra ,{"method":"POST","body":"{{String(speakText).replace(/&/g, '&').replace(/\"/g, '"').replace(/'/g, ''').replace(//g, '>')}}"} """ + Log.e("TAG", url) + val a = AnalyzeUrl(url, speakText = "t\\\\est\\测\\\\试") + Log.e("TAG", "baseUrl: " + a.eval().toString()) + + println(AppConst.SCRIPT_ENGINE.eval(""" String("Test\\\\测试") """)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/jing332/tts_server_android/OkHttpTest.kt b/app/src/androidTest/java/com/github/jing332/tts_server_android/OkHttpTest.kt new file mode 100644 index 000000000..93bb6ab76 --- /dev/null +++ b/app/src/androidTest/java/com/github/jing332/tts_server_android/OkHttpTest.kt @@ -0,0 +1,32 @@ +package com.github.jing332.tts_server_android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.drake.net.Net +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OkHttpTest { + @Test + fun postMultiPart() { + val json = """ + {"11":"11", "22": "22"} + """.trimIndent() + val url = "http://v2.jt12.de/up-v2.php" + val resp: Response = Net.post(url) { + body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + "filename.json", + json.toRequestBody("text/javascript".toMediaType()) + ) + .build() + }.execute() + println("${resp.code} ${resp.message}: ${resp.body?.string()}") + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/jing332/tts_server_android/RhinoEngineTest.kt b/app/src/androidTest/java/com/github/jing332/tts_server_android/RhinoEngineTest.kt new file mode 100644 index 000000000..0b1e417be --- /dev/null +++ b/app/src/androidTest/java/com/github/jing332/tts_server_android/RhinoEngineTest.kt @@ -0,0 +1,28 @@ +package com.github.jing332.tts_server_android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import cn.hutool.crypto.symmetric.SymmetricCrypto +import com.script.javascript.RhinoScriptEngine +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.javascript.NativeObject + +@RunWith(AndroidJUnit4::class) +class RhinoEngineTest { + @Test + fun script() { + val jsCode = """ + + """.trimIndent() + + RhinoScriptEngine().apply { + val compiledScript = compile(jsCode) + compiledScript.eval() + println((get("tts") as NativeObject).get("name")) + } + +// PluginEngine().apply { +// println(runScript(jsCode, "测试文本", 1)) +// } + } +} \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 000000000..9e597a171 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + DB·TTS Server + \ No newline at end of file diff --git a/app/src/dev/res/values/strings.xml b/app/src/dev/res/values/strings.xml new file mode 100644 index 000000000..f00a46260 --- /dev/null +++ b/app/src/dev/res/values/strings.xml @@ -0,0 +1,4 @@ + + + D·TTS Server + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 96cdd9303..258dfcbf2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,50 +1,221 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + + + + + + - + + + android:theme="@style/Theme.TtsServer" + android:usesCleartextTraffic="true" + tools:ignore="UnusedAttribute"> + + + android:label="@string/replace_rule_manager" + android:windowSoftInputMode="adjustResize" /> + + + - - + + + + + + + + + - + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" + android:enabled="false" + android:exported="false"> + + + + + + + + + + + - - + - + - + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/defaultData/direct_link_upload.js b/app/src/main/assets/defaultData/direct_link_upload.js new file mode 100644 index 000000000..cf8678ac1 --- /dev/null +++ b/app/src/main/assets/defaultData/direct_link_upload.js @@ -0,0 +1,52 @@ +let DirectUploadJS = { + "橘途网盘 (永久有效)": function(config) { + let resp = upload('http://v2.jt12.de/up-v2.php', config) + let str = resp.body().string() + let result = JSON.parse(str) + if (result['code'] !== 0) { + throw "error: " + result['msg'] + } + + return result['msg'] + }, + + "喵公子 (有效期2天)": function(config) { + let url = 'https://sy.mgz6.cc/shuyuan' + let resp = upload(url, config) + let result = JSON.parse(resp.body().string()) + if (result['msg'] !== 'success') { + throw "error: " + result['msg'] + } + + return url + '/' + result['data'] + }, + + "Catbox (有效期未知)": function(config) { + let form = { + 'fileToUpload': { + 'body': config, + 'fileName': "config.json", + 'contentType': "application/json" + }, + 'reqtype': 'fileupload', + } + let resp = ttsrv.httpPostMultipart('https://catbox.moe/user/api.php', form) + if (resp.code() !== 200) { + throw 'error: HTTP-' + resp.code() + } + + return resp.body().string() + } +} + +function upload(url, config, extra) { + let form = { + "file":{ + 'body': config, + 'fileName': 'config.json', + 'contentType': 'application/json', + } + } + + return ttsrv.httpPostMultipart(url, form) +} diff --git a/app/src/main/assets/defaultData/list.json b/app/src/main/assets/defaultData/list.json new file mode 100644 index 000000000..6ad8d5895 --- /dev/null +++ b/app/src/main/assets/defaultData/list.json @@ -0,0 +1,56 @@ +[ + { + "group": { + "id": 12333, + "name": "示例-旁白对话BGM", + "isExpanded": true + }, + "list": [ + { + "id": 1690331046541, + "displayName": "⚠️请在右上角打开多语音!晓晓(zh-CN-XiaoxiaoNeural)", + "groupId": "12333", + "isEnabled": true, + "speechRule": { + "target": 4, + "tag": "dialogue", + "tagRuleId": "ttsrv.multi_voice" + }, + "tts": { + "#type": "internal" + } + }, + { + "id": 1690331074092, + "displayName": "云健(zh-CN-YunjianNeural)", + "groupId": "12333", + "isEnabled": true, + "speechRule": { + "target": 4, + "tag": "narration", + "tagRuleId": "ttsrv.multi_voice" + }, + "tts": { + "#type": "internal", + "voiceName": "zh-CN-YunjianNeural" + } + }, + { + "id": 1681521093149, + "displayName": "bgm", + "groupId": "12333", + "isEnabled": true, + "speechRule": { + "target": 3 + }, + "tts": { + "#type": "bgm", + "volume": 50, + "audioFormat": { + } + }, + "order": 3 + } + ] + } +] \ No newline at end of file diff --git a/app/src/main/assets/defaultData/plugin-azure.js b/app/src/main/assets/defaultData/plugin-azure.js new file mode 100644 index 000000000..967976fa9 --- /dev/null +++ b/app/src/main/assets/defaultData/plugin-azure.js @@ -0,0 +1,354 @@ +// 请点击保存后在 更多选项按钮(垂直三个点) -> 设置变量 中设置密钥和区域 +// Please set the key and region in "More options" -> "Variables" after clicking save. + +let key = ttsrv.userVars['key'] || 'Default_KEY' +let region = ttsrv.userVars['region'] || 'eastus' + +let format = "audio-24khz-48kbitrate-mono-mp3" +let sampleRate = 24000 // 对应24khz. 格式后带有opus的实际采样率是其2倍 +let isNeedDecode = true // 是否需要解码,如 format 为 raw 请设为 false + +let PluginJS = { + "name": "Azure", + "id": "com.microsoft.azure", + "author": "TTS Server", + "description": "", + "version": 3, + "vars": { // 声明变量,再由用户设置。 + key: {label: "密钥 Key"}, + region: {label: "区域 Region", hint: "为空时使用默认'eastus'"}, + }, + + "onLoad": function () { + checkKeyRegion() + }, + + "getAudio": function (text, locale, voice, rate, volume, pitch) { + rate = (rate * 2) - 100 + pitch = pitch - 50 + + let styleDegree = ttsrv.tts.data['styleDegree'] + if (!styleDegree || Number(styleDegree) < 0.01) { + styleDegree = '1.0' + } + + let style = ttsrv.tts.data['style'] + let role = ttsrv.tts.data['role'] + if (!style || style === "") { + style = 'general' + } + if (!role || role === "") { + role = 'default' + } + + let textSsml = '' + let langSkill = ttsrv.tts.data['languageSkill'] + if (langSkill === "" || langSkill == null) { + textSsml = escapeXml(text) + } else { + textSsml = `${escapeXml(text)}` + } + + let ssml = ` + + + + ${textSsml} + + + + ` + + return getAudioInternal(ssml, format) + }, +} + +function escapeXml(s) { + return s.replace(/'/g, ''').replace(/"/g, '"').replace(//g, '>').replace(/&/g, '&').replace(/\//g, '').replace(/\\/g, ''); +} + +function checkKeyRegion() { + key = (key + '').trim() + region = (region + '').trim() + if (key === '' || region === '') { + throw "请设置变量: 密钥Key与区域Region。 Please set the key and region." + } +} + +let ttsUrl = 'https://' + region + '.tts.speech.microsoft.com/cognitiveservices/v1' + +function getAudioInternal(ssml, format) { + let headers = { + 'Ocp-Apim-Subscription-Key': key, + "X-Microsoft-OutputFormat": format, + "Content-Type": "application/ssml+xml", + } + let resp = ttsrv.httpPost(ttsUrl, ssml, headers) + if (resp.code() !== 200) { + if (resp.code() === 401) { + throw "401 Unauthorized 未授权,请检查密钥与区域是否正确。" + }else if (resp.code() === 403) { + throw "403 Forbidden 被禁止,您的Azure账户可能已被禁用。" + } + + throw "音频获取失败: HTTP-" + resp.code() + } + + return resp.body().byteStream() +} + +// 全部voice数据 +let voices = {} +// 当前语言下的voice +let currentVoices = new Map() + +// 语言技能 二级语言 +let skillSpinner + +let styleSpinner +let roleSpinner +let seekStyle + +let EditorJS = { + //音频的采样率 编辑TTS界面保存时调用 + "getAudioSampleRate": function (locale, voice) { + return sampleRate + }, + + "isNeedDecode": function (locale, voice) { + return isNeedDecode + }, + + "getLocales": function () { + let locales = new Array() + + voices.forEach(function (v) { + let loc = v["Locale"] + if (!locales.includes(loc)) { + locales.push(loc) + } + }) + + return locales + }, + + // 当语言变更时调用 + "getVoices": function (locale) { + currentVoices = new Map() + voices.forEach(function (v) { + if (v['Locale'] === locale) { + currentVoices.set(v['ShortName'], v) + } + }) + + let mm = {} + for (let [key, value] of currentVoices.entries()) { + mm[key] = new java.lang.String(value['LocalName'] + ' (' + key + ')') + } + return mm + }, + + // 加载本地或网络数据,运行在IO线程。 + "onLoadData": function () { + // 获取数据并缓存以便复用 + let jsonStr = '' + if (ttsrv.fileExist('voices.json')) { + jsonStr = ttsrv.readTxtFile('voices.json') + } else { + checkKeyRegion() + let url = 'https://' + region + '.tts.speech.microsoft.com/cognitiveservices/voices/list' + let header = { + "Ocp-Apim-Subscription-Key": key, + "Content-Type": "application/json", + } + jsonStr = ttsrv.httpGetString(url, header) + + + ttsrv.writeTxtFile('voices.json', jsonStr) + } + + voices = JSON.parse(jsonStr) + }, + + "onLoadUI": function (ctx, linerLayout) { + let layout = new LinearLayout(ctx) + layout.orientation = LinearLayout.HORIZONTAL // 水平布局 + let params = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1) + + skillSpinner = JSpinner(ctx, "语言技能 (language skill)") + linerLayout.addView(skillSpinner) + ttsrv.setMargins(skillSpinner, 2, 4, 0, 0) + skillSpinner.setOnItemSelected(function (spinner, pos, item) { + ttsrv.tts.data['languageSkill'] = item.value + '' + }) + + styleSpinner = JSpinner(ctx, "风格 (style)") + styleSpinner.layoutParams = params + layout.addView(styleSpinner) + ttsrv.setMargins(styleSpinner, 2, 4, 0, 0) + styleSpinner.setOnItemSelected(function (spinner, pos, item) { + ttsrv.tts.data['style'] = item.value + // 默认 || value为空 || value空字符串 + if (pos === 0 || !item.value || item.value === "") { + seekStyle.visibility = View.GONE // 移除风格强度 + } else { + seekStyle.visibility = View.VISIBLE // 显示 + } + }) + + roleSpinner = JSpinner(ctx, "角色 (role)") + roleSpinner.layoutParams = params + layout.addView(roleSpinner) + ttsrv.setMargins(roleSpinner, 0, 4, 2, 0) + roleSpinner.setOnItemSelected(function (spinner, pos, item) { + ttsrv.tts.data['role'] = item.value + }) + linerLayout.addView(layout) + + seekStyle = JSeekBar(ctx, "风格强度 (Style degree):") + linerLayout.addView(seekStyle) + ttsrv.setMargins(seekStyle, 0, 4, 0, -4) + seekStyle.setFloatType(2) // 二位小数 + seekStyle.max = 200 //最大200个刻度 + + let styleDegree = Number(ttsrv.tts.data['styleDegree']) + if (!styleDegree || isNaN(styleDegree)) { + styleDegree = 1.0 + } + seekStyle.value = new java.lang.Float(styleDegree) + + seekStyle.setOnChangeListener( + { + // 开始时 + onStartTrackingTouch: function (seek) { + + }, + // 进度滑动更改时 + onProgressChanged: function (seek, progress, fromUser) { + + }, + // 停止时 + onStopTrackingTouch: function (seek) { + ttsrv.tts.data['styleDegree'] = Number(seek.value).toFixed(2) + }, + } + ) + }, + + "onVoiceChanged": function (locale, voiceCode) { + let vic = currentVoices.get(voiceCode) + + let locale2List = vic['SecondaryLocaleList'] + let locale2Items = [] + let locale2Pos = 0 + + if (locale2List) { + locale2Items.push(Item("默认 (default)", "")) + locale2List.map(function (v, i) { + let loc = java.util.Locale.forLanguageTag(v) + let name = loc.getDisplayName(loc) + locale2Items.push(Item(name, v)) + if (v === ttsrv.tts.data['languageSkill'] + '') { + locale2Pos = i + 1 + } + }) + } + skillSpinner.items = locale2Items + skillSpinner.selectedPosition = locale2Pos + + if (locale2Items.length === 0) { + skillSpinner.visibility = View.GONE + } else { + skillSpinner.visibility = View.VISIBLE + } + + let styles = vic['StyleList'] + let styleItems = [] + let stylePos = 0 + if (styles) { + styleItems.push(Item("默认 (general)", "")) + styles.map(function (v, i) { + styleItems.push(Item(getString(v), v)) + if (v === ttsrv.tts.data['style'] + '') { + stylePos = i + 1 //算上默认的item 所以要 +1 + } + }) + } else { + seekStyle.visibility = View.GONE + } + styleSpinner.items = styleItems + styleSpinner.selectedPosition = stylePos + + let roles = vic['RolePlayList'] + let roleItems = [] + let rolePos = 0 + if (roles) { + roleItems.push(Item("默认 (default)", "")) + roles.map(function (v, i) { + roleItems.push(Item(getString(v), v)) + if (v === ttsrv.tts.data['role'] + '') { + rolePos = i + 1 //算上默认的item 所以要 +1 + } + }) + } + roleSpinner.items = roleItems + roleSpinner.selectedPosition = rolePos + } +} + +let cnLocales = { + "narrator": "旁白", + "girl": "女孩", + "boy": "男孩", + "youngadultfemale": "年轻女性", + "youngadultmale": "年轻男性", + "olderadultfemale": "年长女性", + "olderadultmale": "年长男性", + "seniorfemale": "年老女性", + "seniormale": "年老男性", + + "advertisement_upbeat": "广告推销", + "affectionate": "亲切", + "angry": "生气", + "assistant": "数字助理", + "calm": "平静", + "chat": "闲聊", + "cheerful": "愉快", + "customerservice": "客户服务", + "depressed": "沮丧", + "disgruntled": "不满", + "documentary-narration": "纪录片", + "embarrassed": "尴尬", + "empathetic": "同情", + "envious": "嫉妒", + "excited": "兴奋", + "fearful": "恐惧", + "friendly": "友好", + "gentle": "温柔", + "hopeful": "希望", + "lyrical": "抒情", + "narration-professional": "专业", + "narration-relaxed": "轻松", + "newscast": "新闻", + "newscast-casual": "新闻-休闲", + "newscast-formal": "新闻-正式", + "poetry-reading": "诗歌朗诵", + "sad": "悲伤", + "serious": "严肃", + "shouting": "喊叫", + "sports_commentary": "体育", + "sports_commentary_excited": "体育-兴奋", + "whispering": "耳语", + "terrified": "恐惧", + "unfriendly": "不友好", +} + +let isZh = java.util.Locale.getDefault().getLanguage() == 'zh' + +function getString(key) { + if (isZh) { + return cnLocales[key.toLowerCase()] || key + } else { + return key + } +} \ No newline at end of file diff --git a/app/src/main/assets/defaultData/speech_rule.js b/app/src/main/assets/defaultData/speech_rule.js new file mode 100644 index 000000000..3fd088e27 --- /dev/null +++ b/app/src/main/assets/defaultData/speech_rule.js @@ -0,0 +1,52 @@ +let SpeechRuleJS = { + name: "旁白/对话", + id: "ttsrv.multi_voice", + author: "TTS Server", + version: 4, + tags: {narration: "旁白", dialogue: "对话"}, + + handleText(text) { + const list = []; + let tmpStr = ""; + let endTag = "narration"; + + text.split("").forEach((char, index) => { + tmpStr += char; + + if (char === '“') { + endTag = "dialogue"; + list.push({text: tmpStr, tag: "narration"}); + tmpStr = ""; + } else if (char === '”') { + endTag = "narration"; + tmpStr = tmpStr.slice(0, -1) + list.push({text: tmpStr, tag: "dialogue"}); + tmpStr = ""; + } else if (index === text.length - 1) { + list.push({text: tmpStr, tag: endTag}); + } + }); + + return list; + }, + + splitText(text) { + let separatorStr = "。??!!;;" + + let list = [] + let tmpStr = "" + text.split("").forEach((char, index) => { + tmpStr += char + + if (separatorStr.includes(char)) { + list.push(tmpStr) + tmpStr = "" + } else if (index === text.length - 1) { + list.push(tmpStr); + } + }) + + return list.filter(item => item.replace(/[“”]/g, '').trim().length > 0); + } + +}; diff --git a/app/src/main/assets/help/app.md b/app/src/main/assets/help/app.md new file mode 100644 index 000000000..d50b7fe32 --- /dev/null +++ b/app/src/main/assets/help/app.md @@ -0,0 +1,64 @@ +version-1 + +### 此帮助文档可在左侧滑菜单打开。 + +[![Q群](https://img.shields.io/badge/Q%E7%BE%A4-124841768-blue.svg)](https://jq.qq.com/?_wv=1027&k=y7WCDjEA) +[![Issue](https://img.shields.io/badge/Github-Issue-greeb.svg)](https://github.com/jing332/tts-server-android/issues) +[![Dev](https://img.shields.io/github/actions/workflow/status/jing332/tts-server-android/test.yml?label=%E5%BC%80%E5%8F%91%E7%89%88)](https://github.com/jing332/tts-server-android/actions/workflows/test.yml) + +# TTS Server +本应用有3个独立功能,通过左侧滑菜单进行切换。 + + +## 1️⃣ 系统TTS +以下4个界面,在右上角更多选项中都有独立的导入、导出功能。您还可在设置中进行全部备份、恢复等操作。 + +### 主界面 +配置列表,用于管理TTS配置,您可使用分组功能进行一键切换多个配置。 +- 可在设置中调换 `编辑`与`试听` 按钮的位置 ( 长按编辑按钮进行试听,反之,长按试听按钮进行编辑 ) + +### 朗读规则 +用于处理朗读文本,根据用户配置的标签进行匹配TTS配置(如:旁白/对话)。 +程序已内置 基于中文的双引号的 `旁白对话` 朗读规则,您可直接进行使用。 + +### 插件 +用于扩展TTS功能,使用JS脚本进行调用互联网的上的TTS接口,如:内置的`Azure插件`。 + +### 替换规则 +用于替换朗读文本进行纠正发音等操作,如:将“你好”替换为“您好” + +高级示例: +- 将字数5以内的对话的双引号替换为【】,以达到旁白朗读的目的。 +``` +(启用正则表达式) +替换规则:(“)(.{1,5})(”) +替换为:【$2】 +``` + +## 👨‍🏫 系统TTS 常见问题 +### 1. 锁屏后一段时间朗读突然停止? +> 在 `系统设置->应用->电池优化` 中将本APP与阅读APP加入电池优化白名单。 +> +> 对于本APP,您可在左侧滑菜单中单击 `电池优化白名单` 进行快捷设置。 +> +> PS: 对于国内系统,您可能还需对后台任务上锁,启用后台权限等操作。 + +### 2. 段落间隔时间长? +> 一般是由于网络延迟原因,因为 安卓系统TTS 服务的技术限制,导致无法预缓存音频,故每次只能同步获取。 + +### 3. 启动朗读时提示 `⚠️ 缺少{朗读全部},...` ? +> 添加一个`朗读全部`类型的TTS配置并启用。或尝试开启多语音选项使用 `标签`配置 + +### 4. 启动朗读时提示 `⚠️无标签配置,...!` +> 添加一个 `标签` 类型的TTS配置并启用。或尝试关闭多语音选项使用 `朗读全部` 。 + + +## 2️⃣ 系统TTS转发器 +用于将安卓系统TTS转为HTTP网络接口形式,便于在网页调用。 + +## 3️⃣ 微软TTS转发器 +用于将Edge大声朗读接口简化为HTTP网络接口形式,便于`开源阅读`进行调用。 + +这也是`TTS Server`名称的来源: + +早期,我将 tts-server-go 移植到安卓,即得名 tts-server-android (Github项目名) \ No newline at end of file diff --git a/app/src/main/assets/textmate/abyss.json b/app/src/main/assets/textmate/abyss.json new file mode 100644 index 000000000..24ae2408e --- /dev/null +++ b/app/src/main/assets/textmate/abyss.json @@ -0,0 +1,223 @@ +{ + "name": "Abyss", + "settings": [{ + "settings": { + "background": "#000c18", + "caret": "#ddbb88", + "foreground": "#6688cc", + "invisibles": "#002040", + "lineHighlight": "#082050", + "selection": "#770811", + "guide": "#002952" + } + }, { + "scope": ["meta.embedded", "source.groovy.embedded"], + "settings": { + "foreground": "#6688cc" + } + }, { + "name": "Comment", + "scope": "comment", + "settings": { + "foreground": "#384887" + } + }, { + "name": "String", + "scope": "string", + "settings": { + "foreground": "#22aa44" + } + }, { + "name": "Number", + "scope": "constant.numeric", + "settings": { + "foreground": "#f280d0" + } + }, { + "name": "Built-in constant", + "scope": "constant.language", + "settings": { + "foreground": "#f280d0" + } + }, { + "name": "User-defined constant", + "scope": ["constant.character", "constant.other"], + "settings": { + "foreground": "#f280d0" + } + }, { + "name": "Variable", + "scope": "variable", + "settings": { + "fontStyle": "" + } + }, { + "name": "Keyword", + "scope": "keyword", + "settings": { + "foreground": "#225588" + } + }, { + "name": "Storage", + "scope": "storage", + "settings": { + "fontStyle": "", + "foreground": "#225588" + } + }, { + "name": "Storage type", + "scope": "storage.type", + "settings": { + "fontStyle": "italic", + "foreground": "#9966b8" + } + }, { + "name": "Class name", + "scope": ["entity.name.class", "entity.name.type", "entity.name.namespace", "entity.name.scope-resolution"], + "settings": { + "fontStyle": "underline", + "foreground": "#ffeebb" + } + }, { + "name": "Inherited class", + "scope": "entity.other.inherited-class", + "settings": { + "fontStyle": "italic underline", + "foreground": "#ddbb88" + } + }, { + "name": "Function name", + "scope": "entity.name.function", + "settings": { + "fontStyle": "", + "foreground": "#ddbb88" + } + }, { + "name": "Function argument", + "scope": "variable.parameter", + "settings": { + "fontStyle": "italic", + "foreground": "#2277ff" + } + }, { + "name": "Tag name", + "scope": "entity.name.tag", + "settings": { + "fontStyle": "", + "foreground": "#225588" + } + }, { + "name": "Tag attribute", + "scope": "entity.other.attribute-name", + "settings": { + "fontStyle": "", + "foreground": "#ddbb88" + } + }, { + "name": "Library function", + "scope": "support.function", + "settings": { + "fontStyle": "", + "foreground": "#9966b8" + } + }, { + "name": "Library constant", + "scope": "support.constant", + "settings": { + "fontStyle": "", + "foreground": "#9966b8" + } + }, { + "name": "Library class/type", + "scope": ["support.type", "support.class"], + "settings": { + "fontStyle": "italic", + "foreground": "#9966b8" + } + }, { + "name": "Library variable", + "scope": "support.other.variable", + "settings": { + "fontStyle": "" + } + }, { + "name": "Invalid", + "scope": "invalid", + "settings": { + "fontStyle": "", + "foreground": "#A22D44" + } + }, { + "name": "Invalid deprecated", + "scope": "invalid.deprecated", + "settings": { + "foreground": "#A22D44" + } + }, { + "name": "diff: header", + "scope": ["meta.diff", "meta.diff.header"], + "settings": { + "fontStyle": "italic", + "foreground": "#E0EDDD" + } + }, { + "name": "diff: deleted", + "scope": "markup.deleted", + "settings": { + "fontStyle": "", + "foreground": "#dc322f" + } + }, { + "name": "diff: changed", + "scope": "markup.changed", + "settings": { + "fontStyle": "", + "foreground": "#cb4b16" + } + }, { + "name": "diff: inserted", + "scope": "markup.inserted", + "settings": { + "foreground": "#219186" + } + }, { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#22aa44" + } + }, { + "name": "Markup Styling", + "scope": ["markup.bold", "markup.italic"], + "settings": { + "foreground": "#22aa44" + } + }, { + "name": "Markup: Strong", + "scope": "markup.bold", + "settings": { + "fontStyle": "bold" + } + }, { + "name": "Markup: Emphasis", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, { + "name": "Markup Inline", + "scope": "markup.inline.raw", + "settings": { + "fontStyle": "", + "foreground": "#9966b8" + } + }, { + "name": "Markup Headings", + "scope": ["markup.heading", "markup.heading.setext"], + "settings": { + "fontStyle": "bold", + "foreground": "#6688cc" + } + }] + +} \ No newline at end of file diff --git a/app/src/main/assets/textmate/darcula.json b/app/src/main/assets/textmate/darcula.json new file mode 100644 index 000000000..7c07f8d1d --- /dev/null +++ b/app/src/main/assets/textmate/darcula.json @@ -0,0 +1,465 @@ +{ + "name": "darcula", + "settings": [{ + "settings": { + "background": "#242424", + "foreground": "#cccccc", + "lineHighlight": "#2B2B2B", + "selection": "#214283", + "highlightedDelimetersForeground": "#57f6c0" + } + }, + { + "name": "Comment", + "scope": "comment", + "settings": { + "foreground": "#707070" + } + }, + { + "name": "Operator Keywords", + "scope": "keyword.operator,keyword.operator.logical,keyword.operator.relational,keyword.operator.assignment,keyword.operator.comparison,keyword.operator.ternary,keyword.operator.arithmetic,keyword.operator.spread", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Strings", + "scope": "string,string.character.escape,string.template.quoted,string.template.quoted.punctuation,string.template.quoted.punctuation.single,string.template.quoted.punctuation.double,string.type.declaration.annotation,string.template.quoted.punctuation.tag", + "settings": { + "foreground": "#6A8759" + } + }, + { + "name": "String Interpolation Begin and End", + "scope": "punctuation.definition.template-expression.begin,punctuation.definition.template-expression.end", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "String Interpolation Body", + "scope": "expression.string,meta.template.expression", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Number", + "scope": "constant.numeric", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "Built-in constant", + "scope": "constant.language,variable.language", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "User-defined constant", + "scope": "constant.character, constant.other", + "settings": { + "foreground": "#9E7BB0" + } + }, + { + "name": "Keyword", + "scope": "keyword,keyword.operator.new,keyword.operator.delete,keyword.operator.static,keyword.operator.this,keyword.operator.expression", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Types, Class Types", + "scope": "entity.name.type,meta.return.type,meta.type.annotation,meta.type.parameters,support.type.primitive", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "Storage type", + "scope": "storage,storage.type,storage.modifier,storage.arrow", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Class constructor", + "scope": "class.instance.constructor,new.expr entity.name.type", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "Function", + "scope": "support.function, entity.name.function", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "Function Types", + "scope": "annotation.meta.ts, annotation.meta.tsx", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Function Argument", + "scope": "variable.parameter, operator.rest.parameters", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Variable, Property", + "scope": "variable.property,variable.other.property,variable.other.object.property,variable.object.property,support.variable.property", + "settings": { + "foreground": "#9E7BB0" + } + }, + { + "name": "Module Name", + "scope": "quote.module", + "settings": { + "foreground": "#6A8759" + } + }, + { + "name": "Markup Headings", + "scope": "markup.heading", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Tag name", + "scope": "punctuation.definition.tag.html, punctuation.definition.tag.begin, punctuation.definition.tag.end, entity.name.tag", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "Tag attribute", + "scope": "entity.other.attribute-name", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Object Keys", + "scope": "meta.object-literal.key", + "settings": { + "foreground": "#9E7BB0" + } + }, + { + "name": "TypeScript Class Modifiers", + "scope": "storage.modifier.ts", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "TypeScript Type Casting", + "scope": "ts.cast.expr,ts.meta.entity.class.method.new.expr.cast,ts.meta.entity.type.name.new.expr.cast,ts.meta.entity.type.name.var-single-variable.annotation,tsx.cast.expr,tsx.meta.entity.class.method.new.expr.cast,tsx.meta.entity.type.name.new.expr.cast,tsx.meta.entity.type.name.var-single-variable.annotation", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "TypeScript Type Declaration", + "scope": "ts.meta.type.support,ts.meta.type.entity.name,ts.meta.class.inherited-class,tsx.meta.type.support,tsx.meta.type.entity.name,tsx.meta.class.inherited-class,type-declaration,enum-declaration", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "TypeScript Method Declaration", + "scope": "function-declaration,method-declaration,method-overload-declaration,type-fn-type-parameters", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "Documentation Block", + "scope": "comment.block.documentation", + "settings": { + "foreground": "#6A8759" + } + }, + { + "name": "Documentation Highlight (JSDoc)", + "scope": "storage.type.class.jsdoc", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Import-Export-All (*) Keyword", + "scope": "constant.language.import-export-all", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Object Key Seperator", + "scope": "objectliteral.key.separator, punctuation.separator.key-value", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Regex", + "scope": "regex", + "settings": { + "fontStyle": " italic" + } + }, + { + "name": "Typescript Namespace", + "scope": "ts.meta.entity.name.namespace,tsx.meta.entity.name.namespace", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Regex Character-class", + "scope": "regex.character-class", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Class Name", + "scope": "entity.name.type.class", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Class Inheritances", + "scope": "entity.other.inherited-class", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "Documentation Entity", + "scope": "entity.name.type.instance.jsdoc", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "YAML entity", + "scope": "yaml.entity.name,yaml.string.entity.name", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "YAML string value", + "scope": "yaml.string.out", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Ignored (Exceptions Rules)", + "scope": "meta.brace.square.ts,block.support.module,block.support.type.module,block.support.function.variable,punctuation.definition.typeparameters.begin,punctuation.definition.typeparameters.end", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Regex", + "scope": "string.regexp", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Regex Group/Set", + "scope": "punctuation.definition.group.regexp,punctuation.definition.character-class.regexp", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "Regex Character Class", + "scope": "constant.other.character-class.regexp, constant.character.escape.ts", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Regex Or Operator", + "scope": "expr.regex.or.operator", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Tag string", + "scope": "string.template.tag,string.template.punctuation.tag,string.quoted.punctuation.tag,string.quoted.embedded.tag, string.quoted.double.tag", + "settings": { + "foreground": "#6A8759" + } + }, + { + "name": "Tag function parenthesis", + "scope": "tag.punctuation.begin.arrow.parameters.embedded,tag.punctuation.end.arrow.parameters.embedded", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "Object-literal key class", + "scope": "object-literal.object.member.key.field.other,object-literal.object.member.key.accessor,object-literal.object.member.key.array.brace.square", + "settings": { + "foreground": "#CCCCCC" + } + }, + { + "name": "CSS Property-value", + "scope": "property-list.property-value,property-list.constant", + "settings": { + "foreground": "#A5C261" + } + }, + { + "name": "CSS Property variable", + "scope": "support.type.property-name.variable.css,support.type.property-name.variable.scss,variable.scss", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "CSS Property entity", + "scope": "entity.other.attribute-name.class.css,entity.other.attribute-name.class.scss,entity.other.attribute-name.parent-selector-suffix.css,entity.other.attribute-name.parent-selector-suffix.scss", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "CSS Property-value", + "scope": "property-list.property-value.rgb-value, keyword.other.unit.css,keyword.other.unit.scss", + "settings": { + "foreground": "#7A9EC2" + } + }, + { + "name": "CSS Property-value function", + "scope": "property-list.property-value.function", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "CSS constant variables", + "scope": "support.constant.property-value.css,support.constant.property-value.scss", + "settings": { + "foreground": "#A5C261" + } + }, + { + "name": "CSS Tag", + "scope": "css.entity.name.tag,scss.entity.name.tag", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "CSS ID, Selector", + "scope": "meta.selector.css, entity.attribute-name.id, entity.other.attribute-name.pseudo-class.css,entity.other.attribute-name.pseudo-element.css", + "settings": { + "foreground": "#FFC66D" + } + }, + { + "name": "CSS Keyword", + "scope": "keyword.scss,keyword.css", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Triple-slash Directive Tag", + "scope": "triple-slash.tag", + "settings": { + "foreground": "#CCCCCC", + "fontStyle": "italic" + } + }, + { + "scope": "token.info-token", + "settings": { + "foreground": "#6796e6" + } + }, + { + "scope": "token.warn-token", + "settings": { + "foreground": "#cd9731" + } + }, + { + "scope": "token.error-token", + "settings": { + "foreground": "#f44747" + } + }, + { + "scope": "token.debug-token", + "settings": { + "foreground": "#b267e6" + } + }, + { + "name": "Python operators", + "scope": "keyword.operator.logical.python", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "Dart class type", + "scope": "support.class.dart", + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "PHP variables", + "scope": ["variable.language.php", "variable.other.php"], + "settings": { + "foreground": "#9E7BB0" + } + }, + { + "name": "Perl specific", + "scope": ["variable.other.readwrite.perl"], + "settings": { + "foreground": "#9E7BB0" + } + }, + { + "name": "PHP variables", + "scope": ["variable.other.property.php"], + "settings": { + "foreground": "#CC8242" + } + }, + { + "name": "PHP variables", + "scope": ["support.variable.property.php"], + "settings": { + "foreground": "#FFC66D" + } + } + ] +} diff --git a/app/src/main/assets/textmate/javascript/language-configuration.json b/app/src/main/assets/textmate/javascript/language-configuration.json new file mode 100644 index 000000000..acccdad11 --- /dev/null +++ b/app/src/main/assets/textmate/javascript/language-configuration.json @@ -0,0 +1,188 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": [ + "/*", + "*/" + ] + }, + "brackets": [ + [ + "${", + "}" + ], + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "'", + "close": "'", + "notIn": [ + "string", + "comment" + ] + }, + { + "open": "\"", + "close": "\"", + "notIn": [ + "string" + ] + }, + { + "open": "`", + "close": "`", + "notIn": [ + "string", + "comment" + ] + }, + { + "open": "/**", + "close": " */", + "notIn": [ + "string" + ] + } + ], + "surroundingPairs": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ], + [ + "'", + "'" + ], + [ + "\"", + "\"" + ], + [ + "`", + "`" + ], + [ + "<", + ">" + ] + ], + "autoCloseBefore": ";:.,=}])>` \n\t", + "folding": { + "markers": { + "start": "^\\s*//\\s*#?region\\b", + "end": "^\\s*//\\s*#?endregion\\b" + } + }, + "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\%\\^\\&\\*\\(\\)\\-\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:\\'\\\"\\,\\.\\<\\>/\\?\\s]+)", + "indentationRules": { + "decreaseIndentPattern": { + "pattern": "^((?!.*?/\\*).*\\*\/)?\\s*[\\}\\]].*$" + }, + "increaseIndentPattern": { + "pattern": "^((?!//).)*(\\{([^}\"'`/]*|(\\t|[ ])*//.*)|\\([^)\"'`/]*|\\[[^\\]\"'`/]*)$" + }, + + "unIndentedLinePattern": { + "pattern": "^(\\t|[ ])*[ ]\\*[^/]*\\*/\\s*$|^(\\t|[ ])*[ ]\\*/\\s*$|^(\\t|[ ])*[ ]\\*([ ]([^\\*]|\\*(?!/))*)?$" + } + }, + "onEnterRules": [ + { + "beforeText": { + "pattern": "^\\s*/\\*\\*(?!/)([^\\*]|\\*(?!/))*$" + }, + "afterText": { + "pattern": "^\\s*\\*/$" + }, + "action": { + "indent": "indentOutdent", + "appendText": " * " + } + }, + { + + "beforeText": { + "pattern": "^\\s*/\\*\\*(?!/)([^\\*]|\\*(?!/))*$" + }, + "action": { + "indent": "none", + "appendText": " * " + } + }, + { + "beforeText": { + "pattern": "^(\\t|[ ])*[ ]\\*([ ]([^\\*]|\\*(?!/))*)?$" + }, + "previousLineText": { + "pattern": "(?=^(\\s*(/\\*\\*|\\*)).*)(?=(?!(\\s*\\*/)))" + }, + "action": { + "indent": "none", + "appendText": "* " + } + }, + { + + "beforeText": { + "pattern": "^(\\t|[ ])*[ ]\\*/\\s*$" + }, + "action": { + "indent": "none", + "removeText": 1 + } + }, + { + + "beforeText": { + "pattern": "^(\\t|[ ])*[ ]\\*[^/]*\\*/\\s*$" + }, + "action": { + "indent": "none", + "removeText": 1 + } + }, + { + "beforeText": { + "pattern": "^\\s*(\\bcase\\s.+:|\\bdefault:)$" + }, + "afterText": { + "pattern": "^(?!\\s*(\\bcase\\b|\\bdefault\\b))" + }, + "action": { + "indent": "indent" + } + } + ] +} diff --git a/app/src/main/assets/textmate/javascript/syntaxes/JavaScript.tmLanguage.json b/app/src/main/assets/textmate/javascript/syntaxes/JavaScript.tmLanguage.json new file mode 100644 index 000000000..c1070ccdd --- /dev/null +++ b/app/src/main/assets/textmate/javascript/syntaxes/JavaScript.tmLanguage.json @@ -0,0 +1,5876 @@ +{ + "information_for_contributors": [ + "This file has been converted from https://github.com/microsoft/TypeScript-TmLanguage/blob/master/TypeScriptReact.tmLanguage", + "If you want to provide a fix or improvement, please create a pull request against the original repository.", + "Once accepted there, we are happy to receive an update request." + ], + "version": "https://github.com/microsoft/TypeScript-TmLanguage/commit/4d30ff834ec324f56291addd197aa1e423cedfdd", + "name": "JavaScript (with React support)", + "scopeName": "source.js", + "patterns": [ + { + "include": "#directives" + }, + { + "include": "#statements" + }, + { + "include": "#shebang" + } + ], + "repository": { + "shebang": { + "name": "comment.line.shebang.js", + "match": "\\A(#!).*(?=$)", + "captures": { + "1": { + "name": "punctuation.definition.comment.js" + } + } + }, + "statements": { + "patterns": [ + { + "include": "#declaration" + }, + { + "include": "#control-statement" + }, + { + "include": "#after-operator-block-as-object-literal" + }, + { + "include": "#decl-block" + }, + { + "include": "#label" + }, + { + "include": "#expression" + }, + { + "include": "#punctuation-semicolon" + }, + { + "include": "#string" + }, + { + "include": "#comment" + } + ] + }, + "declaration": { + "patterns": [ + { + "include": "#decorator" + }, + { + "include": "#var-expr" + }, + { + "include": "#function-declaration" + }, + { + "include": "#class-declaration" + }, + { + "include": "#interface-declaration" + }, + { + "include": "#enum-declaration" + }, + { + "include": "#namespace-declaration" + }, + { + "include": "#type-alias-declaration" + }, + { + "include": "#import-equals-declaration" + }, + { + "include": "#import-declaration" + }, + { + "include": "#export-declaration" + }, + { + "name": "storage.modifier.js", + "match": "(?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)) |\n# typeannotation is fn type: < | () | (... | (param: | (param, | (param? | (param= | (param) =>\n(:\\s*(\n (<) |\n ([(]\\s*(\n ([)]) |\n (\\.\\.\\.) |\n ([_$[:alnum:]]+\\s*(\n ([:,?=])|\n ([)]\\s*=>)\n ))\n ))\n)) |\n(:\\s*(?\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*))))))) |\n(:\\s*(=>|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(<[^<>]*>)|[^<>(),=])+=\\s*(\n ((async\\s+)?(\n (function\\s*[(<*]) |\n (function\\s+) |\n ([_$[:alpha:]][_$[:alnum:]]*\\s*=>)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)))", + "beginCaptures": { + "1": { + "name": "meta.definition.variable.js entity.name.function.js" + }, + "2": { + "name": "keyword.operator.definiteassignment.js" + } + }, + "end": "(?=$|^|[;,=}]|((?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)) |\n# typeannotation is fn type: < | () | (... | (param: | (param, | (param? | (param= | (param) =>\n(:\\s*(\n (<) |\n ([(]\\s*(\n ([)]) |\n (\\.\\.\\.) |\n ([_$[:alnum:]]+\\s*(\n ([:,?=])|\n ([)]\\s*=>)\n ))\n ))\n)) |\n(:\\s*(?\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*))))))) |\n(:\\s*(=>|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(<[^<>]*>)|[^<>(),=])+=\\s*(\n ((async\\s+)?(\n (function\\s*[(<*]) |\n (function\\s+) |\n ([_$[:alpha:]][_$[:alnum:]]*\\s*=>)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)))", + "beginCaptures": { + "1": { + "name": "meta.definition.variable.js variable.other.constant.js entity.name.function.js" + } + }, + "end": "(?=$|^|[;,=}]|((?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)) |\n# typeannotation is fn type: < | () | (... | (param: | (param, | (param? | (param= | (param) =>\n(:\\s*(\n (<) |\n ([(]\\s*(\n ([)]) |\n (\\.\\.\\.) |\n ([_$[:alnum:]]+\\s*(\n ([:,?=])|\n ([)]\\s*=>)\n ))\n ))\n)) |\n(:\\s*(?\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*))))))) |\n(:\\s*(=>|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(<[^<>]*>)|[^<>(),=])+=\\s*(\n ((async\\s+)?(\n (function\\s*[(<*]) |\n (function\\s+) |\n ([_$[:alpha:]][_$[:alnum:]]*\\s*=>)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)))", + "captures": { + "1": { + "name": "storage.modifier.js" + }, + "2": { + "name": "keyword.operator.rest.js" + }, + "3": { + "name": "entity.name.function.js variable.language.this.js" + }, + "4": { + "name": "entity.name.function.js" + }, + "5": { + "name": "keyword.operator.optional.js" + } + } + }, + { + "match": "(?x)(?:(?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)) |\n# typeannotation is fn type: < | () | (... | (param: | (param, | (param? | (param= | (param) =>\n(:\\s*(\n (<) |\n ([(]\\s*(\n ([)]) |\n (\\.\\.\\.) |\n ([_$[:alnum:]]+\\s*(\n ([:,?=])|\n ([)]\\s*=>)\n ))\n ))\n)) |\n(:\\s*(?\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*))))))) |\n(:\\s*(=>|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(<[^<>]*>)|[^<>(),=])+=\\s*(\n ((async\\s+)?(\n (function\\s*[(<*]) |\n (function\\s+) |\n ([_$[:alpha:]][_$[:alnum:]]*\\s*=>)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)))", + "captures": { + "1": { + "name": "meta.definition.property.js entity.name.function.js" + }, + "2": { + "name": "keyword.operator.optional.js" + }, + "3": { + "name": "keyword.operator.definiteassignment.js" + } + } + }, + { + "name": "meta.definition.property.js variable.object.property.js", + "match": "\\#?[_$[:alpha:]][_$[:alnum:]]*" + }, + { + "name": "keyword.operator.optional.js", + "match": "\\?" + }, + { + "name": "keyword.operator.definiteassignment.js", + "match": "\\!" + } + ] + }, + "variable-initializer": { + "patterns": [ + { + "begin": "(?\\s*$)", + "beginCaptures": { + "1": { + "name": "keyword.operator.assignment.js" + } + }, + "end": "(?=$|^|[,);}\\]]|((?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*))?[\\(])", + "beginCaptures": { + "1": { + "name": "storage.modifier.js" + }, + "2": { + "name": "storage.modifier.js" + }, + "3": { + "name": "storage.modifier.js" + }, + "4": { + "name": "storage.modifier.async.js" + }, + "5": { + "name": "keyword.operator.new.js" + }, + "6": { + "name": "keyword.generator.asterisk.js" + } + }, + "end": "(?=\\}|;|,|$)|(?<=\\})", + "patterns": [ + { + "include": "#method-declaration-name" + }, + { + "include": "#function-body" + } + ] + }, + { + "name": "meta.method.declaration.js", + "begin": "(?x)(?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*))?[\\(])", + "beginCaptures": { + "1": { + "name": "storage.modifier.js" + }, + "2": { + "name": "storage.modifier.js" + }, + "3": { + "name": "storage.modifier.js" + }, + "4": { + "name": "storage.modifier.async.js" + }, + "5": { + "name": "storage.type.property.js" + }, + "6": { + "name": "keyword.generator.asterisk.js" + } + }, + "end": "(?=\\}|;|,|$)|(?<=\\})", + "patterns": [ + { + "include": "#method-declaration-name" + }, + { + "include": "#function-body" + } + ] + } + ] + }, + "object-literal-method-declaration": { + "name": "meta.method.declaration.js", + "begin": "(?x)(?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*))?[\\(])", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + }, + "2": { + "name": "storage.type.property.js" + }, + "3": { + "name": "keyword.generator.asterisk.js" + } + }, + "end": "(?=\\}|;|,)|(?<=\\})", + "patterns": [ + { + "include": "#method-declaration-name" + }, + { + "include": "#function-body" + }, + { + "begin": "(?x)(?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*))?[\\(])", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + }, + "2": { + "name": "storage.type.property.js" + }, + "3": { + "name": "keyword.generator.asterisk.js" + } + }, + "end": "(?=\\(|\\<)", + "patterns": [ + { + "include": "#method-declaration-name" + } + ] + } + ] + }, + "method-declaration-name": { + "begin": "(?x)(?=((\\b(?)", + "captures": { + "1": { + "name": "storage.modifier.async.js" + }, + "2": { + "name": "variable.parameter.js" + } + } + }, + { + "name": "meta.arrow.js", + "begin": "(?x) (?:\n (? is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n )\n)", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + } + }, + "end": "(?==>|\\{|(^\\s*(export|function|class|interface|let|var|const|import|enum|namespace|module|type|abstract|declare)\\s+))", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#type-parameters" + }, + { + "include": "#function-parameters" + }, + { + "include": "#arrow-return-type" + }, + { + "include": "#possibly-arrow-return-type" + } + ] + }, + { + "name": "meta.arrow.js", + "begin": "=>", + "beginCaptures": { + "0": { + "name": "storage.type.function.arrow.js" + } + }, + "end": "((?<=\\}|\\S)(?)|((?!\\{)(?=\\S)))(?!\\/[\\/\\*])", + "patterns": [ + { + "include": "#single-line-comment-consuming-line-ending" + }, + { + "include": "#decl-block" + }, + { + "include": "#expression" + } + ] + } + ] + }, + "indexer-declaration": { + "name": "meta.indexer.declaration.js", + "begin": "(?:(?]|^await|[^\\._$[:alnum:]]await|^return|[^\\._$[:alnum:]]return|^yield|[^\\._$[:alnum:]]yield|^throw|[^\\._$[:alnum:]]throw|^in|[^\\._$[:alnum:]]in|^of|[^\\._$[:alnum:]]of|^typeof|[^\\._$[:alnum:]]typeof|&&|\\|\\||\\*)\\s*(\\{)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.block.js" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.js" + } + }, + "patterns": [ + { + "include": "#object-member" + } + ] + }, + "object-literal": { + "name": "meta.objectliteral.js", + "begin": "\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.block.js" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.block.js" + } + }, + "patterns": [ + { + "include": "#object-member" + } + ] + }, + "object-member": { + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#object-literal-method-declaration" + }, + { + "name": "meta.object.member.js meta.object-literal.key.js", + "begin": "(?=\\[)", + "end": "(?=:)|((?<=[\\]])(?=\\s*[\\(\\<]))", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#array-literal" + } + ] + }, + { + "name": "meta.object.member.js meta.object-literal.key.js", + "begin": "(?=[\\'\\\"\\`])", + "end": "(?=:)|((?<=[\\'\\\"\\`])(?=((\\s*[\\(\\<,}])|(\\s+(as)\\s+))))", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#string" + } + ] + }, + { + "name": "meta.object.member.js meta.object-literal.key.js", + "begin": "(?x)(?=(\\b(?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)))", + "captures": { + "0": { + "name": "meta.object-literal.key.js" + }, + "1": { + "name": "entity.name.function.js" + } + } + }, + { + "name": "meta.object.member.js", + "match": "(?:[_$[:alpha:]][_$[:alnum:]]*)\\s*(?=(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*:)", + "captures": { + "0": { + "name": "meta.object-literal.key.js" + } + } + }, + { + "name": "meta.object.member.js", + "begin": "\\.\\.\\.", + "beginCaptures": { + "0": { + "name": "keyword.operator.spread.js" + } + }, + "end": "(?=,|\\})", + "patterns": [ + { + "include": "#expression" + } + ] + }, + { + "name": "meta.object.member.js", + "match": "([_$[:alpha:]][_$[:alnum:]]*)\\s*(?=,|\\}|$|\\/\\/|\\/\\*)", + "captures": { + "1": { + "name": "variable.other.readwrite.js" + } + } + }, + { + "name": "meta.object.member.js", + "match": "(?]|\\|\\||\\&\\&|\\!\\=\\=|$|^|((?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)\\(\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + } + }, + "end": "(?<=\\))", + "patterns": [ + { + "include": "#type-parameters" + }, + { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "meta.brace.round.js" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "meta.brace.round.js" + } + }, + "patterns": [ + { + "include": "#expression-inside-possibly-arrow-parens" + } + ] + } + ] + }, + { + "begin": "(?<=:)\\s*(async)?\\s*(\\()(?=\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + }, + "2": { + "name": "meta.brace.round.js" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "meta.brace.round.js" + } + }, + "patterns": [ + { + "include": "#expression-inside-possibly-arrow-parens" + } + ] + }, + { + "begin": "(?<=:)\\s*(async)?\\s*(?=\\<\\s*$)", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + } + }, + "end": "(?<=\\>)", + "patterns": [ + { + "include": "#type-parameters" + } + ] + }, + { + "begin": "(?<=\\>)\\s*(\\()(?=\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))", + "beginCaptures": { + "1": { + "name": "meta.brace.round.js" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "meta.brace.round.js" + } + }, + "patterns": [ + { + "include": "#expression-inside-possibly-arrow-parens" + } + ] + }, + { + "include": "#possibly-arrow-return-type" + }, + { + "include": "#expression" + } + ] + }, + { + "include": "#punctuation-comma" + } + ] + }, + "ternary-expression": { + "begin": "(?!\\?\\.\\s*[^[:digit:]])(\\?)(?!\\?)", + "beginCaptures": { + "1": { + "name": "keyword.operator.ternary.js" + } + }, + "end": "\\s*(:)", + "endCaptures": { + "1": { + "name": "keyword.operator.ternary.js" + } + }, + "patterns": [ + { + "include": "#expression" + } + ] + }, + "function-call": { + "patterns": [ + { + "begin": "(?=(((([_$[:alpha:]][_$[:alnum:]]*)(\\s*\\??\\.\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*))*)|(\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*))|(?<=[\\)]))\\s*(?:(\\?\\.\\s*)|(\\!))?((<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))(([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>)*(?))*(?)*(?\\s*)?\\())", + "end": "(?<=\\))(?!(((([_$[:alpha:]][_$[:alnum:]]*)(\\s*\\??\\.\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*))*)|(\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*))|(?<=[\\)]))\\s*(?:(\\?\\.\\s*)|(\\!))?((<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))(([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>)*(?))*(?)*(?\\s*)?\\())", + "patterns": [ + { + "name": "meta.function-call.js", + "begin": "(?=(([_$[:alpha:]][_$[:alnum:]]*)(\\s*\\??\\.\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*))*)|(\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*))", + "end": "(?=\\s*(?:(\\?\\.\\s*)|(\\!))?((<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))(([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>)*(?))*(?)*(?\\s*)?\\())", + "patterns": [ + { + "include": "#function-call-target" + } + ] + }, + { + "include": "#comment" + }, + { + "include": "#function-call-optionals" + }, + { + "include": "#type-arguments" + }, + { + "include": "#paren-expression" + } + ] + }, + { + "begin": "(?=(((([_$[:alpha:]][_$[:alnum:]]*)(\\s*\\??\\.\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*))*)|(\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*))|(?<=[\\)]))(<\\s*[\\{\\[\\(]\\s*$))", + "end": "(?<=\\>)(?!(((([_$[:alpha:]][_$[:alnum:]]*)(\\s*\\??\\.\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*))*)|(\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*))|(?<=[\\)]))(<\\s*[\\{\\[\\(]\\s*$))", + "patterns": [ + { + "name": "meta.function-call.js", + "begin": "(?=(([_$[:alpha:]][_$[:alnum:]]*)(\\s*\\??\\.\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*))*)|(\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*))", + "end": "(?=(<\\s*[\\{\\[\\(]\\s*$))", + "patterns": [ + { + "include": "#function-call-target" + } + ] + }, + { + "include": "#comment" + }, + { + "include": "#function-call-optionals" + }, + { + "include": "#type-arguments" + } + ] + } + ] + }, + "function-call-target": { + "patterns": [ + { + "include": "#support-function-call-identifiers" + }, + { + "name": "entity.name.function.js", + "match": "(\\#?[_$[:alpha:]][_$[:alnum:]]*)" + } + ] + }, + "function-call-optionals": { + "patterns": [ + { + "name": "meta.function-call.js punctuation.accessor.optional.js", + "match": "\\?\\." + }, + { + "name": "meta.function-call.js keyword.operator.definiteassignment.js", + "match": "\\!" + } + ] + }, + "support-function-call-identifiers": { + "patterns": [ + { + "include": "#literal" + }, + { + "include": "#support-objects" + }, + { + "include": "#object-identifiers" + }, + { + "include": "#punctuation-accessor" + }, + { + "name": "keyword.operator.expression.import.js", + "match": "(?:(?]|\\|\\||\\&\\&|\\!\\=\\=|$|((?]|\\|\\||\\&\\&|\\!\\=\\=|$|(([\\&\\~\\^\\|]\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s+instanceof(?![_$[:alnum:]])(?:(?=\\.\\.\\.)|(?!\\.)))|((?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*))?\\(\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + } + }, + "end": "(?<=\\))", + "patterns": [ + { + "include": "#paren-expression-possibly-arrow-with-typeparameters" + } + ] + }, + { + "begin": "(?<=[(=,]|=>|^return|[^\\._$[:alnum:]]return)\\s*(async)?(?=\\s*((((<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*))?\\()|(<))\\s*$)", + "beginCaptures": { + "1": { + "name": "storage.modifier.async.js" + } + }, + "end": "(?<=\\))", + "patterns": [ + { + "include": "#paren-expression-possibly-arrow-with-typeparameters" + } + ] + }, + { + "include": "#possibly-arrow-return-type" + } + ] + }, + "paren-expression-possibly-arrow-with-typeparameters": { + "patterns": [ + { + "include": "#type-parameters" + }, + { + "begin": "\\(", + "beginCaptures": { + "0": { + "name": "meta.brace.round.js" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "meta.brace.round.js" + } + }, + "patterns": [ + { + "include": "#expression-inside-possibly-arrow-parens" + } + ] + } + ] + }, + "expression-inside-possibly-arrow-parens": { + "patterns": [ + { + "include": "#expressionWithoutIdentifiers" + }, + { + "include": "#comment" + }, + { + "include": "#string" + }, + { + "include": "#decorator" + }, + { + "include": "#destructuring-parameter" + }, + { + "match": "(?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)) |\n# typeannotation is fn type: < | () | (... | (param: | (param, | (param? | (param= | (param) =>\n(:\\s*(\n (<) |\n ([(]\\s*(\n ([)]) |\n (\\.\\.\\.) |\n ([_$[:alnum:]]+\\s*(\n ([:,?=])|\n ([)]\\s*=>)\n ))\n ))\n)) |\n(:\\s*(?\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*))))))) |\n(:\\s*(=>|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(<[^<>]*>)|[^<>(),=])+=\\s*(\n ((async\\s+)?(\n (function\\s*[(<*]) |\n (function\\s+) |\n ([_$[:alpha:]][_$[:alnum:]]*\\s*=>)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n)))", + "captures": { + "1": { + "name": "storage.modifier.js" + }, + "2": { + "name": "keyword.operator.rest.js" + }, + "3": { + "name": "entity.name.function.js variable.language.this.js" + }, + "4": { + "name": "entity.name.function.js" + }, + "5": { + "name": "keyword.operator.optional.js" + } + } + }, + { + "match": "(?x)(?:(?]|\\|\\||\\&\\&|\\!\\=\\=|$|((?>=|>>>=|\\|=" + }, + { + "name": "keyword.operator.bitwise.shift.js", + "match": "<<|>>>|>>" + }, + { + "name": "keyword.operator.comparison.js", + "match": "===|!==|==|!=" + }, + { + "name": "keyword.operator.relational.js", + "match": "<=|>=|<>|<|>" + }, + { + "match": "(?<=[_$[:alnum:]])(\\!)\\s*(?:(/=)|(?:(/)(?![/*])))", + "captures": { + "1": { + "name": "keyword.operator.logical.js" + }, + "2": { + "name": "keyword.operator.assignment.compound.js" + }, + "3": { + "name": "keyword.operator.arithmetic.js" + } + } + }, + { + "name": "keyword.operator.logical.js", + "match": "\\!|&&|\\|\\||\\?\\?" + }, + { + "name": "keyword.operator.bitwise.js", + "match": "\\&|~|\\^|\\|" + }, + { + "name": "keyword.operator.assignment.js", + "match": "\\=" + }, + { + "name": "keyword.operator.decrement.js", + "match": "--" + }, + { + "name": "keyword.operator.increment.js", + "match": "\\+\\+" + }, + { + "name": "keyword.operator.arithmetic.js", + "match": "%|\\*|/|-|\\+" + }, + { + "begin": "(?<=[_$[:alnum:])\\]])\\s*(?=(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)+(?:(/=)|(?:(/)(?![/*]))))", + "end": "(?:(/=)|(?:(/)(?!\\*([^\\*]|(\\*[^\\/]))*\\*\\/)))", + "endCaptures": { + "1": { + "name": "keyword.operator.assignment.compound.js" + }, + "2": { + "name": "keyword.operator.arithmetic.js" + } + }, + "patterns": [ + { + "include": "#comment" + } + ] + }, + { + "match": "(?<=[_$[:alnum:])\\]])\\s*(?:(/=)|(?:(/)(?![/*])))", + "captures": { + "1": { + "name": "keyword.operator.assignment.compound.js" + }, + "2": { + "name": "keyword.operator.arithmetic.js" + } + } + } + ] + }, + "typeof-operator": { + "begin": "(?:&|{\\?]|$|;|^\\s*$|(?:^\\s*(?:abstract|async|class|const|declare|enum|export|function|import|interface|let|module|namespace|return|type|var)\\b))", + "patterns": [ + { + "include": "#expression" + } + ] + }, + "literal": { + "patterns": [ + { + "include": "#numeric-literal" + }, + { + "include": "#boolean-literal" + }, + { + "include": "#null-literal" + }, + { + "include": "#undefined-literal" + }, + { + "include": "#numericConstant-literal" + }, + { + "include": "#array-literal" + }, + { + "include": "#this-literal" + }, + { + "include": "#super-literal" + } + ] + }, + "array-literal": { + "name": "meta.array.literal.js", + "begin": "\\s*(\\[)", + "beginCaptures": { + "1": { + "name": "meta.brace.square.js" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "meta.brace.square.js" + } + }, + "patterns": [ + { + "include": "#expression" + }, + { + "include": "#punctuation-comma" + } + ] + }, + "numeric-literal": { + "patterns": [ + { + "name": "constant.numeric.hex.js", + "match": "\\b(?]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\\())\n |\n (?:(EPSILON|MAX_SAFE_INTEGER|MAX_VALUE|MIN_SAFE_INTEGER|MIN_VALUE|NEGATIVE_INFINITY|POSITIVE_INFINITY)\\b(?!\\$)))", + "captures": { + "1": { + "name": "punctuation.accessor.js" + }, + "2": { + "name": "punctuation.accessor.optional.js" + }, + "3": { + "name": "support.variable.property.js" + }, + "4": { + "name": "support.constant.js" + } + } + }, + { + "match": "(?)\n )) |\n ((async\\s*)?(\n ((<\\s*$)|([\\(]\\s*((([\\{\\[]\\s*)?$)|((\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})\\s*((:\\s*\\{?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))))) |\n # sure shot arrow functions even if => is on new line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)?\n [(]\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*\n (\n ([)]\\s*:) | # ():\n ((\\.\\.\\.\\s*)?[_$[:alpha:]][_$[:alnum:]]*\\s*:) # [(]param: | [(]...param:\n )\n) |\n(\n [<]\\s*[_$[:alpha:]][_$[:alnum:]]*\\s+extends\\s*[^=>] # < typeparam extends\n) |\n# arrow function possible to detect only with => on same line\n(\n (<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<]|\\<\\s*([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\]))([^=<>]|=[^<])*\\>)*\\>)*>\\s*)? # typeparameters\n \\(\\s*(\\/\\*([^\\*]|(\\*[^\\/]))*\\*\\/\\s*)*(([_$[:alpha:]]|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\.\\.\\.\\s*[_$[:alpha:]]))([^()\\'\\\"\\`]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))*)?\\) # parameters\n (\\s*:\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+)? # return type\n \\s*=> # arrow operator\n)\n ))\n))", + "captures": { + "1": { + "name": "punctuation.accessor.js" + }, + "2": { + "name": "punctuation.accessor.optional.js" + }, + "3": { + "name": "entity.name.function.js" + } + } + }, + { + "match": "(?:(\\.)|(\\?\\.(?!\\s*[[:digit:]])))\\s*(\\#?[[:upper:]][_$[:digit:][:upper:]]*)(?![_$[:alnum:]])", + "captures": { + "1": { + "name": "punctuation.accessor.js" + }, + "2": { + "name": "punctuation.accessor.optional.js" + }, + "3": { + "name": "variable.other.constant.property.js" + } + } + }, + { + "match": "(?:(\\.)|(\\?\\.(?!\\s*[[:digit:]])))\\s*(\\#?[_$[:alpha:]][_$[:alnum:]]*)", + "captures": { + "1": { + "name": "punctuation.accessor.js" + }, + "2": { + "name": "punctuation.accessor.optional.js" + }, + "3": { + "name": "variable.other.property.js" + } + } + }, + { + "name": "variable.other.constant.js", + "match": "([[:upper:]][_$[:digit:][:upper:]]*)(?![_$[:alnum:]])" + }, + { + "name": "variable.other.readwrite.js", + "match": "[_$[:alpha:]][_$[:alnum:]]*" + } + ] + }, + "object-identifiers": { + "patterns": [ + { + "name": "support.class.js", + "match": "([_$[:alpha:]][_$[:alnum:]]*)(?=\\s*\\??\\.\\s*prototype\\b(?!\\$))" + }, + { + "match": "(?x)(?:(\\.)|(\\?\\.(?!\\s*[[:digit:]])))\\s*(?:\n (\\#?[[:upper:]][_$[:digit:][:upper:]]*) |\n (\\#?[_$[:alpha:]][_$[:alnum:]]*)\n)(?=\\s*\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*)", + "captures": { + "1": { + "name": "punctuation.accessor.js" + }, + "2": { + "name": "punctuation.accessor.optional.js" + }, + "3": { + "name": "variable.other.constant.object.property.js" + }, + "4": { + "name": "variable.other.object.property.js" + } + } + }, + { + "match": "(?x)(?:\n ([[:upper:]][_$[:digit:][:upper:]]*) |\n ([_$[:alpha:]][_$[:alnum:]]*)\n)(?=\\s*\\??\\.\\s*\\#?[_$[:alpha:]][_$[:alnum:]]*)", + "captures": { + "1": { + "name": "variable.other.constant.object.js" + }, + "2": { + "name": "variable.other.object.js" + } + } + } + ] + }, + "type-annotation": { + "patterns": [ + { + "name": "meta.type.annotation.js", + "begin": "(:)(?=\\s*\\S)", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.js" + } + }, + "end": "(?])|((?<=[\\}>\\]\\)]|[_$[:alpha:]])\\s*(?=\\{)))", + "patterns": [ + { + "include": "#type" + } + ] + }, + { + "name": "meta.type.annotation.js", + "begin": "(:)", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.js" + } + }, + "end": "(?])|(?=^\\s*$)|((?<=\\S)(?=\\s*$))|((?<=[\\}>\\]\\)]|[_$[:alpha:]])\\s*(?=\\{)))", + "patterns": [ + { + "include": "#type" + } + ] + } + ] + }, + "parameter-type-annotation": { + "patterns": [ + { + "name": "meta.type.annotation.js", + "begin": "(:)", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.js" + } + }, + "end": "(?=[,)])|(?==[^>])", + "patterns": [ + { + "include": "#type" + } + ] + } + ] + }, + "return-type": { + "patterns": [ + { + "name": "meta.return.type.js", + "begin": "(?<=\\))\\s*(:)(?=\\s*\\S)", + "beginCaptures": { + "1": { + "name": "keyword.operator.type.annotation.js" + } + }, + "end": "(?|\\{|(^\\s*(export|function|class|interface|let|var|const|import|enum|namespace|module|type|abstract|declare)\\s+))", + "patterns": [ + { + "include": "#arrow-return-type-body" + } + ] + }, + "possibly-arrow-return-type": { + "begin": "(?<=\\)|^)\\s*(:)(?=\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*=>)", + "beginCaptures": { + "1": { + "name": "meta.arrow.js meta.return.type.arrow.js keyword.operator.type.annotation.js" + } + }, + "end": "(?==>|\\{|(^\\s*(export|function|class|interface|let|var|const|import|enum|namespace|module|type|abstract|declare)\\s+))", + "contentName": "meta.arrow.js meta.return.type.arrow.js", + "patterns": [ + { + "include": "#arrow-return-type-body" + } + ] + }, + "arrow-return-type-body": { + "patterns": [ + { + "begin": "(?<=[:])(?=\\s*\\{)", + "end": "(?<=\\})", + "patterns": [ + { + "include": "#type-object" + } + ] + }, + { + "include": "#type-predicate-operator" + }, + { + "include": "#type" + } + ] + }, + "type-parameters": { + "name": "meta.type.parameters.js", + "begin": "(<)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.typeparameters.begin.js" + } + }, + "end": "(>)", + "endCaptures": { + "1": { + "name": "punctuation.definition.typeparameters.end.js" + } + }, + "patterns": [ + { + "include": "#comment" + }, + { + "name": "storage.modifier.js", + "match": "(?)" + } + ] + }, + "type-arguments": { + "name": "meta.type.parameters.js", + "begin": "\\<", + "beginCaptures": { + "0": { + "name": "punctuation.definition.typeparameters.begin.js" + } + }, + "end": "\\>", + "endCaptures": { + "0": { + "name": "punctuation.definition.typeparameters.end.js" + } + }, + "patterns": [ + { + "include": "#type-arguments-body" + } + ] + }, + "type-arguments-body": { + "patterns": [ + { + "match": "(?)\n ))\n ))\n)) |\n(:\\s*(?\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*)))|((\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])\\s*((:\\s*\\[?$)|((\\s*([^<>\\(\\)\\{\\}]|\\<([^<>]|\\<([^<>]|\\<[^<>]+\\>)+\\>)+\\>|\\([^\\(\\)]+\\)|\\{[^\\{\\}]+\\})+\\s*)?=\\s*))))))))", + "captures": { + "1": { + "name": "storage.modifier.js" + }, + "2": { + "name": "keyword.operator.rest.js" + }, + "3": { + "name": "entity.name.function.js variable.language.this.js" + }, + "4": { + "name": "entity.name.function.js" + }, + "5": { + "name": "keyword.operator.optional.js" + } + } + }, + { + "match": "(?x)(?:(?)", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#type-parameters" + } + ] + }, + { + "name": "meta.type.constructor.js", + "begin": "(?)\n ))\n )\n )\n)", + "end": "(?<=\\))", + "patterns": [ + { + "include": "#function-parameters" + } + ] + } + ] + }, + "type-function-return-type": { + "patterns": [ + { + "name": "meta.type.function.return.js", + "begin": "(=>)(?=\\s*\\S)", + "beginCaptures": { + "1": { + "name": "storage.type.function.arrow.js" + } + }, + "end": "(?)(?:\\?]|//|$)", + "patterns": [ + { + "include": "#type-function-return-type-core" + } + ] + }, + { + "name": "meta.type.function.return.js", + "begin": "=>", + "beginCaptures": { + "0": { + "name": "storage.type.function.arrow.js" + } + }, + "end": "(?)(?]|//|^\\s*$)|((?<=\\S)(?=\\s*$)))", + "patterns": [ + { + "include": "#type-function-return-type-core" + } + ] + } + ] + }, + "type-function-return-type-core": { + "patterns": [ + { + "include": "#comment" + }, + { + "begin": "(?<==>)(?=\\s*\\{)", + "end": "(?<=\\})", + "patterns": [ + { + "include": "#type-object" + } + ] + }, + { + "include": "#type-predicate-operator" + }, + { + "include": "#type" + } + ] + }, + "type-operators": { + "patterns": [ + { + "include": "#typeof-operator" + }, + { + "include": "#type-infer" + }, + { + "begin": "([&|])(?=\\s*\\{)", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.js" + } + }, + "end": "(?<=\\})", + "patterns": [ + { + "include": "#type-object" + } + ] + }, + { + "begin": "[&|]", + "beginCaptures": { + "0": { + "name": "keyword.operator.type.js" + } + }, + "end": "(?=\\S)" + }, + { + "name": "keyword.operator.expression.keyof.js", + "match": "(?)", + "endCaptures": { + "1": { + "name": "meta.type.parameters.js punctuation.definition.typeparameters.end.js" + } + }, + "contentName": "meta.type.parameters.js", + "patterns": [ + { + "include": "#type-arguments-body" + } + ] + }, + { + "begin": "([_$[:alpha:]][_$[:alnum:]]*)\\s*(<)", + "beginCaptures": { + "1": { + "name": "entity.name.type.js" + }, + "2": { + "name": "meta.type.parameters.js punctuation.definition.typeparameters.begin.js" + } + }, + "end": "(>)", + "endCaptures": { + "1": { + "name": "meta.type.parameters.js punctuation.definition.typeparameters.end.js" + } + }, + "contentName": "meta.type.parameters.js", + "patterns": [ + { + "include": "#type-arguments-body" + } + ] + }, + { + "match": "([_$[:alpha:]][_$[:alnum:]]*)\\s*(?:(\\.)|(\\?\\.(?!\\s*[[:digit:]])))", + "captures": { + "1": { + "name": "entity.name.type.module.js" + }, + "2": { + "name": "punctuation.accessor.js" + }, + "3": { + "name": "punctuation.accessor.optional.js" + } + } + }, + { + "name": "entity.name.type.js", + "match": "[_$[:alpha:]][_$[:alnum:]]*" + } + ] + }, + "punctuation-comma": { + "name": "punctuation.separator.comma.js", + "match": "," + }, + "punctuation-semicolon": { + "name": "punctuation.terminator.statement.js", + "match": ";" + }, + "punctuation-accessor": { + "match": "(?:(\\.)|(\\?\\.(?!\\s*[[:digit:]])))", + "captures": { + "1": { + "name": "punctuation.accessor.js" + }, + "2": { + "name": "punctuation.accessor.optional.js" + } + } + }, + "string": { + "patterns": [ + { + "include": "#qstring-single" + }, + { + "include": "#qstring-double" + }, + { + "include": "#template" + } + ] + }, + "qstring-double": { + "name": "string.quoted.double.js", + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.js" + } + }, + "end": "(\")|((?:[^\\\\\\n])$)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.js" + }, + "2": { + "name": "invalid.illegal.newline.js" + } + }, + "patterns": [ + { + "include": "#string-character-escape" + } + ] + }, + "qstring-single": { + "name": "string.quoted.single.js", + "begin": "'", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.js" + } + }, + "end": "(\\')|((?:[^\\\\\\n])$)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.js" + }, + "2": { + "name": "invalid.illegal.newline.js" + } + }, + "patterns": [ + { + "include": "#string-character-escape" + } + ] + }, + "string-character-escape": { + "name": "constant.character.escape.js", + "match": "\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|u\\{[0-9A-Fa-f]+\\}|[0-2][0-7]{0,2}|3[0-6][0-7]?|37[0-7]?|[4-7][0-7]?|.|$)" + }, + "template": { + "patterns": [ + { + "include": "#template-call" + }, + { + "name": "string.template.js", + "begin": "([_$[:alpha:]][_$[:alnum:]]*)?(`)", + "beginCaptures": { + "1": { + "name": "entity.name.function.tagged-template.js" + }, + "2": { + "name": "punctuation.definition.string.template.begin.js" + } + }, + "end": "`", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.template.end.js" + } + }, + "patterns": [ + { + "include": "#template-substitution-element" + }, + { + "include": "#string-character-escape" + } + ] + } + ] + }, + "template-call": { + "patterns": [ + { + "name": "string.template.js", + "begin": "(?=(([_$[:alpha:]][_$[:alnum:]]*\\s*\\??\\.\\s*)*|(\\??\\.\\s*)?)([_$[:alpha:]][_$[:alnum:]]*)(<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))(([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>)*(?))*(?)*(?\\s*)?`)", + "end": "(?=`)", + "patterns": [ + { + "begin": "(?=(([_$[:alpha:]][_$[:alnum:]]*\\s*\\??\\.\\s*)*|(\\??\\.\\s*)?)([_$[:alpha:]][_$[:alnum:]]*))", + "end": "(?=(<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))(([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>)*(?))*(?)*(?\\s*)?`)", + "patterns": [ + { + "include": "#support-function-call-identifiers" + }, + { + "name": "entity.name.function.tagged-template.js", + "match": "([_$[:alpha:]][_$[:alnum:]]*)" + } + ] + }, + { + "include": "#type-arguments" + } + ] + }, + { + "name": "string.template.js", + "begin": "([_$[:alpha:]][_$[:alnum:]]*)?\\s*(?=(<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))(([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>|\\<\\s*(((keyof|infer|typeof|readonly)\\s+)|(([_$[:alpha:]][_$[:alnum:]]*|(\\{([^\\{\\}]|(\\{([^\\{\\}]|\\{[^\\{\\}]*\\})*\\}))*\\})|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(\\[([^\\[\\]]|(\\[([^\\[\\]]|\\[[^\\[\\]]*\\])*\\]))*\\])|(\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`))(?=\\s*([\\<\\>\\,\\.\\[]|=>|&(?!&)|\\|(?!\\|)))))([^<>\\(]|(\\(([^\\(\\)]|(\\(([^\\(\\)]|\\([^\\(\\)]*\\))*\\)))*\\))|(?<==)\\>)*(?))*(?)*(?\\s*)`)", + "beginCaptures": { + "1": { + "name": "entity.name.function.tagged-template.js" + } + }, + "end": "(?=`)", + "patterns": [ + { + "include": "#type-arguments" + } + ] + } + ] + }, + "template-substitution-element": { + "name": "meta.template.expression.js", + "begin": "\\$\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.template-expression.begin.js" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.template-expression.end.js" + } + }, + "patterns": [ + { + "include": "#expression" + } + ], + "contentName": "meta.embedded.line.js" + }, + "type-string": { + "patterns": [ + { + "include": "#qstring-single" + }, + { + "include": "#qstring-double" + }, + { + "include": "#template-type" + } + ] + }, + "template-type": { + "patterns": [ + { + "include": "#template-call" + }, + { + "name": "string.template.js", + "begin": "([_$[:alpha:]][_$[:alnum:]]*)?(`)", + "beginCaptures": { + "1": { + "name": "entity.name.function.tagged-template.js" + }, + "2": { + "name": "punctuation.definition.string.template.begin.js" + } + }, + "end": "`", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.template.end.js" + } + }, + "patterns": [ + { + "include": "#template-type-substitution-element" + }, + { + "include": "#string-character-escape" + } + ] + } + ] + }, + "template-type-substitution-element": { + "name": "meta.template.expression.js", + "begin": "\\$\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.template-expression.begin.js" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.template-expression.end.js" + } + }, + "patterns": [ + { + "include": "#type" + } + ], + "contentName": "meta.embedded.line.js" + }, + "regex": { + "patterns": [ + { + "name": "string.regexp.js", + "begin": "(?|&&|\\|\\||\\*\\/)\\s*(\\/)(?![\\/*])(?=(?:[^\\/\\\\\\[\\()]|\\\\.|\\[([^\\]\\\\]|\\\\.)+\\]|\\(([^\\)\\\\]|\\\\.)+\\))+\\/([dgimsuy]+|(?![\\/\\*])|(?=\\/\\*))(?!\\s*[a-zA-Z0-9_$]))", + "beginCaptures": { + "1": { + "name": "punctuation.definition.string.begin.js" + } + }, + "end": "(/)([dgimsuy]*)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.js" + }, + "2": { + "name": "keyword.other.js" + } + }, + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "name": "string.regexp.js", + "begin": "((?", + "captures": { + "0": { + "name": "keyword.other.back-reference.regexp" + }, + "1": { + "name": "variable.other.regexp" + } + } + }, + { + "name": "keyword.operator.quantifier.regexp", + "match": "[?+*]|\\{(\\d+,\\d+|\\d+,|,\\d+|\\d+)\\}\\??" + }, + { + "name": "keyword.operator.or.regexp", + "match": "\\|" + }, + { + "name": "meta.group.assertion.regexp", + "begin": "(\\()((\\?=)|(\\?!)|(\\?<=)|(\\?))?", + "beginCaptures": { + "0": { + "name": "punctuation.definition.group.regexp" + }, + "1": { + "name": "punctuation.definition.group.no-capture.regexp" + }, + "2": { + "name": "variable.other.regexp" + } + }, + "end": "\\)", + "endCaptures": { + "0": { + "name": "punctuation.definition.group.regexp" + } + }, + "patterns": [ + { + "include": "#regexp" + } + ] + }, + { + "name": "constant.other.character-class.set.regexp", + "begin": "(\\[)(\\^)?", + "beginCaptures": { + "1": { + "name": "punctuation.definition.character-class.regexp" + }, + "2": { + "name": "keyword.operator.negation.regexp" + } + }, + "end": "(\\])", + "endCaptures": { + "1": { + "name": "punctuation.definition.character-class.regexp" + } + }, + "patterns": [ + { + "name": "constant.other.character-class.range.regexp", + "match": "(?:.|(\\\\(?:[0-7]{3}|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}))|(\\\\c[A-Z])|(\\\\.))\\-(?:[^\\]\\\\]|(\\\\(?:[0-7]{3}|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}))|(\\\\c[A-Z])|(\\\\.))", + "captures": { + "1": { + "name": "constant.character.numeric.regexp" + }, + "2": { + "name": "constant.character.control.regexp" + }, + "3": { + "name": "constant.character.escape.backslash.regexp" + }, + "4": { + "name": "constant.character.numeric.regexp" + }, + "5": { + "name": "constant.character.control.regexp" + }, + "6": { + "name": "constant.character.escape.backslash.regexp" + } + } + }, + { + "include": "#regex-character-class" + } + ] + }, + { + "include": "#regex-character-class" + } + ] + }, + "regex-character-class": { + "patterns": [ + { + "name": "constant.other.character-class.regexp", + "match": "\\\\[wWsSdDtrnvf]|\\." + }, + { + "name": "constant.character.numeric.regexp", + "match": "\\\\([0-7]{3}|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4})" + }, + { + "name": "constant.character.control.regexp", + "match": "\\\\c[A-Z]" + }, + { + "name": "constant.character.escape.backslash.regexp", + "match": "\\\\." + } + ] + }, + "comment": { + "patterns": [ + { + "name": "comment.block.documentation.js", + "begin": "/\\*\\*(?!/)", + "beginCaptures": { + "0": { + "name": "punctuation.definition.comment.js" + } + }, + "end": "\\*/", + "endCaptures": { + "0": { + "name": "punctuation.definition.comment.js" + } + }, + "patterns": [ + { + "include": "#docblock" + } + ] + }, + { + "name": "comment.block.js", + "begin": "(/\\*)(?:\\s*((@)internal)(?=\\s|(\\*/)))?", + "beginCaptures": { + "1": { + "name": "punctuation.definition.comment.js" + }, + "2": { + "name": "storage.type.internaldeclaration.js" + }, + "3": { + "name": "punctuation.decorator.internaldeclaration.js" + } + }, + "end": "\\*/", + "endCaptures": { + "0": { + "name": "punctuation.definition.comment.js" + } + } + }, + { + "begin": "(^[ \\t]+)?((//)(?:\\s*((@)internal)(?=\\s|$))?)", + "beginCaptures": { + "1": { + "name": "punctuation.whitespace.comment.leading.js" + }, + "2": { + "name": "comment.line.double-slash.js" + }, + "3": { + "name": "punctuation.definition.comment.js" + }, + "4": { + "name": "storage.type.internaldeclaration.js" + }, + "5": { + "name": "punctuation.decorator.internaldeclaration.js" + } + }, + "end": "(?=$)", + "contentName": "comment.line.double-slash.js" + } + ] + }, + "single-line-comment-consuming-line-ending": { + "begin": "(^[ \\t]+)?((//)(?:\\s*((@)internal)(?=\\s|$))?)", + "beginCaptures": { + "1": { + "name": "punctuation.whitespace.comment.leading.js" + }, + "2": { + "name": "comment.line.double-slash.js" + }, + "3": { + "name": "punctuation.definition.comment.js" + }, + "4": { + "name": "storage.type.internaldeclaration.js" + }, + "5": { + "name": "punctuation.decorator.internaldeclaration.js" + } + }, + "end": "(?=^)", + "contentName": "comment.line.double-slash.js" + }, + "directives": { + "name": "comment.line.triple-slash.directive.js", + "begin": "^(///)\\s*(?=<(reference|amd-dependency|amd-module)(\\s+(path|types|no-default-lib|lib|name)\\s*=\\s*((\\'([^\\'\\\\]|\\\\.)*\\')|(\\\"([^\\\"\\\\]|\\\\.)*\\\")|(\\`([^\\`\\\\]|\\\\.)*\\`)))+\\s*/>\\s*$)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.comment.js" + } + }, + "end": "(?=$)", + "patterns": [ + { + "name": "meta.tag.js", + "begin": "(<)(reference|amd-dependency|amd-module)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.directive.js" + }, + "2": { + "name": "entity.name.tag.directive.js" + } + }, + "end": "/>", + "endCaptures": { + "0": { + "name": "punctuation.definition.tag.directive.js" + } + }, + "patterns": [ + { + "name": "entity.other.attribute-name.directive.js", + "match": "path|types|no-default-lib|lib|name" + }, + { + "name": "keyword.operator.assignment.js", + "match": "=" + }, + { + "include": "#string" + } + ] + } + ] + }, + "docblock": { + "patterns": [ + { + "match": "(?x)\n((@)(?:access|api))\n\\s+\n(private|protected|public)\n\\b", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "constant.language.access-type.jsdoc" + } + } + }, + { + "match": "(?x)\n((@)author)\n\\s+\n(\n [^@\\s<>*/]\n (?:[^@<>*/]|\\*[^/])*\n)\n(?:\n \\s*\n (<)\n ([^>\\s]+)\n (>)\n)?", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "entity.name.type.instance.jsdoc" + }, + "4": { + "name": "punctuation.definition.bracket.angle.begin.jsdoc" + }, + "5": { + "name": "constant.other.email.link.underline.jsdoc" + }, + "6": { + "name": "punctuation.definition.bracket.angle.end.jsdoc" + } + } + }, + { + "match": "(?x)\n((@)borrows) \\s+\n((?:[^@\\s*/]|\\*[^/])+) # \n\\s+ (as) \\s+ # as\n((?:[^@\\s*/]|\\*[^/])+) # ", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "entity.name.type.instance.jsdoc" + }, + "4": { + "name": "keyword.operator.control.jsdoc" + }, + "5": { + "name": "entity.name.type.instance.jsdoc" + } + } + }, + { + "name": "meta.example.jsdoc", + "begin": "((@)example)\\s+", + "end": "(?=@|\\*/)", + "beginCaptures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + } + }, + "patterns": [ + { + "match": "^\\s\\*\\s+" + }, + { + "contentName": "constant.other.description.jsdoc", + "begin": "\\G(<)caption(>)", + "beginCaptures": { + "0": { + "name": "entity.name.tag.inline.jsdoc" + }, + "1": { + "name": "punctuation.definition.bracket.angle.begin.jsdoc" + }, + "2": { + "name": "punctuation.definition.bracket.angle.end.jsdoc" + } + }, + "end": "()|(?=\\*/)", + "endCaptures": { + "0": { + "name": "entity.name.tag.inline.jsdoc" + }, + "1": { + "name": "punctuation.definition.bracket.angle.begin.jsdoc" + }, + "2": { + "name": "punctuation.definition.bracket.angle.end.jsdoc" + } + } + }, + { + "match": "[^\\s@*](?:[^*]|\\*[^/])*", + "captures": { + "0": { + "name": "source.embedded.js" + } + } + } + ] + }, + { + "match": "(?x) ((@)kind) \\s+ (class|constant|event|external|file|function|member|mixin|module|namespace|typedef) \\b", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "constant.language.symbol-type.jsdoc" + } + } + }, + { + "match": "(?x)\n((@)see)\n\\s+\n(?:\n # URL\n (\n (?=https?://)\n (?:[^\\s*]|\\*[^/])+\n )\n |\n # JSDoc namepath\n (\n (?!\n # Avoid matching bare URIs (also acceptable as links)\n https?://\n |\n # Avoid matching {@inline tags}; we match those below\n (?:\\[[^\\[\\]]*\\])? # Possible description [preceding]{@tag}\n {@(?:link|linkcode|linkplain|tutorial)\\b\n )\n # Matched namepath\n (?:[^@\\s*/]|\\*[^/])+\n )\n)", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "variable.other.link.underline.jsdoc" + }, + "4": { + "name": "entity.name.type.instance.jsdoc" + } + } + }, + { + "match": "(?x)\n((@)template)\n\\s+\n# One or more valid identifiers\n(\n [A-Za-z_$] # First character: non-numeric word character\n [\\w$.\\[\\]]* # Rest of identifier\n (?: # Possible list of additional identifiers\n \\s* , \\s*\n [A-Za-z_$]\n [\\w$.\\[\\]]*\n )*\n)", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "variable.other.jsdoc" + } + } + }, + { + "begin": "(?x)((@)template)\\s+(?={)", + "beginCaptures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + } + }, + "end": "(?=\\s|\\*/|[^{}\\[\\]A-Za-z_$])", + "patterns": [ + { + "include": "#jsdoctype" + }, + { + "name": "variable.other.jsdoc", + "match": "([A-Za-z_$][\\w$.\\[\\]]*)" + } + ] + }, + { + "match": "(?x)\n(\n (@)\n (?:arg|argument|const|constant|member|namespace|param|var)\n)\n\\s+\n(\n [A-Za-z_$]\n [\\w$.\\[\\]]*\n)", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "variable.other.jsdoc" + } + } + }, + { + "begin": "((@)typedef)\\s+(?={)", + "beginCaptures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + } + }, + "end": "(?=\\s|\\*/|[^{}\\[\\]A-Za-z_$])", + "patterns": [ + { + "include": "#jsdoctype" + }, + { + "name": "entity.name.type.instance.jsdoc", + "match": "(?:[^@\\s*/]|\\*[^/])+" + } + ] + }, + { + "begin": "((@)(?:arg|argument|const|constant|member|namespace|param|prop|property|var))\\s+(?={)", + "beginCaptures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + } + }, + "end": "(?=\\s|\\*/|[^{}\\[\\]A-Za-z_$])", + "patterns": [ + { + "include": "#jsdoctype" + }, + { + "name": "variable.other.jsdoc", + "match": "([A-Za-z_$][\\w$.\\[\\]]*)" + }, + { + "name": "variable.other.jsdoc", + "match": "(?x)\n(\\[)\\s*\n[\\w$]+\n(?:\n (?:\\[\\])? # Foo[ ].bar properties within an array\n \\. # Foo.Bar namespaced parameter\n [\\w$]+\n)*\n(?:\n \\s*\n (=) # [foo=bar] Default parameter value\n \\s*\n (\n # The inner regexes are to stop the match early at */ and to not stop at escaped quotes\n (?>\n \"(?:(?:\\*(?!/))|(?:\\\\(?!\"))|[^*\\\\])*?\" | # [foo=\"bar\"] Double-quoted\n '(?:(?:\\*(?!/))|(?:\\\\(?!'))|[^*\\\\])*?' | # [foo='bar'] Single-quoted\n \\[ (?:(?:\\*(?!/))|[^*])*? \\] | # [foo=[1,2]] Array literal\n (?:(?:\\*(?!/))|\\s(?!\\s*\\])|\\[.*?(?:\\]|(?=\\*/))|[^*\\s\\[\\]])* # Everything else\n )*\n )\n)?\n\\s*(?:(\\])((?:[^*\\s]|\\*[^\\s/])+)?|(?=\\*/))", + "captures": { + "1": { + "name": "punctuation.definition.optional-value.begin.bracket.square.jsdoc" + }, + "2": { + "name": "keyword.operator.assignment.jsdoc" + }, + "3": { + "name": "source.embedded.js" + }, + "4": { + "name": "punctuation.definition.optional-value.end.bracket.square.jsdoc" + }, + "5": { + "name": "invalid.illegal.syntax.jsdoc" + } + } + } + ] + }, + { + "begin": "(?x)\n(\n (@)\n (?:define|enum|exception|export|extends|lends|implements|modifies\n |namespace|private|protected|returns?|suppress|this|throws|type\n |yields?)\n)\n\\s+(?={)", + "beginCaptures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + } + }, + "end": "(?=\\s|\\*/|[^{}\\[\\]A-Za-z_$])", + "patterns": [ + { + "include": "#jsdoctype" + } + ] + }, + { + "match": "(?x)\n(\n (@)\n (?:alias|augments|callback|constructs|emits|event|fires|exports?\n |extends|external|function|func|host|lends|listens|interface|memberof!?\n |method|module|mixes|mixin|name|requires|see|this|typedef|uses)\n)\n\\s+\n(\n (?:\n [^{}@\\s*] | \\*[^/]\n )+\n)", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "entity.name.type.instance.jsdoc" + } + } + }, + { + "contentName": "variable.other.jsdoc", + "begin": "((@)(?:default(?:value)?|license|version))\\s+(([''\"]))", + "beginCaptures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "variable.other.jsdoc" + }, + "4": { + "name": "punctuation.definition.string.begin.jsdoc" + } + }, + "end": "(\\3)|(?=$|\\*/)", + "endCaptures": { + "0": { + "name": "variable.other.jsdoc" + }, + "1": { + "name": "punctuation.definition.string.end.jsdoc" + } + } + }, + { + "match": "((@)(?:default(?:value)?|license|tutorial|variation|version))\\s+([^\\s*]+)", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + }, + "3": { + "name": "variable.other.jsdoc" + } + } + }, + { + "name": "storage.type.class.jsdoc", + "match": "(?x) (@) (?:abstract|access|alias|api|arg|argument|async|attribute|augments|author|beta|borrows|bubbles |callback|chainable|class|classdesc|code|config|const|constant|constructor|constructs|copyright |default|defaultvalue|define|deprecated|desc|description|dict|emits|enum|event|example|exception |exports?|extends|extension(?:_?for)?|external|externs|file|fileoverview|final|fires|for|func |function|generator|global|hideconstructor|host|ignore|implements|implicitCast|inherit[Dd]oc |inner|instance|interface|internal|kind|lends|license|listens|main|member|memberof!?|method |mixes|mixins?|modifies|module|name|namespace|noalias|nocollapse|nocompile|nosideeffects |override|overview|package|param|polymer(?:Behavior)?|preserve|private|prop|property|protected |public|read[Oo]nly|record|require[ds]|returns?|see|since|static|struct|submodule|summary |suppress|template|this|throws|todo|tutorial|type|typedef|unrestricted|uses|var|variation |version|virtual|writeOnce|yields?) \\b", + "captures": { + "1": { + "name": "punctuation.definition.block.tag.jsdoc" + } + } + }, + { + "include": "#inline-tags" + }, + { + "match": "((@)(?:[_$[:alpha:]][_$[:alnum:]]*))(?=\\s+)", + "captures": { + "1": { + "name": "storage.type.class.jsdoc" + }, + "2": { + "name": "punctuation.definition.block.tag.jsdoc" + } + } + } + ] + }, + "brackets": { + "patterns": [ + { + "begin": "{", + "end": "}|(?=\\*/)", + "patterns": [ + { + "include": "#brackets" + } + ] + }, + { + "begin": "\\[", + "end": "\\]|(?=\\*/)", + "patterns": [ + { + "include": "#brackets" + } + ] + } + ] + }, + "inline-tags": { + "patterns": [ + { + "name": "constant.other.description.jsdoc", + "match": "(\\[)[^\\]]+(\\])(?={@(?:link|linkcode|linkplain|tutorial))", + "captures": { + "1": { + "name": "punctuation.definition.bracket.square.begin.jsdoc" + }, + "2": { + "name": "punctuation.definition.bracket.square.end.jsdoc" + } + } + }, + { + "name": "entity.name.type.instance.jsdoc", + "begin": "({)((@)(?:link(?:code|plain)?|tutorial))\\s*", + "beginCaptures": { + "1": { + "name": "punctuation.definition.bracket.curly.begin.jsdoc" + }, + "2": { + "name": "storage.type.class.jsdoc" + }, + "3": { + "name": "punctuation.definition.inline.tag.jsdoc" + } + }, + "end": "}|(?=\\*/)", + "endCaptures": { + "0": { + "name": "punctuation.definition.bracket.curly.end.jsdoc" + } + }, + "patterns": [ + { + "match": "\\G((?=https?://)(?:[^|}\\s*]|\\*[/])+)(\\|)?", + "captures": { + "1": { + "name": "variable.other.link.underline.jsdoc" + }, + "2": { + "name": "punctuation.separator.pipe.jsdoc" + } + } + }, + { + "match": "\\G((?:[^{}@\\s|*]|\\*[^/])+)(\\|)?", + "captures": { + "1": { + "name": "variable.other.description.jsdoc" + }, + "2": { + "name": "punctuation.separator.pipe.jsdoc" + } + } + } + ] + } + ] + }, + "jsdoctype": { + "patterns": [ + { + "contentName": "entity.name.type.instance.jsdoc", + "begin": "\\G({)", + "beginCaptures": { + "0": { + "name": "entity.name.type.instance.jsdoc" + }, + "1": { + "name": "punctuation.definition.bracket.curly.begin.jsdoc" + } + }, + "end": "((}))\\s*|(?=\\*/)", + "endCaptures": { + "1": { + "name": "entity.name.type.instance.jsdoc" + }, + "2": { + "name": "punctuation.definition.bracket.curly.end.jsdoc" + } + }, + "patterns": [ + { + "include": "#brackets" + } + ] + } + ] + }, + "jsx": { + "patterns": [ + { + "include": "#jsx-tag-without-attributes-in-expression" + }, + { + "include": "#jsx-tag-in-expression" + } + ] + }, + "jsx-tag-without-attributes-in-expression": { + "begin": "(?:*]|&&|\\|\\||\\?|\\*\\/|^await|[^\\._$[:alnum:]]await|^return|[^\\._$[:alnum:]]return|^default|[^\\._$[:alnum:]]default|^yield|[^\\._$[:alnum:]]yield|^)\\s*(?=(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?))", + "end": "(?!(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?))", + "patterns": [ + { + "include": "#jsx-tag-without-attributes" + } + ] + }, + "jsx-tag-without-attributes": { + "name": "meta.tag.without-attributes.js", + "begin": "(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?)", + "end": "()", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.js" + }, + "2": { + "name": "entity.name.tag.namespace.js" + }, + "3": { + "name": "punctuation.separator.namespace.js" + }, + "4": { + "name": "entity.name.tag.js" + }, + "5": { + "name": "support.class.component.js" + }, + "6": { + "name": "punctuation.definition.tag.end.js" + } + }, + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.js" + }, + "2": { + "name": "entity.name.tag.namespace.js" + }, + "3": { + "name": "punctuation.separator.namespace.js" + }, + "4": { + "name": "entity.name.tag.js" + }, + "5": { + "name": "support.class.component.js" + }, + "6": { + "name": "punctuation.definition.tag.end.js" + } + }, + "contentName": "meta.jsx.children.js", + "patterns": [ + { + "include": "#jsx-children" + } + ] + }, + "jsx-tag-in-expression": { + "begin": "(?x)\n (?:*]|&&|\\|\\||\\?|\\*\\/|^await|[^\\._$[:alnum:]]await|^return|[^\\._$[:alnum:]]return|^default|[^\\._$[:alnum:]]default|^yield|[^\\._$[:alnum:]]yield|^)\\s*\n (?!<\\s*[_$[:alpha:]][_$[:alnum:]]*((\\s+extends\\s+[^=>])|,)) # look ahead is not type parameter of arrow\n (?=(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?))", + "end": "(?!(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?))", + "patterns": [ + { + "include": "#jsx-tag" + } + ] + }, + "jsx-tag": { + "name": "meta.tag.js", + "begin": "(?=(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?))", + "end": "(/>)|(?:())", + "endCaptures": { + "1": { + "name": "punctuation.definition.tag.end.js" + }, + "2": { + "name": "punctuation.definition.tag.begin.js" + }, + "3": { + "name": "entity.name.tag.namespace.js" + }, + "4": { + "name": "punctuation.separator.namespace.js" + }, + "5": { + "name": "entity.name.tag.js" + }, + "6": { + "name": "support.class.component.js" + }, + "7": { + "name": "punctuation.definition.tag.end.js" + } + }, + "patterns": [ + { + "begin": "(<)\\s*(?:([_$[:alpha:]][-_$[:alnum:].]*)(?)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.begin.js" + }, + "2": { + "name": "entity.name.tag.namespace.js" + }, + "3": { + "name": "punctuation.separator.namespace.js" + }, + "4": { + "name": "entity.name.tag.js" + }, + "5": { + "name": "support.class.component.js" + } + }, + "end": "(?=[/]?>)", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#type-arguments" + }, + { + "include": "#jsx-tag-attributes" + } + ] + }, + { + "begin": "(>)", + "beginCaptures": { + "1": { + "name": "punctuation.definition.tag.end.js" + } + }, + "end": "(?=)", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#jsx-tag-attribute-name" + }, + { + "include": "#jsx-tag-attribute-assignment" + }, + { + "include": "#jsx-string-double-quoted" + }, + { + "include": "#jsx-string-single-quoted" + }, + { + "include": "#jsx-evaluated-code" + }, + { + "include": "#jsx-tag-attributes-illegal" + } + ] + }, + "jsx-tag-attribute-name": { + "match": "(?x)\n \\s*\n (?:([_$[:alpha:]][-_$[:alnum:].]*)(:))?\n ([_$[:alpha:]][-_$[:alnum:]]*)\n (?=\\s|=|/?>|/\\*|//)", + "captures": { + "1": { + "name": "entity.other.attribute-name.namespace.js" + }, + "2": { + "name": "punctuation.separator.namespace.js" + }, + "3": { + "name": "entity.other.attribute-name.js" + } + } + }, + "jsx-tag-attribute-assignment": { + "name": "keyword.operator.assignment.js", + "match": "=(?=\\s*(?:'|\"|{|/\\*|//|\\n))" + }, + "jsx-string-double-quoted": { + "name": "string.quoted.double.js", + "begin": "\"", + "end": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.js" + } + }, + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.js" + } + }, + "patterns": [ + { + "include": "#jsx-entities" + } + ] + }, + "jsx-string-single-quoted": { + "name": "string.quoted.single.js", + "begin": "'", + "end": "'", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.js" + } + }, + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.js" + } + }, + "patterns": [ + { + "include": "#jsx-entities" + } + ] + }, + "jsx-tag-attributes-illegal": { + "name": "invalid.illegal.attribute.js", + "match": "\\S+" + } + } +} \ No newline at end of file diff --git a/app/src/main/assets/textmate/quietlight.json b/app/src/main/assets/textmate/quietlight.json new file mode 100644 index 000000000..9b8bec180 --- /dev/null +++ b/app/src/main/assets/textmate/quietlight.json @@ -0,0 +1,542 @@ +{ + "name": "Quiet Light", + "tokenColors": [ + { + "settings": { + "foreground": "#333333" + } + }, + { + "scope": [ + "meta.embedded", + "source.groovy.embedded" + ], + "settings": { + "foreground": "#333333" + } + }, + { + "name": "Comments", + "scope": [ + "comment", + "punctuation.definition.comment" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#AAAAAA" + } + }, + { + "name": "Comments: Preprocessor", + "scope": "comment.block.preprocessor", + "settings": { + "fontStyle": "", + "foreground": "#AAAAAA" + } + }, + { + "name": "Comments: Documentation", + "scope": [ + "comment.documentation", + "comment.block.documentation", + "comment.block.documentation punctuation.definition.comment " + ], + "settings": { + "foreground": "#448C27" + } + }, + { + "name": "Invalid", + "scope": "invalid", + "settings": { + "foreground": "#cd3131" + } + }, + { + "name": "Invalid - Illegal", + "scope": "invalid.illegal", + "settings": { + "foreground": "#660000" + } + }, + { + "name": "Operators", + "scope": "keyword.operator", + "settings": { + "foreground": "#777777" + } + }, + { + "name": "Keywords", + "scope": [ + "keyword", + "storage" + ], + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "Types", + "scope": [ + "storage.type", + "support.type" + ], + "settings": { + "foreground": "#7A3E9D" + } + }, + { + "name": "Language Constants", + "scope": [ + "constant.language", + "support.constant", + "variable.language" + ], + "settings": { + "foreground": "#9C5D27" + } + }, + { + "name": "Variables", + "scope": [ + "variable", + "support.variable" + ], + "settings": { + "foreground": "#7A3E9D" + } + }, + { + "name": "Functions", + "scope": [ + "entity.name.function", + "support.function" + ], + "settings": { + "fontStyle": "bold", + "foreground": "#AA3731" + } + }, + { + "name": "Classes", + "scope": [ + "entity.name.type", + "entity.name.namespace", + "entity.name.scope-resolution", + "entity.other.inherited-class", + "support.class" + ], + "settings": { + "fontStyle": "bold", + "foreground": "#7A3E9D" + } + }, + { + "name": "Exceptions", + "scope": "entity.name.exception", + "settings": { + "foreground": "#660000" + } + }, + { + "name": "Sections", + "scope": "entity.name.section", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Numbers, Characters", + "scope": [ + "constant.numeric", + "constant.character", + "constant" + ], + "settings": { + "foreground": "#9C5D27" + } + }, + { + "name": "Strings", + "scope": "string", + "settings": { + "foreground": "#448C27" + } + }, + { + "name": "Strings: Escape Sequences", + "scope": "constant.character.escape", + "settings": { + "foreground": "#777777" + } + }, + { + "name": "Strings: Regular Expressions", + "scope": "string.regexp", + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "Strings: Symbols", + "scope": "constant.other.symbol", + "settings": { + "foreground": "#9C5D27" + } + }, + { + "name": "Punctuation", + "scope": "punctuation", + "settings": { + "foreground": "#777777" + } + }, + { + "name": "HTML: Doctype Declaration", + "scope": [ + "meta.tag.sgml.doctype", + "meta.tag.sgml.doctype string", + "meta.tag.sgml.doctype entity.name.tag", + "meta.tag.sgml punctuation.definition.tag.html" + ], + "settings": { + "foreground": "#AAAAAA" + } + }, + { + "name": "HTML: Tags", + "scope": [ + "meta.tag", + "punctuation.definition.tag.html", + "punctuation.definition.tag.begin.html", + "punctuation.definition.tag.end.html" + ], + "settings": { + "foreground": "#91B3E0" + } + }, + { + "name": "HTML: Tag Names", + "scope": "entity.name.tag", + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "HTML: Attribute Names", + "scope": [ + "meta.tag entity.other.attribute-name", + "entity.other.attribute-name.html" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#8190A0" + } + }, + { + "name": "HTML: Entities", + "scope": [ + "constant.character.entity", + "punctuation.definition.entity" + ], + "settings": { + "foreground": "#9C5D27" + } + }, + { + "name": "CSS: Selectors", + "scope": [ + "meta.selector", + "meta.selector entity", + "meta.selector entity punctuation", + "entity.name.tag.css" + ], + "settings": { + "foreground": "#7A3E9D" + } + }, + { + "name": "CSS: Property Names", + "scope": [ + "meta.property-name", + "support.type.property-name" + ], + "settings": { + "foreground": "#9C5D27" + } + }, + { + "name": "CSS: Property Values", + "scope": [ + "meta.property-value", + "meta.property-value constant.other", + "support.constant.property-value" + ], + "settings": { + "foreground": "#448C27" + } + }, + { + "name": "CSS: Important Keyword", + "scope": "keyword.other.important", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Markup: Changed", + "scope": "markup.changed", + "settings": { + "foreground": "#000000" + } + }, + { + "name": "Markup: Deletion", + "scope": "markup.deleted", + "settings": { + "foreground": "#000000" + } + }, + { + "name": "Markup: Emphasis", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, + { + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markup: Error", + "scope": "markup.error", + "settings": { + "foreground": "#660000" + } + }, + { + "name": "Markup: Insertion", + "scope": "markup.inserted", + "settings": { + "foreground": "#000000" + } + }, + { + "name": "Markup: Link", + "scope": "meta.link", + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "Markup: Output", + "scope": [ + "markup.output", + "markup.raw" + ], + "settings": { + "foreground": "#777777" + } + }, + { + "name": "Markup: Prompt", + "scope": "markup.prompt", + "settings": { + "foreground": "#777777" + } + }, + { + "name": "Markup: Heading", + "scope": "markup.heading", + "settings": { + "foreground": "#AA3731" + } + }, + { + "name": "Markup: Strong", + "scope": "markup.bold", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Markup: Traceback", + "scope": "markup.traceback", + "settings": { + "foreground": "#660000" + } + }, + { + "name": "Markup: Underline", + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#7A3E9D" + } + }, + { + "name": "Markup Lists", + "scope": "markup.list", + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "Markup Styling", + "scope": [ + "markup.bold", + "markup.italic" + ], + "settings": { + "foreground": "#448C27" + } + }, + { + "name": "Markup Inline", + "scope": "markup.inline.raw", + "settings": { + "fontStyle": "", + "foreground": "#9C5D27" + } + }, + { + "name": "Extra: Diff Range", + "scope": [ + "meta.diff.range", + "meta.diff.index", + "meta.separator" + ], + "settings": { + "foreground": "#434343" + } + }, + { + "name": "Extra: Diff From", + "scope": [ + "meta.diff.header.from-file", + "punctuation.definition.from-file.diff" + ], + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "Extra: Diff To", + "scope": [ + "meta.diff.header.to-file", + "punctuation.definition.to-file.diff" + ], + "settings": { + "foreground": "#4B69C6" + } + }, + { + "name": "diff: deleted", + "scope": "markup.deleted.diff", + "settings": { + "foreground": "#C73D20" + } + }, + { + "name": "diff: changed", + "scope": "markup.changed.diff", + "settings": { + "foreground": "#9C5D27" + } + }, + { + "name": "diff: inserted", + "scope": "markup.inserted.diff", + "settings": { + "foreground": "#448C27" + } + }, + { + "name": "JSX: Tags", + "scope": [ + "punctuation.definition.tag.js", + "punctuation.definition.tag.begin.js", + "punctuation.definition.tag.end.js" + ], + "settings": { + "foreground": "#91B3E0" + } + }, + { + "name": "JSX: InnerText", + "scope": "meta.jsx.children.js", + "settings": { + "foreground": "#333333ff" + } + } + ], + "colors": { + "focusBorder": "#A6B39B", + "pickerGroup.foreground": "#A6B39B", + "pickerGroup.border": "#749351", + "list.activeSelectionForeground": "#6c6c6c", + "quickInputList.focusBackground": "#CADEB9", + "list.hoverBackground": "#e0e0e0", + "list.activeSelectionBackground": "#c4d9b1", + "list.inactiveSelectionBackground": "#d3dbcd", + "list.highlightForeground": "#9769dc", + "selection.background": "#C9D0D9", + "editor.background": "#F5F5F5", + "editorWhitespace.foreground": "#AAAAAA", + "editor.lineHighlightBackground": "#E4F6D4", + "editorLineNumber.activeForeground": "#9769dc", + "editor.selectionBackground": "#C9D0D9", + "minimap.selectionHighlight": "#C9D0D9", + "panel.background": "#F5F5F5", + "sideBar.background": "#F2F2F2", + "sideBarSectionHeader.background": "#ede8ef", + "editorLineNumber.foreground": "#6D705B", + "editorCursor.foreground": "#54494B", + "inputOption.activeBorder": "#adafb7", + "dropdown.background": "#F5F5F5", + "editor.findMatchBackground": "#BF9CAC", + "editor.findMatchHighlightBackground": "#edc9d8", + "peekViewEditor.matchHighlightBackground": "#C2DFE3", + "peekViewTitle.background": "#F2F8FC", + "peekViewEditor.background": "#F2F8FC", + "peekViewResult.background": "#F2F8FC", + "peekView.border": "#705697", + "peekViewResult.matchHighlightBackground": "#93C6D6", + "tab.lastPinnedBorder": "#c9d0d9", + "statusBar.background": "#705697", + "welcomePage.tileBackground": "#f0f0f7", + "statusBar.noFolderBackground": "#705697", + "statusBar.debuggingBackground": "#705697", + "statusBarItem.remoteBackground": "#4e3c69", + "ports.iconRunningProcessForeground": "#749351", + "activityBar.background": "#EDEDF5", + "activityBar.foreground": "#705697", + "activityBarBadge.background": "#705697", + "titleBar.activeBackground": "#c4b7d7", + "button.background": "#705697", + "editorGroup.dropBackground": "#C9D0D988", + "inputValidation.infoBorder": "#4ec1e5", + "inputValidation.infoBackground": "#f2fcff", + "inputValidation.warningBackground": "#fffee2", + "inputValidation.warningBorder": "#ffe055", + "inputValidation.errorBackground": "#ffeaea", + "inputValidation.errorBorder": "#f1897f", + "errorForeground": "#f1897f", + "badge.background": "#705697AA", + "progressBar.background": "#705697", + "walkThrough.embeddedEditorBackground": "#00000014", + "editorIndentGuide.background": "#aaaaaa60", + "editorIndentGuide.activeBackground": "#777777b0" + }, + "semanticHighlighting": true +} \ No newline at end of file diff --git a/app/src/main/assets/textmate/solarized_drak.json b/app/src/main/assets/textmate/solarized_drak.json new file mode 100644 index 000000000..b0de1a5da --- /dev/null +++ b/app/src/main/assets/textmate/solarized_drak.json @@ -0,0 +1,421 @@ +{ + "name": "Solarized (dark)", + "tokenColors": [ + { + "settings": { + "foreground": "#839496" + } + }, + { + "scope": [ + "meta.embedded", + "source.groovy.embedded" + ], + "settings": { + "foreground": "#839496" + } + }, + { + "name": "Comment", + "scope": "comment", + "settings": { + "fontStyle": "italic", + "foreground": "#586E75" + } + }, + { + "name": "String", + "scope": "string", + "settings": { + "foreground": "#2AA198" + } + }, + { + "name": "Regexp", + "scope": "string.regexp", + "settings": { + "foreground": "#DC322F" + } + }, + { + "name": "Number", + "scope": "constant.numeric", + "settings": { + "foreground": "#D33682" + } + }, + { + "name": "Variable", + "scope": [ + "variable.language", + "variable.other" + ], + "settings": { + "foreground": "#268BD2" + } + }, + { + "name": "Keyword", + "scope": "keyword", + "settings": { + "foreground": "#859900" + } + }, + { + "name": "Storage", + "scope": "storage", + "settings": { + "fontStyle": "bold", + "foreground": "#93A1A1" + } + }, + { + "name": "Class name", + "scope": [ + "entity.name.class", + "entity.name.type", + "entity.name.namespace", + "entity.name.scope-resolution" + ], + "settings": { + "fontStyle": "", + "foreground": "#CB4B16" + } + }, + { + "name": "Function name", + "scope": "entity.name.function", + "settings": { + "foreground": "#268BD2" + } + }, + { + "name": "Variable start", + "scope": "punctuation.definition.variable", + "settings": { + "foreground": "#859900" + } + }, + { + "name": "Embedded code markers", + "scope": [ + "punctuation.section.embedded.begin", + "punctuation.section.embedded.end" + ], + "settings": { + "foreground": "#DC322F" + } + }, + { + "name": "Built-in constant", + "scope": [ + "constant.language", + "meta.preprocessor" + ], + "settings": { + "foreground": "#B58900" + } + }, + { + "name": "Support.construct", + "scope": [ + "support.function.construct", + "keyword.other.new" + ], + "settings": { + "foreground": "#CB4B16" + } + }, + { + "name": "User-defined constant", + "scope": [ + "constant.character", + "constant.other" + ], + "settings": { + "foreground": "#CB4B16" + } + }, + { + "name": "Inherited class", + "scope": "entity.other.inherited-class", + "settings": { + "foreground": "#6C71C4" + } + }, + { + "name": "Function argument", + "scope": "variable.parameter", + "settings": {} + }, + { + "name": "Tag name", + "scope": "entity.name.tag", + "settings": { + "foreground": "#268BD2" + } + }, + { + "name": "Tag start/end", + "scope": "punctuation.definition.tag", + "settings": { + "foreground": "#586E75" + } + }, + { + "name": "Tag attribute", + "scope": "entity.other.attribute-name", + "settings": { + "foreground": "#93A1A1" + } + }, + { + "name": "Library function", + "scope": "support.function", + "settings": { + "foreground": "#268BD2" + } + }, + { + "name": "Continuation", + "scope": "punctuation.separator.continuation", + "settings": { + "foreground": "#DC322F" + } + }, + { + "name": "Library constant", + "scope": [ + "support.constant", + "support.variable" + ], + "settings": {} + }, + { + "name": "Library class/type", + "scope": [ + "support.type", + "support.class" + ], + "settings": { + "foreground": "#859900" + } + }, + { + "name": "Library Exception", + "scope": "support.type.exception", + "settings": { + "foreground": "#CB4B16" + } + }, + { + "name": "Library variable", + "scope": "support.other.variable", + "settings": {} + }, + { + "name": "Invalid", + "scope": "invalid", + "settings": { + "foreground": "#DC322F" + } + }, + { + "name": "diff: header", + "scope": [ + "meta.diff", + "meta.diff.header" + ], + "settings": { + "fontStyle": "italic", + "foreground": "#268BD2" + } + }, + { + "name": "diff: deleted", + "scope": "markup.deleted", + "settings": { + "fontStyle": "", + "foreground": "#DC322F" + } + }, + { + "name": "diff: changed", + "scope": "markup.changed", + "settings": { + "fontStyle": "", + "foreground": "#CB4B16" + } + }, + { + "name": "diff: inserted", + "scope": "markup.inserted", + "settings": { + "foreground": "#859900" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#859900" + } + }, + { + "name": "Markup Lists", + "scope": "markup.list", + "settings": { + "foreground": "#B58900" + } + }, + { + "name": "Markup Styling", + "scope": [ + "markup.bold", + "markup.italic" + ], + "settings": { + "foreground": "#D33682" + } + }, + { + "name": "Markup: Strong", + "scope": "markup.bold", + "settings": { + "fontStyle": "bold" + } + }, + { + "name": "Markup: Emphasis", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, + { + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markup Inline", + "scope": "markup.inline.raw", + "settings": { + "fontStyle": "", + "foreground": "#2AA198" + } + }, + { + "name": "Markup Headings", + "scope": "markup.heading", + "settings": { + "fontStyle": "bold", + "foreground": "#268BD2" + } + }, + { + "name": "Markup Setext Header", + "scope": "markup.heading.setext", + "settings": { + "fontStyle": "", + "foreground": "#268BD2" + } + } + ], + "colors": { + "focusBorder": "#2AA19899", + "selection.background": "#2AA19899", + "input.background": "#003847", + "input.foreground": "#93A1A1", + "input.placeholderForeground": "#93A1A1AA", + "inputOption.activeBorder": "#2AA19899", + "inputValidation.infoBorder": "#363b5f", + "inputValidation.infoBackground": "#052730", + "inputValidation.warningBackground": "#5d5938", + "inputValidation.warningBorder": "#9d8a5e", + "inputValidation.errorBackground": "#571b26", + "inputValidation.errorBorder": "#a92049", + "errorForeground": "#ffeaea", + "badge.background": "#047aa6", + "progressBar.background": "#047aa6", + "dropdown.background": "#00212B", + "dropdown.border": "#2AA19899", + "button.background": "#2AA19899", + "list.activeSelectionBackground": "#005A6F", + "quickInputList.focusBackground": "#005A6F", + "list.hoverBackground": "#004454AA", + "list.inactiveSelectionBackground": "#00445488", + "list.dropBackground": "#00445488", + "list.highlightForeground": "#1ebcc5", + "editor.background": "#002B36", + "editor.foreground": "#839496", + "editorWidget.background": "#00212B", + "editorCursor.foreground": "#D30102", + "editorWhitespace.foreground": "#93A1A180", + "editor.lineHighlightBackground": "#073642", + "editorLineNumber.activeForeground": "#949494", + "editor.selectionBackground": "#274642", + "minimap.selectionHighlight": "#274642", + "editorIndentGuide.background": "#93A1A180", + "editorIndentGuide.activeBackground": "#C3E1E180", + "editorHoverWidget.background": "#004052", + "editorMarkerNavigationError.background": "#AB395B", + "editorMarkerNavigationWarning.background": "#5B7E7A", + "editor.selectionHighlightBackground": "#005A6FAA", + "editor.wordHighlightBackground": "#004454AA", + "editor.wordHighlightStrongBackground": "#005A6FAA", + "editorBracketHighlight.foreground1": "#cdcdcdff", + "editorBracketHighlight.foreground2": "#b58900ff", + "editorBracketHighlight.foreground3": "#d33682ff", + "peekViewResult.background": "#00212B", + "peekViewEditor.background": "#10192c", + "peekViewTitle.background": "#00212B", + "peekView.border": "#2b2b4a", + "peekViewEditor.matchHighlightBackground": "#7744AA40", + "titleBar.activeBackground": "#002C39", + "editorGroup.border": "#00212B", + "editorGroup.dropBackground": "#2AA19844", + "editorGroupHeader.tabsBackground": "#004052", + "tab.activeForeground": "#d6dbdb", + "tab.activeBackground": "#002B37", + "tab.inactiveForeground": "#93A1A1", + "tab.inactiveBackground": "#004052", + "tab.border": "#003847", + "tab.lastPinnedBorder": "#2AA19844", + "activityBar.background": "#003847", + "panel.border": "#2b2b4a", + "sideBar.background": "#00212B", + "sideBarTitle.foreground": "#93A1A1", + "statusBar.foreground": "#93A1A1", + "statusBar.background": "#00212B", + "statusBar.debuggingBackground": "#00212B", + "statusBar.noFolderBackground": "#00212B", + "statusBarItem.remoteBackground": "#2AA19899", + "ports.iconRunningProcessForeground": "#369432", + "statusBarItem.prominentBackground": "#003847", + "statusBarItem.prominentHoverBackground": "#003847", + "debugToolBar.background": "#00212B", + "debugExceptionWidget.background": "#00212B", + "debugExceptionWidget.border": "#AB395B", + "pickerGroup.foreground": "#2AA19899", + "pickerGroup.border": "#2AA19899", + "terminal.ansiBlack": "#073642", + "terminal.ansiRed": "#dc322f", + "terminal.ansiGreen": "#859900", + "terminal.ansiYellow": "#b58900", + "terminal.ansiBlue": "#268bd2", + "terminal.ansiMagenta": "#d33682", + "terminal.ansiCyan": "#2aa198", + "terminal.ansiWhite": "#eee8d5", + "terminal.ansiBrightBlack": "#002b36", + "terminal.ansiBrightRed": "#cb4b16", + "terminal.ansiBrightGreen": "#586e75", + "terminal.ansiBrightYellow": "#657b83", + "terminal.ansiBrightBlue": "#839496", + "terminal.ansiBrightMagenta": "#6c71c4", + "terminal.ansiBrightCyan": "#93a1a1", + "terminal.ansiBrightWhite": "#fdf6e3" + }, + "semanticHighlighting": true +} \ No newline at end of file diff --git a/app/src/main/ic_app-playstore.png b/app/src/main/ic_app-playstore.png deleted file mode 100644 index 7d4fa1f42..000000000 Binary files a/app/src/main/ic_app-playstore.png and /dev/null differ diff --git a/app/src/main/ic_new_app-playstore.png b/app/src/main/ic_new_app-playstore.png new file mode 100644 index 000000000..9ba224c01 Binary files /dev/null and b/app/src/main/ic_new_app-playstore.png differ diff --git a/app/src/main/ic_new_app_launcher-playstore.png b/app/src/main/ic_new_app_launcher-playstore.png new file mode 100644 index 000000000..ec2ba5cbb Binary files /dev/null and b/app/src/main/ic_new_app_launcher-playstore.png differ diff --git a/app/src/main/java/com/github/jing332/tts_server_android/App.kt b/app/src/main/java/com/github/jing332/tts_server_android/App.kt new file mode 100644 index 000000000..b313e13c3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/App.kt @@ -0,0 +1,54 @@ +package com.github.jing332.tts_server_android + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.content.Intent +import android.os.Process +import com.github.jing332.tts_server_android.model.hanlp.HanlpManager +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import java.util.* +import kotlin.properties.Delegates + + +val app: App + inline get() = App.instance + +@Suppress("DEPRECATION") +class App : Application() { + companion object { + const val TAG = "App" + var instance: App by Delegates.notNull() + val context: Context by lazy { instance } + } + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base.apply { AppLocale.setLocale(base) }) + } + + @SuppressLint("SdCardPath") + @OptIn(DelicateCoroutinesApi::class) + override fun onCreate() { + super.onCreate() + instance = this + CrashHandler(this) + + GlobalScope.launch { + HanlpManager.initDir( + context.getExternalFilesDir("hanlp")?.absolutePath + ?: "/data/data/$packageName/files/hanlp" + ) + } + } + + @SuppressLint("UnspecifiedImmutableFlag") + fun restart() { + val intent = packageManager.getLaunchIntentForPackage(packageName)!! + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(intent); + //杀掉以前进程 + Process.killProcess(Process.myPid()); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/AppLocale.kt b/app/src/main/java/com/github/jing332/tts_server_android/AppLocale.kt new file mode 100644 index 000000000..83ceeea42 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/AppLocale.kt @@ -0,0 +1,108 @@ +package com.github.jing332.tts_server_android + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.LocaleList +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import com.github.jing332.tts_server_android.utils.FileUtils +import com.github.jing332.tts_server_android.utils.sysConfiguration +import java.io.File +import java.util.* + + +@Suppress("DEPRECATION") +object AppLocale { + val localeMap by lazy { + linkedMapOf().apply { + BuildConfig.TRANSLATION_ARRAY.sorted().forEach { + this[it] = Locale.forLanguageTag(it) + } + } + } + + fun getLocaleCodeFromFile(context: Context): String { + kotlin.runCatching { + val file = File(context.filesDir.absolutePath + "/application_locale") + return file.readText() + } + return "" + } + + fun saveLocaleCodeToFile(context: Context, lang: String) { + val file = File(context.filesDir.absolutePath + "/application_locale") + FileUtils.saveFile(file, lang.toByteArray()) + } + + fun setLocale(context: Context, locale: Locale = getLocaleFromFile(context)) { + val resources = context.resources + val metrics = resources.displayMetrics + val configuration = resources.configuration + val newLocale = getLocaleFromFile(context) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + configuration.setLocale(newLocale) + val localeList = LocaleList(newLocale) + LocaleList.setDefault(localeList) + configuration.setLocales(localeList) + } else { + Locale.setDefault(newLocale) + configuration.setLocale(newLocale) + } + + resources.updateConfiguration(configuration, metrics) + AppCompatDelegate.setApplicationLocales(LocaleListCompat.create(locale)) + } + + /** + * 当前系统语言 + */ + @SuppressLint("ObsoleteSdkInt") + private fun getSystemLocale(): Locale { + val locale: Locale + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { //7.0有多语言设置获取顶部的语言 + locale = sysConfiguration.locales.get(0) + } else { + @Suppress("DEPRECATION") + locale = sysConfiguration.locale + } + return locale + } + + /** + * 当前App语言 + */ + @SuppressLint("ObsoleteSdkInt") + fun getAppLocale(context: Context): Locale { + val locale: Locale + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + locale = context.resources.configuration.locales[0] + } else { + @Suppress("DEPRECATION") + locale = context.resources.configuration.locale + } + return locale + } + + + /** + * 当前设置语言 + */ + fun getLocaleFromFile(context: Context): Locale { + return localeMap[getLocaleCodeFromFile(context)] ?: getSystemLocale() + } + + /** + * 判断App语言和设置语言是否相同 + */ + fun isSameWithSetting(context: Context): Boolean { + val locale = getAppLocale(context) + val language = locale.language + val country = locale.country + val pfLocale = getLocaleFromFile(context) + val pfLanguage = pfLocale.language + val pfCountry = pfLocale.country + return language == pfLanguage && country == pfCountry + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/CrashHandler.kt b/app/src/main/java/com/github/jing332/tts_server_android/CrashHandler.kt new file mode 100644 index 000000000..a80459d5e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/CrashHandler.kt @@ -0,0 +1,44 @@ +package com.github.jing332.tts_server_android + +import android.content.Context +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.runOnUI +import tts_server_lib.Tts_server_lib +import java.time.LocalDateTime + + +class CrashHandler(var context: Context) : Thread.UncaughtExceptionHandler { + private var mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler() + + init { + Thread.setDefaultUncaughtExceptionHandler(this) + } + + override fun uncaughtException(t: Thread, e: Throwable) { + handleException(e) + mDefaultHandler?.uncaughtException(t, e) + } + + private fun handleException(e: Throwable) { + context.longToast("TTS Server已崩溃 上传日志中 稍后将会复制到剪贴板") + val log = "\n${LocalDateTime.now()}" + + "\n版本代码:${AppConst.appInfo.versionCode}, 版本名称:${AppConst.appInfo.versionName}\n" + + "崩溃详情:\n${e.stackTraceToString()}" + val copyContent: String = try { + if (BuildConfig.DEBUG) + log + else + Tts_server_lib.uploadLog(log) + } catch (e: Exception) { + e.printStackTrace() + log + } + + runOnUI { + ClipboardUtils.copyText("TTS-Server崩溃日志", copyContent) + context.longToast("已将日志复制到剪贴板") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/GoLog.kt b/app/src/main/java/com/github/jing332/tts_server_android/GoLog.kt deleted file mode 100644 index 15f6e53bd..000000000 --- a/app/src/main/java/com/github/jing332/tts_server_android/GoLog.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.jing332.tts_server_android - -import android.graphics.Color -import java.io.Serializable - -class GoLog(var level: Int, var msg: String) : Serializable { - fun toText(): String { - return GoLogLevel.toString(level) - } - - fun toColor(): Int { - return when { - level == GoLogLevel.WarnLevel -> { - Color.rgb(255, 215, 0) /* 金色 */ - } - level <= GoLogLevel.ErrorLevel -> { - Color.RED - } - else -> { - Color.GRAY - } - } - } - -} - -object GoLogLevel { - const val PanicLevel = 0 - const val FatalLevel = 1 - const val ErrorLevel = 2 - const val WarnLevel = 3 - const val InfoLevel = 4 - const val DebugLevel = 5 - const val TraceLevel = 6 - - fun toString(level: Int): String { - when (level) { - PanicLevel -> return "宕机" - FatalLevel -> return "致命" - ErrorLevel -> return "错误" - WarnLevel -> return "警告" - InfoLevel -> return "信息" - DebugLevel -> return "调试" - TraceLevel -> return "详细" - } - return level.toString() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ShortCuts.kt b/app/src/main/java/com/github/jing332/tts_server_android/ShortCuts.kt new file mode 100644 index 000000000..3aede614b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ShortCuts.kt @@ -0,0 +1,85 @@ +package com.github.jing332.tts_server_android + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.github.jing332.tts_server_android.compose.systts.plugin.PluginManagerActivity +import com.github.jing332.tts_server_android.compose.systts.replace.ReplaceManagerActivity +import com.github.jing332.tts_server_android.compose.systts.speechrule.SpeechRuleManagerActivity +import com.github.jing332.tts_server_android.ui.forwarder.MsForwarderSwitchActivity +import com.github.jing332.tts_server_android.ui.forwarder.SystemForwarderSwitchActivity + +object ShortCuts { + private inline fun buildIntent(context: Context): Intent { + val intent = Intent(context, T::class.java) + intent.action = Intent.ACTION_VIEW + return intent + } + + private fun buildSysSwitchShortCutInfo(context: Context): ShortcutInfoCompat { + val msSwitchIntent = + buildIntent( + context + ) + return ShortcutInfoCompat.Builder(context, "forwarder_sys_switch") + .setShortLabel(context.getString(R.string.forwarder_systts)) + .setLongLabel(context.getString(R.string.forwarder_systts)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_switch)) + .setIntent(msSwitchIntent) + .build() + } + + + private fun buildMsSwitchShortCutInfo(context: Context): ShortcutInfoCompat { + val msSwitchIntent = buildIntent(context) + return ShortcutInfoCompat.Builder(context, "forwarder_ms_switch") + .setShortLabel(context.getString(R.string.forwarder_ms)) + .setLongLabel(context.getString(R.string.forwarder_ms)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_switch)) + .setIntent(msSwitchIntent) + .build() + } + + private fun buildReplaceManagerShortCutInfo(context: Context): ShortcutInfoCompat { + return ShortcutInfoCompat.Builder(context, "replace_manager") + .setShortLabel(context.getString(R.string.replace_rule_manager)) + .setLongLabel(context.getString(R.string.replace_rule_manager)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_replace)) + .setIntent(buildIntent(context)) + .build() + } + + private fun buildSpeechManagerShortCutInfo(context: Context): ShortcutInfoCompat { + return ShortcutInfoCompat.Builder(context, "speech_rule_manager") + .setShortLabel(context.getString(R.string.speech_rule_manager)) + .setLongLabel(context.getString(R.string.speech_rule_manager)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_speech_rule)) + .setIntent(buildIntent(context)) + .build() + } + + private fun buildPluginManagerShortCutInfo(context: Context): ShortcutInfoCompat { + return ShortcutInfoCompat.Builder(context, "plugin_manager") + .setShortLabel(context.getString(R.string.plugin_manager)) + .setLongLabel(context.getString(R.string.plugin_manager)) + .setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_plugin)) + .setIntent(buildIntent(context)) + .build() + } + + fun buildShortCuts(context: Context) { + ShortcutManagerCompat.setDynamicShortcuts( + context, listOf( + buildMsSwitchShortCutInfo(context), + buildSysSwitchShortCutInfo(context), + buildReplaceManagerShortCutInfo(context), + buildSpeechManagerShortCutInfo(context), + buildPluginManagerShortCutInfo(context) + ) + ) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/bean/EdgeVoiceBean.kt b/app/src/main/java/com/github/jing332/tts_server_android/bean/EdgeVoiceBean.kt new file mode 100644 index 000000000..90bda67cb --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/bean/EdgeVoiceBean.kt @@ -0,0 +1,23 @@ +package com.github.jing332.tts_server_android.bean + + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EdgeVoiceBean( + @SerialName("Gender") + val gender: String, + @SerialName("Locale") + val locale: String, + @SerialName("Name") + val name: String, + @SerialName("ShortName") + val shortName: String, + @SerialName("FriendlyName") + val friendlyName: String, +// @SerialName("Status") +// val status: String, +// @SerialName("SuggestedCodec") +// val suggestedCodec: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/bean/GithubReleaseApiBean.kt b/app/src/main/java/com/github/jing332/tts_server_android/bean/GithubReleaseApiBean.kt new file mode 100644 index 000000000..df80cbdfd --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/bean/GithubReleaseApiBean.kt @@ -0,0 +1,24 @@ +package com.github.jing332.tts_server_android.bean + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GithubReleaseApiBean( + @SerialName("assets") + val assets: List, + @SerialName("body") + val body: String, + @SerialName("tag_name") + val tagName: String, +) + +@Serializable +data class Asset( + @SerialName("browser_download_url") + val browserDownloadUrl: String, + @SerialName("name") + val name: String, + @SerialName("size") + val size: Int, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/bean/LegadoHttpTts.kt b/app/src/main/java/com/github/jing332/tts_server_android/bean/LegadoHttpTts.kt new file mode 100644 index 000000000..d071661af --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/bean/LegadoHttpTts.kt @@ -0,0 +1,28 @@ +package com.github.jing332.tts_server_android.bean + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class LegadoHttpTts( + @SerialName("header") val header: String = "", + @SerialName("id") val id: Long = 0, + @SerialName("name") val name: String = "", + @SerialName("url") val url: String = "" + +// @SerialName("concurrentRate") +// val concurrentRate: String, +// @SerialName("contentType") +// val contentType: String, +// @SerialName("enabledCookieJar") +// val enabledCookieJar: Boolean, +// @SerialName("lastUpdateTime") +// val lastUpdateTime: Long, +// @SerialName("loginCheckJs") +// val loginCheckJs: String, +// @SerialName("loginUi") +// val loginUi: String, +// @SerialName("loginUrl") +// val loginUrl: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/AboutDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/AboutDialog.kt new file mode 100644 index 000000000..207739b41 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/AboutDialog.kt @@ -0,0 +1,93 @@ +package com.github.jing332.tts_server_android.compose + +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextAlign.Companion +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.github.jing332.tts_server_android.BuildConfig +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.AppLauncherIcon + +@Composable +fun AboutDialog(onDismissRequest: () -> Unit) { + val context = LocalContext.current + + AppDialog( + onDismissRequest = onDismissRequest, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + AppLauncherIcon(modifier = Modifier.size(64.dp)) + Text( + stringResource(id = R.string.app_name), + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 8.dp) + ) + } + }, + content = { + fun openUrl(uri: String) { + context.startActivity( + Intent(Intent.ACTION_VIEW).apply { + data = uri.toUri() + } + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SelectionContainer { + Column(Modifier.padding(vertical = 4.dp)) { + Text("${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})") + } + } + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Text( + "Github - TTS Server", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .clickable { + openUrl("https://github.com/jing332/tts-server-android") + } + .padding(vertical = 8.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + }, + buttons = { + TextButton(onClick = { + onDismissRequest() + context.startActivity( + Intent(context, LibrariesActivity::class.java).setAction(Intent.ACTION_VIEW) + ) + }) { + Text(text = stringResource(id = R.string.open_source_license)) + } + + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.confirm)) + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/AppUpdateDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/AppUpdateDialog.kt new file mode 100644 index 000000000..881aaad18 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/AppUpdateDialog.kt @@ -0,0 +1,148 @@ +package com.github.jing332.tts_server_android.compose + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.Markdown +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.model.updater.AppUpdateChecker +import com.github.jing332.tts_server_android.utils.ClipboardUtils + +@Preview +@Composable +private fun PreviewAppUpdateDialog() { + var show by remember { androidx.compose.runtime.mutableStateOf(true) } + if (show) + AppUpdateDialog( + onDismissRequest = { + show = false + }, version = "1.0.0", content = "## 更新内容\n\n- 123", downloadUrl = "url" + ) + +} + +@Composable +fun AppUpdateActionDialog(onDismissRequest: () -> Unit, result: AppUpdateChecker.ActionResult) { + val context = LocalContext.current + fun openDownloadUrl(url: String) { + ClipboardUtils.copyText("TTS Server", url) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + AppDialog(onDismissRequest = onDismissRequest, title = { + Column { + Text( + stringResource(id = R.string.check_update) + " (Github Actions)", + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = AppConst.dateFormatSec.format(result.time * 1000), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + }, content = { + Column(Modifier.padding(12.dp)) { + Text( + text = result.title, + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + buttons = { + Row { + TextButton(onClick = { + onDismissRequest() + openDownloadUrl(result.url) + }) { + Text("Github") + } + + TextButton(onClick = { onDismissRequest() }) { + Text(stringResource(id = R.string.cancel)) + } + } + } + ) +} + +@Composable +fun AppUpdateDialog( + onDismissRequest: () -> Unit, + version: String, + content: String, + downloadUrl: String +) { + val context = LocalContext.current + fun openDownloadUrl(url: String) { + ClipboardUtils.copyText("TTS Server", url) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } + + AppDialog(onDismissRequest = onDismissRequest, + title = { + Text( + stringResource(id = R.string.check_update), + style = MaterialTheme.typography.titleLarge, + ) + }, + content = { + Column { + Text( + text = version, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + val scrollState = rememberScrollState() + Column( + Modifier + .padding(8.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.Center + ) { + Markdown( + content = content, + modifier = Modifier + .padding(4.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + } + } + }, + buttons = { + Row { + TextButton(onClick = { openDownloadUrl(downloadUrl) }) { + Text("下载(Github)") + } + TextButton(onClick = { openDownloadUrl("https://ghproxy.com/${downloadUrl}") }) { + Text("下载(ghproxy加速)") + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/AutoUpdateCheckerDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/AutoUpdateCheckerDialog.kt new file mode 100644 index 000000000..60b75f0fe --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/AutoUpdateCheckerDialog.kt @@ -0,0 +1,79 @@ +package com.github.jing332.tts_server_android.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.model.updater.AppUpdateChecker +import com.github.jing332.tts_server_android.model.updater.UpdateResult +import com.github.jing332.tts_server_android.utils.longToast +import kotlinx.coroutines.CancellationException + +@Composable +internal fun AutoUpdateCheckerDialog( + showUpdateToast: Boolean = true, + fromAction: Boolean = false, + dismiss: () -> Unit +) { + val context = LocalContext.current + var showDialog by remember { mutableStateOf(null) } + if (showDialog != null) { + val ret = showDialog!! + LaunchedEffect(ret) { + if (showUpdateToast && ret.hasUpdate()) + context.longToast(R.string.new_version_available, ret.version) + } + AppUpdateDialog( + onDismissRequest = { + showDialog = null + dismiss() + }, + version = ret.version, + content = ret.content, + downloadUrl = ret.downloadUrl, + ) + } + + var showActionDialog by remember { mutableStateOf(null) } + if (showActionDialog != null) { + val ret = showActionDialog!! + AppUpdateActionDialog( + onDismissRequest = { + showActionDialog = null + dismiss() + }, + result = ret + ) + } + + LaunchedEffect(Unit) { + val result = try { + withIO { + if (fromAction) AppUpdateChecker.checkUpdateFromActions() + else AppUpdateChecker.checkUpdate() + } + } catch (_: CancellationException) { + null + } catch (e: Exception) { + e.printStackTrace() + context.longToast(context.getString(R.string.check_update_failed) + "\n$e") + null + } + + if (result is AppUpdateChecker.ActionResult?) { + if (result == null) { + dismiss() + } else { + showActionDialog = result + } + } else if (result is UpdateResult?) { + if (result?.hasUpdate() == true) showDialog = result + else dismiss() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/LibrariesActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/LibrariesActivity.kt new file mode 100644 index 000000000..22c1e951e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/LibrariesActivity.kt @@ -0,0 +1,67 @@ +package com.github.jing332.tts_server_android.compose + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults + +class LibrariesActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppTheme { + Content() + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun Content() { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = stringResource(id = R.string.open_source_license)) }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.nav_back) + ) + } + } + ) + } + ) { + LibrariesContainer( + Modifier + .fillMaxSize() + .padding(it), + colors = LibraryDefaults.libraryColors( + backgroundColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + badgeBackgroundColor = MaterialTheme.colorScheme.primaryContainer, + badgeContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/MainActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/MainActivity.kt new file mode 100644 index 000000000..b2d50d0cd --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/MainActivity.kt @@ -0,0 +1,505 @@ +@file:Suppress("DEPRECATION") + +package com.github.jing332.tts_server_android.compose + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowCircleUp +import androidx.compose.material.icons.filled.BatteryFull +import androidx.compose.material.icons.filled.HelpOutline +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.navigation.NavController +import androidx.navigation.NavDeepLinkRequest +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.github.jing332.tts_server_android.BuildConfig +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.ShortCuts +import com.github.jing332.tts_server_android.compose.forwarder.ms.MsTtsForwarderScreen +import com.github.jing332.tts_server_android.compose.forwarder.systts.SystemTtsForwarderScreen +import com.github.jing332.tts_server_android.compose.nav.NavRoutes +import com.github.jing332.tts_server_android.compose.settings.SettingsScreen +import com.github.jing332.tts_server_android.compose.systts.SystemTtsScreen +import com.github.jing332.tts_server_android.compose.systts.list.edit.TtsEditContainerScreen +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.AppLauncherIcon +import com.github.jing332.tts_server_android.conf.AppConfig +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import com.github.jing332.tts_server_android.service.systts.SystemTtsService +import com.github.jing332.tts_server_android.ui.AppHelpDocumentActivity +import com.github.jing332.tts_server_android.utils.MyTools.killBattery +import com.github.jing332.tts_server_android.utils.clone +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.performLongPress +import com.github.jing332.tts_server_android.utils.toast +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import kotlinx.coroutines.launch + + +val LocalNavController = compositionLocalOf { error("No nav controller") } +val LocalDrawerState = compositionLocalOf { error("No drawer state") } + +fun Context.asAppCompatActivity(): AppCompatActivity { + return this as? AppCompatActivity ?: error("Context is not an AppCompatActivity") +} + +fun Context.asActivity(): Activity { + return this as? Activity ?: error("Context is not an Activity") +} + +private var updateCheckTrigger by mutableStateOf(false) + +class MainActivity : AppCompatActivity() { + @OptIn(ExperimentalPermissionsApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + ShortCuts.buildShortCuts(this) + setContent { + AppTheme { + var showAutoCheckUpdaterDialog by remember { mutableStateOf(false) } + if (showAutoCheckUpdaterDialog) { + val fromUser by remember { mutableStateOf(updateCheckTrigger) } + AutoUpdateCheckerDialog(fromUser, fromAction = true) { + showAutoCheckUpdaterDialog = false + updateCheckTrigger = false + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // A13 + val notificationPermission = + rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) + if (!notificationPermission.status.isGranted) { + LaunchedEffect(notificationPermission) { + notificationPermission.launchPermissionRequest() + } + } + }/* else { + val enabled = NotificationManagerCompat.from(this).areNotificationsEnabled() + if (!enabled) { + LaunchedEffect(Unit) { + gotoNotificationManager(this@MainActivity) + } + } + }*/ + + LaunchedEffect(Unit) { + showAutoCheckUpdaterDialog = AppConfig.isAutoCheckUpdateEnabled.value + } + + LaunchedEffect(updateCheckTrigger) { + if (updateCheckTrigger) showAutoCheckUpdaterDialog = true + } + + MainScreen { finish() } + } + } + } +} + +//private fun gotoNotificationManager(context: Context) { +// try { +// val intent = Intent() +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { //A8.0 +// intent.setAction(Settings.ACTION_APP_NOTIFICATION_SETTINGS) +// intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) +// intent.putExtra(Settings.EXTRA_CHANNEL_ID, context.applicationInfo.uid) +// } +// intent.putExtra("app_package", context.packageName) +// intent.putExtra("app_uid", context.applicationInfo.uid) +// context.startActivity(intent) +// } catch (e: Exception) { +// e.printStackTrace() +// val intent = Intent() +// intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) +// val uri = Uri.fromParts("package", context.packageName, null) +// intent.setData(uri) +// context.startActivity(intent) +// } +//} + +@Composable +private fun MainScreen(finish: () -> Unit) { + val context = LocalContext.current + val navController = rememberNavController() + val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) + val entryState by navController.currentBackStackEntryAsState() + + val gesturesEnabled = remember(entryState) { + NavRoutes.routes.find { it.id == entryState?.destination?.route } != null + } + + var lastBackDownTime by remember { mutableLongStateOf(0L) } + BackHandler(enabled = drawerState.isClosed) { + val duration = 2000 + SystemClock.elapsedRealtime().let { + if (it - lastBackDownTime <= duration) { + finish() + } else { + lastBackDownTime = it + context.toast(R.string.app_down_again_to_exit) + } + } + } + CompositionLocalProvider( + LocalNavController provides navController, + LocalDrawerState provides drawerState, + ) { + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = gesturesEnabled, + drawerContent = { + NavDrawerContent( + Modifier + .fillMaxHeight() + .width(300.dp) + .clip( + MaterialTheme.shapes.large.copy( + topStart = CornerSize(0.dp), + bottomStart = CornerSize(0.dp) + ) + ) + .background(MaterialTheme.colorScheme.background) + .padding(12.dp), + navController, + drawerState, + ) + }) { + NavHost( + navController = navController, + startDestination = NavRoutes.SystemTTS.id + ) { + composable(NavRoutes.SystemTTS.id) { SystemTtsScreen() } + composable(NavRoutes.SystemTtsForwarder.id) { + SystemTtsForwarderScreen() + } + composable(NavRoutes.MsTtsForwarder.id) { MsTtsForwarderScreen() } + composable(NavRoutes.Settings.id) { SettingsScreen(drawerState) } + + composable(NavRoutes.TtsEdit.id) { stackEntry -> + val systts: SystemTts = + stackEntry.arguments?.getParcelable(NavRoutes.TtsEdit.DATA) + ?: return@composable + var stateSystts by rememberSaveable { + mutableStateOf(systts.run { + if (tts.locale.isBlank()) { + copy( + tts = tts.clone()!! + .apply { locale = AppConst.localeCode } + ) + } else + this + }) + } + TtsEditContainerScreen( + modifier = Modifier + .fillMaxSize(), + systts = stateSystts, + onSysttsChange = { + stateSystts = it + println("UpdateSystemTTS: $it") + }, + onSave = { + navController.popBackStack() + appDb.systemTtsDao.insertTts(stateSystts) + if (stateSystts.isEnabled) SystemTtsService.notifyUpdateConfig() + }, + onCancel = { + navController.popBackStack() + } + ) + } + } + } + + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NavDrawerContent( + modifier: Modifier = Modifier, + navController: NavHostController, + drawerState: DrawerState, +) { + val scope = rememberCoroutineScope() + BackHandler(enabled = drawerState.isOpen) { + scope.launch { drawerState.close() } + } + + @Composable + fun DrawerItem( + selected: Boolean = false, + icon: @Composable () -> Unit, + label: @Composable () -> Unit, + onClick: () -> Unit + ) { + NavigationDrawerItem( + modifier = Modifier.padding(vertical = 2.dp), + icon = icon, + label = label, + selected = selected, + onClick = onClick, + colors = NavigationDrawerItemDefaults.colors(unselectedContainerColor = Color.Transparent) + ) + } + + + @Composable + fun NavDrawerItem( + icon: @Composable () -> Unit, + targetScreen: NavRoutes, + onClick: () -> Unit = { + scope.launch { drawerState.close() } + navController.navigateSingleTop(targetScreen.id, popUpToMain = true) + } + ) { + val isSelected = navController.currentDestination?.route == targetScreen.id + DrawerItem( + icon = icon, + label = { Text(text = stringResource(id = targetScreen.strId)) }, + selected = isSelected, + onClick = onClick, + ) + } + + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + Spacer(modifier = Modifier.height(24.dp)) + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val view = LocalView.current + + var isBuildTimeExpanded by remember { mutableStateOf(false) } + val versionNameText = remember { + "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" + } + Column(modifier = Modifier + .padding(end = 4.dp) + .clip(MaterialTheme.shapes.small) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = true), + onClick = { + isBuildTimeExpanded = !isBuildTimeExpanded + }, + onLongClick = { + view.performLongPress() + clipboardManager.setText(AnnotatedString(versionNameText)) + context.longToast(R.string.copied) + } + )) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppLauncherIcon(Modifier.size(64.dp)) + Column( + modifier = Modifier + .padding(start = 8.dp) + .align(Alignment.CenterVertically) + ) { + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = versionNameText, + style = MaterialTheme.typography.bodyMedium + ) + } + } + AnimatedVisibility(visible = isBuildTimeExpanded) { + Text( + text = AppConst.dateFormatSec.format(BuildConfig.BUILD_TIME * 1000), + modifier = Modifier.padding(4.dp) + ) + } + } + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 4.dp) + ) + + for (route in NavRoutes.routes) { + NavDrawerItem(icon = route.icon, targetScreen = route) + } + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 4.dp) + ) + + DrawerItem( + icon = { Icon(Icons.Default.BatteryFull, null) }, + label = { Text(stringResource(id = R.string.battery_optimization_whitelist)) }, + selected = false, + onClick = { context.killBattery() } + ) + + DrawerItem( + icon = { Icon(Icons.Default.ArrowCircleUp, null) }, + label = { Text(stringResource(id = R.string.check_update)) }, + selected = false, + onClick = { + scope.launch { + drawerState.close() + updateCheckTrigger = true + } + }, + ) + + DrawerItem( + icon = { Icon(Icons.Default.HelpOutline, null) }, + label = { Text(stringResource(id = R.string.app_help_document)) }, + selected = false, + onClick = { + context.startActivity(Intent(context, AppHelpDocumentActivity::class.java).apply { + action = Intent.ACTION_VIEW + }) + }, + ) + + var showAboutDialog by remember { mutableStateOf(false) } + if (showAboutDialog) + AboutDialog { showAboutDialog = false } + + DrawerItem( + icon = { Icon(Icons.Default.Info, null) }, + label = { Text(stringResource(id = R.string.about)) }, + selected = false, + onClick = { showAboutDialog = true } + ) + } +} + +@SuppressLint("RestrictedApi") +fun NavController.navigate( + route: String, + argsBuilder: Bundle.() -> Unit = {}, + navOptions: NavOptions? = null, + navigatorExtras: Navigator.Extras? = null +) { + navigate(route, Bundle().apply(argsBuilder), navOptions, navigatorExtras) +} + +/* +* 可传递 Bundle 到 Navigation +* */ +@SuppressLint("RestrictedApi") +fun NavController.navigate( + route: String, + args: Bundle, + navOptions: NavOptions? = null, + navigatorExtras: Navigator.Extras? = null +) { + val routeLink = NavDeepLinkRequest + .Builder + .fromUri(NavDestination.createRoute(route).toUri()) + .build() + + val deepLinkMatch = graph.matchDeepLink(routeLink) + if (deepLinkMatch != null) { + val destination = deepLinkMatch.destination + val id = destination.id + navigate(id, args, navOptions, navigatorExtras) + } else { + navigate(route, navOptions, navigatorExtras) + } +} + +/** + * 单例并清空其他栈 + */ +fun NavHostController.navigateSingleTop( + route: String, + args: Bundle? = null, + popUpToMain: Boolean = false +) { + val navController = this + val navOptions = NavOptions.Builder() + .setLaunchSingleTop(true) + .apply { + if (popUpToMain) setPopUpTo( + navController.graph.startDestinationId, + inclusive = false, + saveState = true + ) + } + .setRestoreState(true) + .build() + if (args == null) + navController.navigate(route, navOptions) + else + navController.navigate(route, args, navOptions) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/ShadowReorderableItem.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/ShadowReorderableItem.kt new file mode 100644 index 000000000..4d134ce49 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/ShadowReorderableItem.kt @@ -0,0 +1,38 @@ +package com.github.jing332.tts_server_android.compose + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import org.burnoutcrew.reorderable.ReorderableItem +import org.burnoutcrew.reorderable.ReorderableState + +@Composable +fun LazyItemScope.ShadowReorderableItem( + reorderableState: ReorderableState<*>, + key: Any, + content: @Composable LazyItemScope.(isDragging: Boolean) -> Unit +) { + val view = LocalView.current + ReorderableItem(reorderableState, key) { isDragging -> + if (isDragging) { + view.isHapticFeedbackEnabled = true + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + val elevation = + animateDpAsState(if (isDragging) 24.dp else 0.dp, label = "") + Box( + modifier = Modifier + .shadow(elevation.value) + ) { + content(isDragging) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupDialog.kt new file mode 100644 index 000000000..0083caaac --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupDialog.kt @@ -0,0 +1,111 @@ +package com.github.jing332.tts_server_android.compose.backup + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.TextCheckBox +import com.github.jing332.tts_server_android.ui.AppActivityResultContracts +import com.github.jing332.tts_server_android.ui.FilePickerActivity +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import kotlinx.coroutines.launch + +@Composable +internal fun BackupDialog( + onDismissRequest: () -> Unit, + vm: BackupRestoreViewModel = viewModel(), +) { + val filePicker = + rememberLauncherForActivityResult(contract = AppActivityResultContracts.filePickerActivity()) + { + } + + var isLoading by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val checkedList = remember { + mutableStateListOf( + Type.Preference, + Type.List, + Type.ReplaceRule, + Type.SpeechRule, + Type.Plugin + ) + } + AppDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.backup)) }, + content = { + LazyColumn(Modifier.fillMaxWidth()) { + items(Type.typeList) { + TextCheckBox( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart), + text = { Text(stringResource(id = it.nameStrId)) }, + checked = checkedList.contains(it), + onCheckedChange = { check -> + if (check) { + if (it == Type.PluginVars) { + checkedList.contains(Type.Plugin) || checkedList.add(Type.Plugin) + } + + checkedList.add(it) + } else { + if (it == Type.Plugin) { + checkedList.remove(Type.PluginVars) + } + checkedList.remove(it) + } + }, + horizontalArrangement = Arrangement.Start + ) + } + } + }, + buttons = { + Row { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + + TextButton(onClick = { + scope.launch { + runCatching { + val data = vm.backup(checkedList) + filePicker.launch( + FilePickerActivity.RequestSaveFile( + fileName = "ttsrv-backup.zip", + fileMime = "application/zip", + fileBytes = data + ) + ) + }.onFailure { + context.displayErrorDialog(it, context.getString(R.string.backup)) + } + onDismissRequest() + } + }) { + Text(stringResource(id = R.string.confirm)) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupRestoreActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupRestoreActivity.kt new file mode 100644 index 000000000..3ae3f690d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupRestoreActivity.kt @@ -0,0 +1,105 @@ +package com.github.jing332.tts_server_android.compose.backup + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Input +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Input +import androidx.compose.material.icons.filled.Output +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.settings.BasePreferenceWidget +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.utils.FileUtils.readBytes + +class BackupRestoreActivity : AppCompatActivity() { + companion object { + const val TAG = "BackupRestoreActivity" + } + + private var showFromFileRestoreDialog = mutableStateOf(null) + + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AppTheme { + var showBackupDialog by remember { mutableStateOf(false) } + if (showBackupDialog) { + BackupDialog(onDismissRequest = { showBackupDialog = false }) + } + + var showRestoreDialog by remember { mutableStateOf(false) } + if (showRestoreDialog) { + RestoreDialog(onDismissRequest = { showRestoreDialog = false }) + } + + Scaffold(topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.backup_restore)) }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(id = R.string.nav_back) + ) + } + }) + }) { + Column(Modifier.padding(it)) { + BasePreferenceWidget( + onClick = { showBackupDialog = true }, + title = { Text(stringResource(id = R.string.backup)) }, + icon = { Icon(Icons.Default.Output, null) } + ) + + BasePreferenceWidget( + onClick = { showRestoreDialog = true }, + title = { Text(stringResource(id = R.string.restore)) }, + icon = { Icon(Icons.AutoMirrored.Filled.Input, null) } + ) + } + } + } + } + restoreFromIntent(intent) + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + restoreFromIntent(intent) + } + + private fun restoreFromIntent(intent: Intent?) { + intent?.data?.let { + showFromFileRestoreDialog.value = it.readBytes(this) + intent.data = null +// MaterialAlertDialogBuilder(this) +// .setTitle(R.string.restore) +// .setMessage(R.string.restore_confirm) +// .setNegativeButton(R.string.cancel, null) +// .setPositiveButton(R.string.restore) { _, _ -> +// val bytes = it.readBytes(this) +// fragment.restore(bytes) +// }.setOnDismissListener { intent.data = null } +// .show() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupRestoreViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupRestoreViewModel.kt new file mode 100644 index 000000000..1c2cebfbe --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/BackupRestoreViewModel.kt @@ -0,0 +1,152 @@ +package com.github.jing332.tts_server_android.compose.backup + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.data.entities.replace.GroupWithReplaceRule +import com.github.jing332.tts_server_android.data.entities.systts.GroupWithSystemTts +import com.github.jing332.tts_server_android.utils.FileUtils +import com.github.jing332.tts_server_android.utils.ZipUtils +import kotlinx.serialization.encodeToString +import java.io.ByteArrayInputStream +import java.io.File +import java.util.zip.ZipInputStream + +class BackupRestoreViewModel(application: Application) : AndroidViewModel(application) { + // ... /cache/backupRestore + private val backupRestorePath by lazy { + application.externalCacheDir!!.absolutePath + File.separator + "backupRestore" + } + + // /data/data/{package name} + private val internalDataFile by lazy { + application.filesDir!!.parentFile!! + } + + // ... /cache/backupRestore/restore + private val restorePath by lazy { + backupRestorePath + File.separator + "restore" + } + + // ... /cache/backupRestore/restore/shared_prefs + private val restorePrefsPath by lazy { + restorePath + File.separator + "shared_prefs" + } + + + suspend fun restore(bytes: ByteArray): Boolean { + var isRestart = false + val outFileDir = File(restorePath) + ZipUtils.unzipFile(ZipInputStream(ByteArrayInputStream(bytes)), outFileDir) + if (outFileDir.exists()) { + // shared_prefs + val restorePrefsFile = File(restorePrefsPath) + if (restorePrefsFile.exists()) { + FileUtils.copyFolder(restorePrefsFile, internalDataFile) + restorePrefsFile.deleteRecursively() + isRestart = true + } + + // *.json + for (file in outFileDir.listFiles()!!) { + if (file.isFile) importFromJsonFile(file) + } + } + + return isRestart + } + + private fun importFromJsonFile(file: File) { + val jsonStr = file.readText() + if (file.name.endsWith("list.json")) { + val list: List = AppConst.jsonBuilder.decodeFromString(jsonStr) + appDb.systemTtsDao.insertGroupWithTts(*list.toTypedArray()) + } else if (file.name.endsWith("speechRules.json")) { + val list: List = AppConst.jsonBuilder.decodeFromString(jsonStr) + appDb.speechRuleDao.insert(*list.toTypedArray()) + } else if (file.name.endsWith("replaceRules.json")) { + val list: List = + AppConst.jsonBuilder.decodeFromString(jsonStr) + appDb.replaceRuleDao.insertRuleWithGroup(*list.toTypedArray()) + } else if (file.name.endsWith("plugins.json")) { + val list: List = AppConst.jsonBuilder.decodeFromString(jsonStr) + appDb.pluginDao.insertOrUpdate(*list.toTypedArray()) + } + } + + suspend fun backup(_types: List): ByteArray = withIO { + File(tmpZipPath).deleteRecursively() + File(tmpZipPath).mkdirs() + + val types = _types.toMutableList() + if (types.contains(Type.PluginVars)) types.remove(Type.Plugin) + types.forEach { + createConfigFile(it) + } + + val zipFile = File(tmpZipFile) + ZipUtils.zipFolder(File(tmpZipPath), zipFile) + return@withIO zipFile.readBytes() + } + + override fun onCleared() { + super.onCleared() + File(backupRestorePath).deleteRecursively() + } + + // ... /cache/backupRestore/backup + private val tmpZipPath by lazy { + backupRestorePath + File.separator + "backup" + } + + private val tmpZipFile by lazy { + backupRestorePath + File.separator + "backup.zip" + } + + private fun createConfigFile(type: Type) { + when (type) { + is Type.Preference -> { + val folder = internalDataFile.absolutePath + File.separator + "shared_prefs" + FileUtils.copyFilesFromDir( + File(folder), + File(tmpZipPath + File.separator + "shared_prefs"), + ) + } + + is Type.List -> { + encodeJsonAndCopyToTmpZipPath(appDb.systemTtsDao.getSysTtsWithGroups(), "list") + } + + is Type.SpeechRule -> { + encodeJsonAndCopyToTmpZipPath(appDb.speechRuleDao.all, "speechRules") + } + + is Type.ReplaceRule -> { + encodeJsonAndCopyToTmpZipPath( + appDb.replaceRuleDao.allGroupWithReplaceRules(), + "replaceRules" + ) + } + + is Type.IPlugin -> { + if (type.includeVars) { + encodeJsonAndCopyToTmpZipPath(appDb.pluginDao.all, "plugins") + } else { + encodeJsonAndCopyToTmpZipPath(appDb.pluginDao.all.map { + it.userVars = mutableMapOf() + it + }, "plugins") + } + } + } + } + + private inline fun encodeJsonAndCopyToTmpZipPath(v: T, name: String) { + val s = AppConst.jsonBuilder.encodeToString(v) + File(tmpZipPath + File.separator + name + ".json").writeText(s) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/RestoreDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/RestoreDialog.kt new file mode 100644 index 000000000..dcc9caefd --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/RestoreDialog.kt @@ -0,0 +1,95 @@ +package com.github.jing332.tts_server_android.compose.backup + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.ui.AppActivityResultContracts +import com.github.jing332.tts_server_android.ui.FilePickerActivity +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.FileUtils.readBytes +import kotlinx.coroutines.launch + +@Composable +internal fun RestoreDialog(onDismissRequest: () -> Unit, vm: BackupRestoreViewModel = viewModel()) { + var isLoading by remember { mutableStateOf(true) } + var needRestart by remember { mutableStateOf(false) } + + val scope = rememberCoroutineScope() + val context = LocalContext.current + val filePicker = + rememberLauncherForActivityResult(contract = AppActivityResultContracts.filePickerActivity()) + { + if (it.second == null) { + onDismissRequest() + return@rememberLauncherForActivityResult + } + scope.launch { + runCatching { + needRestart = vm.restore(it.second!!.readBytes(context)) + isLoading = false + }.onFailure { + context.displayErrorDialog(it) + } + } + } + + LaunchedEffect(Unit) { + filePicker.launch(FilePickerActivity.RequestSelectFile(listOf("application/zip"))) + } + + AppDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.restore)) }, + content = { + LoadingContent( + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + isLoading = isLoading + ) { + if (!isLoading) + if (needRestart) + Text(stringResource(id = R.string.restore_restart_msg)) + else + Text(stringResource(id = R.string.restore_finished)) + } + }, + buttons = { + if (needRestart) { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + + TextButton(onClick = { + app.restart() + }) { + Text(stringResource(id = R.string.restart)) + } + } else { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.confirm)) + } + } + + + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/Type.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/Type.kt new file mode 100644 index 000000000..ce13ef489 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/backup/Type.kt @@ -0,0 +1,28 @@ +package com.github.jing332.tts_server_android.compose.backup + +import com.github.jing332.tts_server_android.R + + +sealed class Type(val nameStrId: Int) { + companion object { + val typeList by lazy { + listOf( + Preference, + List, + SpeechRule, + ReplaceRule, + Plugin, + PluginVars + ) + } + } + + data object Preference : Type(R.string.preference_settings) + data object List : Type(R.string.config_list) + data object SpeechRule : Type(R.string.speech_rule) + data object ReplaceRule : Type(R.string.replace_rule) + + abstract class IPlugin(val id: Int, val includeVars: Boolean) : Type(id) + object Plugin : IPlugin(R.string.plugin, false) + object PluginVars : IPlugin(R.string.plugin_vars, true) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditor.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditor.kt new file mode 100644 index 000000000..1acf96dc6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditor.kt @@ -0,0 +1,29 @@ +package com.github.jing332.tts_server_android.compose.codeeditor + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.github.jing332.text_searcher.ui.plugin.CodeEditorHelper +import com.github.jing332.tts_server_android.conf.CodeEditorConfig +import io.github.rosemoe.sora.widget.CodeEditor + +@Composable +fun CodeEditor(modifier: Modifier, onUpdate: (CodeEditor) -> Unit) { + val context = LocalContext.current + + AndroidView(modifier = modifier, factory = { + CodeEditor(it).apply { + val helper = CodeEditorHelper(context, this) + helper.initEditor() + tag = helper + } + }, update = { +// val helper = (it.tag as CodeEditorHelper) + onUpdate(it) + }) +} + +fun CodeEditor.helper(): CodeEditorHelper { + return tag as CodeEditorHelper +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorHelper.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorHelper.kt new file mode 100644 index 000000000..e51e557d8 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorHelper.kt @@ -0,0 +1,76 @@ +package com.github.jing332.text_searcher.ui.plugin + +import android.content.Context +import android.content.res.Configuration +import com.github.jing332.tts_server_android.constant.CodeEditorTheme +import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme +import io.github.rosemoe.sora.langs.textmate.TextMateLanguage +import io.github.rosemoe.sora.langs.textmate.registry.FileProviderRegistry +import io.github.rosemoe.sora.langs.textmate.registry.GrammarRegistry +import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry +import io.github.rosemoe.sora.langs.textmate.registry.dsl.languages +import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel +import io.github.rosemoe.sora.langs.textmate.registry.provider.AssetsFileResolver +import io.github.rosemoe.sora.widget.CodeEditor +import org.eclipse.tm4e.core.registry.IThemeSource + + +class CodeEditorHelper(val context: Context, val editor: CodeEditor) { + fun initEditor() { + FileProviderRegistry.getInstance().addFileProvider(AssetsFileResolver(context.assets)) + + val themes = arrayOf( + "textmate/quietlight.json", + "textmate/solarized_drak.json", + "textmate/darcula.json", + "textmate/abyss.json" + ) + val themeRegistry = ThemeRegistry.getInstance() + for (theme in themes) { + themeRegistry.loadTheme( + ThemeModel( + IThemeSource.fromInputStream( + FileProviderRegistry.getInstance().tryGetInputStream(theme), theme, null + ) + ) + ) + } + + GrammarRegistry.getInstance().loadGrammars(languages { + language("js") { + grammar = "textmate/javascript/syntaxes/JavaScript.tmLanguage.json" + defaultScopeName() + languageConfiguration = "textmate/javascript/language-configuration.json" + } + }) + editor.setEditorLanguage(TextMateLanguage.create("source.js", true)) + + } + + fun setTheme(theme: CodeEditorTheme) { + val themeRegistry = ThemeRegistry.getInstance() + when (theme) { + CodeEditorTheme.AUTO -> { + val isNight = + (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + setTheme(if (isNight) CodeEditorTheme.DARCULA else CodeEditorTheme.QUIET_LIGHT) + return + } + + else-> { + themeRegistry.setTheme(theme.id) + ensureTextmateTheme() + return + } + } + } + + private fun ensureTextmateTheme() { + var editorColorScheme = editor.colorScheme + if (editorColorScheme !is TextMateColorScheme) { + editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance()) + editor.colorScheme = editorColorScheme + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorScreen.kt new file mode 100644 index 000000000..6c7070811 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorScreen.kt @@ -0,0 +1,273 @@ +package com.github.jing332.tts_server_android.compose.codeeditor + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.WrapText +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.ColorLens +import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.SettingsRemote +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.CheckedMenuItem +import com.github.jing332.tts_server_android.compose.widgets.LongClickIconButton +import com.github.jing332.tts_server_android.conf.CodeEditorConfig +import com.github.jing332.tts_server_android.ui.AppActivityResultContracts +import com.github.jing332.tts_server_android.ui.FilePickerActivity +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.clickableRipple +import io.github.rosemoe.sora.widget.CodeEditor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CodeEditorScreen( + title: @Composable () -> Unit, + onBack: () -> Unit, + onSave: () -> Unit, + onLongClickSave: () -> Unit = {}, + onUpdate: (CodeEditor) -> Unit, + onSaveFile: (() -> Pair)?, + + onDebug: () -> Unit, + onRemoteAction: (name: String, body: ByteArray?) -> Unit = { _, _ -> }, + + vm: CodeEditorViewModel = viewModel(), + + debugIconContent: @Composable () -> Unit = {}, + onLongClickMore: () -> Unit = {}, + onLongClickMoreLabel: String? = null, + actions: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {}, +) { + var codeEditor by remember { mutableStateOf(null) } + + var showThemeDialog by remember { mutableStateOf(false) } + if (showThemeDialog) + ThemeSettingsDialog { showThemeDialog = false } + + var showRemoteSyncDialog by remember { mutableStateOf(false) } + if (showRemoteSyncDialog) + RemoteSyncSettings { showRemoteSyncDialog = false } + + val fileSaver = + rememberLauncherForActivityResult(AppActivityResultContracts.filePickerActivity()) { + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + LaunchedEffect(vm) { + runCatching { + scope.launch(Dispatchers.IO) { + vm.startSyncServer( + port = CodeEditorConfig.remoteSyncPort.value, + onPush = { codeEditor?.setText(it) }, + onPull = { codeEditor?.text.toString() }, + onDebug = onDebug, + onAction = onRemoteAction + ) + } + }.onFailure { + context.displayErrorDialog(it, context.getString(R.string.remote_sync_service)) + } + } + + Scaffold( + topBar = { + TopAppBar(title = title, navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.nav_back) + ) + } + }, + actions = { + IconButton(onClick = onDebug) { + Icon( + Icons.Filled.BugReport, + contentDescription = stringResource(id = R.string.nav_back) + ) + debugIconContent() + } + LongClickIconButton(onClick = onSave, onLongClick = onLongClickSave) { + Icon( + Icons.Filled.Save, + contentDescription = stringResource(id = R.string.save) + ) + } + + var showOptions by remember { mutableStateOf(false) } + + LongClickIconButton( + onClick = { showOptions = true }, + onLongClick = onLongClickMore, + onLongClickLabel = onLongClickMoreLabel + ) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + if (onSaveFile != null) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.save_as_file)) }, + onClick = { + onSaveFile.invoke().let { + fileSaver.launch( + FilePickerActivity.RequestSaveFile( + fileName = it.first, + fileBytes = it.second + ) + ) + } + }, + leadingIcon = { Icon(Icons.Default.InsertDriveFile, null) } + ) + + var syncEnabled by remember { CodeEditorConfig.isRemoteSyncEnabled } + CheckedMenuItem( + text = { Text(stringResource(id = R.string.remote_sync_service)) }, + checked = syncEnabled, + onClick = { showRemoteSyncDialog = true }, + onClickCheckBox = { syncEnabled = it }, + leadingIcon = { + Icon(Icons.Default.SettingsRemote, null) + } + ) + + HorizontalDivider() + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.theme)) }, + onClick = { showThemeDialog = true }, + leadingIcon = { Icon(Icons.Default.ColorLens, null) } + ) + + var wordWrap by remember { CodeEditorConfig.isWordWrapEnabled } + CheckedMenuItem( + text = { Text(stringResource(id = R.string.word_wrap)) }, + checked = wordWrap, + onClick = { wordWrap = it }, + leadingIcon = { + Icon(Icons.AutoMirrored.Default.WrapText, null) + } + ) + + actions { showOptions = false } + } + + } + } + ) + } + ) { paddingValues -> + val theme by remember { CodeEditorConfig.theme } + LaunchedEffect(codeEditor, theme) { + codeEditor?.helper()?.setTheme(theme) + } + + val wordWrap by remember { CodeEditorConfig.isWordWrapEnabled } + LaunchedEffect(codeEditor, wordWrap) { + codeEditor?.isWordwrap = wordWrap + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + CodeEditor( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), onUpdate = { + codeEditor = it + onUpdate(it) + } + ) + + val symbolMap = remember { + linkedMapOf( + "\t" to "TAB", + "=" to "=", + ">" to ">", + "{" to "{", + "}" to "}", + "(" to "(", + ")" to ")", + "," to ",", + "." to ".", + ";" to ";", + "'" to "'", + "\"" to "\"", + "?" to "?", + "+" to "+", + "-" to "-", + "*" to "*", + "/" to "/", + ) + } + + HorizontalDivider(thickness = 1.dp) + LazyRow(Modifier.background(MaterialTheme.colorScheme.background)) { + items(symbolMap.toList()) { + Box( + Modifier + .clickableRipple { + codeEditor?.let { editor -> + val text = it.second + if (editor.isEditable) + if ("\t" == text && editor.snippetController.isInSnippet()) + editor.snippetController.shiftToNextTabStop() + else + editor.insertText(text, 1) + } + }) { + Text( + text = it.second, + Modifier + .minimumInteractiveComponentSize() + .align(Alignment.Center) + ) + } + } + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorViewModel.kt new file mode 100644 index 000000000..fd7c7d24b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/CodeEditorViewModel.kt @@ -0,0 +1,69 @@ +package com.github.jing332.tts_server_android.compose.codeeditor + +import android.util.Log +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.utils.runOnUI +import kotlinx.coroutines.runBlocking +import tts_server_lib.ScriptCodeSyncServerCallback +import tts_server_lib.ScriptSyncServer + +class CodeEditorViewModel : ViewModel() { + companion object { + const val TAG = "CodeEditorViewModel" + + const val SYNC_ACTION_DEBUG = "debug" + } + + private var server: ScriptSyncServer? = null + + // 代码同步服务器 + fun startSyncServer( + port: Int, + onPush: (code: String) -> Unit, + onPull: () -> String, + onDebug: () -> Unit, + onAction: (name: String, body: ByteArray?) -> Unit + ) { + if (server != null) return + server = ScriptSyncServer() + server?.init(object : ScriptCodeSyncServerCallback { + override fun log(level: Int, msg: String?) { + Log.i(TAG, "$level $msg") + } + + override fun action(name: String, body: ByteArray?) { + runOnUI { + if (name == SYNC_ACTION_DEBUG) { + onDebug.invoke() + } else + onAction.invoke(name, body) + } + } + + override fun pull(): String = runBlocking { + return@runBlocking withMain { + return@withMain onPull.invoke() + } + } + + override fun push(code: String) { + runOnUI { + onPush.invoke(code) + } + } + }) + server?.start(port.toLong()) + } + + private fun closeSyncServer() { + server?.close() + server = null + } + + override fun onCleared() { + super.onCleared() + closeSyncServer() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/LoggerBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/LoggerBottomSheet.kt new file mode 100644 index 000000000..b224ab7c6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/LoggerBottomSheet.kt @@ -0,0 +1,72 @@ +package com.github.jing332.tts_server_android.compose.codeeditor + +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.compose.widgets.AppBottomSheet +import com.github.jing332.tts_server_android.constant.LogLevel +import com.github.jing332.tts_server_android.model.rhino.core.Logger + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoggerBottomSheet( + logger: Logger, + onDismissRequest: () -> Unit, + onLaunched: () -> Unit +) { + var logText by remember { mutableStateOf(AnnotatedString("")) } + + val listener = remember { + Logger.LogListener { text, level -> + logText = buildAnnotatedString { + append(logText) + val color = LogLevel.toColor(level) + withStyle(SpanStyle(color = Color(color))) { + appendLine(text) + } + } + } + } + + LaunchedEffect(logger) { + logger.addListener(listener) + onLaunched() + } + + DisposableEffect(logger) { + onDispose { + logger.removeListener(listener) + } + } + + AppBottomSheet(onDismissRequest = onDismissRequest) { + SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + logText, modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp) + .padding(bottom = 8.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/RemoteSyncSettings.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/RemoteSyncSettings.kt new file mode 100644 index 000000000..c7c669150 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/RemoteSyncSettings.kt @@ -0,0 +1,63 @@ +package com.github.jing332.tts_server_android.compose.codeeditor + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.DenseOutlinedField +import com.github.jing332.tts_server_android.conf.CodeEditorConfig + +@Composable +internal fun RemoteSyncSettings(onDismissRequest: () -> Unit) { + AppDialog( + title = { Text(stringResource(id = R.string.remote_sync_service)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(id = R.string.remote_sync_service_description), + modifier = Modifier.padding(8.dp) + ) + + var port by remember { CodeEditorConfig.remoteSyncPort } + DenseOutlinedField(value = port.toString(), onValueChange = { + try { + port = it.toInt() + } catch (_: NumberFormatException) { + } + }) + + } + }, + onDismissRequest = onDismissRequest, + buttons = { + val context = LocalContext.current + Row { + TextButton(onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("https://github.com/jing332/tts-server-psc") + }) + }) { + Text(stringResource(id = R.string.learn_more)) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.close)) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/ThemeSettingsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/ThemeSettingsDialog.kt new file mode 100644 index 000000000..90c1984d5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/codeeditor/ThemeSettingsDialog.kt @@ -0,0 +1,40 @@ +package com.github.jing332.tts_server_android.compose.codeeditor + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppSelectionDialog +import com.github.jing332.tts_server_android.conf.CodeEditorConfig +import com.github.jing332.tts_server_android.constant.CodeEditorTheme + +@Composable +internal fun ThemeSettingsDialog(onDismissRequest: () -> Unit) { + AppSelectionDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.theme)) }, + value = CodeEditorConfig.theme.value, + values = CodeEditorTheme.values().toList(), + entries = CodeEditorTheme.values().map { it.id.ifBlank { stringResource(id = R.string.theme_default) } }, + onClick = { value, _ -> + CodeEditorConfig.theme.value = value as CodeEditorTheme + onDismissRequest() + } + ) +} + +@Preview +@Composable +fun PreviewEditorThemeDialog() { + var show by remember { mutableStateOf(true) } + if (show) { + ThemeSettingsDialog { + show = false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/BasicConfigScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/BasicConfigScreen.kt new file mode 100644 index 000000000..4d463bb75 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/BasicConfigScreen.kt @@ -0,0 +1,83 @@ +package com.github.jing332.tts_server_android.compose.forwarder + +import android.content.IntentFilter +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.LogScreen +import com.github.jing332.tts_server_android.compose.widgets.DenseOutlinedField +import com.github.jing332.tts_server_android.compose.widgets.LocalBroadcastReceiver +import com.github.jing332.tts_server_android.compose.widgets.SwitchFloatingButton +import com.github.jing332.tts_server_android.constant.KeyConst +import com.github.jing332.tts_server_android.constant.LogLevel +import com.github.jing332.tts_server_android.ui.AppLog + +@Suppress("DEPRECATION") +@Composable +internal fun BasicConfigScreen( + modifier: Modifier, + vm: ConfigViewModel, + intentFilter: IntentFilter, + actionOnLog: String, + actionOnClosed: String, + actionOnStarting: String, + isRunning: Boolean, + onRunningChange: (Boolean) -> Unit, + switch: () -> Unit, + port: Int, + onPortChange: (Int) -> Unit +) { + val context = LocalContext.current + LocalBroadcastReceiver(intentFilter = intentFilter) { intent -> + if (intent == null) return@LocalBroadcastReceiver + when (intent.action) { + actionOnLog -> { + intent.getParcelableExtra(KeyConst.KEY_DATA)?.let { log -> + vm.logs.add(log) + } + } + + actionOnClosed -> { + onRunningChange(false) + vm.logs.add(AppLog(LogLevel.INFO, "服务已关闭")) + } + + actionOnStarting -> { + onRunningChange(true) + vm.logs.add(AppLog(LogLevel.INFO, "服务已启动")) + } + } + } + + Column(modifier) { + LogScreen( + modifier = Modifier.weight(1f), list = vm.logs, vm.logState + ) + + Row(Modifier.align(Alignment.CenterHorizontally)) { + DenseOutlinedField( + label = { Text(stringResource(id = R.string.listen_port)) }, + modifier = Modifier.align(Alignment.CenterVertically), + value = port.toString(), onValueChange = { + kotlin.runCatching { + onPortChange(it.toInt()) + } + } + ) + + SwitchFloatingButton( + modifier = Modifier.padding(8.dp), + switch = isRunning, + onSwitchChange = { switch() } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/BasicForwarderScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/BasicForwarderScreen.kt new file mode 100644 index 000000000..c9c222413 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/BasicForwarderScreen.kt @@ -0,0 +1,88 @@ +package com.github.jing332.tts_server_android.compose.forwarder + +import android.content.IntentFilter +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TextSnippet +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.LocalBroadcastReceiver +import com.github.jing332.tts_server_android.service.forwarder.system.SysTtsForwarderService +import com.google.accompanist.web.rememberWebViewNavigator +import com.google.accompanist.web.rememberWebViewState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +internal fun BasicForwarderScreen( + topBar: @Composable () -> Unit, + configScreen: @Composable () -> Unit, + onGetUrl: () -> String, +) { + val pages = remember { listOf(R.string.log, R.string.web) } + val state = rememberPagerState { pages.size } + val scope = rememberCoroutineScope() + Scaffold( + topBar = topBar, + bottomBar = { + NavigationBar { + pages.forEachIndexed { index, strId -> + val selected = state.currentPage == index + NavigationBarItem( + selected = selected, + onClick = { + scope.launch { + state.animateScrollToPage(index) + } + }, + icon = { + if (index == 0) + Icon(Icons.Default.TextSnippet, null) + else + Icon(painter = painterResource(R.drawable.ic_web), null) + }, + label = { Text(stringResource(id = strId)) } + ) + } + } + }) { paddingValues -> + HorizontalPager( + modifier = Modifier.padding(paddingValues).fillMaxSize(), + state = state, + userScrollEnabled = false + ) { + when (it) { + 0 -> configScreen() + 1 -> { + val webState = rememberWebViewState(url = onGetUrl()) + val navigator = rememberWebViewNavigator() + LocalBroadcastReceiver(intentFilter = IntentFilter(SysTtsForwarderService.ACTION_ON_STARTING)) { + navigator.loadUrl(onGetUrl()) + } + + WebScreen( + modifier = Modifier.fillMaxSize(), + state = webState, + navigator = navigator + ) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ConfigViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ConfigViewModel.kt new file mode 100644 index 000000000..b0d034f50 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ConfigViewModel.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.compose.forwarder + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import com.github.jing332.tts_server_android.ui.AppLog + +class ConfigViewModel : ViewModel() { + val logs = mutableStateListOf() + val logState by lazy { LazyListState() } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ForwarderTopAppBar.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ForwarderTopAppBar.kt new file mode 100644 index 000000000..a9bd22292 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ForwarderTopAppBar.kt @@ -0,0 +1,118 @@ +package com.github.jing332.tts_server_android.compose.forwarder + +import android.content.Intent +import android.webkit.CookieManager +import android.webkit.WebStorage +import android.webkit.WebView +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddBusiness +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.nav.NavTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.CheckedMenuItem +import com.github.jing332.tts_server_android.utils.toast + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ForwarderTopAppBar( + title: @Composable () -> Unit, + wakeLockEnabled: Boolean, + onWakeLockEnabledChange: (Boolean) -> Unit, + + actions: @Composable ColumnScope.(onDismissRequest: () -> Unit) -> Unit = { }, + onClearWebData: (() -> Unit)? = null, + onOpenWeb: () -> String, + onAddDesktopShortCut: () -> Unit, +) { + val context = LocalContext.current + NavTopAppBar( + title = title, + actions = { + IconButton(onClick = { + val url = onOpenWeb.invoke() + if (url.isNotEmpty()) { + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } + }) { + Icon( + painter = painterResource(id = R.drawable.ic_web), + contentDescription = stringResource( + id = R.string.open_web + ) + ) + } + + var showOptions by remember { mutableStateOf(false) } + + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + + DropdownMenu(expanded = showOptions, onDismissRequest = { showOptions = false }) { + CheckedMenuItem( + text = { Text(text = stringResource(id = R.string.wake_lock)) }, + checked = wakeLockEnabled, + onClick = onWakeLockEnabledChange, + leadingIcon = { + Icon(Icons.Default.Lock, null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.clear_web_data)) }, + onClick = { + showOptions = false + if (onClearWebData == null) { + WebView(context).apply { + clearCache(true) + clearFormData() + clearSslPreferences() + } + CookieManager.getInstance().apply { + removeAllCookies(null) + flush() + } + WebStorage.getInstance().deleteAllData() + context.toast(R.string.cleared) + } else + onClearWebData.invoke() + }, + leadingIcon = { + Icon(Icons.Default.CleaningServices, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.desktop_shortcut)) }, + onClick = { + showOptions = false + onAddDesktopShortCut() + }, + leadingIcon = { + Icon(Icons.Default.AddBusiness, null) + } + ) + + actions { showOptions = false } + } + } + + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/WebScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/WebScreen.kt new file mode 100644 index 000000000..4626d074d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/WebScreen.kt @@ -0,0 +1,168 @@ +package com.github.jing332.tts_server_android.compose.forwarder + +import android.annotation.SuppressLint +import android.content.Intent +import android.webkit.JsResult +import android.webkit.WebResourceRequest +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pullrefresh.PullRefreshIndicator +import androidx.compose.material3.pullrefresh.pullRefresh +import androidx.compose.material3.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.utils.longToast +import com.google.accompanist.web.AccompanistWebChromeClient +import com.google.accompanist.web.AccompanistWebViewClient +import com.google.accompanist.web.LoadingState +import com.google.accompanist.web.WebView +import com.google.accompanist.web.WebViewNavigator +import com.google.accompanist.web.WebViewState +import com.google.accompanist.web.rememberWebViewNavigator +import com.google.accompanist.web.rememberWebViewState + +@SuppressLint("SetJavaScriptEnabled") +@Composable +internal fun WebScreen( + modifier: Modifier, + url: String = "", + state: WebViewState = rememberWebViewState(url), + navigator: WebViewNavigator = rememberWebViewNavigator(), +) { + var showAlertDialog by remember { mutableStateOf?>(null) } + if (showAlertDialog != null) { + val webUrl = showAlertDialog!!.first + val msg = showAlertDialog!!.second + val result = showAlertDialog!!.third + AlertDialog(onDismissRequest = { + result.cancel() + showAlertDialog = null + }, + title = { Text(webUrl) }, + text = { Text(msg) }, + confirmButton = { + TextButton( + onClick = { + result.confirm() + showAlertDialog = null + }) { + Text(stringResource(id = android.R.string.ok)) + } + }, dismissButton = { + result.cancel() + showAlertDialog = null + }) + } + + val context = LocalContext.current + val chromeClient = remember { + object : AccompanistWebChromeClient() { + override fun onJsConfirm( + view: WebView?, + url: String?, + message: String?, + result: JsResult? + ): Boolean { + if (result == null) return false + showAlertDialog = Triple(url ?: "", message ?: "", result) + return true + } + + override fun onJsAlert( + view: WebView?, + url: String?, + message: String?, + result: JsResult? + ): Boolean { + if (result == null) return false + showAlertDialog = Triple(url ?: "", message ?: "", result) + + return true + } + } + } + + val client = remember { + object : AccompanistWebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + kotlin.runCatching { + if (request?.url?.scheme?.startsWith("http") == false) { + val intent = Intent(Intent.ACTION_VIEW, request.url) + context.startActivity(Intent.createChooser(intent, request.url.toString())) + return true + } + }.onFailure { + context.longToast("跳转APP失败: ${request?.url}") + } + + return super.shouldOverrideUrlLoading(view, request) + } + } + } + + Column(modifier = modifier) { + val process = + if (state.loadingState is LoadingState.Loading) (state.loadingState as LoadingState.Loading).progress else 0f + + if (process > 0) + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = process + ) + + var lastTitle by remember { mutableStateOf("") } + val refreshState = rememberPullRefreshState(refreshing = state.isLoading, onRefresh = { + navigator.reload() + }) + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .pullRefresh(refreshState), + text = state.pageTitle?.apply { lastTitle = this } ?: lastTitle, + maxLines = 1, + style = MaterialTheme.typography.titleMedium, + ) + + Box( + modifier = Modifier + .fillMaxSize() + ) { + WebView( + modifier = Modifier.fillMaxSize(), + state = state, + navigator = navigator, + onCreated = { + it.settings.javaScriptEnabled = true + }, + client = client, + chromeClient = chromeClient, + ) + + Column(Modifier.fillMaxWidth()) { + PullRefreshIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + refreshing = refreshState.refreshing, + state = refreshState + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ms/MsTtsForwarderScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ms/MsTtsForwarderScreen.kt new file mode 100644 index 000000000..249a833ff --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/ms/MsTtsForwarderScreen.kt @@ -0,0 +1,102 @@ +package com.github.jing332.tts_server_android.compose.forwarder.ms + +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Token +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.forwarder.BasicConfigScreen +import com.github.jing332.tts_server_android.compose.forwarder.BasicForwarderScreen +import com.github.jing332.tts_server_android.compose.forwarder.ConfigViewModel +import com.github.jing332.tts_server_android.compose.forwarder.ForwarderTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.TextFieldDialog +import com.github.jing332.tts_server_android.conf.MsForwarderConfig +import com.github.jing332.tts_server_android.service.forwarder.ForwarderServiceManager.switchMsTtsForwarder +import com.github.jing332.tts_server_android.service.forwarder.ms.MsTtsForwarderService +import com.github.jing332.tts_server_android.ui.forwarder.MsForwarderSwitchActivity +import com.github.jing332.tts_server_android.utils.MyTools + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MsTtsForwarderScreen( + cfgVM: ConfigViewModel = viewModel() +) { + var showTokenDialog by remember { mutableStateOf(false) } + if (showTokenDialog) { + var text by remember { mutableStateOf(MsForwarderConfig.token.value) } + TextFieldDialog( + title = stringResource(id = R.string.server_set_token), + text = text, + onTextChange = { text = it }, + onDismissRequest = { showTokenDialog = false }) { + MsForwarderConfig.token.value = text + } + } + + var wakeLockEnabled by remember { MsForwarderConfig.isWakeLockEnabled } + var port by remember { MsForwarderConfig.port } + val context = LocalContext.current + BasicForwarderScreen(topBar = { + ForwarderTopAppBar( + title = { Text(stringResource(id = R.string.forwarder_ms)) }, + wakeLockEnabled = wakeLockEnabled, + onWakeLockEnabledChange = { wakeLockEnabled = it }, + onOpenWeb = { "http://localhost:${port}" }, + onAddDesktopShortCut = { + MyTools.addShortcut( + ctx = context, + name = context.getString(R.string.forwarder_ms), + id = "switch_ms_forwarder", + iconResId = R.mipmap.ic_app_launcher_round, + launcherIntent = Intent(context, MsForwarderSwitchActivity::class.java) + ) + }, + actions = { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.server_set_token)) }, + onClick = { showTokenDialog = true }, + leadingIcon = { + Icon(Icons.Default.Token, null) + } + ) + } + ) + + }, + configScreen = { + var isRunning by remember { mutableStateOf(MsTtsForwarderService.isRunning) } + BasicConfigScreen(modifier = Modifier.fillMaxSize(), + vm = cfgVM, + intentFilter = IntentFilter().apply { + addAction(MsTtsForwarderService.ACTION_ON_LOG) + addAction(MsTtsForwarderService.ACTION_ON_CLOSED) + addAction(MsTtsForwarderService.ACTION_ON_STARTING) + }, + actionOnLog = MsTtsForwarderService.ACTION_ON_LOG, + actionOnClosed = MsTtsForwarderService.ACTION_ON_CLOSED, + actionOnStarting = MsTtsForwarderService.ACTION_ON_STARTING, + isRunning = isRunning, + onRunningChange = { isRunning = it }, + switch = { context.switchMsTtsForwarder() }, + port = port, + onPortChange = { port = it }) + }) { + + "http://localhost:${port}" + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/systts/SystemTtsForwarderScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/systts/SystemTtsForwarderScreen.kt new file mode 100644 index 000000000..8f8a15755 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/forwarder/systts/SystemTtsForwarderScreen.kt @@ -0,0 +1,71 @@ +package com.github.jing332.tts_server_android.compose.forwarder.systts + +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.forwarder.BasicConfigScreen +import com.github.jing332.tts_server_android.compose.forwarder.BasicForwarderScreen +import com.github.jing332.tts_server_android.compose.forwarder.ConfigViewModel +import com.github.jing332.tts_server_android.compose.forwarder.ForwarderTopAppBar +import com.github.jing332.tts_server_android.conf.SysttsForwarderConfig +import com.github.jing332.tts_server_android.service.forwarder.ForwarderServiceManager.switchSysTtsForwarder +import com.github.jing332.tts_server_android.service.forwarder.system.SysTtsForwarderService +import com.github.jing332.tts_server_android.ui.forwarder.SystemForwarderSwitchActivity +import com.github.jing332.tts_server_android.utils.MyTools + +@Composable +fun SystemTtsForwarderScreen(cfgVM: ConfigViewModel = viewModel()) { + val context = LocalContext.current + var port by remember { SysttsForwarderConfig.port } + BasicForwarderScreen( + topBar = { + var wakeLockEnabled by remember { SysttsForwarderConfig.isWakeLockEnabled } + ForwarderTopAppBar( + title = { Text(text = stringResource(id = R.string.forwarder_systts)) }, + wakeLockEnabled = wakeLockEnabled, + onWakeLockEnabledChange = { wakeLockEnabled = it }, + onOpenWeb = { "http://localhost:${port}" } + ) { + MyTools.addShortcut( + ctx = context, + name = context.getString(R.string.forwarder_systts), + id = "switch_systts_forwarder", + iconResId = R.mipmap.ic_app_launcher_round, + launcherIntent = Intent(context, SystemForwarderSwitchActivity::class.java) + ) + } + }, + configScreen = { + var isRunning by remember { mutableStateOf(SysTtsForwarderService.isRunning) } + BasicConfigScreen( + modifier = Modifier.fillMaxSize(), + vm = cfgVM, + intentFilter = IntentFilter().apply { + addAction(SysTtsForwarderService.ACTION_ON_LOG) + addAction(SysTtsForwarderService.ACTION_ON_CLOSED) + addAction(SysTtsForwarderService.ACTION_ON_STARTING) + }, + actionOnLog = SysTtsForwarderService.ACTION_ON_LOG, + actionOnClosed = SysTtsForwarderService.ACTION_ON_CLOSED, + actionOnStarting = SysTtsForwarderService.ACTION_ON_STARTING, + isRunning = isRunning, + onRunningChange = { isRunning = it }, + switch = { context.switchSysTtsForwarder() }, + port = port, + onPortChange = { port = it } + ) + }) { + "http://localhost:${port}" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/nav/NavRoutes.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/nav/NavRoutes.kt new file mode 100644 index 000000000..eb48f9f71 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/nav/NavRoutes.kt @@ -0,0 +1,51 @@ +package com.github.jing332.tts_server_android.compose.nav + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R + +sealed class NavRoutes( + val id: String, + @StringRes val strId: Int, + val icon: @Composable () -> Unit = {}, +) { + companion object { + val routes by lazy { + listOf( + SystemTTS, + SystemTtsForwarder, + MsTtsForwarder, + Settings + ) + } + } + + data object SystemTTS : NavRoutes("system_tts", R.string.system_tts, icon = { + Icon(modifier = Modifier.size(24.dp), painter = painterResource(id = R.drawable.ic_tts), contentDescription = null) + }) + + data object SystemTtsForwarder : + NavRoutes("system_tts_forwarder", R.string.forwarder_systts, icon = { + Icon(modifier = Modifier.size(24.dp), painter = painterResource(id = R.drawable.ic_tts), contentDescription = null) + }) + + data object MsTtsForwarder : NavRoutes("ms_tts_forwarder", R.string.forwarder_ms, icon = { + Icon(painter = painterResource(id = R.drawable.ic_microsoft), null) + }) + + data object Settings : NavRoutes("settings", R.string.settings, icon = { + Icon(Icons.Default.Settings, null) + }) + + // ============= + data object TtsEdit : NavRoutes("tts_edit", 0) { + const val DATA = "data" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/nav/NavTopAppBar.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/nav/NavTopAppBar.kt new file mode 100644 index 000000000..a21bee24f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/nav/NavTopAppBar.kt @@ -0,0 +1,60 @@ +package com.github.jing332.tts_server_android.compose.nav + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.DrawerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.RichTooltip +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalDrawerState +import com.github.jing332.tts_server_android.compose.widgets.AppTooltip +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NavTopAppBar( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + drawerState: DrawerState = LocalDrawerState.current, + navigationIcon: @Composable() (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + scrollBehavior: TopAppBarScrollBehavior? = null +) { + val scope = rememberCoroutineScope() + TopAppBar( + title = title, + modifier = modifier, + navigationIcon = { + if (navigationIcon == null) { + AppTooltip(tooltip = stringResource(id = R.string.nav_app_bar_open_drawer_description)) { + IconButton(onClick = { + scope.launch { drawerState.open() } + }) { + Icon(Icons.Default.Menu, it) + } + } + } else + navigationIcon() + }, + actions = actions, + windowInsets = windowInsets, + colors = colors, + scrollBehavior = scrollBehavior + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/SettingsScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/SettingsScreen.kt new file mode 100644 index 000000000..7f5e1a0f3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/SettingsScreen.kt @@ -0,0 +1,393 @@ +package com.github.jing332.tts_server_android.compose.settings + +import android.content.Intent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuOpen +import androidx.compose.material.icons.automirrored.filled.TextSnippet +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.ArrowCircleUp +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.ColorLens +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.PlayCircleOutline +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.SettingsBackupRestore +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.filled.TextSnippet +import androidx.compose.material.icons.filled.Waves +import androidx.compose.material3.DrawerState +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.AppLocale +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.compose.backup.BackupRestoreActivity +import com.github.jing332.tts_server_android.compose.nav.NavTopAppBar +import com.github.jing332.tts_server_android.compose.systts.directlink.LinkUploadRuleActivity +import com.github.jing332.tts_server_android.compose.theme.getAppTheme +import com.github.jing332.tts_server_android.compose.theme.setAppTheme +import com.github.jing332.tts_server_android.conf.AppConfig +import com.github.jing332.tts_server_android.conf.SystemTtsConfig +import com.github.jing332.tts_server_android.constant.FilePickerMode + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(drawerState: DrawerState) { + var showThemeDialog by remember { mutableStateOf(false) } + if (showThemeDialog) + ThemeSelectionDialog( + onDismissRequest = { showThemeDialog = false }, + currentTheme = getAppTheme(), + onChangeTheme = { + setAppTheme(it) + } + ) + + Scaffold( + topBar = { + NavTopAppBar( + title = { Text(stringResource(R.string.settings)) }, + drawerState = drawerState + ) + } + ) { paddingValues -> + val context = LocalContext.current + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + DividerPreference { Text(stringResource(id = R.string.app_name)) } + + BasePreferenceWidget( + icon = { + Icon(Icons.Default.SettingsBackupRestore, null) + }, + onClick = { + context.startActivity( + Intent( + context, + BackupRestoreActivity::class.java + ).apply { action = Intent.ACTION_VIEW }) + }, + title = { Text(stringResource(id = R.string.backup_restore)) }, + ) + + BasePreferenceWidget( + icon = { + Icon(Icons.Default.Link, null) + }, + onClick = { + context.startActivity( + Intent( + context, LinkUploadRuleActivity::class.java + ).apply { action = Intent.ACTION_VIEW }) + }, + title = { Text(stringResource(id = R.string.direct_link_settings)) }, + ) + + BasePreferenceWidget( + icon = { Icon(Icons.Default.ColorLens, null) }, + onClick = { showThemeDialog = true }, + title = { Text(stringResource(id = R.string.theme)) }, + subTitle = { Text(stringResource(id = getAppTheme().stringResId)) }, + ) + + val languageKeys = remember { + mutableListOf("").apply { addAll(AppLocale.localeMap.keys.toList()) } + } + + val languageNames = remember { + AppLocale.localeMap.map { "${it.value.displayName} - ${it.value.getDisplayName(it.value)}" } + .toMutableList() + .apply { add(0, context.getString(R.string.follow_system)) } + } + + var langMenu by remember { mutableStateOf(false) } + DropdownPreference( + Modifier.minimumInteractiveComponentSize(), + expanded = langMenu, + onExpandedChange = { langMenu = it }, + icon = { + Icon(Icons.Default.Language, null) + }, + title = { Text(stringResource(id = R.string.language)) }, + subTitle = { + Text( + if (AppLocale.getLocaleCodeFromFile(context).isEmpty()) { + stringResource(id = R.string.follow_system) + } else { + AppLocale.getLocaleFromFile(context).displayName + } + ) + }) { + languageNames.forEachIndexed { index, name -> + DropdownMenuItem( + text = { + Text(name) + }, onClick = { + langMenu = false + + AppLocale.saveLocaleCodeToFile(context, languageKeys[index]) + AppLocale.setLocale(app) + } + ) + } + } + + var filePickerMode by remember { AppConfig.filePickerMode } + var expanded by remember { mutableStateOf(false) } + DropdownPreference( + expanded = expanded, + onExpandedChange = { expanded = it }, + icon = { Icon(Icons.Default.FileOpen, null) }, + title = { Text(stringResource(id = R.string.file_picker_mode)) }, + subTitle = { + Text( + when (filePickerMode) { + FilePickerMode.PROMPT -> stringResource(id = R.string.file_picker_mode_prompt) + FilePickerMode.BUILTIN -> stringResource(id = R.string.file_picker_mode_builtin) + else -> stringResource(id = R.string.file_picker_mode_system) + } + ) + }, + actions = { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.file_picker_mode_prompt)) }, + onClick = { + expanded = false + filePickerMode = FilePickerMode.PROMPT + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.file_picker_mode_builtin)) }, + onClick = { + expanded = false + filePickerMode = FilePickerMode.BUILTIN + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.file_picker_mode_system)) }, + onClick = { + expanded = false + filePickerMode = FilePickerMode.SYSTEM + } + ) + } + ) + + var autoCheck by remember { AppConfig.isAutoCheckUpdateEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.auto_check_update)) }, + subTitle = { Text(stringResource(id = R.string.check_update_summary)) }, + checked = autoCheck, + onCheckedChange = { autoCheck = it }, + icon = { + Icon(Icons.Default.ArrowCircleUp, contentDescription = null) + } + ) + + var maxDropdownCount by remember { AppConfig.spinnerMaxDropDownCount } + SliderPreference( + title = { Text(stringResource(id = R.string.spinner_drop_down_max_count)) }, + subTitle = { Text(stringResource(id = R.string.spinner_drop_down_max_count_summary)) }, + value = maxDropdownCount.toFloat(), + onValueChange = { + maxDropdownCount = it.toInt() + }, + label = if (maxDropdownCount == 0) stringResource(id = R.string.unlimited) else maxDropdownCount.toString(), + valueRange = 0f..50f, + icon = { Icon(Icons.AutoMirrored.Filled.MenuOpen, null) } + ) + + DividerPreference { + Text(stringResource(id = R.string.system_tts)) + } + + var useExoDecoder by remember { SystemTtsConfig.isExoDecoderEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.use_exo_decoder)) }, + subTitle = { Text(stringResource(id = R.string.use_exo_decoder_summary)) }, + checked = useExoDecoder, + onCheckedChange = { useExoDecoder = it }, + icon = { Icon(Icons.Default.PlayCircleOutline, null) } + ) + + var streamPlay by remember { SystemTtsConfig.isStreamPlayModeEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.stream_audio_mode)) }, + subTitle = { Text(stringResource(id = R.string.stream_audio_mode_summary)) }, + checked = streamPlay, + onCheckedChange = { streamPlay = it }, + icon = { Icon(Icons.Default.Waves, null) } + ) + + var skipSilentText by remember { SystemTtsConfig.isSkipSilentText } + SwitchPreference( + title = { Text(stringResource(id = R.string.skip_request_silent_text)) }, + subTitle = { Text(stringResource(id = R.string.skip_request_silent_text_summary)) }, + checked = skipSilentText, + onCheckedChange = { skipSilentText = it }, + icon = { Icon(Icons.AutoMirrored.Filled.TextSnippet, null) } + ) + + var foregroundService by remember { SystemTtsConfig.isForegroundServiceEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.foreground_service_and_notification)) }, + subTitle = { Text(stringResource(id = R.string.foreground_service_and_notification_summary)) }, + checked = foregroundService, + onCheckedChange = { foregroundService = it }, + icon = { Icon(Icons.Default.NotificationsNone, null) } + ) + + var wakeLock by remember { SystemTtsConfig.isWakeLockEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.wake_lock)) }, + subTitle = { Text(stringResource(id = R.string.wake_lock_summary)) }, + checked = wakeLock, + onCheckedChange = { wakeLock = it }, + icon = { Icon(Icons.Default.Lock, null) } + ) + + var maxRetry by remember { SystemTtsConfig.maxRetryCount } + val maxRetryValue = + if (maxRetry == 0) stringResource(id = R.string.no_retries) else maxRetry.toString() + SliderPreference( + title = { Text(stringResource(id = R.string.max_retry_count)) }, + subTitle = { Text(stringResource(id = R.string.max_retry_count_summary)) }, + value = maxRetry.toFloat(), + onValueChange = { maxRetry = it.toInt() }, + valueRange = 0f..10f, + icon = { Icon(Icons.Default.Repeat, null) }, + label = maxRetryValue, + ) + + var emptyAudioCount by remember { SystemTtsConfig.maxEmptyAudioRetryCount } + val emptyAudioCountValue = + if (emptyAudioCount == 0) stringResource(id = R.string.no_retries) else emptyAudioCount.toString() + SliderPreference( + title = { Text(stringResource(id = R.string.retry_count_when_audio_empty)) }, + subTitle = { Text(stringResource(id = R.string.retry_count_when_audio_empty_summary)) }, + value = emptyAudioCount.toFloat(), + onValueChange = { emptyAudioCount = it.toInt() }, + valueRange = 0f..10f, + icon = { Icon(Icons.Default.Audiotrack, null) }, + label = emptyAudioCountValue + ) + + var standbyTriggeredIndex by remember { SystemTtsConfig.standbyTriggeredRetryIndex } + val standbyTriggeredIndexValue = standbyTriggeredIndex.toString() + SliderPreference( + title = { Text(stringResource(id = R.string.systts_standby_triggered_retry_index)) }, + subTitle = { Text(stringResource(id = R.string.systts_standby_triggered_retry_index_summary)) }, + value = standbyTriggeredIndex.toFloat(), + onValueChange = { standbyTriggeredIndex = it.toInt() }, + valueRange = 0f..10f, + icon = { Icon(Icons.Default.Repeat, null) }, + label = standbyTriggeredIndexValue + ) + + + var requestTimeout by remember { SystemTtsConfig.requestTimeout } + val requestTimeoutValue = "${requestTimeout / 1000}s" + SliderPreference( + title = { Text(stringResource(id = R.string.request_timeout)) }, + subTitle = { Text(stringResource(id = R.string.request_timeout_summary)) }, + value = (requestTimeout / 1000).toFloat(), + onValueChange = { requestTimeout = it.toInt() * 1000 }, + valueRange = 1f..30f, + icon = { Icon(Icons.Default.AccessTime, null) }, + label = requestTimeoutValue + ) + + DividerPreference { + Text(stringResource(id = R.string.systts_interface_preference)) + } + + var limitTagLen by remember { AppConfig.limitTagLength } + val limitTagLenString = + if (limitTagLen == 0) stringResource(id = R.string.unlimited) else limitTagLen.toString() + SliderPreference( + title = { Text(stringResource(id = R.string.limit_tag_length)) }, + subTitle = { Text(stringResource(id = R.string.limit_tag_length_summary)) }, + value = limitTagLen.toFloat(), + onValueChange = { limitTagLen = it.toInt() }, + valueRange = 0f..50f, + icon = { Icon(Icons.Default.Tag, null) }, + label = limitTagLenString + ) + + var limitNameLen by remember { AppConfig.limitNameLength } + val limitNameLenString = + if (limitNameLen == 0) stringResource(id = R.string.unlimited) else limitNameLen.toString() + SliderPreference( + title = { Text(stringResource(id = R.string.limit_name_length)) }, + subTitle = { Text(stringResource(id = R.string.limit_name_length_summary)) }, + value = limitNameLen.toFloat(), + onValueChange = { limitNameLen = it.toInt() }, + valueRange = 0f..50f, + icon = { Icon(Icons.Default.TextFields, null) }, + label = limitNameLenString + ) + + var wrapButton by remember { AppConfig.isSwapListenAndEditButton } + SwitchPreference( + title = { Text(stringResource(id = R.string.pref_swap_listen_and_edit_button)) }, + subTitle = {}, + checked = wrapButton, + onCheckedChange = { wrapButton = it }, + icon = { + Icon(Icons.Default.Headset, contentDescription = null) + } + ) + + var targetMultiple by remember { SystemTtsConfig.isVoiceMultipleEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.voice_multiple_option)) }, + subTitle = { Text(stringResource(id = R.string.voice_multiple_summary)) }, + checked = targetMultiple, + onCheckedChange = { targetMultiple = it }, + icon = { + Icon(Icons.Default.SelectAll, contentDescription = null) + } + ) + + var groupMultiple by remember { SystemTtsConfig.isGroupMultipleEnabled } + SwitchPreference( + title = { Text(stringResource(id = R.string.groups_multiple)) }, + subTitle = { Text(stringResource(id = R.string.groups_multiple_summary)) }, + checked = groupMultiple, + onCheckedChange = { groupMultiple = it }, + icon = { + Icon(Icons.Default.Groups, contentDescription = null) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/SettingsWidgets.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/SettingsWidgets.kt new file mode 100644 index 000000000..ab143eb39 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/SettingsWidgets.kt @@ -0,0 +1,226 @@ +package com.github.jing332.tts_server_android.compose.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LabelSlider + +@Composable +internal fun DropdownPreference( + modifier: Modifier = Modifier, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + icon: @Composable () -> Unit, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + actions: @Composable ColumnScope. () -> Unit = {} +) { + BasePreferenceWidget(modifier = modifier, icon = icon, onClick = { + onExpandedChange(true) + }, title = title, subTitle = subTitle) { + DropdownMenu( + modifier = Modifier.align(Alignment.Top), + expanded = expanded, + onDismissRequest = { onExpandedChange(false) }) { + actions() + } + } +} + +@Composable +internal fun DividerPreference(title: @Composable () -> Unit) { + Column(Modifier.padding(top = 4.dp)) { + HorizontalDivider(thickness = 0.5.dp) + Row( + Modifier + .padding(vertical = 8.dp) + .align(Alignment.CenterHorizontally) + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ), + ) { + title() + } + } + } + +} + +@Composable +internal fun SwitchPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + icon: @Composable () -> Unit = {}, + + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + BasePreferenceWidget( + modifier = modifier.semantics(mergeDescendants = true) { + role = Role.Switch + }, + onClick = { onCheckedChange(!checked) }, + title = title, + subTitle = subTitle, + icon = icon, + content = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.align(Alignment.CenterVertically) + ) + } + ) +} + +@Composable +internal fun BasePreferenceWidget( + modifier: Modifier = Modifier, + onClick: () -> Unit, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit = {}, + icon: @Composable () -> Unit = {}, + content: @Composable RowScope.() -> Unit = {}, +) { + Row(modifier = modifier + .minimumInteractiveComponentSize() + .defaultMinSize(minHeight = 64.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple() + ) { + onClick() + } + .padding(8.dp) + ) { + Column( + Modifier.align(Alignment.CenterVertically) + ) { + icon() + } + + Column( + Modifier + .weight(1f) + .align(Alignment.CenterVertically) + .padding(horizontal = 8.dp) + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleMedium) { + title() + } + + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + subTitle() + } + } + + Row(Modifier.align(Alignment.CenterVertically)) { + content() + } + } +} + + +@Composable +internal fun SliderPreference( + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + icon: @Composable () -> Unit = {}, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + label: String, +) { + val view = LocalView.current + LaunchedEffect(value) { + view.announceForAccessibility(value.toString()) + } + + PreferenceDialog( + title = title, + subTitle = subTitle, + dialogContent = { + LabelSlider( + modifier = Modifier.padding(vertical = 16.dp), + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + buttonSteps = 1f, + buttonLongSteps = 2f, + text = label + ) + }, + icon = icon, + endContent = { Text(label) } + ) +} + +@Composable +internal fun PreferenceDialog( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + subTitle: @Composable () -> Unit, + icon: @Composable () -> Unit, + + dialogContent: @Composable ColumnScope.() -> Unit, + endContent: @Composable RowScope.() -> Unit = {}, +) { + var showDialog by remember { mutableStateOf(false) } + if (showDialog) { + AppDialog(title = title, content = { + Column { + dialogContent() + } + }, buttons = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(id = R.string.close)) + } + }, onDismissRequest = { showDialog = false }) + } + BasePreferenceWidget(modifier, onClick = { + showDialog = true + }, title = title, icon = icon, subTitle = subTitle) { + endContent() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/ThemeSelectionDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/ThemeSelectionDialog.kt new file mode 100644 index 000000000..717e2ccb9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/settings/ThemeSelectionDialog.kt @@ -0,0 +1,98 @@ +package com.github.jing332.tts_server_android.compose.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import kotlinx.coroutines.delay + + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ThemeSelectionDialog( + onDismissRequest: () -> Unit, + currentTheme: AppTheme, + onChangeTheme: (AppTheme) -> Unit +) { + AlertDialog(onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(id = R.string.theme)) + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.close)) + } + }, text = { + val view = LocalView.current + val context = LocalContext.current + Column { + var showWarn by remember { androidx.compose.runtime.mutableStateOf(false) } + AnimatedVisibility( + modifier = Modifier.align(Alignment.CenterHorizontally), + visible = showWarn + ) { + LaunchedEffect(showWarn) { + view.announceForAccessibility(context.getString(R.string.dynamic_color_not_support)) + delay(2000) + showWarn = false + } + + Text( + text = stringResource(id = R.string.dynamic_color_not_support), + modifier = Modifier.padding(bottom = 8.dp), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + + + FlowRow { + AppTheme.values().forEach { + val leadingIcon: @Composable () -> Unit = + { Icon(Icons.Default.Check, null) } +// var selected by remember { mutableStateOf(it == AppTheme.DEFAULT) } + val selected = currentTheme.id == it.id + FilterChip( + selected, + modifier = Modifier.padding(horizontal = 2.dp), + leadingIcon = if (selected) leadingIcon else null, + onClick = { + if (it == AppTheme.DYNAMIC_COLOR && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + showWarn = true + return@FilterChip + } + + onChangeTheme(it) + }, + label = { Text(stringResource(id = it.stringResId), color = it.color) } + ) + } + } + } + }) +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/AuditionDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/AuditionDialog.kt new file mode 100644 index 000000000..0741fb6c3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/AuditionDialog.kt @@ -0,0 +1,125 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.conf.AppConfig +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.help.audio.AudioDecoder +import com.github.jing332.tts_server_android.help.audio.AudioPlayer +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.StringUtils.sizeToReadable +import com.github.jing332.tts_server_android.utils.clickableRipple +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun AuditionDialog( + systts: SystemTts, + text: String = AppConfig.testSampleText.value, + onDismissRequest: () -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val audioPlayer = remember { AudioPlayer(context) } + + var error by remember { mutableStateOf("") } + + var audioInfo by remember { mutableStateOf?>(null) } + + LaunchedEffect(systts.id) { + scope.launch(Dispatchers.IO) { + kotlin.runCatching { + systts.tts.onLoad() + systts.tts.getAudioWithSystemParams(text) + ?.use { ins -> + val audio = ins.readBytes() + val info = AudioDecoder.getSampleRateAndMime(audio) + if (audio.isEmpty()) { + error = context.getString(R.string.systts_log_audio_empty, "") + return@launch + } + audioInfo = Triple(audio.size, info.first, info.second) + + if (systts.tts.audioFormat.isNeedDecode) + audioPlayer.play(audio) + else + audioPlayer.play(audio, systts.tts.audioFormat.sampleRate) + } + }.onFailure { + withMain { error = it.stackTraceToString() } + + return@launch + } + withMain { onDismissRequest() } + } + } + + DisposableEffect(systts.id) { + onDispose { + audioPlayer.release() + systts.tts.onDestroy() + } + } + + AppDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.audition)) }, + content = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text( + error.ifEmpty { text }, + color = if (error.isEmpty()) Color.Unspecified else MaterialTheme.colorScheme.error, +// maxLines = if (error.isEmpty()) Int.MAX_VALUE else 1, + style = MaterialTheme.typography.bodySmall + ) + + val infoStr = stringResource( + id = R.string.systts_test_success_info, + audioInfo?.first?.toLong()?.sizeToReadable() ?: 0, + audioInfo?.second ?: 0, + audioInfo?.third ?: "" + ) + if (error.isEmpty()) + LoadingContent( + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .clickableRipple { + ClipboardUtils.copyText("TTS Server", infoStr) + context.toast(R.string.copied) + }, isLoading = audioInfo == null + ) { + Text(infoStr, style = MaterialTheme.typography.bodyMedium) + } + + } + }, + buttons = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = R.string.cancel)) } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigDeleteDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigDeleteDialog.kt new file mode 100644 index 000000000..4e3f2b005 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigDeleteDialog.kt @@ -0,0 +1,51 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.github.jing332.tts_server_android.R + +@Composable +fun ConfigDeleteDialog(onDismissRequest: () -> Unit, name: String, onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.is_confirm_delete)) }, + text = { + Text( + name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + }, + confirmButton = { + TextButton(onClick = { + onConfirm() + }) { + Text( + stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + }, + icon = { + Icon( + Icons.Default.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigExportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigExportBottomSheet.kt new file mode 100644 index 000000000..d86b1ab2a --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigExportBottomSheet.kt @@ -0,0 +1,120 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.directlink.LinkUploadSelectionDialog +import com.github.jing332.tts_server_android.compose.widgets.AppBottomSheet +import com.github.jing332.tts_server_android.ui.AppActivityResultContracts +import com.github.jing332.tts_server_android.ui.FilePickerActivity +import com.github.jing332.tts_server_android.ui.view.BigTextView +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.toast + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigExportBottomSheet( + json: String, + fileName: String = "config.json", + content: @Composable ColumnScope.() -> Unit = {}, + onDismissRequest: () -> Unit, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val fileSaver = + rememberLauncherForActivityResult(AppActivityResultContracts.filePickerActivity()) { + } + + var showSelectUploadTargetDialog by remember { mutableStateOf(false) } + if (showSelectUploadTargetDialog) + LinkUploadSelectionDialog( + onDismissRequest = { showSelectUploadTargetDialog = false }, + json = json + ) + + AppBottomSheet(onDismissRequest = onDismissRequest) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + content() + Row(Modifier.align(Alignment.CenterHorizontally)) { + TextButton( + onClick = { + ClipboardUtils.copyText(json) + context.toast(R.string.copied) + } + ) { + Text(stringResource(id = R.string.copy)) + } + + TextButton( + onClick = { + showSelectUploadTargetDialog = true + } + ) { + Text(stringResource(id = R.string.upload_to_url)) + } + + TextButton( + onClick = { + fileSaver.launch( + FilePickerActivity.RequestSaveFile( + fileName = fileName, + fileMime = "application/json", + fileBytes = json.toByteArray() + ) + ) + }) { + Text(stringResource(id = R.string.save_as_file)) + } + } + + var tv by remember { + mutableStateOf(null) + } + + AndroidView(modifier = Modifier.verticalScroll(rememberScrollState()), factory = { + tv = BigTextView(it) + + tv!! + }, update = { + it.setText(json) + }) + + LaunchedEffect(key1 = json) { + tv?.setText(json) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigImportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigImportBottomSheet.kt new file mode 100644 index 000000000..f6c5cf2d0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ConfigImportBottomSheet.kt @@ -0,0 +1,309 @@ +package com.github.jing332.tts_server_android.compose.systts + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.Input +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.drake.net.Net +import com.drake.net.okhttp.trustSSLCertificate +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppBottomSheet +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.RowToggleButtonGroup +import com.github.jing332.tts_server_android.ui.AppActivityResultContracts +import com.github.jing332.tts_server_android.ui.FilePickerActivity +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.toJsonListString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Response + +class ImportSource { + companion object { + const val CLIPBOARD = 0 + const val FILE = 1 + const val URL = 2 + } +} + +val LocalImportRemoteUrl = compositionLocalOf { mutableStateOf("") } +val LocalImportFilePath = compositionLocalOf { mutableStateOf("") } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigImportBottomSheet( + content: @Composable ColumnScope.() -> Unit = {}, + onDismissRequest: () -> Unit, + onImport: (json: String) -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + suspend fun getConfig( + src: Int, + url: String? = null, + uri: Uri? = null + ): String { + return when (src) { + ImportSource.URL -> withContext(Dispatchers.IO) { + val resp: Response = Net.get(url.toString()) { + setClient { trustSSLCertificate() } + }.execute() + val str = resp.body?.string() + if (resp.isSuccessful && !str.isNullOrBlank()) { + return@withContext str + } else { + throw Exception("GET $url failed: code=${resp.code}, message=${resp.message}, body=${str}") + } + } + + ImportSource.FILE -> withContext(Dispatchers.IO) { + uri?.readAllText(context) ?: throw Exception("file uri is null!") + } + + ImportSource.CLIPBOARD -> withMain { ClipboardUtils.text.toString() } // CLIPBOARD + + else -> throw IllegalArgumentException("unknown source: $src") + } + } + + var source by remember { mutableIntStateOf(0) } + var path by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + + AppBottomSheet( + onDismissRequest = onDismissRequest + ) { + Column(Modifier.padding(horizontal = 8.dp)) { + Column( + Modifier + .weight(weight = 1f, fill = false) + .align(Alignment.Start) + ) { + Text( + stringResource(id = R.string.import_config), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.displayMedium + ) + + Column(Modifier.fillMaxWidth()) { + content() + + Text( + stringResource(id = R.string.source), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.titleMedium + ) + + RowToggleButtonGroup( + selectionIndex = source, + buttonCount = 3, + onButtonClick = { source = it }, + buttonTexts = arrayOf( + R.string.clipboard, R.string.file, R.string.url_net + ).map { stringResource(id = it) }.toTypedArray(), + buttonIcons = arrayOf( + R.drawable.ic_baseline_select_all_24, + R.drawable.ic_baseline_insert_drive_file_24, + R.drawable.ic_web + ).map { painterResource(id = it) }.toTypedArray(), + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + if (LocalImportRemoteUrl.current.value.isNotBlank()) { + source = ImportSource.URL + url = LocalImportRemoteUrl.current.value + LocalImportRemoteUrl.current.value = "" + } else if (LocalImportFilePath.current.value.isNotBlank()) { + source = ImportSource.FILE + path = LocalImportFilePath.current.value + LocalImportFilePath.current.value = "" + } + + AnimatedVisibility( + modifier = Modifier.animateContentSize(), + visible = source != ImportSource.CLIPBOARD + ) { + when (source) { + ImportSource.URL -> OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = url, + onValueChange = { url = it }, + label = { + Text(stringResource(id = R.string.url_net)) + }, + ) + + ImportSource.FILE -> { + val filePicker = + rememberLauncherForActivityResult(contract = AppActivityResultContracts.filePickerActivity()) { + it.second?.let { uri -> + path = uri.toString() + } + } + + OutlinedTextField( + readOnly = true, + modifier = Modifier.fillMaxWidth(), + value = path, + onValueChange = { path = it }, + label = { + Text(stringResource(id = R.string.file)) + }, + trailingIcon = { + IconButton(onClick = { + filePicker.launch( + FilePickerActivity.RequestSelectFile( + listOf("application/json", "text/*") + ) + ) + }) { + Icon( + Icons.Default.FileOpen, + stringResource(id = R.string.select_file) + ) + } + } + ) + } + } + } + } + } + + + Box( + Modifier + .fillMaxWidth() + .align(Alignment.End) + .padding(top = 8.dp) + ) { + TextButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = { + scope.launch { + runCatching { + val jsonStr = + getConfig(src = source, url = url, uri = Uri.parse(path)) + onImport(jsonStr.toJsonListString()) + }.onFailure { + context.displayErrorDialog(it) + } + } + }) { + Row { + Icon(Icons.Default.Input, null) + Text(stringResource(id = R.string.import_config)) + } + } + } + } + } +} + +data class ConfigModel( + val isSelected: Boolean, + val title: String, + val subtitle: String, + val data: Any +) + +@Composable +fun SelectImportConfigDialog( + onDismissRequest: () -> Unit, + models: List, + onSelectedList: (list: List) -> Int +) { + val context = LocalContext.current + val modelsState = remember { mutableStateListOf(*models.toTypedArray()) } + AppDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.select_import)) }, + content = { + LazyColumn { + itemsIndexed(modelsState, key = { i, _ -> i }) { index, item -> + Row( + Modifier + .fillMaxWidth() + .minimumInteractiveComponentSize() + .clip(MaterialTheme.shapes.small) + .clickable(role = Role.Checkbox) { + modelsState[index] = item.copy(isSelected = !item.isSelected) + } + .padding(vertical = 4.dp) + ) { + Checkbox( + checked = item.isSelected, + onCheckedChange = null, + modifier = Modifier.align(Alignment.CenterVertically) + ) + Column(Modifier.padding(start = 4.dp)) { + Text(item.title, style = MaterialTheme.typography.titleMedium) + Text(item.subtitle, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + }, + buttons = { + TextButton(onClick = { + val count = + onSelectedList.invoke(modelsState.filter { it.isSelected }.map { it.data }) + if (count > 0) { + onDismissRequest() + context.longToast(R.string.config_import_success_msg, count) + } + }) { + Text(stringResource(id = R.string.import_config)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/GroupItem.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/GroupItem.kt new file mode 100644 index 000000000..6243a9a54 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/GroupItem.kt @@ -0,0 +1,191 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.ExpandCircleDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Output +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R + +fun Int.sizeToToggleableState(total: Int): ToggleableState = when (this) { + 0 -> ToggleableState.Off + total -> ToggleableState.On + else -> ToggleableState.Indeterminate +} + +@Composable +fun GroupItem( + modifier: Modifier, + isExpanded: Boolean, + name: String, + toggleableState: ToggleableState, + onToggleableStateChange: (Boolean) -> Unit, + onClick: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, + actions: @Composable ColumnScope.(() -> Unit) -> Unit, +) { + val view = LocalView.current + val context = LocalContext.current + + var expandedFirst by remember { mutableStateOf(true) } + LaunchedEffect(isExpanded) { + if (expandedFirst) expandedFirst = false + else { + val msg = + if (isExpanded) context.getString( + R.string.group_expanded, + name + ) else context.getString(R.string.group_collapsed, name) + view.announceForAccessibility(msg) + } + } + + var checkFirst by remember { mutableStateOf(true) } + LaunchedEffect(toggleableState) { + if (checkFirst) checkFirst = false + else { + val msg = when (toggleableState) { + ToggleableState.On -> context.getString(R.string.group_all_enabled, name) + ToggleableState.Off -> context.getString(R.string.group_all_disabled, name) + else -> context.getString(R.string.group_part_enabled, name) + } + view.announceForAccessibility(msg) + } + } + + var showDeleteDialog by remember { mutableStateOf(false) } + if (showDeleteDialog) + ConfigDeleteDialog( + onDismissRequest = { showDeleteDialog = false }, name = name, onConfirm = onDelete + ) + + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .semantics(true) { + contentDescription = context.getString( + if (isExpanded) R.string.group_expanded + else R.string.group_collapsed, " " + ) + } + .clickable { onClick() } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded) 0f else -45f, + label = "" + ) + Icon( + Icons.Default.ExpandCircleDown, + contentDescription = null, + modifier = Modifier + .rotate(rotationAngle) + .graphicsLayer { rotationZ = rotationAngle } + ) + + Text( + name, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .align(Alignment.CenterVertically) + .weight(1f) + ) + Row { + TriStateCheckbox( + state = toggleableState, + onClick = { + onToggleableStateChange(toggleableState != ToggleableState.On) + }, + modifier = Modifier.semantics { + contentDescription = context.getString( + when (toggleableState) { + ToggleableState.On -> R.string.group_all_enabled + ToggleableState.Off -> R.string.group_all_disabled + else -> R.string.group_part_enabled + }, name + ) + } + ) + + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(id = R.string.more_options_desc, name) + ) + + DropdownMenu(expanded = showOptions, onDismissRequest = { showOptions = false }) { + actions { showOptions = false } + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + onExport() + }, + leadingIcon = { + Icon(Icons.Default.Output, null) + } + ) + + HorizontalDivider() + + DropdownMenuItem(text = { + Text( + stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.error + ) + }, + leadingIcon = { + Icon( + Icons.Default.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showOptions = false + showDeleteDialog = true + } + ) + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ListSortSettingsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ListSortSettingsDialog.kt new file mode 100644 index 000000000..1a6422f69 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/ListSortSettingsDialog.kt @@ -0,0 +1,111 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.compose.widgets.TextCheckBox +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.launch +import kotlin.system.measureTimeMillis + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ListSortSettingsDialog( + name: String, + onDismissRequest: () -> Unit, + index: Int, + onIndexChange: (Int) -> Unit, + entries: List, + onConfirm: suspend (index: Int, descending: Boolean) -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var descending by remember { mutableStateOf(false) } + var sorting by remember { mutableStateOf(false) } + AppDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.sort)) }, + content = { + LoadingContent(Modifier.padding(vertical = 8.dp), isLoading = sorting) { + Column(Modifier.fillMaxWidth()) { + Text( + name, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 4.dp), + style = MaterialTheme.typography.titleMedium + ) + FlowRow(Modifier.align(Alignment.CenterHorizontally)) { + entries.forEachIndexed { i, s -> + val selected = i == index + FilterChip( + selected, + modifier = Modifier.padding(horizontal = 4.dp), + onClick = { onIndexChange(i) }, + label = { + Text( + s, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ) + } + ) + } + } + } + } + }, + buttons = { + Row(Modifier.fillMaxWidth()) { + TextCheckBox( + text = { + Text(stringResource(id = R.string.descending), modifier = Modifier.padding(end = 8.dp)) + }, checked = descending, onCheckedChange = { descending = it } + ) + + Row(Modifier.weight(1f)) { + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + + TextButton(onClick = { + scope.launch { + sorting = true + val cost = measureTimeMillis { onConfirm(index, descending) } + context.toast(R.string.sorting_complete_msg, cost) + sorting = false + } + }) { + Text(stringResource(id = R.string.start)) + } + } + } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/LogScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/LogScreen.kt new file mode 100644 index 000000000..7aa796b92 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/LogScreen.kt @@ -0,0 +1,122 @@ +package com.github.jing332.tts_server_android.compose.systts + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.ui.AppLog +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.toAnnotatedString +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LogScreen( + modifier: Modifier, + list: List, + lazyListState: LazyListState = rememberLazyListState() +) { + val scope = rememberCoroutineScope() + val view = LocalView.current + val context = LocalContext.current + Box(modifier) { + val isAtBottom by remember { + derivedStateOf { + val layoutInfo = lazyListState.layoutInfo + val visibleItemsInfo = layoutInfo.visibleItemsInfo + if (layoutInfo.totalItemsCount <= 0) { + true + } else { + val lastVisibleItem = visibleItemsInfo.last() + lastVisibleItem.index > layoutInfo.totalItemsCount - 5 + } + } + } + + LaunchedEffect(list.size) { + if (isAtBottom && list.isNotEmpty()) + scope.launch { + lazyListState.animateScrollToItem(list.size - 1) + } + } + + LazyColumn(Modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(list, key = { index, _ -> index }) { _, item -> + val style = MaterialTheme.typography.bodyMedium + val spanned = remember { + HtmlCompat.fromHtml(item.msg, HtmlCompat.FROM_HTML_MODE_COMPACT) + .toAnnotatedString() + } + + Text( + text = spanned, + style = style, + lineHeight = style.lineHeight * 0.75f, + modifier = Modifier + .combinedClickable( + onClick = { + println("onClick") + }, + onLongClick = { + view.isHapticFeedbackEnabled = true + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + ClipboardUtils.copyText("tts-server-log", spanned.text) + context.toast(R.string.copied) + } + ) + .padding(horizontal = 4.dp) + ) + } + } + + AnimatedVisibility( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(12.dp), visible = !isAtBottom + ) { + SmallFloatingActionButton( + shape = CircleShape, + onClick = { + scope.launch { + kotlin.runCatching { + lazyListState.scrollToItem(list.size - 1) + } + } + }) { + Icon( + Icons.Default.KeyboardDoubleArrowDown, + stringResource(id = R.string.move_to_bottom) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/SystemTtsScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/SystemTtsScreen.kt new file mode 100644 index 000000000..bde1e9cb5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/SystemTtsScreen.kt @@ -0,0 +1,96 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TextSnippet +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.list.ListManagerScreen +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SystemTtsScreen( vm: SystemTtsViewModel = viewModel()) { + val pagerState = rememberPagerState { 2 } + val scope = rememberCoroutineScope() + + Scaffold( + bottomBar = { + NavigationBar { + (0 until 2).forEach { + when (it) { + 0 -> { + val isSelected = pagerState.currentPage == it + NavigationBarItem( + selected = isSelected, + onClick = { + scope.launch { + pagerState.animateScrollToPage(it) + } + }, + icon = { + Icon( + painterResource(id = R.drawable.ic_config), + null, + Modifier.size(24.dp) + ) + }, + label = { + Text(stringResource(id = R.string.config)) + } + ) + } + + 1 -> { + val isSelected = pagerState.currentPage == it + NavigationBarItem( + selected = isSelected, + onClick = { + scope.launch { + pagerState.animateScrollToPage(it) + } + }, + icon = { + Icon(Icons.Default.TextSnippet, null) + }, + label = { + Text(stringResource(id = R.string.log)) + } + ) + } + } + + } + } + } + ) { paddingValues -> + HorizontalPager( + modifier = Modifier + .padding(bottom = paddingValues.calculateBottomPadding()) + .fillMaxSize(), + state = pagerState, + userScrollEnabled = false + ) { index -> + when (index) { + 0 -> ListManagerScreen() + 1 -> TtsLogScreen() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/SystemTtsViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/SystemTtsViewModel.kt new file mode 100644 index 000000000..bd6995b3f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/SystemTtsViewModel.kt @@ -0,0 +1,8 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.foundation.lazy.LazyListState +import androidx.lifecycle.ViewModel + +class SystemTtsViewModel : ViewModel() { + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/TtsLogScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/TtsLogScreen.kt new file mode 100644 index 000000000..16f0d524a --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/TtsLogScreen.kt @@ -0,0 +1,52 @@ +package com.github.jing332.tts_server_android.compose.systts + +import android.content.IntentFilter +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.nav.NavTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.LocalBroadcastReceiver +import com.github.jing332.tts_server_android.constant.KeyConst +import com.github.jing332.tts_server_android.service.systts.SystemTtsService +import com.github.jing332.tts_server_android.ui.AppLog + +@Suppress("DEPRECATION") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TtsLogScreen(vm: TtsLogViewModel = viewModel()) { + LocalBroadcastReceiver(intentFilter = IntentFilter(SystemTtsService.ACTION_ON_LOG)) { + if (it?.action == SystemTtsService.ACTION_ON_LOG) { + it.getParcelableExtra(KeyConst.KEY_DATA)?.let { log -> + println("ACTION_ON_LOG ${log.msg}") + vm.logs.add(log) + } + } + } + + Scaffold( + topBar = { + NavTopAppBar(title = { Text(stringResource(id = R.string.log)) }, actions = { + IconButton(onClick = { vm.logs.clear() }) { + Icon(Icons.Default.DeleteOutline, stringResource(id = R.string.clear_log)) + } + }) + } + ) { paddingValues -> + LogScreen( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()), list = vm.logs + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/TtsLogViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/TtsLogViewModel.kt new file mode 100644 index 000000000..2c31d95a9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/TtsLogViewModel.kt @@ -0,0 +1,9 @@ +package com.github.jing332.tts_server_android.compose.systts + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import com.github.jing332.tts_server_android.ui.AppLog + +class TtsLogViewModel : ViewModel() { + val logs = mutableStateListOf() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/directlink/LinkUploadRuleActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/directlink/LinkUploadRuleActivity.kt new file mode 100644 index 000000000..110142660 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/directlink/LinkUploadRuleActivity.kt @@ -0,0 +1,123 @@ +package com.github.jing332.tts_server_android.compose.systts.directlink + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.codeeditor.CodeEditorScreen +import com.github.jing332.tts_server_android.compose.codeeditor.LoggerBottomSheet +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.conf.DirectUploadConfig +import com.github.jing332.tts_server_android.model.rhino.core.Logger +import com.github.jing332.tts_server_android.model.rhino.direct_link_upload.DirectUploadEngine +import com.github.jing332.tts_server_android.model.rhino.direct_link_upload.DirectUploadFunction +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import io.github.rosemoe.sora.widget.CodeEditor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class LinkUploadRuleActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppTheme { + LinkUploadRuleScreen() + } + } + } + + + @Composable + private fun LinkUploadRuleScreen() { + var editor by remember { mutableStateOf(null) } + val logger by remember { mutableStateOf(Logger()) } + var targets by remember { mutableStateOf?>(null) } + val scope = rememberCoroutineScope() + + LaunchedEffect(editor) { + editor?.setText(DirectUploadConfig.code.value) + } + + var showDebugLogger by remember { mutableStateOf("") } + if (showDebugLogger.isNotEmpty()) + LoggerBottomSheet( + logger = logger, + onDismissRequest = { showDebugLogger = "" }) { + scope.launch(Dispatchers.IO) { + runCatching { + val engine = DirectUploadEngine( + context = this@LinkUploadRuleActivity, + logger = logger, + code = editor!!.text.toString() + ) + val func = + engine.obtainFunctionList().find { it.funcName == showDebugLogger } + + val url = func?.invoke(""" {"test":"test"} """) + logger.i("url: $url") + }.onFailure { + this@LinkUploadRuleActivity.displayErrorDialog(it) + } + } + } + + fun obtainFunctionList(): List { + val engine = DirectUploadEngine( + context = this, + logger = logger, + code = editor!!.text.toString() + ) + return engine.obtainFunctionList() + } + + + CodeEditorScreen( + title = { Text(stringResource(id = R.string.direct_link_settings)) }, + onBack = { finishAfterTransition() }, + onSave = { + runCatching { + obtainFunctionList() + DirectUploadConfig.code.value = editor!!.text.toString() + + finishAfterTransition() + }.onFailure { + this.displayErrorDialog(it) + } + }, + onUpdate = { editor = it }, + onDebug = { + kotlin.runCatching { + targets = obtainFunctionList() + }.onFailure { + this.displayErrorDialog(it) + } + }, + debugIconContent = { + DropdownMenu(expanded = targets != null, onDismissRequest = { targets = null }) { + targets?.forEach { + DropdownMenuItem(text = { Text(it.funcName) }, onClick = { + targets = null + showDebugLogger = it.funcName + }) + } + } + }, + onSaveFile = { + "ttsrv-directLink.js" to editor!!.text.toString().toByteArray() + } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/directlink/LinkUploadSelectionDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/directlink/LinkUploadSelectionDialog.kt new file mode 100644 index 000000000..b01f3744c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/directlink/LinkUploadSelectionDialog.kt @@ -0,0 +1,72 @@ +package com.github.jing332.tts_server_android.compose.systts.directlink + + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppSelectionDialog +import com.github.jing332.tts_server_android.model.rhino.direct_link_upload.DirectUploadEngine +import com.github.jing332.tts_server_android.model.rhino.direct_link_upload.DirectUploadFunction +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.longToast +import kotlinx.coroutines.launch + +@Composable +fun LinkUploadSelectionDialog(onDismissRequest: () -> Unit, json: String) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val targetList = remember { + try { + DirectUploadEngine(context = context).obtainFunctionList() + } catch (e: Exception) { + context.displayErrorDialog(e, context.getString(R.string.upload_to_url)) + null + } + } + + var loading by remember { mutableStateOf(false) } + if (targetList != null) + AppSelectionDialog( + isLoading = loading, + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.choose_an_upload_target)) }, + value = Any(), + values = targetList, + entries = targetList.map { it.funcName }, + onClick = { value, _ -> + scope.launch { + runCatching { + loading = true + val url = + withIO { (value as DirectUploadFunction).invoke(json) } + ?: throw Exception("url is null") + ClipboardUtils.copyText("TTS Server", url) + context.longToast(R.string.copied_url) + loading = false + }.onFailure { + loading = false + context.displayErrorDialog(it) + return@launch + } + + onDismissRequest() + } + }, + buttons = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + }, + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/BasicAudioParamsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/BasicAudioParamsDialog.kt new file mode 100644 index 000000000..132d36f9f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/BasicAudioParamsDialog.kt @@ -0,0 +1,97 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LabelSlider +import com.github.jing332.tts_server_android.utils.toScale + +@Composable +fun BasicAudioParamsDialog( + title: @Composable () -> Unit = { Text(stringResource(id = R.string.audio_params)) }, + onDismissRequest: () -> Unit, + + resetValue: Float = 0f, + onReset: () -> Unit, + + defaultSpeed: Float = 0f, + speedRange: ClosedFloatingPointRange = 0f..3f, + speed: Float, + onSpeedChange: (Float) -> Unit, + + defaultVolume : Float = 0f, + volumeRange: ClosedFloatingPointRange = 0f..3f, + volume: Float, + onVolumeChange: (Float) -> Unit, + + defaultPitch: Float = 0f, + pitchRange: ClosedFloatingPointRange = 0f..3f, + pitch: Float, + onPitchChange: (Float) -> Unit, + + buttons: @Composable (BoxScope.() -> Unit) = { + Row { + TextButton( + enabled = speed != resetValue || volume != resetValue || pitch != resetValue, + onClick = { + onReset() + }) { + Text(stringResource(id = R.string.reset)) + } + + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.close)) + } + } + }, +) { + AppDialog( + title = title, + content = { + Column { + val str = stringResource( + id = R.string.label_speech_rate, + if (speed == defaultSpeed) stringResource(R.string.follow) else speed.toString() + ) + LabelSlider( + value = speed, + onValueChange = { onSpeedChange(it.toScale(2)) }, + valueRange = speedRange, + text = str + ) + + val volStr = + stringResource( + id = R.string.label_speech_volume, + if (volume == defaultVolume) stringResource(R.string.follow) else volume.toString() + ) + LabelSlider( + value = volume, + onValueChange = { onVolumeChange(it.toScale(2)) }, + valueRange = volumeRange, + text = volStr + ) + + val pitchStr = + stringResource( + id = R.string.label_speech_pitch, + if (pitch == defaultPitch) stringResource(R.string.follow) else pitch.toString() + ) + LabelSlider( + value = pitch, + onValueChange = { onPitchChange(it.toScale(2)) }, + valueRange = pitchRange, + text = pitchStr + ) + + } + }, + buttons = buttons, onDismissRequest = onDismissRequest + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/BgmSettingsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/BgmSettingsDialog.kt new file mode 100644 index 000000000..512a40ff7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/BgmSettingsDialog.kt @@ -0,0 +1,59 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.conf.SystemTtsConfig +import com.github.jing332.tts_server_android.utils.clickableRipple + +@Composable +fun BgmSettingsDialog(onDismissRequest: () -> Unit) { + AppDialog( + onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.bgm_settings)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + var shuffle by remember { SystemTtsConfig.isBgmShuffleEnabled } + Row( + Modifier + .height(48.dp) + .clip(MaterialTheme.shapes.medium) + .clickableRipple { shuffle = !shuffle } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = shuffle, onCheckedChange = null) + Text(stringResource(id = R.string.shuffle)) + } + + var volume by remember { SystemTtsConfig.bgmVolume } + val volumeStr = stringResource(id = R.string.label_speech_volume, (volume * 1000f).toInt().toString()) + IntSlider( + label = volumeStr, + value = volume * 1000f, + onValueChange = { volume = it / 1000f }, + valueRange = 1f..1000f + ) + + Text( + stringResource(id = R.string.bgm_settings_tip), + modifier = Modifier.padding(4.dp) + ) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/GlobalAudioParamsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/GlobalAudioParamsDialog.kt new file mode 100644 index 000000000..d8cb3c74e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/GlobalAudioParamsDialog.kt @@ -0,0 +1,38 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.conf.SystemTtsConfig + +@Composable +fun GlobalAudioParamsDialog(onDismissRequest: () -> Unit) { + var speed by remember { SystemTtsConfig.audioParamsSpeed } + var volume by remember { SystemTtsConfig.audioParamsVolume } + var pitch by remember { SystemTtsConfig.audioParamsPitch } + BasicAudioParamsDialog( + title = { Text(stringResource(id = R.string.audio_params_settings)) }, + onDismissRequest = onDismissRequest, + + speedRange = 0.1f..3f, + speed = speed, + onSpeedChange = { speed = it }, + + volumeRange = 0.1f..3f, + volume = volume, + onVolumeChange = { volume = it }, + + pitchRange = 0.1f..3f, + pitch = pitch, + onPitchChange = { pitch = it }, + + onReset = { + speed = 1f + volume = 1f + pitch = 1f + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/Group.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/Group.kt new file mode 100644 index 000000000..bf0f859e6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/Group.kt @@ -0,0 +1,120 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.state.ToggleableState +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.GroupItem +import com.github.jing332.tts_server_android.compose.widgets.TextFieldDialog + +@Composable +fun Group( + modifier: Modifier, + name: String, + isExpanded: Boolean, + toggleableState: ToggleableState, + onToggleableStateChange: (Boolean) -> Unit, + onClick: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, + onRename: (newName: String) -> Unit, + onCopy: (newName: String) -> Unit, + onEditAudioParams: () -> Unit, + onSort: () -> Unit, +) { + + var showRenameDialog by remember { mutableStateOf(false) } + if (showRenameDialog) { + var nameValue by remember { mutableStateOf(name) } + TextFieldDialog( + title = stringResource(id = R.string.rename), + text = nameValue, + onTextChange = { nameValue = it }, + onDismissRequest = { showRenameDialog = false }) { + showRenameDialog = false + onRename(nameValue) + } + } + + var showCopyDialog by remember { mutableStateOf(false) } + if (showCopyDialog) { + var nameValue by remember { mutableStateOf(name) } + TextFieldDialog( + title = stringResource(id = R.string.copy), + text = nameValue, + onTextChange = { nameValue = it }, + onDismissRequest = { showCopyDialog = false }) { + showCopyDialog = false + onCopy(nameValue) + } + } + + GroupItem( + modifier = modifier, + isExpanded = isExpanded, + name = name, + toggleableState = toggleableState, + onToggleableStateChange = onToggleableStateChange, + onClick = onClick, + onExport = onExport, + onDelete = onDelete, + actions = { dismiss -> + DropdownMenuItem(text = { Text(stringResource(id = R.string.rename)) }, + onClick = { + dismiss() + showRenameDialog = true + }, + leadingIcon = { + Icon(Icons.Default.DriveFileRenameOutline, null) + } + ) + + DropdownMenuItem(text = { Text(stringResource(id = R.string.copy)) }, + onClick = { + dismiss() + showCopyDialog = true + }, + leadingIcon = { + Icon(Icons.Default.ContentCopy, null) + } + ) + + DropdownMenuItem(text = { Text(stringResource(id = R.string.audio_params)) }, + onClick = { + dismiss() + onEditAudioParams() + }, + leadingIcon = { + Icon(Icons.Default.Speed, null) + } + ) + + DropdownMenuItem(text = { Text(stringResource(id = R.string.sort)) }, + onClick = { + dismiss() + onSort() + }, + leadingIcon = { + Icon(Icons.Default.Sort, null) + } + ) + } + ) + +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/GroupAudioParamsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/GroupAudioParamsDialog.kt new file mode 100644 index 000000000..e2687155d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/GroupAudioParamsDialog.kt @@ -0,0 +1,63 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams + +@Composable +fun GroupAudioParamsDialog( + onDismissRequest: () -> Unit, + params: AudioParams, + onConfirm: (AudioParams) -> Unit +) { + var speed by remember { mutableFloatStateOf(params.speed) } + var volume by remember { mutableFloatStateOf(params.volume) } + var pitch by remember { mutableFloatStateOf(params.pitch) } + + BasicAudioParamsDialog( + onDismissRequest = onDismissRequest, + onReset = { + speed = AudioParams.FOLLOW_GLOBAL_VALUE + volume = AudioParams.FOLLOW_GLOBAL_VALUE + pitch = AudioParams.FOLLOW_GLOBAL_VALUE + }, + speed = speed, + onSpeedChange = { speed = it }, + volume = volume, + onVolumeChange = { volume = it }, + pitch = pitch, + onPitchChange = { pitch = it }, + buttons = { + Row { + TextButton( + enabled = speed != AudioParams.FOLLOW_GLOBAL_VALUE || volume != AudioParams.FOLLOW_GLOBAL_VALUE || pitch != AudioParams.FOLLOW_GLOBAL_VALUE, + onClick = { + speed = AudioParams.FOLLOW_GLOBAL_VALUE + volume = AudioParams.FOLLOW_GLOBAL_VALUE + pitch = AudioParams.FOLLOW_GLOBAL_VALUE + }) { + Text(stringResource(id = R.string.reset)) + } + Spacer(modifier = Modifier.weight(1f)) + Row { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + TextButton(onClick = { onConfirm(AudioParams(speed, volume, pitch)) }) { + Text(stringResource(id = R.string.confirm)) + } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/IntSlider.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/IntSlider.kt new file mode 100644 index 000000000..78ccea521 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/IntSlider.kt @@ -0,0 +1,24 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.jing332.tts_server_android.compose.widgets.LabelSlider + +@Composable +fun IntSlider( + modifier: Modifier = Modifier, + label: String, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange +) { + LabelSlider( + modifier = modifier, + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + text = label, + buttonSteps = 1f, + buttonLongSteps = 10f + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/InternalPlayerDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/InternalPlayerDialog.kt new file mode 100644 index 000000000..91592498c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/InternalPlayerDialog.kt @@ -0,0 +1,73 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LabelSlider +import com.github.jing332.tts_server_android.conf.SystemTtsConfig +import com.github.jing332.tts_server_android.utils.toScale + +@Composable +fun InternalPlayerDialog(onDismissRequest: () -> Unit) { + var speed by remember { SystemTtsConfig.inAppPlaySpeed } + var volume by remember { SystemTtsConfig.inAppPlayVolume } + var pitch by remember { SystemTtsConfig.inAppPlayPitch } + AppDialog( + title = { Text(stringResource(id = R.string.systts_use_internal_audio_player)) }, + content = { + Column { + LabelSlider( + value = speed, + onValueChange = { + speed = it.toScale(2) + }, + valueRange = 0.1f..3.0f, + text = stringResource(id = R.string.label_speed) + speed + ) + LabelSlider( + value = volume, + onValueChange = { + volume = it.toScale(2) + }, + valueRange = 0.1f..1.0f, + text = stringResource(id = R.string.label_volume) + volume + ) + + LabelSlider( + value = pitch, + onValueChange = { + pitch = it.toScale(2) + }, + valueRange = 0.1f..3.0f, + text = stringResource(id = R.string.label_pitch) + pitch + ) + + } + }, + buttons = { + Row { + TextButton( + enabled = speed != 1f || volume != 1f || pitch != 1f, + onClick = { + speed = 1f + volume = 1f + pitch = 1f + }) { + Text(stringResource(id = R.string.reset)) + } + + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.close)) + } + } + }, onDismissRequest = onDismissRequest + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/Item.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/Item.kt new file mode 100644 index 000000000..789893ae0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/Item.kt @@ -0,0 +1,326 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CopyAll +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Output +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.HtmlText +import com.github.jing332.tts_server_android.compose.widgets.LongClickIconButton +import com.github.jing332.tts_server_android.conf.AppConfig +import com.github.jing332.tts_server_android.utils.StringUtils.limitLength +import com.github.jing332.tts_server_android.utils.clickableRipple +import com.github.jing332.tts_server_android.utils.performLongPress +import org.burnoutcrew.reorderable.ReorderableLazyListState +import org.burnoutcrew.reorderable.detectReorder + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun Item( + modifier: Modifier, + name: String, + tagName: String, + type: String, + desc: String, + params: String, + reorderState: ReorderableLazyListState, + + standby: Boolean, + enabled: Boolean, + onEnabledChange: (Boolean) -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + + onCopy: () -> Unit, + onDelete: () -> Unit, + onEdit: () -> Unit, + onAudition: () -> Unit, + onExport: () -> Unit, +) { + val view = LocalView.current + val context = LocalContext.current + + val limitNameLen by remember { AppConfig.limitNameLength } + val limitedName = remember(name, limitNameLen) { + if (limitNameLen == 0) name else name.limitLength(limitNameLen) + } + + ElevatedCard(modifier) { + ConstraintLayout( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .combinedClickable( + onClick = onClick, + onLongClick = { + view.performLongPress() + onLongClick() + } + ) + .padding(vertical = 4.dp) + ) { + val (checkRef, + nameRef, + contentRef, + targetRef, + typeRef, + buttonsRef) = createRefs() + Row( + Modifier + .constrainAs(checkRef) { + start.linkTo(parent.start) + top.linkTo(nameRef.top) + bottom.linkTo(contentRef.bottom) + + height = Dimension.fillToConstraints + } + .detectReorder(reorderState)) { + Checkbox( + modifier = Modifier + .fillMaxHeight() + .semantics { + role = Role.Switch + context + .getString( + if (enabled) R.string.config_enabled_desc else R.string.config_disabled_desc, + limitedName + ) + .let { + contentDescription = it + stateDescription = it + } + }, + checked = enabled, + onCheckedChange = onEnabledChange, + ) + } + Text( + limitedName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Clip, + modifier = Modifier + .constrainAs(nameRef) { + start.linkTo(checkRef.end) + top.linkTo(parent.top) + } + .padding(bottom = 4.dp) + ) + + Column( + Modifier + .constrainAs(contentRef) { + start.linkTo(checkRef.end) + top.linkTo(nameRef.bottom) + bottom.linkTo(parent.bottom) + } + .fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + HtmlText( + text = desc, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground), + ) + + HtmlText( + text = params, + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onBackground), + ) + } + + val limitLen by remember { AppConfig.limitTagLength } + val limitedTagName = remember(tagName, limitLen) { + if (limitLen == 0) tagName else tagName.limitLength(limitLen) + } + if (limitedTagName.isNotEmpty()) + TagScreen( + Modifier + .constrainAs(targetRef) { + top.linkTo(nameRef.top) + end.linkTo(parent.end) + } + .padding(end = 4.dp), + tag = limitedTagName, + ) + + Row(modifier = Modifier.constrainAs(buttonsRef) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + }) { + val swapButton = AppConfig.isSwapListenAndEditButton.value + IconButton( + modifier = Modifier, + onClick = { if (swapButton) onAudition() else onEdit() } + ) { + if (swapButton) + Icon(Icons.Default.Headphones, stringResource(id = R.string.audition)) + else + Icon(Icons.Default.Edit, stringResource(id = R.string.edit_desc, name)) + } + + var showOptions by remember { mutableStateOf(false) } + LongClickIconButton( + onClick = { showOptions = true }, + onLongClick = { if (swapButton) onEdit() else onAudition() }, + onLongClickLabel = stringResource(id = if (swapButton) R.string.edit else R.string.audition) + ) { + Icon( + Icons.Default.MoreVert, + stringResource(id = R.string.more_options_desc, name) + ) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + + DropdownMenuItem( + text = { Text(stringResource(id = if (swapButton) R.string.edit else R.string.audition)) }, + onClick = { + showOptions = false + if (swapButton) + onEdit() + else + onAudition() + }, + leadingIcon = { + Icon( + if (swapButton) Icons.Default.Edit else Icons.Default.Headphones, + null + ) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.copy)) }, + onClick = { + showOptions = false + onCopy() + }, + leadingIcon = { + Icon(Icons.Default.CopyAll, null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + onExport() + }, + leadingIcon = { + Icon(Icons.Default.Output, null) + } + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(id = R.string.delete)) }, + onClick = { + showOptions = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.DeleteForever, + null, + tint = MaterialTheme.colorScheme.error + ) + } + ) + } + } + } + + Row( + modifier = Modifier + .constrainAs(typeRef) { + end.linkTo(parent.end) +// top.linkTo(buttonsRef.bottom) + bottom.linkTo(parent.bottom) + } + .padding(end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (standby) { + Text( + modifier = Modifier.padding(end = 4.dp), + text = stringResource(id = R.string.systts_standby), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Bold, + ) + } + + Text( + text = type, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + } + + } +} + +@Composable +private fun TagScreen(modifier: Modifier = Modifier, tag: String) { + OutlinedCard(shape = MaterialTheme.shapes.extraSmall, modifier = modifier) { + Text( + text = tag, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + overflow = TextOverflow.Clip, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListExportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListExportBottomSheet.kt new file mode 100644 index 000000000..4f669fc39 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListExportBottomSheet.kt @@ -0,0 +1,16 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.runtime.Composable +import com.github.jing332.tts_server_android.compose.systts.ConfigExportBottomSheet +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.systts.GroupWithSystemTts +import kotlinx.serialization.encodeToString + +@Composable +fun ListExportBottomSheet(onDismissRequest: () -> Unit, list: List) { + ConfigExportBottomSheet( + json = AppConst.jsonBuilder.encodeToString(list), + onDismissRequest = onDismissRequest, + fileName = "ttsrv-list.json" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListImportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListImportBottomSheet.kt new file mode 100644 index 000000000..711f63a45 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListImportBottomSheet.kt @@ -0,0 +1,113 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.jing332.tts_server_android.bean.LegadoHttpTts +import com.github.jing332.tts_server_android.compose.systts.ConfigImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.ConfigModel +import com.github.jing332.tts_server_android.compose.systts.SelectImportConfigDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.systts.CompatSystemTts +import com.github.jing332.tts_server_android.data.entities.systts.GroupWithSystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTtsGroup +import com.github.jing332.tts_server_android.model.speech.tts.BaseAudioFormat +import com.github.jing332.tts_server_android.model.speech.tts.HttpTTS +import com.github.jing332.tts_server_android.utils.StringUtils + +@Composable +fun ListImportBottomSheet(onDismissRequest: () -> Unit) { + var selectDialog by remember { mutableStateOf?>(null) } + if (selectDialog != null) { + SelectImportConfigDialog( + onDismissRequest = { selectDialog = null }, + models = selectDialog!!, + onSelectedList = { list -> + list.map { + @Suppress("UNCHECKED_CAST") + it as Pair + } + .forEach { + val group = it.first + val tts = it.second + appDb.systemTtsDao.insertGroup(group) + appDb.systemTtsDao.insertTts(tts) + } + + list.size + } + ) + } + + ConfigImportBottomSheet(onDismissRequest = onDismissRequest, + onImport = { json -> + val allList = mutableListOf() + getImportList(json, false)?.forEach { groupWithTts -> + val group = groupWithTts.group + groupWithTts.list.forEach { sysTts -> + allList.add( + ConfigModel( + true, sysTts.displayName.toString(), + group.name, group to sysTts + ) + ) + } + } + selectDialog = allList + } + ) +} + +private fun getImportList(json: String, fromLegado: Boolean): List? { + val groupName = StringUtils.formattedDate() + val groupId = System.currentTimeMillis() + val groupCount = appDb.systemTtsDao.groupCount + if (fromLegado) { + AppConst.jsonBuilder.decodeFromString>(json).ifEmpty { return null } + .let { list -> + return listOf(GroupWithSystemTts( + group = SystemTtsGroup( + id = groupId, + name = groupName, + order = groupCount + ), + list = list.map { + SystemTts( + groupId = groupId, + id = it.id, + displayName = it.name, + tts = HttpTTS( + url = it.url, + header = it.header, + audioFormat = BaseAudioFormat(isNeedDecode = true) + ) + ) + } + + )) + } + + } else { + return if (json.contains("\"group\"")) { // 新版数据结构 + AppConst.jsonBuilder.decodeFromString>(json) + } else { + val list = AppConst.jsonBuilder.decodeFromString>(json) + listOf( + GroupWithSystemTts( + group = appDb.systemTtsDao.getGroup()!!, + list = list.mapIndexed { index, value -> + SystemTts( + id = System.currentTimeMillis() + index, + displayName = value.displayName, + tts = value.tts + ) + } + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListManagerScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListManagerScreen.kt new file mode 100644 index 000000000..6c69c52fc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListManagerScreen.kt @@ -0,0 +1,456 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddCard +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.Javascript +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalDrawerState +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.ShadowReorderableItem +import com.github.jing332.tts_server_android.compose.nav.NavRoutes +import com.github.jing332.tts_server_android.compose.nav.NavTopAppBar +import com.github.jing332.tts_server_android.compose.navigate +import com.github.jing332.tts_server_android.compose.systts.AuditionDialog +import com.github.jing332.tts_server_android.compose.systts.ConfigDeleteDialog +import com.github.jing332.tts_server_android.compose.systts.ConfigExportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.list.edit.QuickEditBottomSheet +import com.github.jing332.tts_server_android.compose.systts.list.edit.TagDataClearConfirmDialog +import com.github.jing332.tts_server_android.compose.systts.sizeToToggleableState +import com.github.jing332.tts_server_android.compose.widgets.LazyListIndexStateSaver +import com.github.jing332.tts_server_android.compose.widgets.TextFieldDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup +import com.github.jing332.tts_server_android.data.entities.systts.GroupWithSystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTtsGroup +import com.github.jing332.tts_server_android.model.rhino.speech_rule.SpeechRuleEngine +import com.github.jing332.tts_server_android.model.speech.tts.BgmTTS +import com.github.jing332.tts_server_android.model.speech.tts.LocalTTS +import com.github.jing332.tts_server_android.model.speech.tts.MsTTS +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.github.jing332.tts_server_android.service.systts.SystemTtsService +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.longToast +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import org.burnoutcrew.reorderable.detectReorderAfterLongPress +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +internal fun ListManagerScreen(vm: ListManagerViewModel = viewModel()) { + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + val drawerState = LocalDrawerState.current + + var showSortDialog by remember { mutableStateOf?>(null) } + if (showSortDialog != null) SortDialog( + onDismissRequest = { showSortDialog = null }, + list = showSortDialog!! + ) + + var showQuickEdit by remember { mutableStateOf(null) } + if (showQuickEdit != null) { + QuickEditBottomSheet(onDismissRequest = { + appDb.systemTtsDao.insertTts(showQuickEdit!!) + if (showQuickEdit?.isEnabled == true) SystemTtsService.notifyUpdateConfig() + showQuickEdit = null + }, systts = showQuickEdit!!, onSysttsChange = { + showQuickEdit = it + }) + } + + fun navigateToEdit(systts: SystemTts) { + navController.navigate(NavRoutes.TtsEdit.id, Bundle().apply { + putParcelable(NavRoutes.TtsEdit.DATA, systts) + }) + } + + // 长按Item拖拽提示 + var hasShownTip by rememberSaveable { mutableStateOf(false) } + + var showTagClearDialog by remember { mutableStateOf(null) } + if (showTagClearDialog != null) { + val systts = showTagClearDialog!! + TagDataClearConfirmDialog( + tagData = systts.speechRule.tagData.toString(), + onDismissRequest = { showTagClearDialog = null }, + onConfirm = { + systts.speechRule.target = SpeechTarget.ALL + systts.speechRule.resetTag() + appDb.systemTtsDao.updateTts(systts) + if (systts.isEnabled) SystemTtsService.notifyUpdateConfig() + showTagClearDialog = null + } + ) + } + + fun switchSpeechTarget(systts: SystemTts) { + if (!hasShownTip) { + hasShownTip = true + context.longToast(R.string.systts_drag_tip_msg) + } + + val model = systts.copy() + if (model.speechRule.target == SpeechTarget.BGM) return + + if (model.speechRule.target == SpeechTarget.CUSTOM_TAG) appDb.speechRuleDao.getByRuleId( + model.speechRule.tagRuleId + )?.let { speechRule -> + val keys = speechRule.tags.keys.toList() + val idx = keys.indexOf(model.speechRule.tag) + + val nextIndex = (idx + 1) + val newTag = keys.getOrNull(nextIndex) + if (newTag == null) { + if (model.speechRule.isTagDataEmpty()) { + model.speechRule.target = SpeechTarget.ALL + model.speechRule.resetTag() + } else { + showTagClearDialog = model + return + } + } else { + model.speechRule.tag = newTag + runCatching { + model.speechRule.tagName = + SpeechRuleEngine.getTagName(context, speechRule, info = model.speechRule) + }.onFailure { + model.speechRule.tagName = "" + context.displayErrorDialog(it) + } + + } + } + else { + appDb.speechRuleDao.getByRuleId(model.speechRule.tagRuleId)?.let { + model.speechRule.target = SpeechTarget.CUSTOM_TAG + model.speechRule.tag = it.tags.keys.first() + } + } + + appDb.systemTtsDao.updateTts(model) + if (model.isEnabled) SystemTtsService.notifyUpdateConfig() + } + + var deleteTts by remember { mutableStateOf(null) } + if (deleteTts != null) { + ConfigDeleteDialog( + onDismissRequest = { deleteTts = null }, name = deleteTts?.displayName ?: "" + ) { + appDb.systemTtsDao.deleteTts(deleteTts!!) + deleteTts = null + } + } + + var groupAudioParamsDialog by remember { mutableStateOf(null) } + if (groupAudioParamsDialog != null) { + GroupAudioParamsDialog(onDismissRequest = { groupAudioParamsDialog = null }, + params = groupAudioParamsDialog!!.audioParams, + onConfirm = { + appDb.systemTtsDao.updateGroup( + groupAudioParamsDialog!!.copy(audioParams = it) + ) + + groupAudioParamsDialog = null + }) + } + + val models by vm.list.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + LazyListIndexStateSaver(models = models, listState = listState) + + val reorderState = rememberReorderableLazyListState( + listState = listState, onMove = vm::reorder + ) + + LaunchedEffect(models) { + println("update models: ${models.size}") + } + + var addGroupDialog by remember { mutableStateOf(false) } + if (addGroupDialog) { + var name by remember { mutableStateOf("") } + TextFieldDialog(title = stringResource(id = R.string.add_group), + text = name, + onTextChange = { name = it }, + onDismissRequest = { addGroupDialog = false }) { + addGroupDialog = false + appDb.systemTtsDao.insertGroup(SystemTtsGroup(name = name)) + } + } + + var showGroupExportSheet by remember { mutableStateOf?>(null) } + if (showGroupExportSheet != null) { + val list = showGroupExportSheet!! + ListExportBottomSheet(onDismissRequest = { showGroupExportSheet = null }, list = list) + } + + var showExportSheet by remember { mutableStateOf?>(null) } + if (showExportSheet != null) { + val jStr = remember { AppConst.jsonBuilder.encodeToString(showExportSheet!!) } + ConfigExportBottomSheet(json = jStr) { showExportSheet = null } + } + + var addPluginDialog by remember { mutableStateOf(false) } + if (addPluginDialog) { + PluginSelectionDialog(onDismissRequest = { addPluginDialog = false }) { + navigateToEdit(SystemTts(tts = PluginTTS(pluginId = it.pluginId))) + } + } + + var showAuditionDialog by remember { mutableStateOf(null) } + if (showAuditionDialog != null) AuditionDialog(systts = showAuditionDialog!!) { + showAuditionDialog = null + } + + var showOptions by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { + NavTopAppBar(drawerState = drawerState, title = { + Text(stringResource(id = R.string.system_tts)) + }, actions = { + var showAddMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showAddMenu = true }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add_config)) + + DropdownMenu(expanded = showAddMenu, + onDismissRequest = { showAddMenu = false }) { + + @Composable + fun MenuItem( + icon: @Composable () -> Unit, + @StringRes title: Int, + onClick: () -> Unit + ) { + DropdownMenuItem(text = { + Text(stringResource(id = title)) + }, onClick = { + showAddMenu = false + onClick() + }, leadingIcon = icon) + } + + MenuItem( + icon = { Icon(Icons.AutoMirrored.Default.PlaylistAdd, null) }, + title = R.string.systts_add_internal_tts + ) { + navigateToEdit(SystemTts(tts = MsTTS())) + } + + MenuItem( + icon = { Icon(Icons.Default.PhoneAndroid, null) }, + title = R.string.add_local_tts + ) { + navigateToEdit(SystemTts(tts = LocalTTS())) + } + +// MenuItem( +// icon = { Icon(Icons.Default.Http, null) }, +// title = R.string.systts_add_custom_tts +// ) { +//// startTtsEditor(HttpTtsEditActivity::class.java) +// } + + MenuItem( + icon = { Icon(Icons.Default.Javascript, null) }, + title = R.string.systts_add_plugin_tts + ) { + addPluginDialog = true + } + + MenuItem( + icon = { Icon(Icons.Default.Audiotrack, null) }, + title = R.string.add_bgm_tts + ) { + navigateToEdit(SystemTts(tts = BgmTTS())) + } + + MenuItem( + icon = { Icon(Icons.Default.AddCard, null) }, + title = R.string.add_group + ) { + addGroupDialog = true + } + } + } + + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + MenuMoreOptions( + expanded = showOptions, + onDismissRequest = { showOptions = false }, + onExportAll = { showGroupExportSheet = models }, + ) + } + }) + }, + ) { paddingValues -> + Box(Modifier.padding(top = paddingValues.calculateTopPadding())) { + LazyColumn( + Modifier + .fillMaxSize() + .reorderable(state = reorderState), + state = listState + ) { + models.forEachIndexed { _, groupWithSystemTts -> + val g = groupWithSystemTts.group + val checkState = + groupWithSystemTts.list.filter { it.isEnabled }.size.sizeToToggleableState( + groupWithSystemTts.list.size + ) + val key = "g_${g.id}" + stickyHeader(key = key) { + ShadowReorderableItem(reorderableState = reorderState, key = key) { + Group(modifier = Modifier.detectReorderAfterLongPress(reorderState), + name = g.name, + isExpanded = g.isExpanded, + toggleableState = checkState, + onToggleableStateChange = { + vm.updateGroupEnable(groupWithSystemTts, it) + }, + onClick = { + appDb.systemTtsDao.updateGroup(g.copy(isExpanded = !g.isExpanded)) + }, + onDelete = { + appDb.systemTtsDao.deleteTts(*groupWithSystemTts.list.toTypedArray()) + appDb.systemTtsDao.deleteGroup(g) + }, + onRename = { + appDb.systemTtsDao.updateGroup(g.copy(name = it)) + }, + onCopy = { + scope.launch { + val group = g.copy(id = System.currentTimeMillis(), + name = it.ifBlank { context.getString(R.string.unnamed) }) + appDb.systemTtsDao.insertGroup(group) + appDb.systemTtsDao.getTtsByGroup(g.id) + .forEachIndexed { index, tts -> + appDb.systemTtsDao.insertTts( + tts.copy( + id = System.currentTimeMillis() + index, + groupId = group.id + ) + ) + } + } + }, + onEditAudioParams = { + groupAudioParamsDialog = g + }, + onExport = { + showGroupExportSheet = listOf(groupWithSystemTts) + }, + onSort = { + showSortDialog = groupWithSystemTts.list + } + ) + } + } + + if (g.isExpanded) { + itemsIndexed(groupWithSystemTts.list.sortedBy { it.order }, + key = { _, v -> "${g.id}_${v.id}" }) { _, item -> + if (g.id == 1L) println(item.displayName + ", " + item.order) + + ShadowReorderableItem( + reorderableState = reorderState, + key = "${g.id}_${item.id}" + ) { + Item(reorderState = reorderState, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + name = item.displayName ?: "", + tagName = item.speechRule.tagName, + type = item.tts.getType(), + standby = item.speechRule.isStandby, + enabled = item.isEnabled, + onEnabledChange = { + vm.updateTtsEnabled(item, it) + if (it) SystemTtsService.notifyUpdateConfig() + }, + desc = item.tts.getDescription(), + params = item.tts.getBottomContent(), + onClick = { showQuickEdit = item }, + onLongClick = { switchSpeechTarget(item) }, + onCopy = { + navigateToEdit(item.copy(id = System.currentTimeMillis())) + }, + onDelete = { deleteTts = item }, + onEdit = { + navController.navigate( + NavRoutes.TtsEdit.id, + Bundle().apply { + putParcelable(NavRoutes.TtsEdit.DATA, item) + } + ) + }, + onAudition = { showAuditionDialog = item }, + onExport = { + showExportSheet = + listOf(item.copy(groupId = AbstractListGroup.DEFAULT_GROUP_ID)) + } + ) + } + } + } + } + + item { + Spacer(Modifier.height(60.dp)) + } + } + + + LaunchedEffect(key1 = Unit) { + withIO { + vm.checkListData(context) + } + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListManagerViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListManagerViewModel.kt new file mode 100644 index 000000000..560eafb8d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/ListManagerViewModel.kt @@ -0,0 +1,159 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import android.content.Context +import android.util.Log +import androidx.compose.ui.graphics.vector.DefaultGroupName +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.conf.SystemTtsConfig +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup.Companion.DEFAULT_GROUP_ID +import com.github.jing332.tts_server_android.data.entities.systts.GroupWithSystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTtsGroup +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.ItemPosition +import java.util.Collections + +class ListManagerViewModel : ViewModel() { + companion object { + const val TAG = "ListManagerViewModel" + } + + private val _list = MutableStateFlow>(emptyList()) + val list: StateFlow> get() = _list + + init { + viewModelScope.launch(Dispatchers.IO) { + appDb.systemTtsDao.updateAllOrder() + appDb.systemTtsDao.getFlowAllGroupWithTts().conflate().collectLatest { + _list.value = it + } + } + } + + fun updateTtsEnabled(item: SystemTts, enabled: Boolean) { + if (!SystemTtsConfig.isVoiceMultipleEnabled.value && enabled) + appDb.systemTtsDao.allEnabledTts.forEach { systts -> + if (systts.speechRule.target == SpeechTarget.BGM || systts.speechRule.isStandby) + return@forEach + + if (systts.speechRule.target == item.speechRule.target) { + if (systts.speechRule.tagRuleId == item.speechRule.tagRuleId + && systts.speechRule.tag == item.speechRule.tag + && systts.speechRule.tagName == item.speechRule.tagName + && systts.speechRule.isStandby == item.speechRule.isStandby + ) + appDb.systemTtsDao.updateTts(systts.copy(isEnabled = false)) + } + } + + appDb.systemTtsDao.updateTts(item.copy(isEnabled = enabled)) + } + + fun updateGroupEnable( + item: GroupWithSystemTts, + enabled: Boolean + ) { + if (!SystemTtsConfig.isGroupMultipleEnabled.value && enabled) { + list.value.forEach { + it.list.forEach { systts -> + if (systts.isEnabled) + appDb.systemTtsDao.updateTts(systts.copy(isEnabled = false)) + } + } + } + + appDb.systemTtsDao.updateTts( + *item.list.filter { it.isEnabled != enabled }.map { it.copy(isEnabled = enabled) } + .toTypedArray() + ) + } + + fun reorder(from: ItemPosition, to: ItemPosition) { + if (from.key is String && to.key is String) { + val fromKey = from.key as String + val toKey = to.key as String + + if (fromKey.startsWith("g") && toKey.startsWith("g")) { + val mList = list.value.map { it.group }.toMutableList() + + val fromId = fromKey.substring(2).toLong() + val fromIndex = mList.indexOfFirst { it.id == fromId } + + val toId = toKey.substring(2).toLong() + val toIndex = mList.indexOfFirst { it.id == toId } + + try { + Collections.swap(mList, fromIndex, toIndex) + } catch (_: IndexOutOfBoundsException) { + return + } + mList.forEachIndexed { index, systemTtsGroup -> + if (systemTtsGroup.order != index) + appDb.systemTtsDao.updateGroup(systemTtsGroup.copy(order = index)) + } + } else if (!fromKey.startsWith("g") && !toKey.startsWith("g")) { + val (fromGId, fromId) = fromKey.split("_").map { it.toLong() } + val (toGId, toId) = toKey.split("_").map { it.toLong() } + if (fromGId != toGId) return + + val listInGroup = findListInGroup(fromGId).toMutableList() + val fromIndex = listInGroup.indexOfFirst { it.id == fromId } + val toIndex = listInGroup.indexOfFirst { it.id == toId } + Log.d(TAG, "fromIndex: $fromIndex, toIndex: $toIndex") + + try { + Collections.swap(listInGroup, fromIndex, toIndex) + } catch (_: IndexOutOfBoundsException) { + return + } + + listInGroup.forEachIndexed { index, systts -> + Log.d(TAG, "$index ${systts.displayName}") + if (systts.order != index) + appDb.systemTtsDao.updateTts(systts.copy(order = index)) + } + } + + } + } + + private fun findListInGroup(groupId: Long): List { + return list.value.find { it.group.id == groupId }?.list?.sortedBy { it.order } + ?: emptyList() + } + + fun checkListData(context: Context) { + appDb.systemTtsDao.getGroup(DEFAULT_GROUP_ID) ?: kotlin.run { + appDb.systemTtsDao.insertGroup( + SystemTtsGroup( + DEFAULT_GROUP_ID, + context.getString(R.string.default_group), + appDb.systemTtsDao.groupCount + ) + ) + } + + if (appDb.systemTtsDao.ttsCount == 0) + importDefaultListData(context) + } + + private fun importDefaultListData(context: Context) { + val json = context.assets.open("defaultData/list.json").readAllText() + val list = AppConst.jsonBuilder.decodeFromString>(json) + viewModelScope.launch(Dispatchers.IO) { + appDb.systemTtsDao.insertGroupWithTts(*list.toTypedArray()) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/MenuMoreOptions.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/MenuMoreOptions.kt new file mode 100644 index 000000000..3a1758ea6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/MenuMoreOptions.kt @@ -0,0 +1,178 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.ContentCut +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Input +import androidx.compose.material.icons.filled.ManageSearch +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Output +import androidx.compose.material.icons.filled.SmartDisplay +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.asAppCompatActivity +import com.github.jing332.tts_server_android.compose.systts.plugin.PluginManagerActivity +import com.github.jing332.tts_server_android.compose.systts.replace.ReplaceManagerActivity +import com.github.jing332.tts_server_android.compose.systts.speechrule.SpeechRuleManagerActivity +import com.github.jing332.tts_server_android.compose.widgets.CheckedMenuItem +import com.github.jing332.tts_server_android.conf.SystemTtsConfig +import com.github.jing332.tts_server_android.utils.startActivity + +@Composable +internal fun MenuMoreOptions( + expanded: Boolean, + onDismissRequest: () -> Unit, + onExportAll: () -> Unit, +) { + var showBgmSettingsDialog by remember { mutableStateOf(false) } + if (showBgmSettingsDialog) + BgmSettingsDialog { showBgmSettingsDialog = false } + + var showImportSheet by remember { mutableStateOf(false) } + if (showImportSheet) + ListImportBottomSheet(onDismissRequest = { showImportSheet = false }) + + var showInternalPlayerDialog by remember { mutableStateOf(false) } + if (showInternalPlayerDialog) + InternalPlayerDialog { + showInternalPlayerDialog = false + } + + var showAudioParamsDialog by remember { mutableStateOf(false) } + if (showAudioParamsDialog) + GlobalAudioParamsDialog { + showAudioParamsDialog = false + } + + val context = LocalContext.current + val activity = remember { context.asAppCompatActivity() } + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest + ) { + + var isSplit by remember { SystemTtsConfig.isSplitEnabled } + CheckedMenuItem( + text = { Text(stringResource(id = R.string.systts_split_long_sentences)) }, + checked = isSplit, + onClick = { + isSplit = it + }, + leadingIcon = { + Icon(Icons.Default.ContentCut, null) + } + ) + + var isMultiVoice by remember { SystemTtsConfig.isMultiVoiceEnabled } + CheckedMenuItem( + text = { Text(stringResource(id = R.string.systts_multi_voice_option)) }, + checked = isMultiVoice, + onClick = { + isMultiVoice = it + }, + leadingIcon = { + Icon(Icons.Default.Group, null) + }, + ) + HorizontalDivider() + + var isInternalPlayer by remember { SystemTtsConfig.isInternalPlayerEnabled } + CheckedMenuItem( + text = { Text(stringResource(id = R.string.systts_use_internal_audio_player)) }, + checked = isInternalPlayer, + onClick = { showInternalPlayerDialog = true }, + onClickCheckBox = { isInternalPlayer = it }, + leadingIcon = { + Icon(Icons.Default.SmartDisplay, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.audio_params)) }, + onClick = { showAudioParamsDialog = true }, + leadingIcon = { + Icon(Icons.Default.Speed, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.bgm_settings)) }, + onClick = { showBgmSettingsDialog = true }, + leadingIcon = { + Icon(Icons.Default.Audiotrack, null) + } + ) + + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(id = R.string.speech_rule_manager)) }, + onClick = { + onDismissRequest() + context.startActivity(SpeechRuleManagerActivity::class.java) + }, + leadingIcon = { + Icon(Icons.Default.MenuBook, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.plugin_manager)) }, + onClick = { + onDismissRequest() + context.startActivity(PluginManagerActivity::class.java) + }, + leadingIcon = { + Icon(painterResource(id = R.drawable.ic_shortcut_plugin), null) + } + ) + + CheckedMenuItem( + text = { Text(stringResource(id = R.string.replace_rule_manager)) }, + checked = SystemTtsConfig.isReplaceEnabled.value, + onClick = { + onDismissRequest() + context.startActivity(ReplaceManagerActivity::class.java) + }, + onClickCheckBox = { + SystemTtsConfig.isReplaceEnabled.value = it + }, + leadingIcon = { + Icon(Icons.Default.ManageSearch, null) + } + ) + + HorizontalDivider() + DropdownMenuItem(text = { + Text(stringResource(id = R.string.import_config)) + }, onClick = { + onDismissRequest() + showImportSheet = true }, + leadingIcon = { + Icon(Icons.Default.Input, null) + } + ) + + DropdownMenuItem(text = { + Text(stringResource(id = R.string.export_config)) + }, onClick = { + onDismissRequest() + onExportAll() + }, leadingIcon = { + Icon(Icons.Default.Output, null) + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/PluginSelectionDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/PluginSelectionDialog.kt new file mode 100644 index 000000000..ae0fa5fc1 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/PluginSelectionDialog.kt @@ -0,0 +1,81 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin + +@Composable +fun PluginSelectionDialog(onDismissRequest: () -> Unit, onSelect: (Plugin) -> Unit) { + AlertDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.select_plugin)) }, + text = { + val plugins = appDb.pluginDao.allEnabled + if (plugins.isEmpty()) + Text( + stringResource(id = R.string.no_plugins), + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error + ) + + LazyColumn { + items(plugins, { it.id }) { + Column( + Modifier + .fillMaxWidth() + .minimumInteractiveComponentSize() + .clip(MaterialTheme.shapes.small) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple() + ) { onSelect(it) } + .padding(vertical = 4.dp) + ) { + Text( + text = it.name, + modifier = Modifier + .padding(horizontal = 8.dp), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = it.pluginId, + modifier = Modifier + .padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + } + ) + + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/SortDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/SortDialog.kt new file mode 100644 index 000000000..8c08d6e17 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/SortDialog.kt @@ -0,0 +1,50 @@ +package com.github.jing332.tts_server_android.compose.systts.list + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.ListSortSettingsDialog +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts + +internal enum class SortFields(@StringRes val strResId: Int) { + NAME(R.string.name), + TAG_NAME(R.string.tag), + TYPE(R.string.type), + ENABLE(R.string.enabled), + ID(R.string.created_time_id) +} + +@Composable +internal fun SortDialog(onDismissRequest: () -> Unit, list: List) { + var index by remember { mutableIntStateOf(0) } + ListSortSettingsDialog( + name = list.size.toString(), + index = index, + onIndexChange = { index = it }, + onDismissRequest = onDismissRequest, + entries = SortFields.values().map { stringResource(id = it.strResId) }, + onConfirm = { _, descending -> + withIO { + val sortedList = when (SortFields.values()[index]) { + SortFields.NAME -> list.sortedBy { it.displayName } + SortFields.TAG_NAME -> list.sortedBy { it.speechRule.tagName } + SortFields.TYPE -> list.sortedBy { it.tts.getType() } + SortFields.ENABLE -> list.sortedBy { it.isEnabled } + SortFields.ID -> list.sortedBy { it.id } + }.run { + if (descending) this.reversed() else this + } + sortedList.forEachIndexed { i, systemTts -> + appDb.systemTtsDao.updateTts(systemTts.copy(order = i)) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/BasicInfoEditScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/BasicInfoEditScreen.kt new file mode 100644 index 000000000..51dda0d2b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/BasicInfoEditScreen.kt @@ -0,0 +1,507 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.SmartDisplay +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.list.BasicAudioParamsDialog +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.SaveActionHandler +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.InternalPlayerDialog +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.AppSpinner +import com.github.jing332.tts_server_android.compose.widgets.RowToggleButtonGroup +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup.Companion.DEFAULT_GROUP_ID +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTtsGroup +import com.github.jing332.tts_server_android.model.rhino.speech_rule.SpeechRuleEngine +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.clone +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.serialization.encodeToString + +@Composable +fun BasicInfoEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + + showSpeechTarget: Boolean = true, + group: SystemTtsGroup = rememberUpdatedState( + newValue = appDb.systemTtsDao.getGroup(systts.groupId) + ?: SystemTtsGroup(id = DEFAULT_GROUP_ID, name = "") + ).value, + groups: List = remember { appDb.systemTtsDao.allGroup }, + + speechRules: List = remember { appDb.speechRuleDao.allEnabled }, +) { + val context = LocalContext.current + val speechRule by rememberUpdatedState(newValue = speechRules.find { it.ruleId == systts.speechRule.tagRuleId }) + + // 确保在 SaveActionHandler 中始终引用最新的obj + @Suppress("NAME_SHADOWING") + val systts by rememberUpdatedState(newValue = systts) + + SaveActionHandler { + var tagName = "" + if (speechRule != null) { + runCatching { + tagName = + SpeechRuleEngine.getTagName(context, speechRule!!, info = systts.speechRule) + }.onFailure { + context.displayErrorDialog(it, "获取标签名失败") + } + } + + tagName = tagName.ifBlank { + speechRule?.tags?.getOrDefault(systts.speechRule.tag, "") ?: "" + } + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy(tagName = tagName) + ) + ) + + true + } + + var showStandbyHelpDialog by remember { mutableStateOf(false) } + if (showStandbyHelpDialog) + AppDialog( + title = { Text(stringResource(id = R.string.systts_as_standby_help)) }, + content = { + Text( + stringResource(id = R.string.systts_standby_help_msg) + ) + }, + buttons = { + TextButton(onClick = { showStandbyHelpDialog = false }) { + Text(stringResource(id = R.string.confirm)) + } + }, + onDismissRequest = { showStandbyHelpDialog = false } + ) + + + var showPlayerParamsDialog by remember { mutableStateOf(false) } + if (showPlayerParamsDialog) + InternalPlayerDialog( + onDismissRequest = { showPlayerParamsDialog = false }, + params = systts.tts.audioPlayer, + onParamsChange = { + onSysttsChange( + systts.copy( + tts = systts.tts.clone()!!.apply { audioPlayer = it } + ) + ) + } + ) + + var showParamsDialog by remember { mutableStateOf(false) } + if (showParamsDialog) { + val params = systts.tts.audioParams + fun changeParams( + speed: Float = params.speed, + volume: Float = params.volume, + pitch: Float = params.pitch + ) { + onSysttsChange( + systts.copy( + tts = systts.tts.clone()!!.apply { + audioParams = AudioParams(speed, volume, pitch) + } + ) + ) + } + BasicAudioParamsDialog( + onDismissRequest = { showParamsDialog = false }, + speed = params.speed, + volume = params.volume, + pitch = params.pitch, + + onSpeedChange = { changeParams(speed = it) }, + onVolumeChange = { changeParams(volume = it) }, + onPitchChange = { changeParams(pitch = it) }, + + onReset = { changeParams(0f, 0f, 0f) } + ) + } + + Column(modifier) { + if (showSpeechTarget) + Column(Modifier.fillMaxWidth()) { + Row( + Modifier + .align(Alignment.CenterHorizontally) + .horizontalScroll(rememberScrollState()) + ) { + TextButton(onClick = { showParamsDialog = true }) { + Row { + Icon(Icons.Default.Speed, null) + Text(stringResource(id = R.string.audio_params)) + } + } + + TextButton(onClick = { showPlayerParamsDialog = true }) { + Row { + Icon(Icons.Default.SmartDisplay, null) + Text(stringResource(id = R.string.internal_player)) + } + } + + Row( + Modifier + .minimumInteractiveComponentSize() + .clip(MaterialTheme.shapes.medium) + .clickable(role = Role.Checkbox) { + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy( + isStandby = !systts.speechRule.isStandby + ) + ) + ) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = systts.speechRule.isStandby, onCheckedChange = null) + Text(stringResource(id = R.string.as_standby)) + IconButton(onClick = { showStandbyHelpDialog = true }) { + Icon( + Icons.AutoMirrored.Filled.HelpOutline, + stringResource(id = R.string.systts_as_standby_help) + ) + } + } + } + + var showTagClearDialog by remember { mutableStateOf(false) } + if (showTagClearDialog) { + TagDataClearConfirmDialog( + systts.speechRule.tagData.toString(), + onDismissRequest = { showTagClearDialog = false }, + onConfirm = { + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy( + tagName = "", + target = SpeechTarget.ALL + ).apply { resetTag() } + ) + ) + showTagClearDialog = false + }) + } + Column( + modifier = Modifier + .wrapContentWidth() + .align(Alignment.CenterHorizontally), + ) { + + var showTagOptions by remember { mutableStateOf(false) } + RowToggleButtonGroup( + selectionIndex = if (systts.speechRule.target == SpeechTarget.ALL) 0 else 1, + buttonCount = 2, + buttonIcons = arrayOf( + painterResource(id = R.drawable.ic_baseline_select_all_24), + painterResource(id = R.drawable.baseline_tag_24) + ), + buttonTexts = arrayOf( + stringResource(id = R.string.ra_all), + stringResource(id = R.string.tag) + ), + onButtonClick = { index -> + if (index == 1) { + if (systts.speechRule.target == SpeechTarget.CUSTOM_TAG) + showTagOptions = true + else + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy(target = SpeechTarget.CUSTOM_TAG) + ) + ) + } else { // 朗读全部 + if (systts.speechRule.isTagDataEmpty()) + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy( + tagName = "", + target = SpeechTarget.ALL + ).apply { resetTag() } + ) + ) + else + showTagClearDialog = true + } + }, + ) + + DropdownMenu( + expanded = showTagOptions, + onDismissRequest = { showTagOptions = false }) { + Text( + text = stringResource(R.string.tag_data), + modifier = Modifier.align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.bodyLarge + ) + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(id = R.string.copy)) }, + onClick = { + showTagOptions = false + val info = systts.speechRule + val jStr = AppConst.jsonBuilder.encodeToString(info) + ClipboardUtils.copyText(jStr) + context.toast(R.string.copied) + }) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.paste)) }, + onClick = { + showTagOptions = false + val jStr = ClipboardUtils.text.toString() + if (jStr.isBlank()) { + context.toast(R.string.format_error) + return@DropdownMenuItem + } + + runCatching { + val info = + AppConst.jsonBuilder.decodeFromString(jStr) + onSysttsChange(systts.copy(speechRule = info)) + }.onSuccess { + context.longToast(R.string.save_success) + }.onFailure { + context.displayErrorDialog( + it, + context.getString(R.string.format_error) + ) + } + }) + } + } + + AnimatedVisibility(visible = systts.speechRule.target == SpeechTarget.CUSTOM_TAG) { + Row(Modifier.padding(top = 4.dp)) { + AppSpinner( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + label = { Text(stringResource(id = R.string.speech_rule_script)) }, + value = systts.speechRule.tagRuleId, + values = speechRules.map { it.ruleId }, + entries = speechRules.map { it.name }, + onSelectedChange = { k, v -> + if (systts.speechRule.target != SpeechTarget.CUSTOM_TAG) return@AppSpinner + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy( + tagRuleId = k as String + ) + ) + ) + } + ) + + speechRule?.let { speechRule -> + AppSpinner( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp), + label = { Text(stringResource(id = R.string.tag)) }, + value = systts.speechRule.tag, + values = speechRule.tags.keys.toList(), + entries = speechRule.tags.values.toList(), + onSelectedChange = { k, _ -> + if (systts.speechRule.target != SpeechTarget.CUSTOM_TAG) return@AppSpinner + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy(tag = k as String) + ) + ) + } + ) + } + } + } + + speechRule?.let { + CustomTagScreen( + systts = systts, + onSysttsChange = { + if (systts.speechRule.target == SpeechTarget.CUSTOM_TAG) + onSysttsChange(it) + }, + speechRule = it + ) + } + } + + AppSpinner( + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(id = R.string.group)) }, + value = group, + values = groups, + onValueSame = { current, new -> (current as SystemTtsGroup).id == (new as SystemTtsGroup).id }, + entries = groups.map { it.name }, + onSelectedChange = { k, _ -> + onSysttsChange(systts.copy(groupId = (k as SystemTtsGroup).id)) + } + ) + OutlinedTextField( + label = { Text(stringResource(id = R.string.display_name)) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + value = systts.displayName ?: "", onValueChange = { + onSysttsChange(systts.copy(displayName = it)) + }, + trailingIcon = { + if (systts.displayName?.isNotEmpty() == true) + IconButton(onClick = { + onSysttsChange(systts.copy(displayName = "")) + }) { + Icon(Icons.Default.Clear, stringResource(id = R.string.clear_text_content)) + } + } + ) + } +} + +@Composable +private fun CustomTagScreen( + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + speechRule: SpeechRule +) { + var showHelpDialog by remember { mutableStateOf("" to "") } + if (showHelpDialog.first.isNotEmpty()) { + AppDialog(title = { Text(showHelpDialog.first) }, content = { + Text(showHelpDialog.second) + }, buttons = { + TextButton(onClick = { showHelpDialog = "" to "" }) { + Text(stringResource(id = R.string.confirm)) + } + }, onDismissRequest = { showHelpDialog = "" to "" }) + } + + Column(Modifier.padding(vertical = 4.dp)) { + speechRule.tagsData[systts.speechRule.tag]?.forEach { defTag -> + val key = defTag.key + val label = defTag.value["label"] ?: "" + val hint = defTag.value["hint"] ?: "" + + val items = defTag.value["items"] + val value by rememberUpdatedState(newValue = systts.speechRule.tagData[key] ?: "") + if (items.isNullOrEmpty()) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + leadingIcon = { + if (hint.isNotEmpty()) + IconButton(onClick = { showHelpDialog = label to hint }) { + Icon( + Icons.AutoMirrored.Filled.HelpOutline, + stringResource(id = R.string.help) + ) + } + }, + label = { Text(label) }, + value = value, + onValueChange = { + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy( + tagData = systts.speechRule.tagData.toMutableMap().apply { + this[key] = it + } + ) + ) + ) + } + ) + } else { + val itemsMap by rememberUpdatedState( + newValue = AppConst.jsonBuilder.decodeFromString>(items) + ) + + val defaultValue = remember { defTag.value["default"] ?: "" } + AppSpinner( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + label = { Text(label) }, + value = value.ifEmpty { defaultValue }, + values = itemsMap.keys.toList(), + entries = itemsMap.values.toList(), + leadingIcon = { + if (hint.isNotEmpty()) + IconButton(onClick = { showHelpDialog = label to hint }) { + Icon( + Icons.AutoMirrored.Filled.HelpOutline, + stringResource(id = R.string.help) + ) + } + }, + onSelectedChange = { k, _ -> + onSysttsChange( + systts.copy( + speechRule = systts.speechRule.copy( + tagData = systts.speechRule.mutableTagData.apply { + this[key] = k as String + } + ) + ) + ) + } + ) + + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/QuickEditBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/QuickEditBottomSheet.kt new file mode 100644 index 000000000..f499fa6ba --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/QuickEditBottomSheet.kt @@ -0,0 +1,70 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.LocalSaveCallBack +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.TtsUiFactory +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.rememberSaveCallBacks +import com.github.jing332.tts_server_android.compose.widgets.AppBottomSheet +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.BgmTTS +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun QuickEditBottomSheet( + onDismissRequest: () -> Unit, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit +) { + val ui = remember { TtsUiFactory.from(systts.tts)!! } + val callbacks = rememberSaveCallBacks() + val scope = rememberCoroutineScope() + + AppBottomSheet( + onDismissRequest = { + scope.launch(Dispatchers.Main) { + for (callback in callbacks) { + if (!callback.onSave()) return@launch + } + onDismissRequest() + } + }, + ) { + Column( + Modifier + .padding(top = 12.dp) + .padding(horizontal = 4.dp) + .verticalScroll(rememberScrollState()) + ) { + CompositionLocalProvider(LocalSaveCallBack provides callbacks) { + BasicInfoEditScreen( + modifier = Modifier.fillMaxWidth(), + systts = systts, + onSysttsChange = onSysttsChange, + showSpeechTarget = systts.tts !is BgmTTS + ) + } + ui.ParamsEditScreen( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + systts = systts, + onSysttsChange = onSysttsChange + ) + Spacer(modifier = Modifier.height(48.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/TagDataClearConfirmDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/TagDataClearConfirmDialog.kt new file mode 100644 index 000000000..280b05b5f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/TagDataClearConfirmDialog.kt @@ -0,0 +1,35 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog + +@Composable +fun TagDataClearConfirmDialog( + tagData: String, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit +) { + AppDialog( + title = { Text(stringResource(id = R.string.tag_data_clear_warn)) }, + content = { Text(tagData, style = MaterialTheme.typography.bodySmall) }, + buttons = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + TextButton(onClick = onConfirm) { + Text( + stringResource(id = R.string.delete), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + } + }, + onDismissRequest = onDismissRequest + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/TtsEditContainerScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/TtsEditContainerScreen.kt new file mode 100644 index 000000000..3efb12a98 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/TtsEditContainerScreen.kt @@ -0,0 +1,67 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.LocalSaveCallBack +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.TtsUiFactory +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.rememberSaveCallBacks +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import kotlinx.coroutines.launch + +@Composable +fun TtsEditContainerScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + val ui = TtsUiFactory.from(systts.tts)!! + val callbacks = rememberSaveCallBacks() + val scope = rememberCoroutineScope() + CompositionLocalProvider(LocalSaveCallBack provides callbacks) { + ui.FullEditScreen( + modifier, + systts = systts, + onSysttsChange = onSysttsChange, + onSave = { + scope.launch { + val iterator = callbacks.listIterator(callbacks.size) + while (iterator.hasPrevious()){ + val element = iterator.previous() + if (!element.onSave()) return@launch + } + + onSave() + } + }, + onCancel = onCancel + ) + } +} + +@Preview +@Composable +private fun PreviewContainer() { + var systts by remember { mutableStateOf(SystemTts(tts = PluginTTS())) } + TtsEditContainerScreen( + modifier = Modifier.fillMaxSize(), + systts = systts, + onSysttsChange = { systts = it }, + onSave = { + + }, + onCancel = { + + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/BgmTtsUI.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/BgmTtsUI.kt new file mode 100644 index 000000000..3826fcfd7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/BgmTtsUI.kt @@ -0,0 +1,321 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.list.IntSlider +import com.github.jing332.tts_server_android.compose.systts.list.edit.BasicInfoEditScreen +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.TtsTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.AppSelectionDialog +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.BgmTTS +import com.github.jing332.tts_server_android.ui.AppActivityResultContracts +import com.github.jing332.tts_server_android.ui.ExoPlayerActivity +import com.github.jing332.tts_server_android.ui.FilePickerActivity +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.ASFUriUtils.getPath +import com.github.jing332.tts_server_android.utils.FileUtils.audioList +import com.github.jing332.tts_server_android.utils.clickableRipple +import com.github.jing332.tts_server_android.utils.toast +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import java.io.File + +class BgmTtsUI : TtsUI() { + @Composable + override fun ParamsEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit + ) { + val tts = systts.tts as BgmTTS + LaunchedEffect(Unit) { + onSysttsChange(systts.copy(speechRule = SpeechRuleInfo(target = SpeechTarget.BGM))) + } + + val volStr = + stringResource( + id = R.string.label_speech_volume, + if (tts.volume == 0) stringResource(id = R.string.follow) else tts.volume.toString() + ) + IntSlider( + modifier = Modifier.padding(top = 8.dp), + label = volStr, value = tts.volume.toFloat(), + onValueChange = { + onSysttsChange(systts.copy(tts = tts.copy(volume = it.toInt()))) + }, valueRange = 0f..1000f + ) + } + + @Composable + override fun FullEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit + ) { + LaunchedEffect(Unit) { + + } + + val tts = systts.tts as BgmTTS + val context = LocalContext.current + val filePicker = + rememberLauncherForActivityResult(contract = AppActivityResultContracts.filePickerActivity()) { + runCatching { + val path = + context.getPath(it.second, it.first is FilePickerActivity.RequestSelectDir) + if (path.isNullOrBlank()) context.toast(R.string.path_is_empty) + else { + onSysttsChange( + systts.copy( + tts = tts.copy( + musicList = tts.musicList.toMutableSet().apply { add(path) } + ) + ) + ) + } + }.onFailure { + context.displayErrorDialog(it) + } + } + + var showMusicList by remember { mutableStateOf("") } + if (showMusicList != "") { + val audioFiles = remember(showMusicList) { + try { + File(showMusicList).audioList() + } catch (e: Exception) { + context.displayErrorDialog(e) + null + } + } ?: return + + AppSelectionDialog( + onDismissRequest = { showMusicList = "" }, + title = { Text(showMusicList) }, + value = Any(), + values = audioFiles, + entries = audioFiles.map { it.name }, + onClick = { value, _ -> + context.startActivity(Intent(context, ExoPlayerActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = (value as File).toUri() + }) + } + ) + } + + val saveSignal = remember { mutableStateOf<(() -> Unit)?>(null) } + Scaffold(topBar = { + TtsTopAppBar( + title = { Text(text = stringResource(id = R.string.edit_bgm_tts)) }, + onBackAction = onCancel, + onSaveAction = { + saveSignal.value?.invoke() + onSave() + } + ) + }) { paddingValues -> + Column(Modifier.padding(paddingValues)) { + BasicInfoEditScreen( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + systts = systts, + onSysttsChange = onSysttsChange, + showSpeechTarget = false, + ) + + ParamsEditScreen( + modifier = Modifier.fillMaxWidth(), + systts = systts, + onSysttsChange = onSysttsChange + ) + + OutlinedCard( + Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + FilesAccessPermissionContent(Modifier.fillMaxWidth()) + + Row( + Modifier.align(Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { + filePicker.launch( + FilePickerActivity.RequestSelectFile( + fileMimes = listOf("audio/*") + ) + ) + }) { + Icon(Icons.Default.AudioFile, null) + Text(stringResource(id = R.string.add_file)) + } + VerticalDivider(Modifier.height(16.dp)) + TextButton(onClick = { + filePicker.launch( + FilePickerActivity.RequestSelectDir() + ) + }) { + Icon(Icons.Default.CreateNewFolder, null) + Text(stringResource(id = R.string.add_folder)) + } + } + + LazyColumn(Modifier.padding(8.dp)) { + items(tts.musicList.toList()) { item -> + Row( + Modifier + .clip(MaterialTheme.shapes.small) + .clickableRipple { + showMusicList = item + } + ) { + Text( + item, + modifier = Modifier.weight(1f), + lineHeight = LocalTextStyle.current.lineHeight * 0.8 + ) + IconButton(onClick = { + onSysttsChange( + systts.copy( + tts = tts.copy( + musicList = tts.musicList.toMutableSet() + .apply { remove(item) } + ) + ) + ) + }) { + Icon( + Icons.Default.DeleteForever, + stringResource(id = R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun FilesAccessPermissionContent(modifier: Modifier = Modifier) { + val context = LocalContext.current + + @Composable + fun ColumnScope.warnButton(text: String, onClick: () -> Unit) { + FilledTonalButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp), + onClick = onClick, + content = { + Text( + text, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + ) + } + + Column(modifier) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // A11 + var isGranted by remember { mutableStateOf(Environment.isExternalStorageManager()) } + val permissionCheckerObserver = remember { + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + isGranted = Environment.isExternalStorageManager() + } + } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, permissionCheckerObserver) { + lifecycle.addObserver(permissionCheckerObserver) + onDispose { lifecycle.removeObserver(permissionCheckerObserver) } + } + + if (!isGranted) { + warnButton(text = stringResource(id = R.string.grant_permission_all_file)) { + context.startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION).apply { + setData(Uri.parse("package:${context.packageName}")) + }) + } + } + } + + val storagePermission = rememberPermissionState(Manifest.permission.READ_EXTERNAL_STORAGE) + if (!storagePermission.status.isGranted) + warnButton(text = stringResource(R.string.grant_permission_storage_file)) { + storagePermission.launchPermissionRequest() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // A13 + val audioPermission = rememberPermissionState(Manifest.permission.READ_MEDIA_AUDIO) + + if (!audioPermission.status.isGranted) + warnButton(text = stringResource(R.string.grant_permission_audio_file)) { + audioPermission.launchPermissionRequest() + } + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/LocalTtsUI.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/LocalTtsUI.kt new file mode 100644 index 000000000..b19f2214d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/LocalTtsUI.kt @@ -0,0 +1,275 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.HelpOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.AuditionDialog +import com.github.jing332.tts_server_android.compose.systts.list.IntSlider +import com.github.jing332.tts_server_android.compose.systts.list.edit.BasicInfoEditScreen +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.AuditionTextField +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.TtsTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.AppSpinner +import com.github.jing332.tts_server_android.compose.widgets.DenseOutlinedField +import com.github.jing332.tts_server_android.compose.widgets.LabelSlider +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.LocalTTS +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog + +class LocalTtsUI : TtsUI() { + @Composable + override fun ParamsEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit + ) { + var showDirectPlayHelpDialog by remember { mutableStateOf(false) } + if (showDirectPlayHelpDialog) + AlertDialog( + onDismissRequest = { showDirectPlayHelpDialog = false }, + title = { Text(stringResource(id = R.string.systts_direct_play_help)) }, + text = { Text(stringResource(id = R.string.systts_direct_play_help_msg)) }, + confirmButton = { + TextButton(onClick = { showDirectPlayHelpDialog = false }) { + Text(text = stringResource(id = android.R.string.ok)) + } + }) + + Column(modifier) { + val tts = systts.tts as LocalTTS + val rateStr = stringResource( + id = R.string.label_speech_rate, + if (tts.rate == 0) stringResource(id = R.string.follow_system_or_read_aloud_app) else tts.rate.toString() + ) + IntSlider(label = rateStr, value = tts.rate.toFloat(), onValueChange = { + onSysttsChange(systts.copy(tts = tts.copy(rate = it.toInt()))) + }, valueRange = 0f..100f) + + val pitchStr = stringResource( + id = R.string.label_speech_pitch, + if (tts.pitch == 0) stringResource(id = R.string.follow_system_or_read_aloud_app) else tts.pitch.toString() + ) + LabelSlider(value = tts.pitch * 0.01f, onValueChange = { + onSysttsChange( + systts.copy( + tts = tts.copy( + pitch = (it * 100).toInt() + ) + ) + ) + }, valueRange = 0f..2f, text = pitchStr) + + Row { + var sampleRateStr by remember { mutableStateOf(tts.audioFormat.sampleRate.toString()) } + DenseOutlinedField( + label = { Text(stringResource(id = R.string.systts_sample_rate)) }, + modifier = Modifier + .weight(1f) + .padding(8.dp), + value = sampleRateStr, + onValueChange = { + if (it.isEmpty()) { + sampleRateStr = it + } else { + sampleRateStr = it.toInt().toString() + onSysttsChange(systts.copy(tts = tts.copy(audioFormat = tts.audioFormat.apply { + this.sampleRate = it.toInt() + }))) + } + } + ) + + Row( + Modifier + .minimumInteractiveComponentSize() + .padding(8.dp) + .clip(MaterialTheme.shapes.medium) + .clickable(role = Role.Checkbox) { + onSysttsChange(systts.copy(tts = tts.copy(isDirectPlayMode = !tts.isDirectPlayMode))) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = tts.isDirectPlayMode, onCheckedChange = null) + Text(text = stringResource(id = R.string.direct_play)) + IconButton(onClick = { showDirectPlayHelpDialog = true }) { + Icon( + Icons.Default.HelpOutline, + stringResource(id = R.string.systts_direct_play_help) + ) + } + } + } + } + } + + @Composable + override fun FullEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit + ) { + val scope = rememberCoroutineScope() + Scaffold( + topBar = { + TtsTopAppBar( + title = { Text(stringResource(id = R.string.edit_local_tts)) }, + onBackAction = onCancel, + onSaveAction = { + onSave() + } + ) + } + ) { paddingValues -> + Content( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + systts = systts, + onSysttsChange = onSysttsChange, + ) + } + } + + @Composable + private fun Content( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + vm: LocalTtsViewModel = viewModel() + ) { + var displayName by remember { mutableStateOf("") } + val systts by rememberUpdatedState(newValue = systts) + val tts = systts.tts as LocalTTS + + SaveActionHandler { + if (systts.displayName.isNullOrBlank()) + onSysttsChange( + systts.copy( + displayName = displayName, + ) + ) + + true + } + + var showAuditionDialog by remember { mutableStateOf(false) } + if (showAuditionDialog) + AuditionDialog(systts = systts) { + showAuditionDialog = false + } + + Column(modifier) { + Column(Modifier.padding(horizontal = 8.dp)) { + BasicInfoEditScreen( + modifier = Modifier.fillMaxWidth(), + systts = systts, + onSysttsChange = onSysttsChange, + ) + AuditionTextField(modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), onAudition = { + showAuditionDialog = true + }) + + val context = LocalContext.current + var isLoading by remember { mutableStateOf(false) } + LoadingContent(isLoading = isLoading) { + Column { + LaunchedEffect(tts.engine) { + isLoading = true + + runCatching { + withIO { vm.setEngine(tts.engine ?: "") } + vm.updateLocales() + vm.updateVoices(tts.locale) + }.onFailure { + context.displayErrorDialog(it, tts.engine) + } + + isLoading = false + } + + AppSpinner( + label = { Text(stringResource(id = R.string.label_tts_engine)) }, + value = tts.engine ?: "", + values = vm.engines.map { it.name }, + entries = vm.engines.map { it.label }, + onSelectedChange = { k, name -> + onSysttsChange(systts.copy(tts = tts.copy(engine = k as String))) + displayName = name + } + ) + + AppSpinner( + label = { Text(stringResource(id = R.string.label_language)) }, + value = tts.locale, + values = vm.locales.map { it.toLanguageTag() }, + entries = vm.locales.map { it.displayName }, + onSelectedChange = { loc, _ -> + onSysttsChange(systts.copy(tts = tts.copy(locale = loc as String))) + + vm.updateVoices(loc) + } + ) + + AppSpinner( + label = { Text(stringResource(id = R.string.label_voice)) }, + value = tts.voiceName ?: "", + values = vm.voices.map { it.name }, + entries = vm.voices.map { + val featureStr = + if (it.features == null || it.features.isEmpty()) "" else it.features.toString() + "${it.name} $featureStr" + }, + onSelectedChange = { k, _ -> + onSysttsChange(systts.copy(tts = tts.copy(voiceName = k as String))) + } + ) + } + } + } + ParamsEditScreen( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + systts = systts, + onSysttsChange = onSysttsChange + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/LocalTtsViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/LocalTtsViewModel.kt new file mode 100644 index 000000000..46d6fb244 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/LocalTtsViewModel.kt @@ -0,0 +1,49 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import android.speech.tts.TextToSpeech +import android.speech.tts.Voice +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.ViewModel +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.model.LocalTtsEngine +import java.util.Locale + +class LocalTtsViewModel : ViewModel() { + private val engine by lazy { LocalTtsEngine(App.context) } + + val engines = mutableStateListOf() + val locales = mutableStateListOf() + val voices = mutableStateListOf() + + fun init() { + engines.clear() + engines.addAll(LocalTtsEngine.getEngines()) + } + + suspend fun setEngine(engine: String) { + val ok = this.engine.setEngine(engine) + if (!ok) return + + engines.clear() + engines.addAll(LocalTtsEngine.getEngines()) + } + + fun updateLocales() { + locales.clear() + locales.addAll(engine.locales) + } + + fun updateVoices(locale: String) { + voices.clear() + voices.addAll(engine.voices + .filter { it.locale.toLanguageTag() == locale } + .sortedBy { it.name } + ) + } + + override fun onCleared() { + super.onCleared() + + engine.shutdown() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/MsTtsUI.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/MsTtsUI.kt new file mode 100644 index 000000000..0e9aa0edd --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/MsTtsUI.kt @@ -0,0 +1,261 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.AuditionDialog +import com.github.jing332.tts_server_android.compose.systts.list.IntSlider +import com.github.jing332.tts_server_android.compose.systts.list.edit.BasicInfoEditScreen +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.AuditionTextField +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.TtsTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.AppSpinner +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.constant.MsTtsApiType +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.GeneralVoiceData +import com.github.jing332.tts_server_android.model.speech.tts.MsTTS +import com.github.jing332.tts_server_android.model.speech.tts.MsTtsFormatManger +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog + +class MsTtsUI : TtsUI() { + @Composable + override fun ParamsEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit + ) { + val tts = (systts.tts as MsTTS) + Column(modifier) { + val formats = remember { MsTtsFormatManger.getFormatsByApiType(MsTtsApiType.EDGE) } + AppSpinner( + label = { Text(stringResource(id = R.string.label_audio_format)) }, + value = tts.format, + values = formats, + entries = formats, + onSelectedChange = { k, v -> + onSysttsChange(systts.copy(tts = tts.copy(format = k as String))) + }, + modifier = Modifier, + ) + + val speechRate = tts.prosody.rate + val rateStr = stringResource( + id = R.string.label_speech_rate, + if (speechRate == MsTTS.RATE_FOLLOW_SYSTEM) stringResource(id = R.string.follow_system) else speechRate.toString() + ) + IntSlider( + value = speechRate.toFloat(), + onValueChange = { + onSysttsChange( + systts.copy(tts = tts.copy(prosody = tts.prosody.copy(rate = it.toInt()))) + ) + }, + valueRange = -100f..100f, + label = rateStr, + ) + + val volume = tts.prosody.volume + val volStr = stringResource(id = R.string.label_speech_volume, volume.toString()) + IntSlider( + value = volume.toFloat(), + onValueChange = { + onSysttsChange( + systts.copy(tts = tts.copy(prosody = tts.prosody.copy(volume = it.toInt()))) + ) + }, + valueRange = -50f..50f, + label = volStr + ) + + val pitch = tts.prosody.pitch + val pitchStr = stringResource( + id = R.string.label_speech_pitch, + if (pitch == MsTTS.PITCH_FOLLOW_SYSTEM) stringResource(id = R.string.follow) else pitch.toString() + ) + IntSlider( + value = pitch.toFloat(), + onValueChange = { + onSysttsChange( + systts.copy(tts = tts.copy(prosody = tts.prosody.copy(pitch = it.toInt()))) + ) + }, + valueRange = -50f..50f, + label = pitchStr + ) + + } + } + + @Composable + override fun FullEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit + ) { + Scaffold( + modifier = modifier, + topBar = { + TtsTopAppBar( + title = { Text(text = stringResource(id = R.string.edit_builtin_tts)) }, + onBackAction = onCancel, + onSaveAction = { + onSave() + } + ) + }) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Content( + modifier = Modifier + .padding(8.dp), + systts = systts, + onSysttsChange = onSysttsChange, + ) + } + } + } + + @Composable + private fun Content( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + + vm: MsTtsViewModel = viewModel() + ) { + val context = LocalContext.current + var displayName by remember { mutableStateOf("") } + + @Suppress("NAME_SHADOWING") + val systts by rememberUpdatedState(newValue = systts) + val tts = systts.tts as MsTTS + SaveActionHandler { + if (systts.displayName.isNullOrBlank()) + onSysttsChange( + systts.copy( + displayName = displayName + ) + ) + + true + } + + var showAudition by remember { mutableStateOf(false) } + if (showAudition) { + AuditionDialog(systts = systts) { showAudition = false } + } + + Column(modifier) { + BasicInfoEditScreen( + modifier = Modifier + .fillMaxWidth(), + systts = systts, + onSysttsChange = onSysttsChange + ) + + AuditionTextField(modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), onAudition = { + showAudition = true + }) + + val apis = + remember { listOf(R.string.systts_api_edge, R.string.systts_api_edge_okhttp) } + + LaunchedEffect(vm) { + runCatching { + vm.load() + vm.updateLocales() + }.onFailure { + context.displayErrorDialog(it) + } + } + + AppSpinner( + label = { Text(stringResource(id = R.string.label_api)) }, + value = tts.api, + values = listOf(MsTtsApiType.EDGE, MsTtsApiType.EDGE_OKHTTP), + entries = apis.map { stringResource(id = it) }, + onSelectedChange = { api, _ -> + onSysttsChange(systts.copy(tts = tts.copy(api = api as Int))) + }, + modifier = Modifier.padding(top = 4.dp) + ) + + LoadingContent(isLoading = vm.isLoading) { + Column { + AppSpinner( + label = { Text(stringResource(id = R.string.language)) }, + value = tts.locale, + values = vm.locales.map { it.first }, + entries = vm.locales.map { it.second }, + onSelectedChange = { lang, _ -> + onSysttsChange(systts.copy(tts = tts.copy(locale = lang as String))) + vm.onLocaleChanged(lang) + }, + modifier = Modifier.padding(top = 4.dp) + ) + + LaunchedEffect(tts.locale) { + vm.onLocaleChanged(tts.locale) + } + + fun GeneralVoiceData.name() = localVoiceName + " (${voiceName})" + + AppSpinner( + label = { Text(stringResource(id = R.string.label_voice)) }, + value = tts.voiceName, + values = vm.voices.map { it.voiceName }, + entries = vm.voices.map { it.name() }, + onSelectedChange = { voice, name -> + val lastName = + vm.voices.find { it.voiceName == tts.voiceName }?.name() ?: "" + onSysttsChange( + systts.copy( + displayName = + if (systts.displayName.isNullOrBlank() || lastName == systts.displayName) name + else systts.displayName, + tts = tts.copy(voiceName = voice as String) + ) + ) + + displayName = name + }, + modifier = Modifier.padding(top = 4.dp) + ) + + } + } + + ParamsEditScreen( + modifier = Modifier + .fillMaxWidth().padding(top = 8.dp), + systts = systts, + onSysttsChange = onSysttsChange + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/MsTtsViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/MsTtsViewModel.kt new file mode 100644 index 000000000..9c96e00b6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/MsTtsViewModel.kt @@ -0,0 +1,47 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.constant.MsTtsApiType +import com.github.jing332.tts_server_android.model.GeneralVoiceData +import com.github.jing332.tts_server_android.model.MsTtsEditRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MsTtsViewModel : ViewModel() { + companion object { + private val repo: MsTtsEditRepository by lazy { MsTtsEditRepository() } + } + + private var mAllList: List = emptyList() + + var isLoading by mutableStateOf(true) + + val locales = mutableStateListOf>() + val voices = mutableStateListOf() + + suspend fun load(){ + isLoading = true + mAllList = withIO { repo.voicesByApi(MsTtsApiType.EDGE) } + isLoading = false + } + + fun updateLocales() { + locales.clear() + locales.addAll( + mAllList.map { it.locale to it.localeName }.distinctBy { it.first } + .sortedBy { it.first } + ) + } + + fun onLocaleChanged(locale: String) { + voices.clear() +// if (locale.isEmpty()) return + voices.addAll(mAllList.filter { it.locale == locale }.sortedBy { it.voiceName }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/PluginTtsUI.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/PluginTtsUI.kt new file mode 100644 index 000000000..eec02d068 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/PluginTtsUI.kt @@ -0,0 +1,328 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import android.util.Log +import android.widget.LinearLayout +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.AuditionDialog +import com.github.jing332.tts_server_android.compose.systts.list.IntSlider +import com.github.jing332.tts_server_android.compose.systts.list.edit.BasicInfoEditScreen +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.AuditionTextField +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.TtsTopAppBar +import com.github.jing332.tts_server_android.compose.widgets.AppSpinner +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.compose.widgets.LoadingDialog +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.BaseAudioFormat +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import kotlinx.coroutines.launch + +class PluginTtsUI : TtsUI() { + companion object { + const val TAG = "PluginTtsUI" + } + + @Composable + override fun ParamsEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + ) { + val context = LocalContext.current + val tts = (systts.tts as PluginTTS) + Column(modifier) { + val rateStr = + stringResource( + id = R.string.label_speech_rate, + if (tts.rate == 0) stringResource(id = R.string.follow) else tts.rate.toString() + ) + IntSlider( + label = rateStr, + value = tts.rate.toFloat(), + onValueChange = { + onSysttsChange( + systts.copy( + tts = tts.copy(rate = it.toInt()) + ) + ) + }, + valueRange = 0f..100f + ) + + val volumeStr = + stringResource( + id = R.string.label_speech_volume, + if (tts.volume == 0) stringResource(id = R.string.follow) else tts.volume.toString() + ) + IntSlider( + label = volumeStr, value = tts.volume.toFloat(), onValueChange = { + onSysttsChange( + systts.copy( + tts = tts.copy(volume = it.toInt()) + ) + ) + }, valueRange = 0f..100f + ) + + val pitchStr = stringResource( + id = R.string.label_speech_pitch, + if (tts.pitch == 0) stringResource(id = R.string.follow) else tts.pitch.toString() + ) + IntSlider( + label = pitchStr, value = tts.pitch.toFloat(), onValueChange = { + onSysttsChange( + systts.copy( + tts = tts.copy(pitch = it.toInt()) + ) + ) + }, valueRange = 0f..100f + ) + } + } + + @Preview + @Composable + private fun PreviewParamsEditScreen() { + var systts by remember { mutableStateOf(SystemTts(tts = PluginTTS())) } + ParamsEditScreen(Modifier, systts = systts, onSysttsChange = { systts = it }) + } + + @Composable + override fun FullEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + ) { + Scaffold( + modifier = modifier, + topBar = { + TtsTopAppBar( + title = { Text(text = stringResource(id = R.string.edit_plugin_tts)) }, + onBackAction = onCancel, + onSaveAction = { + onSave() + } + ) + } + ) { paddingValues -> + EditContentScreen( + modifier = Modifier + .padding(paddingValues) + .verticalScroll( + rememberScrollState() + ), + systts = systts, onSysttsChange = onSysttsChange, + ) + } + } + + @Composable + fun EditContentScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + showBasicInfo: Boolean = true, + vm: PluginTtsViewModel = viewModel(), + ) { + var displayName by remember { mutableStateOf("") } + + @Suppress("NAME_SHADOWING") + val systts by rememberUpdatedState(newValue = systts) + val tts by rememberUpdatedState(newValue = systts.tts as PluginTTS) + val context = LocalContext.current + + SaveActionHandler { + val sampleRate = try { + withIO { vm.engine.getSampleRate(tts.locale, tts.voice) ?: 16000 } + } catch (e: Exception) { + context.displayErrorDialog( + e, + context.getString(R.string.plugin_tts_get_sample_rate_failed) + ) + null + } + + val isNeedDecode = try { + withIO { vm.engine.isNeedDecode(tts.locale, tts.voice) } + } catch (e: Exception) { + context.displayErrorDialog( + e, + context.getString(R.string.plugin_tts_get_need_decode_failed) + ) + null + } + + if (sampleRate != null && isNeedDecode != null) { + onSysttsChange( + systts.copy( + displayName = if (systts.displayName.isNullOrBlank()) displayName else systts.displayName, + tts = tts.copy( + audioFormat = BaseAudioFormat( + sampleRate = sampleRate, + isNeedDecode = isNeedDecode + ) + ) + ) + ) + + true + } else + false + } + + var showLoadingDialog by remember { mutableStateOf(false) } + if (showLoadingDialog) + LoadingDialog(onDismissRequest = { showLoadingDialog = false }) + + var showAuditionDialog by remember { mutableStateOf(false) } + if (showAuditionDialog) + AuditionDialog(systts = systts) { + showAuditionDialog = false + } + + Column(modifier) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + if (showBasicInfo) + BasicInfoEditScreen( + Modifier.fillMaxWidth(), + systts = systts, + onSysttsChange = onSysttsChange + ) + + AuditionTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + onAudition = { + showAuditionDialog = true + } + ) + + LoadingContent(isLoading = vm.isLoading) { + Column { + AppSpinner( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + label = { Text(stringResource(id = R.string.language)) }, + value = tts.locale, + values = vm.locales.map { it.first }, + entries = vm.locales.map { it.second }, + onSelectedChange = { locale, _ -> + Log.d("PluginTtsUI", "locale onSelectedChange: $locale") + if (locale.toString().isBlank()) return@AppSpinner + onSysttsChange( + systts.copy(tts = tts.copy(locale = locale as String)) + ) + runCatching { + Log.d("PluginTtsUI", "updateVoices: locale=${locale}") + vm.updateVoices(locale) + } + }, + ) + + + + AppSpinner( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + label = { Text(stringResource(id = R.string.label_voice)) }, + value = tts.voice, + values = vm.voices.map { it.first }, + entries = vm.voices.map { it.second }, + onSelectedChange = { voice, name -> + Log.d( + "PluginTtsUI", + "voice onSelectedChange: voice=$voice, name=$name" + ) + + val lastName = + vm.voices.find { it.first == tts.voice }?.second ?: "" + onSysttsChange( + systts.copy( + displayName = + if (systts.displayName.isNullOrBlank() || lastName == systts.displayName) name + else systts.displayName, + tts = tts.copy(voice = voice as String) + ) + ) + + runCatching { + Log.d( + "PluginTtsUI", + "updateCustomUI: locale=${tts.locale}, voice=$voice" + ) + vm.updateCustomUI(tts.locale, voice) + }.onFailure { + context.displayErrorDialog(it) + } + + displayName = name + } + ) + + val scope = rememberCoroutineScope() + suspend fun load(linearLayout: LinearLayout) { + runCatching { + vm.load(context, systts.tts as PluginTTS, linearLayout) + }.onFailure { + it.printStackTrace() + context.displayErrorDialog(it) + } + } + + AndroidView( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + factory = { + LinearLayout(it).apply { + orientation = LinearLayout.VERTICAL + scope.launch { load(this@apply) } + } + } + ) + } + } + } + + ParamsEditScreen( + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + systts = systts, + onSysttsChange = onSysttsChange + ) + } + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/PluginTtsViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/PluginTtsViewModel.kt new file mode 100644 index 000000000..4e03f71ec --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/PluginTtsViewModel.kt @@ -0,0 +1,63 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import android.content.Context +import android.widget.LinearLayout +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets.BaseViewModel +import com.github.jing332.tts_server_android.model.rhino.tts.TtsPluginUiEngine +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS + +class PluginTtsViewModel : BaseViewModel() { + lateinit var engine: TtsPluginUiEngine + + fun initEngine(tts: PluginTTS) { + if (this::engine.isInitialized) return + + engine = TtsPluginUiEngine(tts, app) + } + + var isLoading by mutableStateOf(true) + + val locales = mutableStateListOf>() + val voices = mutableStateListOf>() + + suspend fun load(context: Context, tts: PluginTTS, linearLayout: LinearLayout) { + isLoading = true + try { + initEngine(tts) + withIO { engine.onLoadData() } + + engine.onLoadUI(context, linearLayout) + + updateLocales() +// updateVoices(tts.locale) + } catch (t: Throwable) { + throw t + } finally { + isLoading = false + } + } + + private fun updateLocales() { + locales.clear() + locales.addAll(engine.getLocales().toList()) + } + + fun updateVoices(locale: String) { + voices.clear() + voices.addAll(engine.getVoices(locale).toList()) + } + + fun updateCustomUI(locale: String, voice: String) { + try { + engine.onVoiceChanged(locale, voice) + } catch (_: NoSuchMethodException) { + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/SaveHandler.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/SaveHandler.kt new file mode 100644 index 000000000..3e9078e7b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/SaveHandler.kt @@ -0,0 +1,27 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf + +internal val LocalSaveCallBack = + staticCompositionLocalOf> { mutableListOf() } + +internal fun interface SaveCallBack { + suspend fun onSave(): Boolean +} + +@Composable +internal fun rememberSaveCallBacks() = remember { mutableListOf() } + +@Composable +internal fun SaveActionHandler(cb: SaveCallBack) { + val cbs = LocalSaveCallBack.current + DisposableEffect(Unit) { + cbs.add(cb) + onDispose { + cbs.remove(cb) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/TtsUI.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/TtsUI.kt new file mode 100644 index 000000000..56152d7df --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/TtsUI.kt @@ -0,0 +1,41 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import kotlinx.coroutines.CoroutineScope + +typealias CallbackState = MutableState<(suspend () -> Unit)?> +typealias CallbackStateWithRet = MutableState<(suspend CoroutineScope.() -> Boolean)?> + +@Composable +fun rememberCallbackState() = + remember { mutableStateOf<(suspend () -> Unit)?>(null) } + +@Composable +fun rememberCallbackStateWithRet() = + remember { mutableStateOf<(suspend CoroutineScope.() -> Boolean)?>(null) } + +open class TtsUI { + @Composable + open fun ParamsEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit + ) { + } + + + @Composable + open fun FullEditScreen( + modifier: Modifier, + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit + ) { + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/TtsUiFactory.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/TtsUiFactory.kt new file mode 100644 index 000000000..3cd8abaaf --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/TtsUiFactory.kt @@ -0,0 +1,20 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui + +import com.github.jing332.tts_server_android.model.speech.tts.BgmTTS +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import com.github.jing332.tts_server_android.model.speech.tts.LocalTTS +import com.github.jing332.tts_server_android.model.speech.tts.MsTTS +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS + +object TtsUiFactory { + fun from(tts: ITextToSpeechEngine): TtsUI? { + return when (tts) { + is PluginTTS -> PluginTtsUI() + is MsTTS -> MsTtsUI() + is LocalTTS -> LocalTtsUI() + is BgmTTS -> BgmTtsUI() + + else -> null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/AuditionTextField.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/AuditionTextField.kt new file mode 100644 index 000000000..c1e845fcf --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/AuditionTextField.kt @@ -0,0 +1,31 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.conf.AppConfig + +@Composable +fun AuditionTextField(modifier: Modifier, onAudition: (String) -> Unit) { + var text by remember { AppConfig.testSampleText } + OutlinedTextField( + modifier = modifier, + label = {Text(stringResource(id = R.string.audition_text))}, + value = text, + onValueChange = { text = it }, + trailingIcon = { + IconButton(onClick = { onAudition(text) }) { + Icon(Icons.Default.Headset, stringResource(id = R.string.audition)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/BaseViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/BaseViewModel.kt new file mode 100644 index 000000000..1082e8c45 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/BaseViewModel.kt @@ -0,0 +1,15 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets + +import androidx.lifecycle.ViewModel +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.help.audio.AudioPlayer + +open class BaseViewModel : ViewModel() { + val audioPlayer by lazy { AudioPlayer(app) } + + override fun onCleared() { + super.onCleared() + + audioPlayer.release() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/InternalPlayerDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/InternalPlayerDialog.kt new file mode 100644 index 000000000..c66016361 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/InternalPlayerDialog.kt @@ -0,0 +1,55 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SmartDisplay +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.list.BasicAudioParamsDialog +import com.github.jing332.tts_server_android.conf.SysTtsConfig +import com.github.jing332.tts_server_android.model.speech.tts.PlayerParams +import com.github.jing332.tts_server_android.utils.longToast + +@Composable +fun InternalPlayerDialog( + onDismissRequest: () -> Unit, params: PlayerParams, + onParamsChange: (PlayerParams) -> Unit +) { + val context = LocalContext.current + LaunchedEffect(Unit) { + if (!SysTtsConfig.isInAppPlayAudio) + context.longToast(R.string.built_in_player_not_enabled) + } + + BasicAudioParamsDialog( + title = { + Row(Modifier.padding(bottom = 8.dp)) { + Icon(imageVector = Icons.Default.SmartDisplay, contentDescription = null) + Text(stringResource(id = R.string.internal_player)) + } + }, + + onDismissRequest = onDismissRequest, + speed = params.rate, + onSpeedChange = { onParamsChange(params.copy(rate = it)) }, + + volumeRange = 0f..1f, + volume = params.volume, + onVolumeChange = { onParamsChange(params.copy(volume = it)) }, + + pitch = params.pitch, + onPitchChange = { onParamsChange(params.copy(pitch = it)) }, + + onReset = { + onParamsChange(params.copy(rate = 0f, volume = 0f, pitch = 0f)) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/TtsTopAppBar.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/TtsTopAppBar.kt new file mode 100644 index 000000000..3b766c8f3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/list/edit/ui/widgets/TtsTopAppBar.kt @@ -0,0 +1,50 @@ +package com.github.jing332.tts_server_android.compose.systts.list.edit.ui.widgets + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TtsTopAppBar( + title: @Composable () -> Unit, + onBackAction: () -> Unit, + onSaveAction: () -> Unit, + + moreOptions: (@Composable (dismiss: () -> Unit) -> Unit)? = null +) { + TopAppBar( + title = title, + navigationIcon = { + IconButton(onClick = onBackAction) { + Icon(Icons.Default.ArrowBack, stringResource(id = R.string.nav_back)) + } + }, + actions = { + IconButton(onClick = onSaveAction) { + Icon(Icons.Default.Save, stringResource(id = R.string.save)) + } + + var showOptions by remember { mutableStateOf(false) } + if (moreOptions != null) + IconButton(onClick = { + + }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + moreOptions { showOptions = false } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/NavRoutes.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/NavRoutes.kt new file mode 100644 index 000000000..ca1d92729 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/NavRoutes.kt @@ -0,0 +1,8 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +internal sealed class NavRoutes(val id: String) { + data object PluginManager : NavRoutes("plugin_manager") + data object PluginEdit : NavRoutes("plugin_edit") { + const val KEY_DATA = "data" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginEditScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginEditScreen.kt new file mode 100644 index 000000000..19a22d3e0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginEditScreen.kt @@ -0,0 +1,188 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.codeeditor.CodeEditorScreen +import com.github.jing332.tts_server_android.compose.codeeditor.LoggerBottomSheet +import com.github.jing332.tts_server_android.compose.widgets.TextFieldDialog +import com.github.jing332.tts_server_android.conf.PluginConfig +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText +import com.github.jing332.tts_server_android.utils.longToast +import io.github.rosemoe.sora.widget.CodeEditor + +@Suppress("DEPRECATION") +@Composable +internal fun PluginEditScreen( + plugin: Plugin, + onSave: (Plugin) -> Unit, + vm: PluginEditorViewModel = viewModel() +) { + val navController = LocalNavController.current + val context = LocalContext.current + + var codeEditor by remember { mutableStateOf(null) } + +// @Suppress("NAME_SHADOWING") +// var plugin by remember { mutableStateOf(plugin) } + + val ttsEditRet by navController.currentBackStackEntry?.savedStateHandle?.getStateFlow( + "ret", + null + )?.collectAsState() ?: rememberUpdatedState(null) + LaunchedEffect(ttsEditRet) { + ttsEditRet?.let { + context.longToast("数据仅本次编辑生效") + vm.updateTTS(it) + } + } + + val code by vm.codeLiveData.asFlow().collectAsState(initial = "") + LaunchedEffect(codeEditor, code) { + if (codeEditor != null && code.isNotEmpty()) + codeEditor?.setText(code) + } + + LaunchedEffect(vm) { + vm.init(plugin, context.assets.open("defaultData/plugin-azure.js").readAllText()) + } + + var showTextParamDialog by remember { mutableStateOf(false) } + if (showTextParamDialog) { + var sampleText by remember { mutableStateOf(PluginConfig.textParam.value) } + TextFieldDialog( + title = stringResource(id = R.string.set_sample_text_param), + text = sampleText, + onTextChange = { sampleText = it }, + onDismissRequest = { showTextParamDialog = false }) { + PluginConfig.textParam.value = sampleText + } + } + + var showDebugLogger by remember { mutableStateOf(false) } + if (showDebugLogger) { + LoggerBottomSheet( + logger = vm.pluginEngine.logger, + onDismissRequest = { showDebugLogger = false }) { + runCatching { + vm.updateCode(codeEditor!!.text.toString()) + vm.debug() + }.onFailure { + context.displayErrorDialog(it) + } + } + } + + val previewLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { + it.data?.let { intent -> + val tts = intent.getParcelableExtra(PluginPreviewActivity.KEY_DATA) + if (tts == null) { + context.longToast("空返回值") + return@let + } + + vm.updateTTS(tts) + } + } + + fun previewUi() { + AppConst.localBroadcast.sendBroadcastSync(Intent(PluginPreviewActivity.ACTION_FINISH)) + vm.updateCode(codeEditor!!.text.toString()) + previewLauncher.launch(Intent(context, PluginPreviewActivity::class.java).apply { + putExtra(PluginPreviewActivity.KEY_DATA, vm.pluginTTS) + }) + } + + CodeEditorScreen( + title = { + Column { + Text( + stringResource(id = R.string.plugin), + style = MaterialTheme.typography.titleLarge + ) + Text(plugin.name, style = MaterialTheme.typography.bodyMedium) + } + }, + onBack = { navController.popBackStack() }, + onDebug = { showDebugLogger = true }, + + onSave = { + if (codeEditor != null) { + vm.updateCode(codeEditor!!.text.toString()) + runCatching { + onSave(vm.pluginEngine.evalPluginInfo()) + navController.popBackStack() + }.onFailure { + context.displayErrorDialog(it) + } + } + }, + onLongClickSave = { // 仅保存 + onSave(vm.plugin.copy(code = codeEditor!!.text.toString())) + navController.popBackStack() + }, + + onRemoteAction = { name, _ -> + when (name) { + "ui" -> { + previewUi() + } + } + }, + onUpdate = { codeEditor = it }, + onSaveFile = { + "ttsrv-plugin-${vm.plugin.name}.js" to codeEditor!!.text.toString().toByteArray() + }, + onLongClickMoreLabel = stringResource(id = R.string.plugin_preview_ui), + onLongClickMore = { previewUi() } + ) { dismiss -> + DropdownMenuItem( + text = { Text(stringResource(id = R.string.plugin_preview_ui)) }, + onClick = { + dismiss() + previewUi() + }, + leadingIcon = { + Icon(Icons.Default.Settings, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.set_sample_text_param)) }, + onClick = { + dismiss() + showTextParamDialog = true + }, + leadingIcon = { + Icon(Icons.Default.TextFields, null) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginEditViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginEditViewModel.kt new file mode 100644 index 000000000..4217ab13c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginEditViewModel.kt @@ -0,0 +1,113 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.conf.PluginConfig +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.model.rhino.tts.TtsPluginUiEngine +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.github.jing332.tts_server_android.utils.StringUtils.sizeToReadable +import com.github.jing332.tts_server_android.utils.readableString +import com.github.jing332.tts_server_android.utils.rootCause +import com.script.ScriptException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +class PluginEditorViewModel(application: Application) : AndroidViewModel(application) { + companion object { + private const val TAG = "PluginEditViewModel" + } + + lateinit var pluginEngine: TtsPluginUiEngine + internal lateinit var plugin: Plugin private set + internal lateinit var pluginTTS: PluginTTS private set + + private val _updateCodeLiveData = MutableLiveData() + + val codeLiveData: LiveData + get() = _updateCodeLiveData + + fun init(plugin: Plugin, defaultCode: String) { + this.plugin = plugin.apply { if (code.isEmpty()) code = defaultCode } + pluginTTS = PluginTTS(plugin = this.plugin) + updateTTS(pluginTTS) + + _updateCodeLiveData.postValue(this.plugin.code) + } + + fun updateTTS(tts: PluginTTS) { + pluginEngine = TtsPluginUiEngine(tts, getApplication()) + pluginTTS = tts.also { + it.pluginEngine = pluginEngine + it.plugin = plugin + } + } + + fun updateCode(code: String) { + plugin.code = code + pluginEngine.code = code +// if (isSave) appDb.pluginDao.update() + } + + fun clearPluginCache() { + val file = File("${app.externalCacheDir!!.absolutePath}/${plugin.pluginId}") + file.deleteRecursively() + } + + private fun evalInfo(): Boolean { + val plugin = try { + pluginEngine.evalPluginInfo() + } catch (e: Exception) { + writeErrorLog(e) + return false + } + pluginEngine.logger.d(plugin.toString().replace(", ", "\n")) + return true + } + + fun debug() { + if (!evalInfo()) return + viewModelScope.launch(Dispatchers.IO) { + kotlin.runCatching { + val sampleRate = pluginEngine.getSampleRate(pluginTTS.locale, pluginTTS.voice) + pluginEngine.logger.d("SampleRate: $sampleRate") + }.onFailure { + writeErrorLog(it) + } + + runCatching { + val isNeedDecode = pluginEngine.isNeedDecode(pluginTTS.locale, pluginTTS.voice) + pluginEngine.logger.d("NeedDecode: $isNeedDecode") + }.onFailure { + writeErrorLog(it) + } + + kotlin.runCatching { + pluginTTS.onLoad() + val audio = pluginTTS.getAudioWithSystemParams(PluginConfig.textParam.value) + if (audio == null) + pluginEngine.logger.w("Audio is empty!") + else{ + val bytes = audio.readBytes() + pluginEngine.logger.i("Audio size: ${bytes.size.toLong().sizeToReadable()}") + } + }.onFailure { + writeErrorLog(it) + } + } + } + + private fun writeErrorLog(t: Throwable) { + val errStr = if (t is ScriptException) { + "${t.lineNumber}Line: ${t.rootCause?.message ?: t}" + } else { + t.message + "(${t.readableString})" + } + pluginEngine.logger.e(errStr) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginExportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginExportBottomSheet.kt new file mode 100644 index 000000000..e9128a5e5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginExportBottomSheet.kt @@ -0,0 +1,44 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.ConfigExportBottomSheet +import com.github.jing332.tts_server_android.compose.widgets.TextCheckBox + +@Composable +internal fun PluginExportBottomSheet( + onDismissRequest: () -> Unit, + fileName: String, + onGetJson: (isExportVars: Boolean) -> String, +) { + var isExportVars by remember { mutableStateOf(false) } + ConfigExportBottomSheet( + fileName = fileName, + json = onGetJson(isExportVars), + onDismissRequest = onDismissRequest, + content = { + TextCheckBox(modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp), + text = { Text(stringResource(id = R.string.export_vars)) }, + checked = isExportVars, + onCheckedChange = { isExportVars = !isExportVars }) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginImportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginImportBottomSheet.kt new file mode 100644 index 000000000..105477ffb --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginImportBottomSheet.kt @@ -0,0 +1,40 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.jing332.tts_server_android.compose.systts.ConfigImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.ConfigModel +import com.github.jing332.tts_server_android.compose.systts.SelectImportConfigDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin + +@Composable +fun PluginImportBottomSheet(onDismissRequest: () -> Unit) { + var list by remember { mutableStateOf?>(null) } + if (list != null) { + SelectImportConfigDialog( + onDismissRequest = { list = null }, + models = list!!.map { + ConfigModel( + isSelected = true, + title = it.name, + subtitle = it.author, + it + ) + }, + onSelectedList = { + appDb.pluginDao.insert(*it.map { plugin -> plugin as Plugin }.toTypedArray()) + + it.size + } + ) + } + + ConfigImportBottomSheet(onDismissRequest = onDismissRequest, onImport = { + list = AppConst.jsonBuilder.decodeFromString>(it) + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerActivity.kt new file mode 100644 index 000000000..870ddcba3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerActivity.kt @@ -0,0 +1,79 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.navigate +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS + +class PluginManagerActivity : AppCompatActivity() { + private var jsCode by mutableStateOf("") + + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent != null) importJsCodeFromIntent(intent) + + setContent { + AppTheme { + val navController = rememberNavController() + CompositionLocalProvider(LocalNavController provides navController) { + LaunchedEffect(jsCode) { + if (jsCode.isNotBlank()) { + navController.navigate(NavRoutes.PluginEdit.id, argsBuilder = { + putParcelable( + NavRoutes.PluginEdit.KEY_DATA, Plugin(code = jsCode) + ) + }) + } + } + + NavHost( + navController = navController, + startDestination = NavRoutes.PluginManager.id + ) { + composable(NavRoutes.PluginManager.id) { + PluginManagerScreen { finish() } + } + + composable(NavRoutes.PluginEdit.id) { stackEntry -> + val plugin: Plugin = + stackEntry.arguments?.getParcelable(NavRoutes.PluginEdit.KEY_DATA) + ?: Plugin() + + PluginEditScreen(plugin, onSave = { appDb.pluginDao.insert(it) }) + } + } + } + } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + if (intent != null) importJsCodeFromIntent(intent) + } + + private fun importJsCodeFromIntent(intent: Intent) { + jsCode = intent.getStringExtra("js") ?: return + intent.removeExtra("js") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerScreen.kt new file mode 100644 index 000000000..60ff70d18 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerScreen.kt @@ -0,0 +1,364 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import android.content.Intent +import android.os.Bundle +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AppShortcut +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.Input +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Output +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.ShadowReorderableItem +import com.github.jing332.tts_server_android.compose.navigateSingleTop +import com.github.jing332.tts_server_android.compose.systts.ConfigDeleteDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.utils.MyTools +import kotlinx.coroutines.flow.conflate +import kotlinx.serialization.encodeToString +import org.burnoutcrew.reorderable.detectReorderAfterLongPress +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable +import java.util.Collections + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun PluginManagerScreen(onFinishActivity: () -> Unit) { + var showImportConfig by remember { mutableStateOf(false) } + if (showImportConfig) { + PluginImportBottomSheet(onDismissRequest = { showImportConfig = false }) + } + + var showExportConfig by remember { mutableStateOf?>(null) } + if (showExportConfig != null) { + val pluginList = showExportConfig!! + PluginExportBottomSheet( + fileName = if (pluginList.size == 1) "ttsrv-plugin-${pluginList[0].name}.json" else "ttsrv-plugins.json", + onDismissRequest = { showExportConfig = null }) { isExportVars -> + if (isExportVars) { + AppConst.jsonBuilder.encodeToString(pluginList) + } else { + AppConst.jsonBuilder.encodeToString(pluginList.map { it.copy(userVars = mutableMapOf()) }) + } + } + } + + var showDeleteDialog by remember { mutableStateOf(null) } + if (showDeleteDialog != null) { + val plugin = showDeleteDialog!! + ConfigDeleteDialog(onDismissRequest = { showDeleteDialog = null }, name = plugin.name) { + appDb.pluginDao.delete(plugin) + showDeleteDialog = null + } + } + + var showVarsSettings by remember { mutableStateOf(null) } + if (showVarsSettings != null) { + var plugin by remember { mutableStateOf(showVarsSettings!!) } + if (plugin.defVars.isEmpty()) { + showVarsSettings = null + } + PluginVarsBottomSheet(onDismissRequest = { + appDb.pluginDao.update(plugin) + showVarsSettings = null + }, plugin = plugin) { + plugin = it + } + } + + val navController = LocalNavController.current + val context = LocalContext.current + Scaffold(Modifier.fillMaxSize(), topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.plugin_manager)) }, + navigationIcon = { + IconButton(onClick = onFinishActivity) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(id = R.string.nav_back) + ) + } + }, + actions = { + IconButton(onClick = { + navController.navigate(NavRoutes.PluginEdit.id) + }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add_config)) + } + + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { + showOptions = true + }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.import_config)) }, + onClick = { + showOptions = false + showImportConfig = true + }, + leadingIcon = { + Icon(Icons.Default.Input, null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + showExportConfig = appDb.pluginDao.allEnabled + }, + leadingIcon = { + Icon(Icons.Default.Output, null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.desktop_shortcut)) }, + onClick = { + showOptions = false + MyTools.addShortcut( + context, + context.getString(R.string.plugin_manager), + "plugin", + R.drawable.ic_shortcut_plugin, + Intent(context, PluginManagerActivity::class.java) + ) + }, + leadingIcon = { Icon(Icons.Default.AppShortcut, null) } + ) + } + } + } + ) + }) { paddingValues -> + val flowAll = remember { appDb.pluginDao.flowAll().conflate() } + val list by flowAll.collectAsStateWithLifecycle(emptyList()) + + val reorderState = rememberReorderableLazyListState(onMove = { from, to -> + val mutList = list.toMutableList() + Collections.swap(mutList, from.index, to.index) + + mutList.forEachIndexed { index, plugin -> + if (index != plugin.order) + appDb.pluginDao.update(plugin.copy(order = index)) + } + }) + + LazyColumn( + state = reorderState.listState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .reorderable(reorderState) + ) { + itemsIndexed(list, key = { _, item -> item.id }) { _, item -> + val desc = remember { "${item.author} - v${item.version}" } + ShadowReorderableItem(reorderableState = reorderState, key = item.id) { + Item( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 8.dp) + .detectReorderAfterLongPress(reorderState) + .animateItemPlacement(), + hasDefVars = item.defVars.isNotEmpty(), + needSetVars = item.defVars.isNotEmpty() && item.userVars.isEmpty(), + name = item.name, + desc = desc, + isEnabled = item.isEnabled, + onEnabledChange = { + appDb.pluginDao.update(item.copy(isEnabled = it)) + }, + onEdit = { + navController.navigateSingleTop( + NavRoutes.PluginEdit.id, + Bundle().apply { + putParcelable(NavRoutes.PluginEdit.KEY_DATA, item) + } + ) + }, + onSetVars = { showVarsSettings = item }, + onDelete = { showDeleteDialog = item }, + onExport = { + showExportConfig = listOf(item) + } + ) + } + } + } + } +} + +@Composable +internal fun Item( + modifier: Modifier, + hasDefVars: Boolean, + needSetVars: Boolean, + name: String, + desc: String, + isEnabled: Boolean, + onEnabledChange: (Boolean) -> Unit, + onEdit: () -> Unit, + onSetVars: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, +) { + val context = LocalContext.current + ElevatedCard(modifier = modifier, onClick = { + if (hasDefVars) onSetVars() + }) { + Box(modifier = Modifier.padding(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isEnabled, + onCheckedChange = onEnabledChange, + modifier = Modifier.semantics { + role = Role.Switch + context + .getString( + if (isEnabled) R.string.plugin_enabled_desc else R.string.plugin_disabled_desc, + name + ) + .let { + contentDescription = it + stateDescription = it + } + } + ) + Column(Modifier.weight(1f)) { + Text(text = name, style = MaterialTheme.typography.titleMedium) + Text(text = desc, style = MaterialTheme.typography.bodyMedium) + } + Row { + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, stringResource(id = R.string.edit_desc, name)) + } + + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon( + Icons.Default.MoreVert, + stringResource(id = R.string.more_options_desc, name) + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + + if (hasDefVars) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.plugin_set_vars)) }, + onClick = { + showOptions = false + onSetVars() + }, + leadingIcon = { + Icon(Icons.Default.EditNote, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + onExport() + }, + leadingIcon = { + Icon(Icons.Default.Output, null) + } + ) + + HorizontalDivider() + + DropdownMenuItem( + text = { + Text( + stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showOptions = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.DeleteForever, + null, + tint = MaterialTheme.colorScheme.error + ) + } + ) + } + } + + } + } + + if (needSetVars) + Text( + text = stringResource(id = R.string.systts_plugin_please_set_vars), + modifier = Modifier.align(Alignment.Center), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Preview +@Composable +fun PreviewPluginManager() { + MaterialTheme { + PluginManagerScreen(onFinishActivity = {}) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerViewModel.kt new file mode 100644 index 000000000..5b3b76deb --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginManagerViewModel.kt @@ -0,0 +1,7 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import androidx.lifecycle.ViewModel + +class PluginManagerViewModel : ViewModel() { + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginPreviewActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginPreviewActivity.kt new file mode 100644 index 000000000..862d5d2a1 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginPreviewActivity.kt @@ -0,0 +1,195 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.AppLocale +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.list.edit.ui.PluginTtsUI +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.github.jing332.tts_server_android.ui.view.ErrorDialogActivity +import com.github.jing332.tts_server_android.utils.clickableRipple + +@Suppress("DEPRECATION") +class PluginPreviewActivity : AppCompatActivity() { + companion object { + const val KEY_DATA = "data" + const val ACTION_FINISH = "finish" + } + + private val mReceiver by lazy { MyBroadcastReceiver() } + + inner class MyBroadcastReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_FINISH) { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + AppConst.localBroadcast.unregisterReceiver(mReceiver) + AppConst.localBroadcast.sendBroadcastSync(Intent(ErrorDialogActivity.ACTION_FINISH)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + AppConst.localBroadcast.registerReceiver(mReceiver, IntentFilter(ACTION_FINISH)) + + val tts = intent.getParcelableExtra(KEY_DATA) + if (tts == null) { + finish() + return + } + if (tts.locale.isBlank()) { + val l = AppLocale.getAppLocale(this) + tts.locale = "${l.language}-${l.country}" // eg: en-US, zh-CN + } + setContent { + AppTheme { + var systts by rememberSaveable { mutableStateOf(SystemTts(tts = tts)) } + PluginPreviewScreen(systts = systts, onSysttsChange = { systts = it }, onSave = { + intent.putExtra(KEY_DATA, systts.tts) + setResult(RESULT_OK, intent) + finish() + }) + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun PluginPreviewScreen( + systts: SystemTts, + onSysttsChange: (SystemTts) -> Unit, + onSave: () -> Unit + ) { + val context = LocalContext.current + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar(title = { Text(stringResource(id = R.string.plugin_preview_ui)) }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(id = R.string.nav_back) + ) + } + }, + actions = { + IconButton(onClick = { + onSave() + }) { + Icon(Icons.Default.Save, stringResource(id = R.string.save)) + } + + var showSaveLogTips by remember { mutableStateOf(false) } + if (showSaveLogTips) + AppDialog( + onDismissRequest = { showSaveLogTips = false }, + title = { Text(stringResource(R.string.write_plugin_log_to_file)) }, + content = { + Text( + modifier = Modifier.clickableRipple { +// runCatching { +// val uri = +// FileProvider.getUriForFile( +// /* context = */ context, +// /* authority = */ AppConst.fileProviderAuthor, +// /* file = */ File(onIniFilePath()) +// ) +// val intent = Intent(Intent.ACTION_VIEW, uri).apply { +// addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); +// addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); +// setDataAndType(uri, "text/*") +// } +// +// context.startActivity( +// Intent.createChooser( +// intent,"") +// ) +// }.onFailure { +// context.longToast(it.toString()) +// } + }, + text = + App.context.getExternalFilesDir("logs")?.absolutePath + ?: "/data/data/$packageName/files/logs" + ) + } + ) + +// var showOptions by remember { mutableStateOf(false) } +// IconButton(onClick = { showOptions = false }) { +// Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) +// +// DropdownMenu( +// expanded = showOptions, +// onDismissRequest = { showOptions = false }) { +// var isSaveRhinoLog by remember { PluginConfig.isSaveRhinoLog } +// CheckedMenuItem( +// text = { Text(stringResource(R.string.write_plugin_log_to_file)) }, +// checked = isSaveRhinoLog, +// onClick = { +// isSaveRhinoLog = !isSaveRhinoLog +// if (isSaveRhinoLog) +// showSaveLogTips = true +// }, +// leadingIcon = { +// Icon(Icons.Default.DeveloperMode, null) +// } +// ) +// +// +// } +// } + } + ) + }) { paddingValues -> + val ui = remember { PluginTtsUI() } + ui.EditContentScreen( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + systts = systts, + onSysttsChange = onSysttsChange, + showBasicInfo = false + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginVarsBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginVarsBottomSheet.kt new file mode 100644 index 000000000..b7ec9f76e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/plugin/PluginVarsBottomSheet.kt @@ -0,0 +1,56 @@ +package com.github.jing332.tts_server_android.compose.systts.plugin + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.compose.widgets.AppBottomSheet +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin + +@Composable +internal fun PluginVarsBottomSheet( + onDismissRequest: () -> Unit, + plugin: Plugin, + onPluginChange: (Plugin) -> Unit +) { + AppBottomSheet(onDismissRequest = onDismissRequest) { + Text( + text = plugin.name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + plugin.defVars.forEach { + val key = it.key + val hint = it.value["hint"] ?: "" + val label = it.value["label"] ?: "" + val value = plugin.userVars.getOrDefault(key, "") + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp, start = 8.dp, end = 8.dp), + value = value, + onValueChange = { + onPluginChange( + plugin.copy( + userVars = plugin.userVars.toMutableMap().apply { + this[key] = it + if (it.isBlank()) this.remove(key) + } + ) + ) + }, + label = { Text(label) }, + placeholder = { Text(hint) }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/Group.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/Group.kt new file mode 100644 index 000000000..6d8502b6e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/Group.kt @@ -0,0 +1,66 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.state.ToggleableState +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.GroupItem + +@Composable +internal fun Group( + modifier: Modifier, + name: String, + isExpanded: Boolean, + toggleableState: ToggleableState, + onToggleableStateChange: (Boolean) -> Unit, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onExport: () -> Unit, + onSort:()->Unit, +) { + GroupItem( + modifier = modifier, + isExpanded = isExpanded, + name = name, + toggleableState = toggleableState, + onToggleableStateChange = onToggleableStateChange, + onClick = onClick, + onExport = onExport, + onDelete = onDelete, + actions = { dismiss -> + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Filled.Edit, "", + tint = MaterialTheme.colorScheme.onBackground + ) + }, + text = { Text(stringResource(R.string.edit)) }, + onClick = { + dismiss() + onEdit.invoke() + } + ) + + DropdownMenuItem( + leadingIcon = { + Icon(Icons.Filled.Sort, null) + }, + text = { Text(stringResource(R.string.sort)) }, + onClick = { + dismiss() + onSort() + } + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/GroupEditDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/GroupEditDialog.kt new file mode 100644 index 000000000..961e44560 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/GroupEditDialog.kt @@ -0,0 +1,69 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.TextCheckBox +import com.github.jing332.tts_server_android.constant.ReplaceExecution +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import com.github.jing332.tts_server_android.utils.clickableRipple + +@Composable +internal fun GroupEditDialog( + onDismissRequest: () -> Unit, + group: ReplaceRuleGroup, + onGroupChange: (ReplaceRuleGroup) -> Unit, + onConfirm: () -> Unit +) { + AppDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.group)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + label = { Text(stringResource(id = R.string.group_name)) }, + value = group.name, + onValueChange = { + onGroupChange(group.copy(name = it)) + } + ) + + TextCheckBox(text = { + Text(stringResource(id = R.string.replace_rule_after_execute)) + }, checked = group.onExecution == ReplaceExecution.AFTER, onCheckedChange = { + onGroupChange( + group.copy(onExecution = if (group.onExecution == ReplaceExecution.BEFORE) ReplaceExecution.AFTER else ReplaceExecution.BEFORE) + ) + }) + } + }, buttons = { + Row { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + TextButton(onClick = onConfirm) { + + Text(stringResource(id = R.string.confirm)) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/Item.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/Item.kt new file mode 100644 index 000000000..c75ff2bbd --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/Item.kt @@ -0,0 +1,160 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.VerticalAlignBottom +import androidx.compose.material.icons.filled.VerticalAlignTop +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.ConfigDeleteDialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun Item( + name: String, + modifier: Modifier, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + onMoveTop: () -> Unit, + onMoveBottom: () -> Unit, + isEnabled: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val context = LocalContext.current + var deleteDialog by remember { mutableStateOf(false) } + if (deleteDialog) + ConfigDeleteDialog(onDismissRequest = { deleteDialog = false }, name = name) { + onDelete() + } + + ElevatedCard( + onClick = onClick, + modifier = modifier + ) { + Row(modifier = modifier.fillMaxSize()) { + Checkbox( + modifier = Modifier + .align(Alignment.CenterVertically) + .semantics { + role = Role.Switch + context + .getString( + if (isEnabled) R.string.rule_enabled_desc else R.string.rule_disabled_desc, + name + ) + .let { + contentDescription = it + stateDescription = it + } + }, + checked = isEnabled, + onCheckedChange = onCheckedChange + ) + Text( + name, + maxLines = 1, + modifier = Modifier + .weight(1f) + .padding(start = 4.dp) + .fillMaxWidth() + .align(Alignment.CenterVertically), + ) + Row { + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, stringResource(id = R.string.edit_desc, name)) + } + var isMoreOptionsVisible by remember { mutableStateOf(false) } + IconButton(onClick = { + isMoreOptionsVisible = true + }, modifier = Modifier.padding(end = 10.dp)) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(id = R.string.more_options_desc, name), + tint = MaterialTheme.colorScheme.onBackground + ) + + DropdownMenu(expanded = isMoreOptionsVisible, + onDismissRequest = { isMoreOptionsVisible = false }) { + + DropdownMenuItem( + text = { Text(stringResource(R.string.move_to_top)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.VerticalAlignTop, + contentDescription = null, + ) + }, + onClick = { + onMoveTop() + isMoreOptionsVisible = false + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.move_to_bottom)) }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.VerticalAlignBottom, + contentDescription = null, + ) + }, + onClick = { + onMoveBottom() + isMoreOptionsVisible = false + } + ) + + HorizontalDivider() + DropdownMenuItem( + text = { Text(stringResource(R.string.delete)) }, + leadingIcon = { + Icon( + Icons.Filled.DeleteForever, + stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.error + ) + }, + onClick = { + isMoreOptionsVisible = false + deleteDialog = true + } + ) + + } + } + } + } + } + +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/NavRoutes.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/NavRoutes.kt new file mode 100644 index 000000000..7df131194 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/NavRoutes.kt @@ -0,0 +1,8 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +internal sealed class NavRoutes(val id: String) { + data object Manager : NavRoutes("manager") + data object Edit : NavRoutes("edit") { + const val KEY_DATA = "data" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceManagerActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceManagerActivity.kt new file mode 100644 index 000000000..e37999687 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceManagerActivity.kt @@ -0,0 +1,55 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.systts.replace.edit.RuleEditScreen +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.service.systts.SystemTtsService + +class ReplaceManagerActivity : AppCompatActivity() { + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppTheme { + val navController = rememberNavController() + CompositionLocalProvider(LocalNavController provides navController) { + NavHost( + navController = navController, + startDestination = NavRoutes.Manager.id + ) { + composable(NavRoutes.Manager.id) { + ManagerScreen { finishAfterTransition() } + } + composable(NavRoutes.Edit.id) { stackEntry -> + var rule by rememberSaveable { + mutableStateOf( + stackEntry.arguments?.getParcelable(NavRoutes.Edit.KEY_DATA) + ?: ReplaceRule() + ) + } + RuleEditScreen(rule, onRuleChange = { rule = it }, onSave = { + appDb.replaceRuleDao.insert(rule) + if (rule.isEnabled) + SystemTtsService.notifyUpdateConfig(isOnlyReplacer = true) + }) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleExportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleExportBottomSheet.kt new file mode 100644 index 000000000..572d1b126 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleExportBottomSheet.kt @@ -0,0 +1,17 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.github.jing332.tts_server_android.compose.systts.ConfigExportBottomSheet +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.replace.GroupWithReplaceRule +import kotlinx.serialization.encodeToString + +@Composable +fun ReplaceRuleExportBottomSheet(onDismissRequest: () -> Unit, list: List) { + val json = remember(list) { + AppConst.jsonBuilder.encodeToString(list) + } + + ConfigExportBottomSheet(json = json, onDismissRequest = onDismissRequest, fileName = "ttsrv-replaces.json") +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleImportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleImportBottomSheet.kt new file mode 100644 index 000000000..171a765ef --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleImportBottomSheet.kt @@ -0,0 +1,75 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.jing332.tts_server_android.compose.systts.ConfigImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.ConfigModel +import com.github.jing332.tts_server_android.compose.systts.SelectImportConfigDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.replace.GroupWithReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import com.github.jing332.tts_server_android.utils.StringUtils +import com.github.jing332.tts_server_android.utils.toJsonListString + +@Suppress("UNCHECKED_CAST") +@Composable +fun ReplaceRuleImportBottomSheet(onDismissRequest: () -> Unit) { + var list by remember { mutableStateOf?>(null) } + if (list != null) { + SelectImportConfigDialog( + onDismissRequest = { list = null }, + models = list!!, + onSelectedList = { selectedList -> + selectedList.map { it as Pair }.forEach { + val group = it.first + val rule = it.second + + appDb.replaceRuleDao.insert(rule) + appDb.replaceRuleDao.insertGroup(group) + } + appDb.replaceRuleDao.updateAllOrder() + selectedList.size + } + ) + } + + ConfigImportBottomSheet(onDismissRequest = onDismissRequest, onImport = { json -> + val allList = mutableListOf() + if (json.contains("\"group\"")) { + AppConst.jsonBuilder.decodeFromString>(json.toJsonListString()) + .forEach { groupWithRule -> + val group = groupWithRule.group + groupWithRule.list.forEach { + allList.add( + ConfigModel( + isSelected = true, + title = it.name, + subtitle = group.name, + data = Pair(group, it) + ) + ) + } + } + + } else { + val groupName = StringUtils.formattedDate() + val group = ReplaceRuleGroup(name = groupName) + AppConst.jsonBuilder.decodeFromString>(json.toJsonListString()) + .forEach { + allList.add( + ConfigModel( + isSelected = true, title = it.name, subtitle = groupName, + data = Pair(group, it.apply { groupId = group.id }) + ) + ) + } + } + + list = allList + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleManagerScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleManagerScreen.kt new file mode 100644 index 000000000..6e8383195 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleManagerScreen.kt @@ -0,0 +1,338 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import android.content.Intent +import android.os.Bundle +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Input +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddCard +import androidx.compose.material.icons.filled.AppShortcut +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Output +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.ShadowReorderableItem +import com.github.jing332.tts_server_android.compose.navigate +import com.github.jing332.tts_server_android.compose.systts.sizeToToggleableState +import com.github.jing332.tts_server_android.compose.widgets.LazyListIndexStateSaver +import com.github.jing332.tts_server_android.compose.widgets.TextFieldDialog +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.replace.GroupWithReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import com.github.jing332.tts_server_android.service.systts.SystemTtsService +import com.github.jing332.tts_server_android.utils.MyTools +import okhttp3.internal.toLongOrDefault +import org.burnoutcrew.reorderable.detectReorderAfterLongPress +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +internal fun ManagerScreen(vm: ReplaceRuleManagerViewModel = viewModel(), finish: () -> Unit) { + val context = LocalContext.current + val navController = LocalNavController.current + + fun navigateToEdit(rule: ReplaceRule = ReplaceRule()) { + navController.navigate(NavRoutes.Edit.id, Bundle().apply { + putParcelable(NavRoutes.Edit.KEY_DATA, rule) + }) + } + + var showImportSheet by remember { mutableStateOf(false) } + if (showImportSheet) + ReplaceRuleImportBottomSheet(onDismissRequest = { showImportSheet = false }) + + var showExportSheet by remember { mutableStateOf?>(null) } + if (showExportSheet != null) + ReplaceRuleExportBottomSheet( + onDismissRequest = { showExportSheet = null }, + list = showExportSheet!!, + ) + + var showAddGroupDialog by remember { mutableStateOf(false) } + if (showAddGroupDialog) { + var text by remember { mutableStateOf("") } + TextFieldDialog( + title = stringResource(id = R.string.add_group), + text = text, + onTextChange = { text = it }, + onDismissRequest = { showAddGroupDialog = false }, + onConfirm = { + appDb.replaceRuleDao.insertGroup(ReplaceRuleGroup(name = text)) + } + ) + } + + var showGroupEditDialog by remember { mutableStateOf(null) } + if (showGroupEditDialog != null) { + var group by remember { mutableStateOf(showGroupEditDialog!!) } + GroupEditDialog( + onDismissRequest = { + showGroupEditDialog = null + }, + group = group, + onGroupChange = { group = it }, + onConfirm = { appDb.replaceRuleDao.updateGroup(group) } + ) + } + + var showSortDialog by remember { mutableStateOf?>(null) } + if (showSortDialog != null) { + SortDialog( + onDismissRequest = { showSortDialog = null }, + list = showSortDialog!! + ) + } + + + val models by vm.list.collectAsStateWithLifecycle() + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + LaunchedEffect(vm.searchText, vm.searchType) { + vm.updateSearchResult() + } + Row( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer), + verticalAlignment = Alignment.CenterVertically, + ) { + SearchTextField( + modifier = Modifier.weight(1f), + value = vm.searchText, + onValueChange = { vm.searchText = it }, + searchType = vm.searchType, + onSearchTypeChange = { vm.searchType = it } + ) + var showAddOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showAddOptions = true }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add_config)) + DropdownMenu( + expanded = showAddOptions, + onDismissRequest = { showAddOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.add_config)) }, + onClick = { + showAddOptions = false + navigateToEdit() + }, + leadingIcon = { + Icon(Icons.AutoMirrored.Filled.PlaylistAdd, null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(id = R.string.add_group)) }, + onClick = { + showAddOptions = false + showAddGroupDialog = true + }, + leadingIcon = { + Icon(Icons.Default.AddCard, null) + } + ) + } + } + } + }, + navigationIcon = { + IconButton(onClick = finish) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(id = R.string.nav_back) + ) + } + }, + actions = { + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.import_config)) }, + onClick = { + showOptions = false + showImportSheet = true + }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Input, null) } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + showExportSheet = models + }, + leadingIcon = { Icon(Icons.Default.Output, null) } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.desktop_shortcut)) }, + onClick = { + showOptions = false + MyTools.addShortcut( + context, + context.getString(R.string.replace_rule_manager), + "replace", + R.drawable.ic_shortcut_replace, + Intent(context, ReplaceManagerActivity::class.java) + ) + }, + leadingIcon = { Icon(Icons.Default.AppShortcut, null) } + ) + } + } + } + ) + } + ) { paddingValues -> + val listState = rememberLazyListState() + LazyListIndexStateSaver( + models = models, + listState = listState, + onIndexUpdate = { index, offset -> + listState.scrollToItem(index, offset) + } + ) + + val reorderState = + rememberReorderableLazyListState(listState = listState, onMove = { from, to -> + val fromKey = from.key.toString() + val toKey = to.key.toString() + if (fromKey.startsWith("g_") && toKey.startsWith("g_")) { + val src = appDb.replaceRuleDao.getGroup(fromKey.substring(2).toLong()) + ?: return@rememberReorderableLazyListState + val target = appDb.replaceRuleDao.getGroup(toKey.substring(2).toLong()) + ?: return@rememberReorderableLazyListState + + appDb.replaceRuleDao.updateGroup( + src.copy(order = target.order), + target.copy(order = src.order) + ) + } else { + val src = appDb.replaceRuleDao.get(fromKey.toLongOrDefault(Long.MIN_VALUE)) + ?: return@rememberReorderableLazyListState + val target = appDb.replaceRuleDao.get(toKey.toLongOrDefault(Long.MIN_VALUE)) + ?: return@rememberReorderableLazyListState + + appDb.replaceRuleDao.update( + src.copy(order = target.order), + target.copy(order = src.order) + ) + } + }) + LazyColumn( + Modifier + .fillMaxSize() + .padding(paddingValues) + .reorderable(reorderState), + state = listState, + ) { + models.forEachIndexed { _, groupWithRules -> + val g = groupWithRules.group + val toggleableState = + groupWithRules.list.filter { it.isEnabled }.size.sizeToToggleableState( + groupWithRules.list.size + ) + val key = "g_${g.id}" + stickyHeader(key = key) { + ShadowReorderableItem(reorderableState = reorderState, key = key) { + Group( + modifier = Modifier + .fillMaxWidth() + .detectReorderAfterLongPress(reorderState), + name = g.name, + isExpanded = g.isExpanded, + toggleableState = toggleableState, + onToggleableStateChange = { enabled -> + groupWithRules.list.map { + if (it.isEnabled != enabled) + appDb.replaceRuleDao.update(it.copy(isEnabled = enabled)) + } + }, + onClick = { appDb.replaceRuleDao.updateGroup(g.copy(isExpanded = !g.isExpanded)) }, + onEdit = { showGroupEditDialog = g }, + onDelete = { + vm.deleteGroup(groupWithRules) + if (groupWithRules.list.find { it.isEnabled } != null) + SystemTtsService.notifyUpdateConfig(isOnlyReplacer = true) + }, + onExport = { showExportSheet = listOf(groupWithRules) }, + onSort = { showSortDialog = groupWithRules.list } + ) + } + } + + if (g.isExpanded) { + items(groupWithRules.list, key = { it.id }) { rule -> + ShadowReorderableItem(reorderableState = reorderState, key = rule.id) { _ -> + Item( + name = rule.name, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .detectReorderAfterLongPress(reorderState), + isEnabled = rule.isEnabled, + onCheckedChange = { enabled -> + appDb.replaceRuleDao.update(rule.copy(isEnabled = enabled)) + if (enabled) SystemTtsService.notifyUpdateConfig(isOnlyReplacer = true) + }, + onClick = { }, + onEdit = { navigateToEdit(rule) }, + onDelete = { + vm.deleteRule(rule) + if (rule.isEnabled) + SystemTtsService.notifyUpdateConfig(isOnlyReplacer = true) + }, + onMoveTop = { vm.moveTop(rule) }, + onMoveBottom = { vm.moveBottom(rule) } + ) + } + } + } + + } + } + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleManagerViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleManagerViewModel.kt new file mode 100644 index 000000000..c462d2ced --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/ReplaceRuleManagerViewModel.kt @@ -0,0 +1,110 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup.Companion.DEFAULT_GROUP_ID +import com.github.jing332.tts_server_android.data.entities.replace.GroupWithReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.launch + +internal class ReplaceRuleManagerViewModel : ViewModel() { + private var allList = listOf() + + private val _list = MutableStateFlow>(emptyList()) + val list: MutableStateFlow> + get() = _list + + var searchType by mutableStateOf(SearchType.NAME) + var searchText by mutableStateOf("") + + init { + viewModelScope.launch(Dispatchers.IO) { + appDb.replaceRuleDao.getGroup(DEFAULT_GROUP_ID) ?: run { + appDb.replaceRuleDao.insertGroup( + ReplaceRuleGroup( + DEFAULT_GROUP_ID, + app.getString(R.string.default_group) + ) + ) + } + + appDb.replaceRuleDao.updateAllOrder() + appDb.replaceRuleDao.flowAllGroupWithReplaceRules().collectLatest { + allList = it + updateSearchResult() + } + } + } + + + fun updateSearchResult( + text: String = searchText, + type: SearchType = searchType, + src: List = allList + ) { + if (src.isEmpty() || text.isBlank()) { + _list.value = src + return + } + + val resultList = mutableListOf() + src.forEach { + val subList = mutableListOf() + val groupWithRules = GroupWithReplaceRule(it.group, subList) + resultList.add(groupWithRules) + + it.list.forEach { rule -> + when (type) { + SearchType.GROUP_NAME -> { + if (it.group.name.contains(text)) subList.add(rule) + } + + SearchType.NAME -> { + if (rule.name.contains(text)) subList.add(rule) + } + + SearchType.PATTERN -> { + if (rule.pattern.contains(text)) subList.add(rule) + } + + SearchType.REPLACEMENT -> { + if (rule.replacement.contains(text)) subList.add(rule) + } + } + } + + if (subList.isEmpty()) + resultList.remove(groupWithRules) + } + + _list.value = resultList + } + + fun moveTop(rule: ReplaceRule) { + appDb.replaceRuleDao.update(rule.copy(order = 0)) + } + + fun moveBottom(rule: ReplaceRule) { + appDb.replaceRuleDao.update(rule.copy(order = appDb.replaceRuleDao.count)) + } + + fun deleteRule(rule: ReplaceRule) { + appDb.replaceRuleDao.delete(rule) + } + + fun deleteGroup(groupWithRules: GroupWithReplaceRule) { + appDb.replaceRuleDao.delete(*groupWithRules.list.toTypedArray()) + appDb.replaceRuleDao.deleteGroup(groupWithRules.group) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/SearchTextField.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/SearchTextField.kt new file mode 100644 index 000000000..544802d95 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/SearchTextField.kt @@ -0,0 +1,143 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import android.os.Parcelable +import androidx.annotation.StringRes +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.DenseTextField +import kotlinx.parcelize.Parcelize + +@Parcelize +internal enum class SearchType(@StringRes val strId: Int) : Parcelable { + NAME(R.string.display_name), + PATTERN(R.string.replace_rule), + REPLACEMENT(R.string.replacement), + GROUP_NAME(R.string.group_name), +} + +@Composable +internal fun SearchTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + searchType: SearchType, + onSearchTypeChange: (SearchType) -> Unit +) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleMedium) { + DenseTextField( + modifier = modifier, + value = value, + onValueChange = onValueChange, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + shape = MaterialTheme.shapes.extraLarge, + placeholder = { + Text(stringResource(id = searchType.strId), maxLines = 1) + }, + singleLine = true, + leadingIcon = { + var showTypeOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showTypeOptions = true }) { + Icon( + Icons.Default.AccountTree, stringResource(id = R.string.type) + ) + + DropdownMenu( + expanded = showTypeOptions, + onDismissRequest = { showTypeOptions = false }) { + + @Composable + fun RadioMenuItem( + isSelected: Boolean, + title: @Composable () -> Unit, + onClick: () -> Unit + ) { + DropdownMenuItem( + modifier = Modifier.semantics { + role = Role.RadioButton + }, + text = title, + onClick = { + showTypeOptions = false + onClick() + }, + leadingIcon = { + RadioButton( + modifier = Modifier.focusable(false), + selected = isSelected, + onClick = null + ) + } + ) + } + + SearchType.values().forEach { + RadioMenuItem( + isSelected = it == searchType, + title = { Text(stringResource(id = it.strId)) }, + onClick = { onSearchTypeChange(it) } + ) + } + + } + } + } + ) + } +} + +@Preview +@Composable +fun PreviewRuleSearchTextField() { + AppTheme { + Column { + Spacer(modifier = Modifier.height(64.dp)) + var value by remember { mutableStateOf("") } + var searchType by remember { mutableStateOf(SearchType.NAME) } + SearchTextField( + value = value, + onValueChange = { value = it }, + searchType = searchType, + onSearchTypeChange = { searchType = it }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/SortDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/SortDialog.kt new file mode 100644 index 000000000..0f58e5223 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/SortDialog.kt @@ -0,0 +1,54 @@ +package com.github.jing332.tts_server_android.compose.systts.replace + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.ListSortSettingsDialog +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule + +internal enum class SortType(@StringRes val strId: Int) { + CREATE_TIME(R.string.created_time_id), + NAME(R.string.display_name), + PATTERN(R.string.replace_rule), + REPLACEMENT(R.string.replacement), + + ENABLED(R.string.enabled), + USE_REGEX(R.string.systts_replace_use_regex), +} + +@Composable +internal fun SortDialog(onDismissRequest: () -> Unit, list: List) { + var index by remember { mutableIntStateOf(0) } + ListSortSettingsDialog( + name = list.size.toString(), + onDismissRequest = onDismissRequest, + index = index, + onIndexChange = { index = it }, + entries = SortType.values().map { stringResource(id = it.strId) }, + onConfirm = { _, descending -> + val sortedList = when (SortType.values()[index]) { + SortType.CREATE_TIME -> list.sortedBy { it.id } + SortType.NAME -> list.sortedBy { it.name } + SortType.PATTERN -> list.sortedBy { it.pattern } + SortType.REPLACEMENT -> list.sortedBy { it.replacement } + + SortType.ENABLED -> list.sortedByDescending { it.isEnabled } + SortType.USE_REGEX -> list.sortedByDescending { it.isRegex } + }.run { + if (descending) this.reversed() else this + } + + appDb.replaceRuleDao.update( + *sortedList.mapIndexed { i, rule -> + rule.copy(order = i) + }.toTypedArray() + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/ReplaceRuleEditScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/ReplaceRuleEditScreen.kt new file mode 100644 index 000000000..5902fee5e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/ReplaceRuleEditScreen.kt @@ -0,0 +1,504 @@ +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) + +package com.github.jing332.tts_server_android.compose.systts.replace.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Abc +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.systts.AuditionDialog +import com.github.jing332.tts_server_android.compose.widgets.AppSpinner +import com.github.jing332.tts_server_android.compose.widgets.TextCheckBox +import com.github.jing332.tts_server_android.conf.ReplaceRuleConfig +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import androidx.compose.material3.AlertDialog as AlertDialog1 + + +@Preview +@Composable +fun PreviewRuleEditScreen() { + var rule by remember { + mutableStateOf( + ReplaceRule( + name = "test", + pattern = "test", + replacement = "test", + isRegex = false, + ) + ) + } + RuleEditScreen( + rule = rule, + onRuleChange = { rule = it }, + groups = listOf( + ReplaceRuleGroup(0, "name1"), + ReplaceRuleGroup(0, "name2") + ), + onSave = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RuleEditScreen( + rule: ReplaceRule, + onRuleChange: (ReplaceRule) -> Unit, + groups: List = remember { appDb.replaceRuleDao.allGroup }, + onSave: () -> Unit, +) { + val group = remember(rule.groupId) { groups.find { it.id == rule.groupId } ?: groups.first() } + + val vm: RuleEditViewModel = viewModel() + val inputKeyState = remember { mutableStateOf("") } +// var toolbarKeyList: List> by rememberDataSaverState( +// key = ConfigConst.KEY_SOFT_KEYBOARD_TOOLBAR, +// default = emptyList() +// ) + var toolBarSymbols by remember { ReplaceRuleConfig.symbols } + var showToolbarSettingsDialog by remember { mutableStateOf(false) } + if (showToolbarSettingsDialog) { + ToolBarSettingsDialog( + onDismissRequest = { showToolbarSettingsDialog = false }, + symbols = toolBarSymbols, + onSave = { + toolBarSymbols = it + showToolbarSettingsDialog = false + }, + onReset = { + toolBarSymbols = ReplaceRuleConfig.defaultSymbols + showToolbarSettingsDialog = false + } + ) + } + + val navController = LocalNavController.current + Scaffold(modifier = Modifier, topBar = { + TopAppBar(modifier = Modifier.fillMaxWidth(), + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = Icons.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.nav_back) + ) + } + }, + title = { Text(text = stringResource(id = R.string.replace_rule)) }, +// colors = TopAppBarDefaults.topAppBarColors( +// containerColor = MaterialTheme.colorScheme.primaryContainer, +// titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer +// ), + actions = { + IconButton(onClick = { + navController.popBackStack() + onSave() + }) { + Icon(Icons.Filled.Save, stringResource(id = R.string.save)) + } + +// IconButton(onClick = {}) { +// Icon( +// Icons.Filled.MoreVert, stringResource(id = R.string.more_options) +// ) +// } + }) + }, bottomBar = { + SoftKeyboardInputToolbar(symbols = toolBarSymbols, onClick = { + inputKeyState.value = it + }, onSettings = { + showToolbarSettingsDialog = true + }) + }, content = { paddingValues -> + Surface( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Screen( + inputKeyState, + group = group, + groupKeys = groups, + groupValues = groups.map { it.name }, + onGroupChange = { + onRuleChange.invoke(rule.copy(groupId = it.id)) + }, + + name = rule.name, + onNameChange = { + onRuleChange.invoke(rule.copy(name = it)) + }, + + patternValue = rule.pattern, + onReplaceValueChange = { + onRuleChange.invoke(rule.copy(pattern = it)) + }, + + replacementValue = rule.replacement, + onReplacementValueChange = { + onRuleChange.invoke(rule.copy(replacement = it)) + }, + + isRegex = rule.isRegex, + onIsRegexChange = { + onRuleChange.invoke(rule.copy(isRegex = it)) + }, + + sampleText = rule.sampleText, + onSampleTextChange = { + onRuleChange.invoke(rule.copy(sampleText = it)) + }, + + onTest = { + (try { + vm.doReplace(rule, it) + } catch (e: Exception) { + e.message ?: "" + }) + }, + ) + } + } + ) +} + +private object InputFieldID { + const val NAME = "name" + const val PATTERN = "pattern" + const val REPLACEMENT = "replacement" + const val SAMPLE_TEXT = "sample_text" +} + +/** + * 插入文本到当前光标前方 + */ +fun TextFieldValue.newValueOfInsertText( + text: String, cursorPosition: Int = selection.end +): TextFieldValue { + val newText = StringBuilder(this.text).insert(cursorPosition, text).toString() + return TextFieldValue(newText, TextRange(cursorPosition + text.length)) +} + +@Composable +private fun Screen( + insertKeyState: MutableState, + group: ReplaceRuleGroup, + groupKeys: List, + groupValues: List, + onGroupChange: (ReplaceRuleGroup) -> Unit, + + name: String, + onNameChange: (String) -> Unit, + patternValue: String, + onReplaceValueChange: (String) -> Unit, + replacementValue: String, + onReplacementValueChange: (String) -> Unit, + isRegex: Boolean, + onIsRegexChange: (Boolean) -> Unit, + + sampleText: String, + onSampleTextChange: (String) -> Unit, + + onTest: (String) -> String, +) { + var isVisiblePinyinDialog by remember { mutableStateOf(false) } + if (isVisiblePinyinDialog) PinyinDialog({ isVisiblePinyinDialog = false }, onInput = { + isVisiblePinyinDialog = false + insertKeyState.value = it + }) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppSpinner( + label = { Text(text = stringResource(R.string.belonging_group)) }, + value = group, + values = groupKeys, + entries = groupValues, + onSelectedChange = { value, _ -> + onGroupChange.invoke(value as ReplaceRuleGroup) + } + ) + + TextFieldInsert( + label = { Text(stringResource(R.string.name)) }, + value = name, + onValueChange = onNameChange, + modifier = Modifier + .fillMaxWidth(), + inertKeyState = insertKeyState, + ) + + TextFieldInsert( + label = { Text(stringResource(R.string.replace_rule)) }, + value = patternValue, + onValueChange = onReplaceValueChange, + modifier = Modifier + .fillMaxWidth(), + inertKeyState = insertKeyState, + trailingIcon = { + IconButton(onClick = { isVisiblePinyinDialog = true }) { + Icon(Icons.Filled.Abc, stringResource(R.string.systts_replace_insert_pinyin)) + } + } + ) + + TextFieldInsert( + label = { Text(stringResource(R.string.replacement)) }, + value = replacementValue, + onValueChange = onReplacementValueChange, + modifier = Modifier + .fillMaxWidth(), + inertKeyState = insertKeyState, + trailingIcon = { + IconButton(onClick = { isVisiblePinyinDialog = true }) { + Icon(Icons.Filled.Abc, stringResource(R.string.systts_replace_insert_pinyin)) + } + } + ) + + TextCheckBox(text = { + Text(text = stringResource(R.string.systts_replace_use_regex)) + }, checked = isRegex, onCheckedChange = onIsRegexChange) + + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) + + var testResult by remember { mutableStateOf("") } + TextFieldInsert( + label = { Text(stringResource(R.string.test)) }, + value = sampleText, + onValueChange = { + onSampleTextChange(it) + testResult = onTest(it) + }, + modifier = Modifier + .fillMaxWidth(), + inertKeyState = insertKeyState, + trailingIcon = { + var showAuditionDialog by remember { mutableStateOf(null) } + if (showAuditionDialog != null) { + AuditionDialog( + onDismissRequest = { showAuditionDialog = null }, + systts = showAuditionDialog!!, + text = testResult, + ) + } + + var showTtsSelectDialog by remember { mutableStateOf(false) } + if (showTtsSelectDialog) { + SysttsSelectBottomSheet(onDismissRequest = { showTtsSelectDialog = false }) { + showTtsSelectDialog = false + showAuditionDialog = it + } + } + + AnimatedVisibility(visible = testResult.isNotBlank()) { + IconButton(onClick = { + showTtsSelectDialog = true + }) { + Icon(Icons.Filled.Headset, stringResource(R.string.click_play)) + } + } + } + ) + + if (sampleText.isNotEmpty()) Text(stringResource(R.string.label_result)) + SelectionContainer { + Text(text = testResult, style = MaterialTheme.typography.bodyMedium) + } + } +} + + +@Composable +fun TextFieldInsert( + modifier: Modifier = Modifier, + label: @Composable (() -> Unit)? = null, + value: String, + onValueChange: (String) -> Unit, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + + inertKeyState: MutableState, +) { + var fieldValue by remember() { mutableStateOf(TextFieldValue()) } + LaunchedEffect(key1 = value) { + fieldValue = fieldValue.copy(text = value) + } + var isFocused by remember { mutableStateOf(false) } + LaunchedEffect(key1 = inertKeyState.value) { + if (!isFocused || inertKeyState.value.isEmpty()) return@LaunchedEffect + + fieldValue = fieldValue.newValueOfInsertText(inertKeyState.value) + onValueChange(fieldValue.text) + + inertKeyState.value = "" + } + OutlinedTextField( + label = label, + value = fieldValue, + modifier = modifier + .onFocusChanged { + isFocused = it.isFocused + }, + onValueChange = { + fieldValue = it + onValueChange.invoke(it.text) + }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) +} + +@Composable +private fun PinyinDialog(onDismissRequest: () -> Unit, onInput: (text: String) -> Unit) { + val pinyinList = remember { + listOf( + listOf( + "ā-a-1声", + "á-a-2声", + "ǎ-a-3声", + "à-a-4声", + ), listOf( + "ê-e-?声", + "ē-e-1声", + "é-e-2声", + "ě-e-3声", + "è-e-4声", + ), listOf( + "ī-i-1声", + "í-i-2声", + "ǐ-i-3声", + "ì-i-4声", + ), listOf( + "ō-o-1声", + "ó-o-2声", + "ǒ-o-3声", + "ò-o-4声", + ), listOf( + "ū-u-1声", + "ú-u-2声", + "ǔ-u-3声", + "ù-u-4声", + ), listOf( + "ǖ-v-1声", "ǘ-v-2声", "ǚ-v-3声", "ǜ-v-4声" + ) + ) + } + + AlertDialog1(onDismissRequest = onDismissRequest) { + Surface( + color = AlertDialogDefaults.containerColor, + shape = AlertDialogDefaults.shape, + ) { + Column { + Text( + text = stringResource(id = R.string.systts_replace_insert_pinyin), + modifier = Modifier + .padding(8.dp) + .align(Alignment.CenterHorizontally), + style = MaterialTheme.typography.headlineSmall, + ) + + LazyColumn { + items(pinyinList) { item -> + Row( + modifier = Modifier + .padding(8.dp) + .horizontalScroll(rememberScrollState()) + ) { + item.forEach { + TextButton( + modifier = Modifier.semantics { + text = AnnotatedString(it) + }, + onClick = { onInput.invoke(it[0].toString()) }) { + Text( + text = it[0].toString(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge + ) + } + } + } + } + } + + TextButton( + modifier = Modifier + .align(Alignment.End) + .padding(8.dp) + .padding(end = 16.dp), + onClick = { onDismissRequest.invoke() }) { + Text(text = stringResource(id = R.string.cancel)) + } + + } + } + } + +} + +@Preview +@Composable +fun PreviewPinyinDialog() { + var isVisible by remember { mutableStateOf(true) } + if (isVisible) PinyinDialog({ isVisible = false }, onInput = {}) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/RuleEditViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/RuleEditViewModel.kt new file mode 100644 index 000000000..d4608fe5f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/RuleEditViewModel.kt @@ -0,0 +1,13 @@ +package com.github.jing332.tts_server_android.compose.systts.replace.edit + +import androidx.lifecycle.ViewModel +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule + +class RuleEditViewModel : ViewModel() { + fun doReplace(rule: ReplaceRule, text: String): String { + return if (rule.isRegex) + Regex(rule.pattern).replace(text, rule.replacement) + else + text.replace(rule.pattern, rule.replacement) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/SoftKeyboardToolbar.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/SoftKeyboardToolbar.kt new file mode 100644 index 000000000..a8e9870ea --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/SoftKeyboardToolbar.kt @@ -0,0 +1,73 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.github.jing332.tts_server_android.compose.systts.replace.edit + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R + +@Composable +fun SoftKeyboardInputToolbar( + symbols: LinkedHashMap, + onClick: (chars: String) -> Unit, + + onSettings: () -> Unit = {}, +) { + Column { + HorizontalDivider(Modifier.fillMaxWidth()) + LazyRow(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(symbols.toList()) { index, entry -> + Text( + text = entry.second, + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = true), + onClick = { + onClick.invoke(entry.first) + } + ) + .size(width = Dp.Unspecified, height = 48.dp) + .widthIn(48.dp) + .wrapContentHeight(Alignment.CenterVertically), + textAlign = TextAlign.Center, + ) + } + item { + IconButton(onClick = onSettings, modifier = Modifier.size(48.dp)) { + Icon(Icons.Filled.Settings, stringResource(id = R.string.settings)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/ToolBarSettingsDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/ToolBarSettingsDialog.kt new file mode 100644 index 000000000..33bdc6792 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/ToolBarSettingsDialog.kt @@ -0,0 +1,70 @@ +package com.github.jing332.tts_server_android.compose.systts.replace.edit + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppDialog + +@Composable +fun ToolBarSettingsDialog( + onDismissRequest: () -> Unit, + symbols: LinkedHashMap, + onSave: (LinkedHashMap) -> Unit, + onReset: () -> Unit +) { + var text by remember(symbols) { + mutableStateOf(symbols.map { "${it.key} = ${it.value}" }.joinToString("\n")) + } + + AppDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.keyboard_toolbar_settings)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.keyboard_toolbar_settings_tips)) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = text, + onValueChange = { text = it } + ) + } + }, + buttons = { + Row(Modifier.fillMaxWidth()) { + TextButton(onClick = onReset) { + Text(stringResource(id = R.string.reset)) + } + Spacer(modifier = Modifier.weight(1f)) + Row { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = R.string.cancel)) + } + + TextButton(onClick = { + onSave( + text.split("\n").associate { + val (key, value) = it.split(" = ") + key to value + } as LinkedHashMap + ) + }) { + Text(stringResource(id = R.string.save)) + } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/TtsConfigSelectDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/TtsConfigSelectDialog.kt new file mode 100644 index 000000000..82ffccbb0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/replace/edit/TtsConfigSelectDialog.kt @@ -0,0 +1,71 @@ +package com.github.jing332.tts_server_android.compose.systts.replace.edit + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.AppBottomSheet +import com.github.jing332.tts_server_android.compose.widgets.HtmlText +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.utils.clickableRipple + +@Preview +@Composable +private fun PreviewTtsConfigSelectDialog() { + AppTheme { + var show by remember { mutableStateOf(true) } + if (show) + SysttsSelectBottomSheet(onDismissRequest = { show = false }, {}) + } +} + +@Composable +internal fun SysttsSelectBottomSheet(onDismissRequest: () -> Unit, onClick: (SystemTts) -> Unit) { + val items = remember { appDb.systemTtsDao.allEnabledTts } + AppBottomSheet(onDismissRequest = onDismissRequest) { + Column(Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = stringResource(id = R.string.choice_item, ""), style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(8.dp)) + LazyColumn(Modifier.weight(1f)) { + itemsIndexed(items) { _, systts -> + Column( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickableRipple { + onClick(systts) + } + ) { + Text( + text = systts.displayName ?: "", + style = MaterialTheme.typography.titleMedium + ) + HtmlText( + text = systts.tts.getDescription(), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/NavRoutes.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/NavRoutes.kt new file mode 100644 index 000000000..c797bf5c3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/NavRoutes.kt @@ -0,0 +1,8 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +internal sealed class NavRoutes(val id: String) { + data object SpeechRuleManager : NavRoutes("manager") + data object SpeechRuleEdit : NavRoutes("edit") { + const val KEY_DATA = "data" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleEditScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleEditScreen.kt new file mode 100644 index 000000000..6d86b1701 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleEditScreen.kt @@ -0,0 +1,108 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.codeeditor.CodeEditorScreen +import com.github.jing332.tts_server_android.compose.codeeditor.LoggerBottomSheet +import com.github.jing332.tts_server_android.compose.widgets.TextFieldDialog +import com.github.jing332.tts_server_android.conf.SpeechRuleConfig +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText +import io.github.rosemoe.sora.widget.CodeEditor + +@Composable +internal fun SpeechRuleEditScreen( + rule: SpeechRule, + onSave: (SpeechRule) -> Unit, + vm: SpeechRuleEditViewModel = viewModel() +) { + val navController = LocalNavController.current + val context = LocalContext.current + var codeEditor by remember { mutableStateOf(null) } + + LaunchedEffect(vm, codeEditor) { + vm.init(rule, context.assets.open("defaultData/speech_rule.js").readAllText()) + } + + val code by vm.codeLiveData.asFlow().collectAsState(initial = "") + LaunchedEffect(code, codeEditor) { + if (codeEditor != null && code.isNotEmpty()) + codeEditor?.setText(code) + } + + var showTextParamDialog by remember { mutableStateOf(false) } + if (showTextParamDialog) { + var textParam by remember { mutableStateOf(SpeechRuleConfig.textParam.value) } + TextFieldDialog( + title = stringResource(id = R.string.set_sample_text_param), + text = textParam, + onDismissRequest = { showTextParamDialog = false }, + onTextChange = { textParam = it }, + onConfirm = { + SpeechRuleConfig.textParam.value = textParam + showTextParamDialog = false + } + ) + } + + var showDebugLogger by remember { mutableStateOf(false) } + if (showDebugLogger) { + LoggerBottomSheet(logger = vm.logger, onDismissRequest = { showDebugLogger = false }) { + runCatching { + vm.code = codeEditor!!.text.toString() + vm.debug(SpeechRuleConfig.textParam.value) + }.onFailure { + context.displayErrorDialog(it) + } + } + } + + CodeEditorScreen( + title = { Text(stringResource(id = R.string.speech_rule)) }, + onBack = { navController.popBackStack() }, + onDebug = { showDebugLogger = true }, + onSave = { + runCatching { + vm.code = codeEditor!!.text.toString() + vm.evalRuleInfo() + + onSave(vm.speechRule) + navController.popBackStack() + }.onFailure { + context.displayErrorDialog(it) + } + }, + onUpdate = { codeEditor = it }, + onSaveFile = { + "ttsrv-speechRule-${vm.speechRule.name}.js" to codeEditor!!.text.toString().toByteArray() + } + ) { dismiss -> + DropdownMenuItem( + text = { Text(stringResource(id = R.string.set_sample_text_param)) }, + onClick = { + dismiss() + showTextParamDialog = true + }, + leadingIcon = { + Icon(Icons.Default.TextFields, null) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleEditViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleEditViewModel.kt new file mode 100644 index 000000000..503f66081 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleEditViewModel.kt @@ -0,0 +1,85 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.model.rhino.ExceptionExt.lineMessage +import com.github.jing332.tts_server_android.model.rhino.core.Logger +import com.github.jing332.tts_server_android.model.rhino.speech_rule.SpeechRuleEngine +import com.script.ScriptException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class SpeechRuleEditViewModel : ViewModel() { + private val _codeLiveData: MutableLiveData = MutableLiveData() + val codeLiveData: LiveData + get() = _codeLiveData + + private lateinit var mSpeechRule: SpeechRule + private lateinit var mRuleEngine: SpeechRuleEngine + + val logger: Logger + get() = mRuleEngine.logger + + val speechRule: SpeechRule + get() = mSpeechRule + + var code: String + get() = mRuleEngine.code + set(value) { + mRuleEngine.code = value + mSpeechRule.code = value + } + + fun init(speechRule: SpeechRule, defaultCode: String) { + mSpeechRule = speechRule + + if (mSpeechRule.code.isBlank()) mSpeechRule.code = defaultCode + mRuleEngine = SpeechRuleEngine(app, mSpeechRule, mSpeechRule.code, Logger()) + + _codeLiveData.value = mSpeechRule.code + } + + fun evalRuleInfo() { + mRuleEngine.evalInfo() + } + + fun debug(text: String) { + evalRuleInfo() + viewModelScope.launch(Dispatchers.IO) { + kotlin.runCatching { + logger.i("handleText()...") + + val rules = + appDb.systemTtsDao.getEnabledListForSort(SpeechTarget.CUSTOM_TAG).map { + it.speechRule.apply { configId = it.id } + } + val list = mRuleEngine.handleText(text, rules) + try { + list.forEach { + val texts = mRuleEngine.splitText(it.text) + logger.i( + "\ntag=${it.tag}, id=${it.id}, text=${it.text.trim()}, splittedTexts=${ + texts.joinToString(" | ").trim() + }" + ) + } + } catch (_: NoSuchMethodException) { + } + }.onFailure { + if (it is ScriptException) { + logger.e(it.lineMessage()) + } else { + logger.e(it.stackTraceToString()) + } + + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleExportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleExportBottomSheet.kt new file mode 100644 index 000000000..7a3040405 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleExportBottomSheet.kt @@ -0,0 +1,16 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +import androidx.compose.runtime.Composable +import com.github.jing332.tts_server_android.compose.systts.ConfigExportBottomSheet +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import kotlinx.serialization.encodeToString + +@Composable +fun SpeechRuleExportBottomSheet(onDismissRequest: () -> Unit, list: List) { + ConfigExportBottomSheet( + onDismissRequest = onDismissRequest, + json = AppConst.jsonBuilder.encodeToString(list), + fileName = if (list.size == 1) "ttsrv-speechRule-${list[0].name}.json" else "ttsrv-speechRules.json" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleImportBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleImportBottomSheet.kt new file mode 100644 index 000000000..6ba2a586b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleImportBottomSheet.kt @@ -0,0 +1,34 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.jing332.tts_server_android.compose.systts.ConfigImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.ConfigModel +import com.github.jing332.tts_server_android.compose.systts.SelectImportConfigDialog +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.SpeechRule + +@Composable +fun SpeechRuleImportBottomSheet(onDismissRequest: () -> Unit) { + var showSelectDialog by remember { mutableStateOf?>(null) } + if (showSelectDialog != null) { + val list = showSelectDialog!! + SelectImportConfigDialog( + onDismissRequest = { showSelectDialog = null }, + models = list.map { ConfigModel(true, it.name, "${it.author} - v${it.version}", it) }, + onSelectedList = { + appDb.speechRuleDao.insert(*it.map { speechRule -> speechRule as SpeechRule }.toTypedArray()) + + it.size + } + ) + } + + ConfigImportBottomSheet(onDismissRequest = onDismissRequest, onImport = { + showSelectDialog = AppConst.jsonBuilder.decodeFromString>(it) + }) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleManagerActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleManagerActivity.kt new file mode 100644 index 000000000..e0991611e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleManagerActivity.kt @@ -0,0 +1,78 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.navigate +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.SpeechRule + +class SpeechRuleManagerActivity : AppCompatActivity() { + private var jsCode by mutableStateOf("") + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (intent != null) importJsCodeFromIntent(intent) + + setContent { + AppTheme { + val navController = rememberNavController() + CompositionLocalProvider(LocalNavController provides navController) { + LaunchedEffect(jsCode) { + if (jsCode.isNotBlank()) { + navController.navigate(NavRoutes.SpeechRuleEdit.id, argsBuilder = { + putParcelable( + NavRoutes.SpeechRuleEdit.KEY_DATA, SpeechRule(code = jsCode) + ) + }) + } + } + + NavHost( + navController = navController, + startDestination = NavRoutes.SpeechRuleManager.id + ) { + composable(NavRoutes.SpeechRuleManager.id) { + SpeechRuleManagerScreen { finishAfterTransition() } + } + + composable(NavRoutes.SpeechRuleEdit.id) { + val rule = remember { + it.arguments?.getParcelable(NavRoutes.SpeechRuleEdit.KEY_DATA) + ?: SpeechRule() + } + SpeechRuleEditScreen(rule, onSave = { + appDb.speechRuleDao.insert(it) + }) + } + } + } + } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + if (intent != null) importJsCodeFromIntent(intent) + } + + private fun importJsCodeFromIntent(intent: Intent) { + jsCode = intent.getStringExtra("js") ?: return + intent.removeExtra("js") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleManagerScreen.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleManagerScreen.kt new file mode 100644 index 000000000..06ef15832 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/systts/speechrule/SpeechRuleManagerScreen.kt @@ -0,0 +1,315 @@ +package com.github.jing332.tts_server_android.compose.systts.speechrule + +import android.content.Intent +import android.os.Bundle +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AppShortcut +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Input +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Output +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.LocalNavController +import com.github.jing332.tts_server_android.compose.ShadowReorderableItem +import com.github.jing332.tts_server_android.compose.navigate +import com.github.jing332.tts_server_android.compose.systts.ConfigDeleteDialog +import com.github.jing332.tts_server_android.compose.systts.plugin.PluginManagerActivity +import com.github.jing332.tts_server_android.compose.widgets.LazyListIndexStateSaver +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.utils.MyTools +import kotlinx.coroutines.flow.conflate +import org.burnoutcrew.reorderable.detectReorderAfterLongPress +import org.burnoutcrew.reorderable.rememberReorderableLazyListState +import org.burnoutcrew.reorderable.reorderable +import java.util.Collections + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SpeechRuleManagerScreen(finish: () -> Unit) { + val navController = LocalNavController.current + val context = LocalContext.current + + var showImportSheet by remember { mutableStateOf(false) } + if (showImportSheet) + SpeechRuleImportBottomSheet { showImportSheet = false } + + var showExportSheet by remember { mutableStateOf?>(null) } + if (showExportSheet != null) + SpeechRuleExportBottomSheet( + onDismissRequest = { showExportSheet = null }, + list = showExportSheet!!, + ) + + var showDeleteDialog by remember { mutableStateOf(null) } + if (showDeleteDialog != null) + ConfigDeleteDialog( + onDismissRequest = { showDeleteDialog = null }, + name = showDeleteDialog!!.name + ) { + appDb.speechRuleDao.delete(showDeleteDialog!!) + showDeleteDialog = null + } + + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.speech_rule_manager)) }, + navigationIcon = { + IconButton(onClick = finish) { + Icon(Icons.Default.ArrowBack, stringResource(id = R.string.nav_back)) + } + }, + + actions = { + IconButton(onClick = { + navController.navigate(NavRoutes.SpeechRuleEdit.id) + }) { + Icon(Icons.Default.Add, stringResource(id = R.string.add_config)) + } + + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(id = R.string.more_options)) + + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.import_config)) }, + onClick = { + showOptions = false + showImportSheet = true + }, + leadingIcon = { Icon(Icons.Default.Input, null) } + ) + + DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + showExportSheet = appDb.speechRuleDao.allEnabled + }, + leadingIcon = { + Icon(Icons.Default.Output, null) + } + ) + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.desktop_shortcut)) }, + onClick = { + showOptions = false + MyTools.addShortcut( + context, + context.getString(R.string.speech_rule_manager), + "speech_rule", + R.drawable.ic_shortcut_speech_rule, + Intent(context, SpeechRuleManagerActivity::class.java) + ) + }, + leadingIcon = { Icon(Icons.Default.AppShortcut, null) } + ) + } + } + } + + ) + } + ) { paddingValues -> + LaunchedEffect(Unit) { + appDb.speechRuleDao.all.forEachIndexed { index, speechRule -> + appDb.speechRuleDao.update(speechRule.copy(order = index)) + } + } + + val flowAll = remember { appDb.speechRuleDao.flowAll().conflate() } + val list by flowAll.collectAsState(initial = emptyList()) + + val listState = remember { LazyListState() } + LazyListIndexStateSaver( + models = list, + listState = listState, + ) + + val reorderState = + rememberReorderableLazyListState(listState = listState, onMove = { from, to -> + val mutList = list.toMutableList() + Collections.swap(mutList, from.index, to.index) + mutList.forEachIndexed { index, speechRule -> + if (speechRule.order != index) + appDb.speechRuleDao.update(speechRule.copy(order = index)) + } + }) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .reorderable(reorderState), + state = reorderState.listState, + ) { + itemsIndexed(list, key = { _, v -> v.id }) { index, item -> + ShadowReorderableItem(reorderableState = reorderState, key = item.id) { + Item( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .detectReorderAfterLongPress(reorderState), + name = item.name, + desc = "${item.author} - v${item.version}", + isEnabled = item.isEnabled, + onEnabledChange = { appDb.speechRuleDao.update(item.copy(isEnabled = it)) }, + onClick = { + + }, + onEdit = { + navController.navigate(NavRoutes.SpeechRuleEdit.id, Bundle().apply { + putParcelable(NavRoutes.SpeechRuleEdit.KEY_DATA, item) + }) + }, + onExport = { showExportSheet = listOf(item) }, + onDelete = { showDeleteDialog = item } + ) + } + } + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun Item( + modifier: Modifier, + name: String, + desc: String, + isEnabled: Boolean, + onEnabledChange: (Boolean) -> Unit, + onClick: () -> Unit, + onEdit: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, +) { + val context = LocalContext.current + ElevatedCard(modifier = modifier, onClick = onClick) { + Box(modifier = Modifier.padding(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = isEnabled, + onCheckedChange = onEnabledChange, + modifier = Modifier.semantics { + role = Role.Switch + context + .getString( + if (isEnabled) R.string.rule_enabled_desc else R.string.rule_disabled_desc, + name + ) + .let { + contentDescription = it + stateDescription = it + } + } + ) + Column(Modifier.weight(1f)) { + Text(text = name, style = MaterialTheme.typography.titleMedium) + Text(text = desc, style = MaterialTheme.typography.bodyMedium) + } + Row { + IconButton(onClick = onEdit) { + Icon(Icons.Default.Edit, stringResource(id = R.string.edit_desc, name)) + } + + var showOptions by remember { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon( + Icons.Default.MoreVert, + stringResource(id = R.string.more_options_desc, name) + ) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + + + DropdownMenuItem( + text = { Text(stringResource(id = R.string.export_config)) }, + onClick = { + showOptions = false + onExport() + }, + leadingIcon = { + Icon(Icons.Default.Output, null) + } + ) + + HorizontalDivider() + + DropdownMenuItem( + text = { + Text( + stringResource(id = R.string.delete), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showOptions = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.DeleteForever, + null, + tint = MaterialTheme.colorScheme.error + ) + } + ) + } + } + + } + } + + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/AppTheme.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/AppTheme.kt new file mode 100644 index 000000000..2de52e106 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/AppTheme.kt @@ -0,0 +1,18 @@ +package com.github.jing332.tts_server_android.compose.theme + +import androidx.compose.ui.graphics.Color +import com.github.jing332.tts_server_android.R + +enum class AppTheme(val id: String, val stringResId: Int = -1, val color: Color) { + DEFAULT("", R.string.theme_default, green_seed), + DYNAMIC_COLOR("dynamicColor", R.string.dynamic_color, Color.Unspecified), + GREEN("green", R.string.green, green_seed), + RED("red", R.string.red, red_seed), + PINK("pink", R.string.pink, pink_seed), + BLUE("blue", R.string.blue, blue_seed), + CYAN("cyan", R.string.cyan, cyan_seed), + ORANGE("orange", R.string.orange, orange_seed), + PURPLE("purple", R.string.purple, purple_seed), + BROWN("brown", R.string.brown, brown_seed), + GRAY("gray", R.string.gray, gray_seed), +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/AppThemeConfiguration.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/AppThemeConfiguration.kt new file mode 100644 index 000000000..50cf322b0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/AppThemeConfiguration.kt @@ -0,0 +1,883 @@ +package com.github.jing332.tts_server_android.compose.theme + +import android.content.Context +import android.os.Build +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.TweenSpec +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** + * 默认主题 + */ +@Composable +fun defaultTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = bean_green_seed, + onPrimary = bean_green_md_theme_light_onPrimary, + primaryContainer = bean_green_md_theme_light_primaryContainer, + onPrimaryContainer = bean_green_md_theme_light_onPrimaryContainer, + secondary = bean_green_md_theme_light_secondary, + onSecondary = bean_green_md_theme_light_onSecondary, + secondaryContainer = bean_green_md_theme_light_secondaryContainer, + onSecondaryContainer = bean_green_md_theme_light_onSecondaryContainer, + tertiary = bean_green_md_theme_light_tertiary, + onTertiary = bean_green_md_theme_light_onTertiary, + tertiaryContainer = bean_green_md_theme_light_tertiaryContainer, + onTertiaryContainer = bean_green_md_theme_light_onTertiaryContainer, + error = bean_green_md_theme_light_error, + errorContainer = bean_green_md_theme_light_errorContainer, + onError = bean_green_md_theme_light_onError, + onErrorContainer = bean_green_md_theme_light_onErrorContainer, + background = bean_green_md_theme_light_background, + onBackground = bean_green_md_theme_light_onBackground, + outline = bean_green_md_theme_light_outline, + inverseOnSurface = bean_green_md_theme_light_inverseOnSurface, + inverseSurface = bean_green_md_theme_light_inverseSurface, + inversePrimary = bean_green_md_theme_light_inversePrimary, + surfaceTint = bean_green_md_theme_light_surfaceTint, + outlineVariant = bean_green_md_theme_light_outlineVariant, + scrim = bean_green_md_theme_light_scrim, + surface = bean_green_md_theme_light_surface, + onSurface = bean_green_md_theme_light_onSurface, + surfaceVariant = bean_green_md_theme_light_surfaceVariant, + onSurfaceVariant = bean_green_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = bean_green_md_theme_dark_primary, + onPrimary = bean_green_md_theme_dark_onPrimary, + primaryContainer = bean_green_md_theme_dark_primaryContainer, + onPrimaryContainer = bean_green_md_theme_dark_onPrimaryContainer, + secondary = bean_green_md_theme_dark_secondary, + onSecondary = bean_green_md_theme_dark_onSecondary, + secondaryContainer = bean_green_md_theme_dark_secondaryContainer, + onSecondaryContainer = bean_green_md_theme_dark_onSecondaryContainer, + tertiary = bean_green_md_theme_dark_tertiary, + onTertiary = bean_green_md_theme_dark_onTertiary, + tertiaryContainer = bean_green_md_theme_dark_tertiaryContainer, + onTertiaryContainer = bean_green_md_theme_dark_onTertiaryContainer, + error = bean_green_md_theme_dark_error, + errorContainer = bean_green_md_theme_dark_errorContainer, + onError = bean_green_md_theme_dark_onError, + onErrorContainer = bean_green_md_theme_dark_onErrorContainer, + background = bean_green_md_theme_dark_background, + onBackground = bean_green_md_theme_dark_onBackground, + outline = bean_green_md_theme_dark_outline, + inverseOnSurface = bean_green_md_theme_dark_inverseOnSurface, + inverseSurface = bean_green_md_theme_dark_inverseSurface, + inversePrimary = bean_green_md_theme_dark_inversePrimary, + surfaceTint = bean_green_md_theme_dark_surfaceTint, + outlineVariant = bean_green_md_theme_dark_outlineVariant, + scrim = bean_green_md_theme_dark_scrim, + surface = bean_green_md_theme_dark_surface, + onSurface = bean_green_md_theme_dark_onSurface, + surfaceVariant = bean_green_md_theme_dark_surfaceVariant, + onSurfaceVariant = bean_green_md_theme_dark_onSurfaceVariant, + ) + + +/** + * 动态颜色主题 + */ +@Composable +fun dynamicColorTheme( + darkTheme: Boolean, + context: Context = LocalContext.current +): ColorScheme = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + else + defaultTheme(darkTheme) + +/** + * 绿色 + */ +@Composable +fun greenTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = green_seed, + onPrimary = green_md_theme_light_onPrimary, + primaryContainer = green_md_theme_light_primaryContainer, + onPrimaryContainer = green_md_theme_light_onPrimaryContainer, + secondary = green_md_theme_light_secondary, + onSecondary = green_md_theme_light_onSecondary, + secondaryContainer = green_md_theme_light_secondaryContainer, + onSecondaryContainer = green_md_theme_light_onSecondaryContainer, + tertiary = green_md_theme_light_tertiary, + onTertiary = green_md_theme_light_onTertiary, + tertiaryContainer = green_md_theme_light_tertiaryContainer, + onTertiaryContainer = green_md_theme_light_onTertiaryContainer, + error = green_md_theme_light_error, + errorContainer = green_md_theme_light_errorContainer, + onError = green_md_theme_light_onError, + onErrorContainer = green_md_theme_light_onErrorContainer, + background = green_md_theme_light_background, + onBackground = green_md_theme_light_onBackground, + outline = green_md_theme_light_outline, + inverseOnSurface = green_md_theme_light_inverseOnSurface, + inverseSurface = green_md_theme_light_inverseSurface, + inversePrimary = green_md_theme_light_inversePrimary, + surfaceTint = green_md_theme_light_surfaceTint, + outlineVariant = green_md_theme_light_outlineVariant, + scrim = green_md_theme_light_scrim, + surface = green_md_theme_light_surface, + onSurface = green_md_theme_light_onSurface, + surfaceVariant = green_md_theme_light_surfaceVariant, + onSurfaceVariant = green_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = green_md_theme_dark_primary, + onPrimary = green_md_theme_dark_onPrimary, + primaryContainer = green_md_theme_dark_primaryContainer, + onPrimaryContainer = green_md_theme_dark_onPrimaryContainer, + secondary = green_md_theme_dark_secondary, + onSecondary = green_md_theme_dark_onSecondary, + secondaryContainer = green_md_theme_dark_secondaryContainer, + onSecondaryContainer = green_md_theme_dark_onSecondaryContainer, + tertiary = green_md_theme_dark_tertiary, + onTertiary = green_md_theme_dark_onTertiary, + tertiaryContainer = green_md_theme_dark_tertiaryContainer, + onTertiaryContainer = green_md_theme_dark_onTertiaryContainer, + error = green_md_theme_dark_error, + errorContainer = green_md_theme_dark_errorContainer, + onError = green_md_theme_dark_onError, + onErrorContainer = green_md_theme_dark_onErrorContainer, + background = green_md_theme_dark_background, + onBackground = green_md_theme_dark_onBackground, + outline = green_md_theme_dark_outline, + inverseOnSurface = green_md_theme_dark_inverseOnSurface, + inverseSurface = green_md_theme_dark_inverseSurface, + inversePrimary = green_md_theme_dark_inversePrimary, + surfaceTint = green_md_theme_dark_surfaceTint, + outlineVariant = green_md_theme_dark_outlineVariant, + scrim = green_md_theme_dark_scrim, + surface = green_md_theme_dark_surface, + onSurface = green_md_theme_dark_onSurface, + surfaceVariant = green_md_theme_dark_surfaceVariant, + onSurfaceVariant = green_md_theme_dark_onSurfaceVariant, + ) + +/** + * 红色 + */ +@Composable +fun redTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = red_seed, + onPrimary = red_md_theme_light_onPrimary, + primaryContainer = red_md_theme_light_primaryContainer, + onPrimaryContainer = red_md_theme_light_onPrimaryContainer, + secondary = red_md_theme_light_secondary, + onSecondary = red_md_theme_light_onSecondary, + secondaryContainer = red_md_theme_light_secondaryContainer, + onSecondaryContainer = red_md_theme_light_onSecondaryContainer, + tertiary = red_md_theme_light_tertiary, + onTertiary = red_md_theme_light_onTertiary, + tertiaryContainer = red_md_theme_light_tertiaryContainer, + onTertiaryContainer = red_md_theme_light_onTertiaryContainer, + error = red_md_theme_light_error, + errorContainer = red_md_theme_light_errorContainer, + onError = red_md_theme_light_onError, + onErrorContainer = red_md_theme_light_onErrorContainer, + background = red_md_theme_light_background, + onBackground = red_md_theme_light_onBackground, + outline = red_md_theme_light_outline, + inverseOnSurface = red_md_theme_light_inverseOnSurface, + inverseSurface = red_md_theme_light_inverseSurface, + inversePrimary = red_md_theme_light_inversePrimary, + surfaceTint = red_md_theme_light_surfaceTint, + outlineVariant = red_md_theme_light_outlineVariant, + scrim = red_md_theme_light_scrim, + surface = red_md_theme_light_surface, + onSurface = red_md_theme_light_onSurface, + surfaceVariant = red_md_theme_light_surfaceVariant, + onSurfaceVariant = red_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = red_md_theme_dark_primary, + onPrimary = red_md_theme_dark_onPrimary, + primaryContainer = red_md_theme_dark_primaryContainer, + onPrimaryContainer = red_md_theme_dark_onPrimaryContainer, + secondary = red_md_theme_dark_secondary, + onSecondary = red_md_theme_dark_onSecondary, + secondaryContainer = red_md_theme_dark_secondaryContainer, + onSecondaryContainer = red_md_theme_dark_onSecondaryContainer, + tertiary = red_md_theme_dark_tertiary, + onTertiary = red_md_theme_dark_onTertiary, + tertiaryContainer = red_md_theme_dark_tertiaryContainer, + onTertiaryContainer = red_md_theme_dark_onTertiaryContainer, + error = red_md_theme_dark_error, + errorContainer = red_md_theme_dark_errorContainer, + onError = red_md_theme_dark_onError, + onErrorContainer = red_md_theme_dark_onErrorContainer, + background = red_md_theme_dark_background, + onBackground = red_md_theme_dark_onBackground, + outline = red_md_theme_dark_outline, + inverseOnSurface = red_md_theme_dark_inverseOnSurface, + inverseSurface = red_md_theme_dark_inverseSurface, + inversePrimary = red_md_theme_dark_inversePrimary, + surfaceTint = red_md_theme_dark_surfaceTint, + outlineVariant = red_md_theme_dark_outlineVariant, + scrim = red_md_theme_dark_scrim, + surface = red_md_theme_dark_surface, + onSurface = red_md_theme_dark_onSurface, + surfaceVariant = red_md_theme_dark_surfaceVariant, + onSurfaceVariant = red_md_theme_dark_onSurfaceVariant, + ) + +/** + * 粉色 + */ +@Composable +fun pinkTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = pink_seed, + onPrimary = pink_md_theme_light_onPrimary, + primaryContainer = pink_md_theme_light_primaryContainer, + onPrimaryContainer = pink_md_theme_light_onPrimaryContainer, + secondary = pink_md_theme_light_secondary, + onSecondary = pink_md_theme_light_onSecondary, + secondaryContainer = pink_md_theme_light_secondaryContainer, + onSecondaryContainer = pink_md_theme_light_onSecondaryContainer, + tertiary = pink_md_theme_light_tertiary, + onTertiary = pink_md_theme_light_onTertiary, + tertiaryContainer = pink_md_theme_light_tertiaryContainer, + onTertiaryContainer = pink_md_theme_light_onTertiaryContainer, + error = pink_md_theme_light_error, + errorContainer = pink_md_theme_light_errorContainer, + onError = pink_md_theme_light_onError, + onErrorContainer = pink_md_theme_light_onErrorContainer, + background = pink_md_theme_light_background, + onBackground = pink_md_theme_light_onBackground, + outline = pink_md_theme_light_outline, + inverseOnSurface = pink_md_theme_light_inverseOnSurface, + inverseSurface = pink_md_theme_light_inverseSurface, + inversePrimary = pink_md_theme_light_inversePrimary, + surfaceTint = pink_md_theme_light_surfaceTint, + outlineVariant = pink_md_theme_light_outlineVariant, + scrim = pink_md_theme_light_scrim, + surface = pink_md_theme_light_surface, + onSurface = pink_md_theme_light_onSurface, + surfaceVariant = pink_md_theme_light_surfaceVariant, + onSurfaceVariant = pink_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = pink_md_theme_dark_primary, + onPrimary = pink_md_theme_dark_onPrimary, + primaryContainer = pink_md_theme_dark_primaryContainer, + onPrimaryContainer = pink_md_theme_dark_onPrimaryContainer, + secondary = pink_md_theme_dark_secondary, + onSecondary = pink_md_theme_dark_onSecondary, + secondaryContainer = pink_md_theme_dark_secondaryContainer, + onSecondaryContainer = pink_md_theme_dark_onSecondaryContainer, + tertiary = pink_md_theme_dark_tertiary, + onTertiary = pink_md_theme_dark_onTertiary, + tertiaryContainer = pink_md_theme_dark_tertiaryContainer, + onTertiaryContainer = pink_md_theme_dark_onTertiaryContainer, + error = pink_md_theme_dark_error, + errorContainer = pink_md_theme_dark_errorContainer, + onError = pink_md_theme_dark_onError, + onErrorContainer = pink_md_theme_dark_onErrorContainer, + background = pink_md_theme_dark_background, + onBackground = pink_md_theme_dark_onBackground, + outline = pink_md_theme_dark_outline, + inverseOnSurface = pink_md_theme_dark_inverseOnSurface, + inverseSurface = pink_md_theme_dark_inverseSurface, + inversePrimary = pink_md_theme_dark_inversePrimary, + surfaceTint = pink_md_theme_dark_surfaceTint, + outlineVariant = pink_md_theme_dark_outlineVariant, + scrim = pink_md_theme_dark_scrim, + surface = pink_md_theme_dark_surface, + onSurface = pink_md_theme_dark_onSurface, + surfaceVariant = pink_md_theme_dark_surfaceVariant, + onSurfaceVariant = pink_md_theme_dark_onSurfaceVariant, + ) + +/** + * 蓝色 + */ +@Composable +fun blueTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = blue_seed, + onPrimary = blue_md_theme_light_onPrimary, + primaryContainer = blue_md_theme_light_primaryContainer, + onPrimaryContainer = blue_md_theme_light_onPrimaryContainer, + secondary = blue_md_theme_light_secondary, + onSecondary = blue_md_theme_light_onSecondary, + secondaryContainer = blue_md_theme_light_secondaryContainer, + onSecondaryContainer = blue_md_theme_light_onSecondaryContainer, + tertiary = blue_md_theme_light_tertiary, + onTertiary = blue_md_theme_light_onTertiary, + tertiaryContainer = blue_md_theme_light_tertiaryContainer, + onTertiaryContainer = blue_md_theme_light_onTertiaryContainer, + error = blue_md_theme_light_error, + errorContainer = blue_md_theme_light_errorContainer, + onError = blue_md_theme_light_onError, + onErrorContainer = blue_md_theme_light_onErrorContainer, + background = blue_md_theme_light_background, + onBackground = blue_md_theme_light_onBackground, + outline = blue_md_theme_light_outline, + inverseOnSurface = blue_md_theme_light_inverseOnSurface, + inverseSurface = blue_md_theme_light_inverseSurface, + inversePrimary = blue_md_theme_light_inversePrimary, + surfaceTint = blue_md_theme_light_surfaceTint, + outlineVariant = blue_md_theme_light_outlineVariant, + scrim = blue_md_theme_light_scrim, + surface = blue_md_theme_light_surface, + onSurface = blue_md_theme_light_onSurface, + surfaceVariant = blue_md_theme_light_surfaceVariant, + onSurfaceVariant = blue_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = blue_md_theme_dark_primary, + onPrimary = blue_md_theme_dark_onPrimary, + primaryContainer = blue_md_theme_dark_primaryContainer, + onPrimaryContainer = blue_md_theme_dark_onPrimaryContainer, + secondary = blue_md_theme_dark_secondary, + onSecondary = blue_md_theme_dark_onSecondary, + secondaryContainer = blue_md_theme_dark_secondaryContainer, + onSecondaryContainer = blue_md_theme_dark_onSecondaryContainer, + tertiary = blue_md_theme_dark_tertiary, + onTertiary = blue_md_theme_dark_onTertiary, + tertiaryContainer = blue_md_theme_dark_tertiaryContainer, + onTertiaryContainer = blue_md_theme_dark_onTertiaryContainer, + error = blue_md_theme_dark_error, + errorContainer = blue_md_theme_dark_errorContainer, + onError = blue_md_theme_dark_onError, + onErrorContainer = blue_md_theme_dark_onErrorContainer, + background = blue_md_theme_dark_background, + onBackground = blue_md_theme_dark_onBackground, + outline = blue_md_theme_dark_outline, + inverseOnSurface = blue_md_theme_dark_inverseOnSurface, + inverseSurface = blue_md_theme_dark_inverseSurface, + inversePrimary = blue_md_theme_dark_inversePrimary, + surfaceTint = blue_md_theme_dark_surfaceTint, + outlineVariant = blue_md_theme_dark_outlineVariant, + scrim = blue_md_theme_dark_scrim, + surface = blue_md_theme_dark_surface, + onSurface = blue_md_theme_dark_onSurface, + surfaceVariant = blue_md_theme_dark_surfaceVariant, + onSurfaceVariant = blue_md_theme_dark_onSurfaceVariant, + ) + +/** + * 青色 + */ +@Composable +fun cyanTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = cyan_seed, + onPrimary = cyan_md_theme_light_onPrimary, + primaryContainer = cyan_md_theme_light_primaryContainer, + onPrimaryContainer = cyan_md_theme_light_onPrimaryContainer, + secondary = cyan_md_theme_light_secondary, + onSecondary = cyan_md_theme_light_onSecondary, + secondaryContainer = cyan_md_theme_light_secondaryContainer, + onSecondaryContainer = cyan_md_theme_light_onSecondaryContainer, + tertiary = cyan_md_theme_light_tertiary, + onTertiary = cyan_md_theme_light_onTertiary, + tertiaryContainer = cyan_md_theme_light_tertiaryContainer, + onTertiaryContainer = cyan_md_theme_light_onTertiaryContainer, + error = cyan_md_theme_light_error, + errorContainer = cyan_md_theme_light_errorContainer, + onError = cyan_md_theme_light_onError, + onErrorContainer = cyan_md_theme_light_onErrorContainer, + background = cyan_md_theme_light_background, + onBackground = cyan_md_theme_light_onBackground, + outline = cyan_md_theme_light_outline, + inverseOnSurface = cyan_md_theme_light_inverseOnSurface, + inverseSurface = cyan_md_theme_light_inverseSurface, + inversePrimary = cyan_md_theme_light_inversePrimary, + surfaceTint = cyan_md_theme_light_surfaceTint, + outlineVariant = cyan_md_theme_light_outlineVariant, + scrim = cyan_md_theme_light_scrim, + surface = cyan_md_theme_light_surface, + onSurface = cyan_md_theme_light_onSurface, + surfaceVariant = cyan_md_theme_light_surfaceVariant, + onSurfaceVariant = cyan_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = cyan_md_theme_dark_primary, + onPrimary = cyan_md_theme_dark_onPrimary, + primaryContainer = cyan_md_theme_dark_primaryContainer, + onPrimaryContainer = cyan_md_theme_dark_onPrimaryContainer, + secondary = cyan_md_theme_dark_secondary, + onSecondary = cyan_md_theme_dark_onSecondary, + secondaryContainer = cyan_md_theme_dark_secondaryContainer, + onSecondaryContainer = cyan_md_theme_dark_onSecondaryContainer, + tertiary = cyan_md_theme_dark_tertiary, + onTertiary = cyan_md_theme_dark_onTertiary, + tertiaryContainer = cyan_md_theme_dark_tertiaryContainer, + onTertiaryContainer = cyan_md_theme_dark_onTertiaryContainer, + error = cyan_md_theme_dark_error, + errorContainer = cyan_md_theme_dark_errorContainer, + onError = cyan_md_theme_dark_onError, + onErrorContainer = cyan_md_theme_dark_onErrorContainer, + background = cyan_md_theme_dark_background, + onBackground = cyan_md_theme_dark_onBackground, + outline = cyan_md_theme_dark_outline, + inverseOnSurface = cyan_md_theme_dark_inverseOnSurface, + inverseSurface = cyan_md_theme_dark_inverseSurface, + inversePrimary = cyan_md_theme_dark_inversePrimary, + surfaceTint = cyan_md_theme_dark_surfaceTint, + outlineVariant = cyan_md_theme_dark_outlineVariant, + scrim = cyan_md_theme_dark_scrim, + surface = cyan_md_theme_dark_surface, + onSurface = cyan_md_theme_dark_onSurface, + surfaceVariant = cyan_md_theme_dark_surfaceVariant, + onSurfaceVariant = cyan_md_theme_dark_onSurfaceVariant, + ) + + +/** + * 橙色 + */ +@Composable +fun orangeTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = orange_seed, + onPrimary = orange_md_theme_light_onPrimary, + primaryContainer = orange_md_theme_light_primaryContainer, + onPrimaryContainer = orange_md_theme_light_onPrimaryContainer, + secondary = orange_md_theme_light_secondary, + onSecondary = orange_md_theme_light_onSecondary, + secondaryContainer = orange_md_theme_light_secondaryContainer, + onSecondaryContainer = orange_md_theme_light_onSecondaryContainer, + tertiary = orange_md_theme_light_tertiary, + onTertiary = orange_md_theme_light_onTertiary, + tertiaryContainer = orange_md_theme_light_tertiaryContainer, + onTertiaryContainer = orange_md_theme_light_onTertiaryContainer, + error = orange_md_theme_light_error, + errorContainer = orange_md_theme_light_errorContainer, + onError = orange_md_theme_light_onError, + onErrorContainer = orange_md_theme_light_onErrorContainer, + background = orange_md_theme_light_background, + onBackground = orange_md_theme_light_onBackground, + outline = orange_md_theme_light_outline, + inverseOnSurface = orange_md_theme_light_inverseOnSurface, + inverseSurface = orange_md_theme_light_inverseSurface, + inversePrimary = orange_md_theme_light_inversePrimary, + surfaceTint = orange_md_theme_light_surfaceTint, + outlineVariant = orange_md_theme_light_outlineVariant, + scrim = orange_md_theme_light_scrim, + surface = orange_md_theme_light_surface, + onSurface = orange_md_theme_light_onSurface, + surfaceVariant = orange_md_theme_light_surfaceVariant, + onSurfaceVariant = orange_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = orange_md_theme_dark_primary, + onPrimary = orange_md_theme_dark_onPrimary, + primaryContainer = orange_md_theme_dark_primaryContainer, + onPrimaryContainer = orange_md_theme_dark_onPrimaryContainer, + secondary = orange_md_theme_dark_secondary, + onSecondary = orange_md_theme_dark_onSecondary, + secondaryContainer = orange_md_theme_dark_secondaryContainer, + onSecondaryContainer = orange_md_theme_dark_onSecondaryContainer, + tertiary = orange_md_theme_dark_tertiary, + onTertiary = orange_md_theme_dark_onTertiary, + tertiaryContainer = orange_md_theme_dark_tertiaryContainer, + onTertiaryContainer = orange_md_theme_dark_onTertiaryContainer, + error = orange_md_theme_dark_error, + errorContainer = orange_md_theme_dark_errorContainer, + onError = orange_md_theme_dark_onError, + onErrorContainer = orange_md_theme_dark_onErrorContainer, + background = orange_md_theme_dark_background, + onBackground = orange_md_theme_dark_onBackground, + outline = orange_md_theme_dark_outline, + inverseOnSurface = orange_md_theme_dark_inverseOnSurface, + inverseSurface = orange_md_theme_dark_inverseSurface, + inversePrimary = orange_md_theme_dark_inversePrimary, + surfaceTint = orange_md_theme_dark_surfaceTint, + outlineVariant = orange_md_theme_dark_outlineVariant, + scrim = orange_md_theme_dark_scrim, + surface = orange_md_theme_dark_surface, + onSurface = orange_md_theme_dark_onSurface, + surfaceVariant = orange_md_theme_dark_surfaceVariant, + onSurfaceVariant = orange_md_theme_dark_onSurfaceVariant, + ) + +/** + * 紫色 + */ +@Composable +fun purpleTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = purple_seed, + onPrimary = purple_md_theme_light_onPrimary, + primaryContainer = purple_md_theme_light_primaryContainer, + onPrimaryContainer = purple_md_theme_light_onPrimaryContainer, + secondary = purple_md_theme_light_secondary, + onSecondary = purple_md_theme_light_onSecondary, + secondaryContainer = purple_md_theme_light_secondaryContainer, + onSecondaryContainer = purple_md_theme_light_onSecondaryContainer, + tertiary = purple_md_theme_light_tertiary, + onTertiary = purple_md_theme_light_onTertiary, + tertiaryContainer = purple_md_theme_light_tertiaryContainer, + onTertiaryContainer = purple_md_theme_light_onTertiaryContainer, + error = purple_md_theme_light_error, + errorContainer = purple_md_theme_light_errorContainer, + onError = purple_md_theme_light_onError, + onErrorContainer = purple_md_theme_light_onErrorContainer, + background = purple_md_theme_light_background, + onBackground = purple_md_theme_light_onBackground, + outline = purple_md_theme_light_outline, + inverseOnSurface = purple_md_theme_light_inverseOnSurface, + inverseSurface = purple_md_theme_light_inverseSurface, + inversePrimary = purple_md_theme_light_inversePrimary, + surfaceTint = purple_md_theme_light_surfaceTint, + outlineVariant = purple_md_theme_light_outlineVariant, + scrim = purple_md_theme_light_scrim, + surface = purple_md_theme_light_surface, + onSurface = purple_md_theme_light_onSurface, + surfaceVariant = purple_md_theme_light_surfaceVariant, + onSurfaceVariant = purple_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = purple_md_theme_dark_primary, + onPrimary = purple_md_theme_dark_onPrimary, + primaryContainer = purple_md_theme_dark_primaryContainer, + onPrimaryContainer = purple_md_theme_dark_onPrimaryContainer, + secondary = purple_md_theme_dark_secondary, + onSecondary = purple_md_theme_dark_onSecondary, + secondaryContainer = purple_md_theme_dark_secondaryContainer, + onSecondaryContainer = purple_md_theme_dark_onSecondaryContainer, + tertiary = purple_md_theme_dark_tertiary, + onTertiary = purple_md_theme_dark_onTertiary, + tertiaryContainer = purple_md_theme_dark_tertiaryContainer, + onTertiaryContainer = purple_md_theme_dark_onTertiaryContainer, + error = purple_md_theme_dark_error, + errorContainer = purple_md_theme_dark_errorContainer, + onError = purple_md_theme_dark_onError, + onErrorContainer = purple_md_theme_dark_onErrorContainer, + background = purple_md_theme_dark_background, + onBackground = purple_md_theme_dark_onBackground, + outline = purple_md_theme_dark_outline, + inverseOnSurface = purple_md_theme_dark_inverseOnSurface, + inverseSurface = purple_md_theme_dark_inverseSurface, + inversePrimary = purple_md_theme_dark_inversePrimary, + surfaceTint = purple_md_theme_dark_surfaceTint, + outlineVariant = purple_md_theme_dark_outlineVariant, + scrim = purple_md_theme_dark_scrim, + surface = purple_md_theme_dark_surface, + onSurface = purple_md_theme_dark_onSurface, + surfaceVariant = purple_md_theme_dark_surfaceVariant, + onSurfaceVariant = purple_md_theme_dark_onSurfaceVariant, + ) + +/** + * 棕色 + */ +@Composable +fun brownTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = brown_seed, + onPrimary = brown_md_theme_light_onPrimary, + primaryContainer = brown_md_theme_light_primaryContainer, + onPrimaryContainer = brown_md_theme_light_onPrimaryContainer, + secondary = brown_md_theme_light_secondary, + onSecondary = brown_md_theme_light_onSecondary, + secondaryContainer = brown_md_theme_light_secondaryContainer, + onSecondaryContainer = brown_md_theme_light_onSecondaryContainer, + tertiary = brown_md_theme_light_tertiary, + onTertiary = brown_md_theme_light_onTertiary, + tertiaryContainer = brown_md_theme_light_tertiaryContainer, + onTertiaryContainer = brown_md_theme_light_onTertiaryContainer, + error = brown_md_theme_light_error, + errorContainer = brown_md_theme_light_errorContainer, + onError = brown_md_theme_light_onError, + onErrorContainer = brown_md_theme_light_onErrorContainer, + background = brown_md_theme_light_background, + onBackground = brown_md_theme_light_onBackground, + outline = brown_md_theme_light_outline, + inverseOnSurface = brown_md_theme_light_inverseOnSurface, + inverseSurface = brown_md_theme_light_inverseSurface, + inversePrimary = brown_md_theme_light_inversePrimary, + surfaceTint = brown_md_theme_light_surfaceTint, + outlineVariant = brown_md_theme_light_outlineVariant, + scrim = brown_md_theme_light_scrim, + surface = brown_md_theme_light_surface, + onSurface = brown_md_theme_light_onSurface, + surfaceVariant = brown_md_theme_light_surfaceVariant, + onSurfaceVariant = brown_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = brown_md_theme_dark_primary, + onPrimary = brown_md_theme_dark_onPrimary, + primaryContainer = brown_md_theme_dark_primaryContainer, + onPrimaryContainer = brown_md_theme_dark_onPrimaryContainer, + secondary = brown_md_theme_dark_secondary, + onSecondary = brown_md_theme_dark_onSecondary, + secondaryContainer = brown_md_theme_dark_secondaryContainer, + onSecondaryContainer = brown_md_theme_dark_onSecondaryContainer, + tertiary = brown_md_theme_dark_tertiary, + onTertiary = brown_md_theme_dark_onTertiary, + tertiaryContainer = brown_md_theme_dark_tertiaryContainer, + onTertiaryContainer = brown_md_theme_dark_onTertiaryContainer, + error = brown_md_theme_dark_error, + errorContainer = brown_md_theme_dark_errorContainer, + onError = brown_md_theme_dark_onError, + onErrorContainer = brown_md_theme_dark_onErrorContainer, + background = brown_md_theme_dark_background, + onBackground = brown_md_theme_dark_onBackground, + outline = brown_md_theme_dark_outline, + inverseOnSurface = brown_md_theme_dark_inverseOnSurface, + inverseSurface = brown_md_theme_dark_inverseSurface, + inversePrimary = brown_md_theme_dark_inversePrimary, + surfaceTint = brown_md_theme_dark_surfaceTint, + outlineVariant = brown_md_theme_dark_outlineVariant, + scrim = brown_md_theme_dark_scrim, + surface = brown_md_theme_dark_surface, + onSurface = brown_md_theme_dark_onSurface, + surfaceVariant = brown_md_theme_dark_surfaceVariant, + onSurfaceVariant = brown_md_theme_dark_onSurfaceVariant, + ) + +/** + * 灰色 + */ +@Composable +fun grayTheme( + darkTheme: Boolean +): ColorScheme = + if (!darkTheme) + lightColorScheme( + primary = gray_seed, + onPrimary = gray_md_theme_light_onPrimary, + primaryContainer = gray_md_theme_light_primaryContainer, + onPrimaryContainer = gray_md_theme_light_onPrimaryContainer, + secondary = gray_md_theme_light_secondary, + onSecondary = gray_md_theme_light_onSecondary, + secondaryContainer = gray_md_theme_light_secondaryContainer, + onSecondaryContainer = gray_md_theme_light_onSecondaryContainer, + tertiary = gray_md_theme_light_tertiary, + onTertiary = gray_md_theme_light_onTertiary, + tertiaryContainer = gray_md_theme_light_tertiaryContainer, + onTertiaryContainer = gray_md_theme_light_onTertiaryContainer, + error = gray_md_theme_light_error, + errorContainer = gray_md_theme_light_errorContainer, + onError = gray_md_theme_light_onError, + onErrorContainer = gray_md_theme_light_onErrorContainer, + background = gray_md_theme_light_background, + onBackground = gray_md_theme_light_onBackground, + outline = gray_md_theme_light_outline, + inverseOnSurface = gray_md_theme_light_inverseOnSurface, + inverseSurface = gray_md_theme_light_inverseSurface, + inversePrimary = gray_md_theme_light_inversePrimary, + surfaceTint = gray_md_theme_light_surfaceTint, + outlineVariant = gray_md_theme_light_outlineVariant, + scrim = gray_md_theme_light_scrim, + surface = gray_md_theme_light_surface, + onSurface = gray_md_theme_light_onSurface, + surfaceVariant = gray_md_theme_light_surfaceVariant, + onSurfaceVariant = gray_md_theme_light_onSurfaceVariant, + ) + else + darkColorScheme( + primary = gray_md_theme_dark_primary, + onPrimary = gray_md_theme_dark_onPrimary, + primaryContainer = gray_md_theme_dark_primaryContainer, + onPrimaryContainer = gray_md_theme_dark_onPrimaryContainer, + secondary = gray_md_theme_dark_secondary, + onSecondary = gray_md_theme_dark_onSecondary, + secondaryContainer = gray_md_theme_dark_secondaryContainer, + onSecondaryContainer = gray_md_theme_dark_onSecondaryContainer, + tertiary = gray_md_theme_dark_tertiary, + onTertiary = gray_md_theme_dark_onTertiary, + tertiaryContainer = gray_md_theme_dark_tertiaryContainer, + onTertiaryContainer = gray_md_theme_dark_onTertiaryContainer, + error = gray_md_theme_dark_error, + errorContainer = gray_md_theme_dark_errorContainer, + onError = gray_md_theme_dark_onError, + onErrorContainer = gray_md_theme_dark_onErrorContainer, + background = gray_md_theme_dark_background, + onBackground = gray_md_theme_dark_onBackground, + outline = gray_md_theme_dark_outline, + inverseOnSurface = gray_md_theme_dark_inverseOnSurface, + inverseSurface = gray_md_theme_dark_inverseSurface, + inversePrimary = gray_md_theme_dark_inversePrimary, + surfaceTint = gray_md_theme_dark_surfaceTint, + outlineVariant = gray_md_theme_dark_outlineVariant, + scrim = gray_md_theme_dark_scrim, + surface = gray_md_theme_dark_surface, + onSurface = gray_md_theme_dark_onSurface, + surfaceVariant = gray_md_theme_dark_surfaceVariant, + onSurfaceVariant = gray_md_theme_dark_onSurfaceVariant, + ) + + +/** + * 主题颜色状态管理 + */ +@Composable +fun themeAnimation(targetTheme: ColorScheme): ColorScheme { + //动画时长 + val durationMillis = 600 + //插值器 + val animationSpec = TweenSpec(durationMillis = durationMillis, easing = FastOutLinearInEasing) + + val primary by animateColorAsState( + targetValue = targetTheme.primary, animationSpec, label = "primary" + ) + val onPrimary by animateColorAsState( + targetValue = targetTheme.onPrimary, animationSpec, label = "onPrimary" + ) + val primaryContainer by animateColorAsState( + targetValue = targetTheme.primaryContainer, animationSpec, label = "primaryContainer" + ) + val onPrimaryContainer by animateColorAsState( + targetValue = targetTheme.onPrimaryContainer, animationSpec, label = "onPrimaryContainer" + ) + val secondary by animateColorAsState( + targetValue = targetTheme.secondary, animationSpec, label = "secondary" + ) + val onSecondary by animateColorAsState( + targetValue = targetTheme.onSecondary, animationSpec, label = "onSecondary" + ) + val secondaryContainer by animateColorAsState( + targetValue = targetTheme.secondaryContainer, animationSpec, label = "secondaryContainer" + ) + val onSecondaryContainer by animateColorAsState( + targetValue = targetTheme.onSecondaryContainer, animationSpec, label = "onSecondaryContainer" + ) + val tertiary by animateColorAsState( + targetValue = targetTheme.tertiary, animationSpec, label = "tertiary" + ) + val onTertiary by animateColorAsState( + targetValue = targetTheme.onTertiary, animationSpec, label = "onTertiary" + ) + val tertiaryContainer by animateColorAsState( + targetValue = targetTheme.tertiaryContainer, animationSpec, label = "tertiaryContainer" + ) + val onTertiaryContainer by animateColorAsState( + targetValue = targetTheme.onTertiaryContainer, animationSpec, label = "onTertiaryContainer" + ) + val error by animateColorAsState( + targetValue = targetTheme.error, animationSpec, label = "error" + ) + val errorContainer by animateColorAsState( + targetValue = targetTheme.errorContainer, animationSpec, label = "errorContainer" + ) + val onError by animateColorAsState( + targetValue = targetTheme.onError, animationSpec, label = "onError" + ) + val onErrorContainer by animateColorAsState( + targetValue = targetTheme.onErrorContainer, animationSpec, label = "onErrorContainer" + ) + val background by animateColorAsState( + targetValue = targetTheme.background, animationSpec, label = "background" + ) + val onBackground by animateColorAsState( + targetValue = targetTheme.onBackground, animationSpec, label = "onBackground" + ) + val outline by animateColorAsState( + targetValue = targetTheme.outline, animationSpec, label = "outline" + ) + val inverseOnSurface by animateColorAsState( + targetValue = targetTheme.inverseOnSurface, animationSpec, label = "inverseOnSurface" + ) + val inverseSurface by animateColorAsState( + targetValue = targetTheme.inverseSurface, animationSpec, label = "inverseSurface" + ) + val inversePrimary by animateColorAsState( + targetValue = targetTheme.inversePrimary, animationSpec, label = "inversePrimary" + ) + val surfaceTint by animateColorAsState( + targetValue = targetTheme.surfaceTint, animationSpec, label = "surfaceTint" + ) + val outlineVariant by animateColorAsState( + targetValue = targetTheme.outlineVariant, animationSpec, label = "outlineVariant" + ) + val scrim by animateColorAsState( + targetValue = targetTheme.scrim, animationSpec, label = "scrim" + ) + val surface by animateColorAsState( + targetValue = targetTheme.surface, animationSpec, label = "surface" + ) + val onSurface by animateColorAsState( + targetValue = targetTheme.onSurface, animationSpec, label = "onSurface" + ) + val surfaceVariant by animateColorAsState( + targetValue = targetTheme.surfaceVariant, animationSpec, label = "surfaceVariant" + ) + val onSurfaceVariant by animateColorAsState( + targetValue = targetTheme.onSurfaceVariant, animationSpec, label = "onSurfaceVariant" + ) + + return targetTheme.copy( + primary = primary, + onPrimary = onPrimary, + primaryContainer = primaryContainer, + onPrimaryContainer = onPrimaryContainer, + secondary = secondary, + onSecondary = onSecondary, + secondaryContainer = secondaryContainer, + onSecondaryContainer = onSecondaryContainer, + tertiary = tertiary, + onTertiary = onTertiary, + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = onTertiaryContainer, + error = error, + errorContainer = errorContainer, + onError = onError, + onErrorContainer = onErrorContainer, + background = background, + onBackground = onBackground, + outline = outline, + inverseOnSurface = inverseOnSurface, + inverseSurface = inverseSurface, + inversePrimary = inversePrimary, + surfaceTint = surfaceTint, + outlineVariant = outlineVariant, + scrim = scrim, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Color.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Color.kt new file mode 100644 index 000000000..51c8fe652 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Color.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.compose.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Color2.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Color2.kt new file mode 100644 index 000000000..417d45219 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Color2.kt @@ -0,0 +1,642 @@ +package com.github.jing332.tts_server_android.compose.theme + +import androidx.compose.ui.graphics.Color +//默认 +val bean_green_md_theme_light_primary = Color(0xFF376A20) +val bean_green_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val bean_green_md_theme_light_primaryContainer = Color(0xFFB7F398) +val bean_green_md_theme_light_onPrimaryContainer = Color(0xFF062100) +val bean_green_md_theme_light_secondary = Color(0xFF55624C) +val bean_green_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val bean_green_md_theme_light_secondaryContainer = Color(0xFFD8E7CB) +val bean_green_md_theme_light_onSecondaryContainer = Color(0xFF131F0D) +val bean_green_md_theme_light_tertiary = Color(0xFF386667) +val bean_green_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val bean_green_md_theme_light_tertiaryContainer = Color(0xFFBBEBEC) +val bean_green_md_theme_light_onTertiaryContainer = Color(0xFF002021) +val bean_green_md_theme_light_error = Color(0xFFBA1A1A) +val bean_green_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val bean_green_md_theme_light_onError = Color(0xFFFFFFFF) +val bean_green_md_theme_light_onErrorContainer = Color(0xFF410002) +val bean_green_md_theme_light_background = Color(0xFFFDFDF6) +val bean_green_md_theme_light_onBackground = Color(0xFF1A1C18) +val bean_green_md_theme_light_outline = Color(0xFF73796D) +val bean_green_md_theme_light_inverseOnSurface = Color(0xFFF1F1EA) +val bean_green_md_theme_light_inverseSurface = Color(0xFF2F312D) +val bean_green_md_theme_light_inversePrimary = Color(0xFF9CD67E) +val bean_green_md_theme_light_surfaceTint = Color(0xFF376A20) +val bean_green_md_theme_light_outlineVariant = Color(0xFFC3C8BB) +val bean_green_md_theme_light_scrim = Color(0xFF000000) +val bean_green_md_theme_light_surface = Color(0xFFFAFAF3) +val bean_green_md_theme_light_onSurface = Color(0xFF1A1C18) +val bean_green_md_theme_light_surfaceVariant = Color(0xFFDFE4D7) +val bean_green_md_theme_light_onSurfaceVariant = Color(0xFF43483E) + +val bean_green_md_theme_dark_primary = Color(0xFF9CD67E) +val bean_green_md_theme_dark_onPrimary = Color(0xFF0F3900) +val bean_green_md_theme_dark_primaryContainer = Color(0xFF1F5108) +val bean_green_md_theme_dark_onPrimaryContainer = Color(0xFFB7F398) +val bean_green_md_theme_dark_secondary = Color(0xFFBCCBB0) +val bean_green_md_theme_dark_onSecondary = Color(0xFF283421) +val bean_green_md_theme_dark_secondaryContainer = Color(0xFF3E4A36) +val bean_green_md_theme_dark_onSecondaryContainer = Color(0xFFD8E7CB) +val bean_green_md_theme_dark_tertiary = Color(0xFFA0CFD0) +val bean_green_md_theme_dark_onTertiary = Color(0xFF003738) +val bean_green_md_theme_dark_tertiaryContainer = Color(0xFF1E4E4F) +val bean_green_md_theme_dark_onTertiaryContainer = Color(0xFFBBEBEC) +val bean_green_md_theme_dark_error = Color(0xFFFFB4AB) +val bean_green_md_theme_dark_errorContainer = Color(0xFF93000A) +val bean_green_md_theme_dark_onError = Color(0xFF690005) +val bean_green_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val bean_green_md_theme_dark_background = Color(0xFF1A1C18) +val bean_green_md_theme_dark_onBackground = Color(0xFFE3E3DC) +val bean_green_md_theme_dark_outline = Color(0xFF8D9287) +val bean_green_md_theme_dark_inverseOnSurface = Color(0xFF1A1C18) +val bean_green_md_theme_dark_inverseSurface = Color(0xFFE3E3DC) +val bean_green_md_theme_dark_inversePrimary = Color(0xFF376A20) +val bean_green_md_theme_dark_surfaceTint = Color(0xFF9CD67E) +val bean_green_md_theme_dark_outlineVariant = Color(0xFF43483E) +val bean_green_md_theme_dark_scrim = Color(0xFF000000) +val bean_green_md_theme_dark_surface = Color(0xFF121410) +val bean_green_md_theme_dark_onSurface = Color(0xFFC6C7C0) +val bean_green_md_theme_dark_surfaceVariant = Color(0xFF43483E) +val bean_green_md_theme_dark_onSurfaceVariant = Color(0xFFC3C8BB) + + +val bean_green_seed = Color(0xFF7B8B70) + +//蓝色 +val blue_md_theme_light_primary = Color(0xFF005AC1) +val blue_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val blue_md_theme_light_primaryContainer = Color(0xFFD8E2FF) +val blue_md_theme_light_onPrimaryContainer = Color(0xFF001A41) +val blue_md_theme_light_secondary = Color(0xFF575E71) +val blue_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val blue_md_theme_light_secondaryContainer = Color(0xFFDBE2F9) +val blue_md_theme_light_onSecondaryContainer = Color(0xFF141B2C) +val blue_md_theme_light_tertiary = Color(0xFF715573) +val blue_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val blue_md_theme_light_tertiaryContainer = Color(0xFFFBD7FC) +val blue_md_theme_light_onTertiaryContainer = Color(0xFF29132D) +val blue_md_theme_light_error = Color(0xFFBA1A1A) +val blue_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val blue_md_theme_light_onError = Color(0xFFFFFFFF) +val blue_md_theme_light_onErrorContainer = Color(0xFF410002) +val blue_md_theme_light_background = Color(0xFFFEFBFF) +val blue_md_theme_light_onBackground = Color(0xFF1B1B1F) +val blue_md_theme_light_outline = Color(0xFF74777F) +val blue_md_theme_light_inverseOnSurface = Color(0xFFF2F0F4) +val blue_md_theme_light_inverseSurface = Color(0xFF303033) +val blue_md_theme_light_inversePrimary = Color(0xFFADC6FF) +val blue_md_theme_light_surfaceTint = Color(0xFF005AC1) +val blue_md_theme_light_outlineVariant = Color(0xFFC4C6D0) +val blue_md_theme_light_scrim = Color(0xFF000000) +val blue_md_theme_light_surface = Color(0xFFFAF9FD) +val blue_md_theme_light_onSurface = Color(0xFF1B1B1F) +val blue_md_theme_light_surfaceVariant = Color(0xFFE1E2EC) +val blue_md_theme_light_onSurfaceVariant = Color(0xFF44474F) + +val blue_md_theme_dark_primary = Color(0xFFADC6FF) +val blue_md_theme_dark_onPrimary = Color(0xFF002E69) +val blue_md_theme_dark_primaryContainer = Color(0xFF004494) +val blue_md_theme_dark_onPrimaryContainer = Color(0xFFD8E2FF) +val blue_md_theme_dark_secondary = Color(0xFFBFC6DC) +val blue_md_theme_dark_onSecondary = Color(0xFF293041) +val blue_md_theme_dark_secondaryContainer = Color(0xFF3F4759) +val blue_md_theme_dark_onSecondaryContainer = Color(0xFFDBE2F9) +val blue_md_theme_dark_tertiary = Color(0xFFDEBCDF) +val blue_md_theme_dark_onTertiary = Color(0xFF402843) +val blue_md_theme_dark_tertiaryContainer = Color(0xFF583E5B) +val blue_md_theme_dark_onTertiaryContainer = Color(0xFFFBD7FC) +val blue_md_theme_dark_error = Color(0xFFFFB4AB) +val blue_md_theme_dark_errorContainer = Color(0xFF93000A) +val blue_md_theme_dark_onError = Color(0xFF690005) +val blue_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val blue_md_theme_dark_background = Color(0xFF1B1B1F) +val blue_md_theme_dark_onBackground = Color(0xFFE3E2E6) +val blue_md_theme_dark_outline = Color(0xFF8E9099) +val blue_md_theme_dark_inverseOnSurface = Color(0xFF1B1B1F) +val blue_md_theme_dark_inverseSurface = Color(0xFFE3E2E6) +val blue_md_theme_dark_inversePrimary = Color(0xFF005AC1) +val blue_md_theme_dark_surfaceTint = Color(0xFFADC6FF) +val blue_md_theme_dark_outlineVariant = Color(0xFF44474F) +val blue_md_theme_dark_scrim = Color(0xFF000000) +val blue_md_theme_dark_surface = Color(0xFF121316) +val blue_md_theme_dark_onSurface = Color(0xFFC7C6CA) +val blue_md_theme_dark_surfaceVariant = Color(0xFF44474F) +val blue_md_theme_dark_onSurfaceVariant = Color(0xFFC4C6D0) + +val blue_seed = Color(0xFF4285F4) + +//GREEN +val green_md_theme_light_primary = Color(0xFF006D3A) +val green_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val green_md_theme_light_primaryContainer = Color(0xFF82FAAB) +val green_md_theme_light_onPrimaryContainer = Color(0xFF00210E) +val green_md_theme_light_secondary = Color(0xFF4F6353) +val green_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val green_md_theme_light_secondaryContainer = Color(0xFFD2E8D4) +val green_md_theme_light_onSecondaryContainer = Color(0xFF0D1F13) +val green_md_theme_light_tertiary = Color(0xFF3A646F) +val green_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val green_md_theme_light_tertiaryContainer = Color(0xFFBEEAF6) +val green_md_theme_light_onTertiaryContainer = Color(0xFF001F26) +val green_md_theme_light_error = Color(0xFFBA1A1A) +val green_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val green_md_theme_light_onError = Color(0xFFFFFFFF) +val green_md_theme_light_onErrorContainer = Color(0xFF410002) +val green_md_theme_light_background = Color(0xFFFBFDF8) +val green_md_theme_light_onBackground = Color(0xFF191C19) +val green_md_theme_light_outline = Color(0xFF717971) +val green_md_theme_light_inverseOnSurface = Color(0xFFF0F1EC) +val green_md_theme_light_inverseSurface = Color(0xFF2E312E) +val green_md_theme_light_inversePrimary = Color(0xFF65DD91) +val green_md_theme_light_surfaceTint = Color(0xFF006D3A) +val green_md_theme_light_outlineVariant = Color(0xFFC1C9BF) +val green_md_theme_light_scrim = Color(0xFF000000) +val green_md_theme_light_surface = Color(0xFFF8FAF5) +val green_md_theme_light_onSurface = Color(0xFF191C19) +val green_md_theme_light_surfaceVariant = Color(0xFFDDE5DB) +val green_md_theme_light_onSurfaceVariant = Color(0xFF414941) + +val green_md_theme_dark_primary = Color(0xFF65DD91) +val green_md_theme_dark_onPrimary = Color(0xFF00391C) +val green_md_theme_dark_primaryContainer = Color(0xFF00522B) +val green_md_theme_dark_onPrimaryContainer = Color(0xFF82FAAB) +val green_md_theme_dark_secondary = Color(0xFFB6CCB8) +val green_md_theme_dark_onSecondary = Color(0xFF223527) +val green_md_theme_dark_secondaryContainer = Color(0xFF384B3C) +val green_md_theme_dark_onSecondaryContainer = Color(0xFFD2E8D4) +val green_md_theme_dark_tertiary = Color(0xFFA2CEDA) +val green_md_theme_dark_onTertiary = Color(0xFF02363F) +val green_md_theme_dark_tertiaryContainer = Color(0xFF214C57) +val green_md_theme_dark_onTertiaryContainer = Color(0xFFBEEAF6) +val green_md_theme_dark_error = Color(0xFFFFB4AB) +val green_md_theme_dark_errorContainer = Color(0xFF93000A) +val green_md_theme_dark_onError = Color(0xFF690005) +val green_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val green_md_theme_dark_background = Color(0xFF191C19) +val green_md_theme_dark_onBackground = Color(0xFFE1E3DE) +val green_md_theme_dark_outline = Color(0xFF8B938A) +val green_md_theme_dark_inverseOnSurface = Color(0xFF191C19) +val green_md_theme_dark_inverseSurface = Color(0xFFE1E3DE) +val green_md_theme_dark_inversePrimary = Color(0xFF006D3A) +val green_md_theme_dark_surfaceTint = Color(0xFF65DD91) +val green_md_theme_dark_outlineVariant = Color(0xFF414941) +val green_md_theme_dark_scrim = Color(0xFF000000) +val green_md_theme_dark_surface = Color(0xFF111411) +val green_md_theme_dark_onSurface = Color(0xFFC5C7C2) +val green_md_theme_dark_surfaceVariant = Color(0xFF414941) +val green_md_theme_dark_onSurfaceVariant = Color(0xFFC1C9BF) + + +val green_seed = Color(0xFF109D58) + +// RED +val red_md_theme_light_primary = Color(0xFFB4271F) +val red_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val red_md_theme_light_primaryContainer = Color(0xFFFFDAD5) +val red_md_theme_light_onPrimaryContainer = Color(0xFF410001) +val red_md_theme_light_secondary = Color(0xFF775652) +val red_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val red_md_theme_light_secondaryContainer = Color(0xFFFFDAD5) +val red_md_theme_light_onSecondaryContainer = Color(0xFF2C1512) +val red_md_theme_light_tertiary = Color(0xFF705C2E) +val red_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val red_md_theme_light_tertiaryContainer = Color(0xFFFCDFA6) +val red_md_theme_light_onTertiaryContainer = Color(0xFF261A00) +val red_md_theme_light_error = Color(0xFFBA1A1A) +val red_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val red_md_theme_light_onError = Color(0xFFFFFFFF) +val red_md_theme_light_onErrorContainer = Color(0xFF410002) +val red_md_theme_light_background = Color(0xFFFFFBFF) +val red_md_theme_light_onBackground = Color(0xFF201A19) +val red_md_theme_light_outline = Color(0xFF857370) +val red_md_theme_light_inverseOnSurface = Color(0xFFFBEEEC) +val red_md_theme_light_inverseSurface = Color(0xFF362F2E) +val red_md_theme_light_inversePrimary = Color(0xFFFFB4A9) +val red_md_theme_light_surfaceTint = Color(0xFFB4271F) +val red_md_theme_light_outlineVariant = Color(0xFFD8C2BE) +val red_md_theme_light_scrim = Color(0xFF000000) +val red_md_theme_light_surface = Color(0xFFFFF8F7) +val red_md_theme_light_onSurface = Color(0xFF201A19) +val red_md_theme_light_surfaceVariant = Color(0xFFF5DDDA) +val red_md_theme_light_onSurfaceVariant = Color(0xFF534341) + +val red_md_theme_dark_primary = Color(0xFFFFB4A9) +val red_md_theme_dark_onPrimary = Color(0xFF690002) +val red_md_theme_dark_primaryContainer = Color(0xFF910809) +val red_md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD5) +val red_md_theme_dark_secondary = Color(0xFFE7BDB7) +val red_md_theme_dark_onSecondary = Color(0xFF442926) +val red_md_theme_dark_secondaryContainer = Color(0xFF5D3F3B) +val red_md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD5) +val red_md_theme_dark_tertiary = Color(0xFFDFC38C) +val red_md_theme_dark_onTertiary = Color(0xFF3E2E04) +val red_md_theme_dark_tertiaryContainer = Color(0xFF574419) +val red_md_theme_dark_onTertiaryContainer = Color(0xFFFCDFA6) +val red_md_theme_dark_error = Color(0xFFFFB4AB) +val red_md_theme_dark_errorContainer = Color(0xFF93000A) +val red_md_theme_dark_onError = Color(0xFF690005) +val red_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val red_md_theme_dark_background = Color(0xFF201A19) +val red_md_theme_dark_onBackground = Color(0xFFEDE0DE) +val red_md_theme_dark_outline = Color(0xFFA08C89) +val red_md_theme_dark_inverseOnSurface = Color(0xFF201A19) +val red_md_theme_dark_inverseSurface = Color(0xFFEDE0DE) +val red_md_theme_dark_inversePrimary = Color(0xFFB4271F) +val red_md_theme_dark_surfaceTint = Color(0xFFFFB4A9) +val red_md_theme_dark_outlineVariant = Color(0xFF534341) +val red_md_theme_dark_scrim = Color(0xFF000000) +val red_md_theme_dark_surface = Color(0xFF181211) +val red_md_theme_dark_onSurface = Color(0xFFD0C4C2) +val red_md_theme_dark_surfaceVariant = Color(0xFF534341) +val red_md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BE) + + +val red_seed = Color(0xFFDC4437) + + +// PINK +val pink_md_theme_light_primary = Color(0xFFA73258) +val pink_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val pink_md_theme_light_primaryContainer = Color(0xFFFFD9E0) +val pink_md_theme_light_onPrimaryContainer = Color(0xFF3F0018) +val pink_md_theme_light_secondary = Color(0xFF75565C) +val pink_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val pink_md_theme_light_secondaryContainer = Color(0xFFFFD9E0) +val pink_md_theme_light_onSecondaryContainer = Color(0xFF2B151A) +val pink_md_theme_light_tertiary = Color(0xFF7B5733) +val pink_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val pink_md_theme_light_tertiaryContainer = Color(0xFFFFDCBE) +val pink_md_theme_light_onTertiaryContainer = Color(0xFF2C1600) +val pink_md_theme_light_error = Color(0xFFBA1A1A) +val pink_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val pink_md_theme_light_onError = Color(0xFFFFFFFF) +val pink_md_theme_light_onErrorContainer = Color(0xFF410002) +val pink_md_theme_light_background = Color(0xFFFFFBFF) +val pink_md_theme_light_onBackground = Color(0xFF201A1B) +val pink_md_theme_light_outline = Color(0xFF847376) +val pink_md_theme_light_inverseOnSurface = Color(0xFFFAEEEF) +val pink_md_theme_light_inverseSurface = Color(0xFF352F30) +val pink_md_theme_light_inversePrimary = Color(0xFFFFB1C2) +val pink_md_theme_light_surfaceTint = Color(0xFFA73258) +val pink_md_theme_light_outlineVariant = Color(0xFFD6C2C4) +val pink_md_theme_light_scrim = Color(0xFF000000) +val pink_md_theme_light_surface = Color(0xFFFFF8F7) +val pink_md_theme_light_onSurface = Color(0xFF201A1B) +val pink_md_theme_light_surfaceVariant = Color(0xFFF3DDE0) +val pink_md_theme_light_onSurfaceVariant = Color(0xFF514346) + +val pink_md_theme_dark_primary = Color(0xFFFFB1C2) +val pink_md_theme_dark_onPrimary = Color(0xFF66002B) +val pink_md_theme_dark_primaryContainer = Color(0xFF881841) +val pink_md_theme_dark_onPrimaryContainer = Color(0xFFFFD9E0) +val pink_md_theme_dark_secondary = Color(0xFFE4BDC4) +val pink_md_theme_dark_onSecondary = Color(0xFF43292F) +val pink_md_theme_dark_secondaryContainer = Color(0xFF5B3F45) +val pink_md_theme_dark_onSecondaryContainer = Color(0xFFFFD9E0) +val pink_md_theme_dark_tertiary = Color(0xFFEDBE91) +val pink_md_theme_dark_onTertiary = Color(0xFF462A09) +val pink_md_theme_dark_tertiaryContainer = Color(0xFF60401E) +val pink_md_theme_dark_onTertiaryContainer = Color(0xFFFFDCBE) +val pink_md_theme_dark_error = Color(0xFFFFB4AB) +val pink_md_theme_dark_errorContainer = Color(0xFF93000A) +val pink_md_theme_dark_onError = Color(0xFF690005) +val pink_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val pink_md_theme_dark_background = Color(0xFF201A1B) +val pink_md_theme_dark_onBackground = Color(0xFFECE0E1) +val pink_md_theme_dark_outline = Color(0xFF9E8C8F) +val pink_md_theme_dark_inverseOnSurface = Color(0xFF201A1B) +val pink_md_theme_dark_inverseSurface = Color(0xFFECE0E1) +val pink_md_theme_dark_inversePrimary = Color(0xFFA73258) +val pink_md_theme_dark_surfaceTint = Color(0xFFFFB1C2) +val pink_md_theme_dark_outlineVariant = Color(0xFF514346) +val pink_md_theme_dark_scrim = Color(0xFF000000) +val pink_md_theme_dark_surface = Color(0xFF171213) +val pink_md_theme_dark_onSurface = Color(0xFFCFC4C5) +val pink_md_theme_dark_surfaceVariant = Color(0xFF514346) +val pink_md_theme_dark_onSurfaceVariant = Color(0xFFD6C2C4) + + +val pink_seed = Color(0xFFFA7298) + +// CYAN +val cyan_md_theme_light_primary = Color(0xFF006B60) +val cyan_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val cyan_md_theme_light_primaryContainer = Color(0xFF74F8E4) +val cyan_md_theme_light_onPrimaryContainer = Color(0xFF00201C) +val cyan_md_theme_light_secondary = Color(0xFF4A635E) +val cyan_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val cyan_md_theme_light_secondaryContainer = Color(0xFFCCE8E2) +val cyan_md_theme_light_onSecondaryContainer = Color(0xFF05201C) +val cyan_md_theme_light_tertiary = Color(0xFF456179) +val cyan_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val cyan_md_theme_light_tertiaryContainer = Color(0xFFCCE5FF) +val cyan_md_theme_light_onTertiaryContainer = Color(0xFF001E31) +val cyan_md_theme_light_error = Color(0xFFBA1A1A) +val cyan_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val cyan_md_theme_light_onError = Color(0xFFFFFFFF) +val cyan_md_theme_light_onErrorContainer = Color(0xFF410002) +val cyan_md_theme_light_background = Color(0xFFFAFDFB) +val cyan_md_theme_light_onBackground = Color(0xFF191C1B) +val cyan_md_theme_light_outline = Color(0xFF6F7976) +val cyan_md_theme_light_inverseOnSurface = Color(0xFFEFF1EF) +val cyan_md_theme_light_inverseSurface = Color(0xFF2D3130) +val cyan_md_theme_light_inversePrimary = Color(0xFF54DBC8) +val cyan_md_theme_light_surfaceTint = Color(0xFF006B60) +val cyan_md_theme_light_outlineVariant = Color(0xFFBEC9C5) +val cyan_md_theme_light_scrim = Color(0xFF000000) +val cyan_md_theme_light_surface = Color(0xFFF7FAF8) +val cyan_md_theme_light_onSurface = Color(0xFF191C1B) +val cyan_md_theme_light_surfaceVariant = Color(0xFFDAE5E1) +val cyan_md_theme_light_onSurfaceVariant = Color(0xFF3F4946) + +val cyan_md_theme_dark_primary = Color(0xFF54DBC8) +val cyan_md_theme_dark_onPrimary = Color(0xFF003731) +val cyan_md_theme_dark_primaryContainer = Color(0xFF005048) +val cyan_md_theme_dark_onPrimaryContainer = Color(0xFF74F8E4) +val cyan_md_theme_dark_secondary = Color(0xFFB1CCC6) +val cyan_md_theme_dark_onSecondary = Color(0xFF1C3531) +val cyan_md_theme_dark_secondaryContainer = Color(0xFF334B47) +val cyan_md_theme_dark_onSecondaryContainer = Color(0xFFCCE8E2) +val cyan_md_theme_dark_tertiary = Color(0xFFADCAE5) +val cyan_md_theme_dark_onTertiary = Color(0xFF143349) +val cyan_md_theme_dark_tertiaryContainer = Color(0xFF2D4960) +val cyan_md_theme_dark_onTertiaryContainer = Color(0xFFCCE5FF) +val cyan_md_theme_dark_error = Color(0xFFFFB4AB) +val cyan_md_theme_dark_errorContainer = Color(0xFF93000A) +val cyan_md_theme_dark_onError = Color(0xFF690005) +val cyan_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val cyan_md_theme_dark_background = Color(0xFF191C1B) +val cyan_md_theme_dark_onBackground = Color(0xFFE0E3E1) +val cyan_md_theme_dark_outline = Color(0xFF899390) +val cyan_md_theme_dark_inverseOnSurface = Color(0xFF191C1B) +val cyan_md_theme_dark_inverseSurface = Color(0xFFE0E3E1) +val cyan_md_theme_dark_inversePrimary = Color(0xFF006B60) +val cyan_md_theme_dark_surfaceTint = Color(0xFF54DBC8) +val cyan_md_theme_dark_outlineVariant = Color(0xFF3F4946) +val cyan_md_theme_dark_scrim = Color(0xFF000000) +val cyan_md_theme_dark_surface = Color(0xFF101413) +val cyan_md_theme_dark_onSurface = Color(0xFFC4C7C5) +val cyan_md_theme_dark_surfaceVariant = Color(0xFF3F4946) +val cyan_md_theme_dark_onSurfaceVariant = Color(0xFFBEC9C5) + + +val cyan_seed = Color(0xFF009788) + +// ORANGE +val orange_md_theme_light_primary = Color(0xFF8B5000) +val orange_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val orange_md_theme_light_primaryContainer = Color(0xFFFFDCBE) +val orange_md_theme_light_onPrimaryContainer = Color(0xFF2D1600) +val orange_md_theme_light_secondary = Color(0xFF735A42) +val orange_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val orange_md_theme_light_secondaryContainer = Color(0xFFFFDCBE) +val orange_md_theme_light_onSecondaryContainer = Color(0xFF291806) +val orange_md_theme_light_tertiary = Color(0xFF586339) +val orange_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val orange_md_theme_light_tertiaryContainer = Color(0xFFDCE8B4) +val orange_md_theme_light_onTertiaryContainer = Color(0xFF161E00) +val orange_md_theme_light_error = Color(0xFFBA1A1A) +val orange_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val orange_md_theme_light_onError = Color(0xFFFFFFFF) +val orange_md_theme_light_onErrorContainer = Color(0xFF410002) +val orange_md_theme_light_background = Color(0xFFFFFBFF) +val orange_md_theme_light_onBackground = Color(0xFF201B16) +val orange_md_theme_light_outline = Color(0xFF837468) +val orange_md_theme_light_inverseOnSurface = Color(0xFFFAEFE7) +val orange_md_theme_light_inverseSurface = Color(0xFF352F2B) +val orange_md_theme_light_inversePrimary = Color(0xFFFFB871) +val orange_md_theme_light_surfaceTint = Color(0xFF8B5000) +val orange_md_theme_light_outlineVariant = Color(0xFFD5C3B5) +val orange_md_theme_light_scrim = Color(0xFF000000) +val orange_md_theme_light_surface = Color(0xFFFFF8F5) +val orange_md_theme_light_onSurface = Color(0xFF201B16) +val orange_md_theme_light_surfaceVariant = Color(0xFFF2DFD1) +val orange_md_theme_light_onSurfaceVariant = Color(0xFF51453A) + +val orange_md_theme_dark_primary = Color(0xFFFFB871) +val orange_md_theme_dark_onPrimary = Color(0xFF4A2800) +val orange_md_theme_dark_primaryContainer = Color(0xFF6A3C00) +val orange_md_theme_dark_onPrimaryContainer = Color(0xFFFFDCBE) +val orange_md_theme_dark_secondary = Color(0xFFE1C1A4) +val orange_md_theme_dark_onSecondary = Color(0xFF402C18) +val orange_md_theme_dark_secondaryContainer = Color(0xFF59422D) +val orange_md_theme_dark_onSecondaryContainer = Color(0xFFFFDCBE) +val orange_md_theme_dark_tertiary = Color(0xFFC0CC9A) +val orange_md_theme_dark_onTertiary = Color(0xFF2B3410) +val orange_md_theme_dark_tertiaryContainer = Color(0xFF414B24) +val orange_md_theme_dark_onTertiaryContainer = Color(0xFFDCE8B4) +val orange_md_theme_dark_error = Color(0xFFFFB4AB) +val orange_md_theme_dark_errorContainer = Color(0xFF93000A) +val orange_md_theme_dark_onError = Color(0xFF690005) +val orange_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val orange_md_theme_dark_background = Color(0xFF201B16) +val orange_md_theme_dark_onBackground = Color(0xFFEBE0D9) +val orange_md_theme_dark_outline = Color(0xFF9E8E81) +val orange_md_theme_dark_inverseOnSurface = Color(0xFF201B16) +val orange_md_theme_dark_inverseSurface = Color(0xFFEBE0D9) +val orange_md_theme_dark_inversePrimary = Color(0xFF8B5000) +val orange_md_theme_dark_surfaceTint = Color(0xFFFFB871) +val orange_md_theme_dark_outlineVariant = Color(0xFF51453A) +val orange_md_theme_dark_scrim = Color(0xFF000000) +val orange_md_theme_dark_surface = Color(0xFF17120F) +val orange_md_theme_dark_onSurface = Color(0xFFCFC5BE) +val orange_md_theme_dark_surfaceVariant = Color(0xFF51453A) +val orange_md_theme_dark_onSurfaceVariant = Color(0xFFD5C3B5) + + +val orange_seed = Color(0xFFFF9700) + +// PURPLE +val purple_md_theme_light_primary = Color(0xFF6F44BF) +val purple_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val purple_md_theme_light_primaryContainer = Color(0xFFEBDDFF) +val purple_md_theme_light_onPrimaryContainer = Color(0xFF250059) +val purple_md_theme_light_secondary = Color(0xFF635B70) +val purple_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val purple_md_theme_light_secondaryContainer = Color(0xFFE9DEF8) +val purple_md_theme_light_onSecondaryContainer = Color(0xFF1F182B) +val purple_md_theme_light_tertiary = Color(0xFF7E525E) +val purple_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val purple_md_theme_light_tertiaryContainer = Color(0xFFFFD9E1) +val purple_md_theme_light_onTertiaryContainer = Color(0xFF31101B) +val purple_md_theme_light_error = Color(0xFFBA1A1A) +val purple_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val purple_md_theme_light_onError = Color(0xFFFFFFFF) +val purple_md_theme_light_onErrorContainer = Color(0xFF410002) +val purple_md_theme_light_background = Color(0xFFFFFBFF) +val purple_md_theme_light_onBackground = Color(0xFF1D1B1E) +val purple_md_theme_light_outline = Color(0xFF7A757F) +val purple_md_theme_light_inverseOnSurface = Color(0xFFF5EFF4) +val purple_md_theme_light_inverseSurface = Color(0xFF323033) +val purple_md_theme_light_inversePrimary = Color(0xFFD3BBFF) +val purple_md_theme_light_surfaceTint = Color(0xFF6F44BF) +val purple_md_theme_light_outlineVariant = Color(0xFFCBC4CF) +val purple_md_theme_light_scrim = Color(0xFF000000) +val purple_md_theme_light_surface = Color(0xFFFDF8FD) +val purple_md_theme_light_onSurface = Color(0xFF1D1B1E) +val purple_md_theme_light_surfaceVariant = Color(0xFFE7E0EB) +val purple_md_theme_light_onSurfaceVariant = Color(0xFF49454E) + +val purple_md_theme_dark_primary = Color(0xFFD3BBFF) +val purple_md_theme_dark_onPrimary = Color(0xFF3F008D) +val purple_md_theme_dark_primaryContainer = Color(0xFF5628A6) +val purple_md_theme_dark_onPrimaryContainer = Color(0xFFEBDDFF) +val purple_md_theme_dark_secondary = Color(0xFFCDC2DB) +val purple_md_theme_dark_onSecondary = Color(0xFF342D40) +val purple_md_theme_dark_secondaryContainer = Color(0xFF4B4358) +val purple_md_theme_dark_onSecondaryContainer = Color(0xFFE9DEF8) +val purple_md_theme_dark_tertiary = Color(0xFFF0B7C5) +val purple_md_theme_dark_onTertiary = Color(0xFF4A2530) +val purple_md_theme_dark_tertiaryContainer = Color(0xFF643B46) +val purple_md_theme_dark_onTertiaryContainer = Color(0xFFFFD9E1) +val purple_md_theme_dark_error = Color(0xFFFFB4AB) +val purple_md_theme_dark_errorContainer = Color(0xFF93000A) +val purple_md_theme_dark_onError = Color(0xFF690005) +val purple_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val purple_md_theme_dark_background = Color(0xFF1D1B1E) +val purple_md_theme_dark_onBackground = Color(0xFFE6E1E6) +val purple_md_theme_dark_outline = Color(0xFF948F99) +val purple_md_theme_dark_inverseOnSurface = Color(0xFF1D1B1E) +val purple_md_theme_dark_inverseSurface = Color(0xFFE6E1E6) +val purple_md_theme_dark_inversePrimary = Color(0xFF6F44BF) +val purple_md_theme_dark_surfaceTint = Color(0xFFD3BBFF) +val purple_md_theme_dark_outlineVariant = Color(0xFF49454E) +val purple_md_theme_dark_scrim = Color(0xFF000000) +val purple_md_theme_dark_surface = Color(0xFF141316) +val purple_md_theme_dark_onSurface = Color(0xFFCAC5CA) +val purple_md_theme_dark_surfaceVariant = Color(0xFF49454E) +val purple_md_theme_dark_onSurfaceVariant = Color(0xFFCBC4CF) + + +val purple_seed = Color(0xFF673BB7) + +// BROWN +val brown_md_theme_light_primary = Color(0xFF9A4520) +val brown_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val brown_md_theme_light_primaryContainer = Color(0xFFFFDBCE) +val brown_md_theme_light_onPrimaryContainer = Color(0xFF370E00) +val brown_md_theme_light_secondary = Color(0xFF77574B) +val brown_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val brown_md_theme_light_secondaryContainer = Color(0xFFFFDBCE) +val brown_md_theme_light_onSecondaryContainer = Color(0xFF2C160D) +val brown_md_theme_light_tertiary = Color(0xFF685F30) +val brown_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val brown_md_theme_light_tertiaryContainer = Color(0xFFF1E3A8) +val brown_md_theme_light_onTertiaryContainer = Color(0xFF211B00) +val brown_md_theme_light_error = Color(0xFFBA1A1A) +val brown_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val brown_md_theme_light_onError = Color(0xFFFFFFFF) +val brown_md_theme_light_onErrorContainer = Color(0xFF410002) +val brown_md_theme_light_background = Color(0xFFFFFBFF) +val brown_md_theme_light_onBackground = Color(0xFF201A18) +val brown_md_theme_light_outline = Color(0xFF85736D) +val brown_md_theme_light_inverseOnSurface = Color(0xFFFBEEEA) +val brown_md_theme_light_inverseSurface = Color(0xFF362F2C) +val brown_md_theme_light_inversePrimary = Color(0xFFFFB599) +val brown_md_theme_light_surfaceTint = Color(0xFF9A4520) +val brown_md_theme_light_outlineVariant = Color(0xFFD8C2BA) +val brown_md_theme_light_scrim = Color(0xFF000000) +val brown_md_theme_light_surface = Color(0xFFFFF8F6) +val brown_md_theme_light_onSurface = Color(0xFF201A18) +val brown_md_theme_light_surfaceVariant = Color(0xFFF5DED6) +val brown_md_theme_light_onSurfaceVariant = Color(0xFF53433E) + +val brown_md_theme_dark_primary = Color(0xFFFFB599) +val brown_md_theme_dark_onPrimary = Color(0xFF5A1C00) +val brown_md_theme_dark_primaryContainer = Color(0xFF7B2F0A) +val brown_md_theme_dark_onPrimaryContainer = Color(0xFFFFDBCE) +val brown_md_theme_dark_secondary = Color(0xFFE7BEAF) +val brown_md_theme_dark_onSecondary = Color(0xFF442A20) +val brown_md_theme_dark_secondaryContainer = Color(0xFF5D4035) +val brown_md_theme_dark_onSecondaryContainer = Color(0xFFFFDBCE) +val brown_md_theme_dark_tertiary = Color(0xFFD4C78E) +val brown_md_theme_dark_onTertiary = Color(0xFF383006) +val brown_md_theme_dark_tertiaryContainer = Color(0xFF4F471B) +val brown_md_theme_dark_onTertiaryContainer = Color(0xFFF1E3A8) +val brown_md_theme_dark_error = Color(0xFFFFB4AB) +val brown_md_theme_dark_errorContainer = Color(0xFF93000A) +val brown_md_theme_dark_onError = Color(0xFF690005) +val brown_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val brown_md_theme_dark_background = Color(0xFF201A18) +val brown_md_theme_dark_onBackground = Color(0xFFEDE0DC) +val brown_md_theme_dark_outline = Color(0xFFA08D86) +val brown_md_theme_dark_inverseOnSurface = Color(0xFF201A18) +val brown_md_theme_dark_inverseSurface = Color(0xFFEDE0DC) +val brown_md_theme_dark_inversePrimary = Color(0xFF9A4520) +val brown_md_theme_dark_surfaceTint = Color(0xFFFFB599) +val brown_md_theme_dark_outlineVariant = Color(0xFF53433E) +val brown_md_theme_dark_scrim = Color(0xFF000000) +val brown_md_theme_dark_surface = Color(0xFF181210) +val brown_md_theme_dark_onSurface = Color(0xFFD0C4C0) +val brown_md_theme_dark_surfaceVariant = Color(0xFF53433E) +val brown_md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BA) + + +val brown_seed = Color(0xFF795547) + +// GRAY +val gray_md_theme_light_primary = Color(0xFF006783) +val gray_md_theme_light_onPrimary = Color(0xFFFFFFFF) +val gray_md_theme_light_primaryContainer = Color(0xFFBDE9FF) +val gray_md_theme_light_onPrimaryContainer = Color(0xFF001F2A) +val gray_md_theme_light_secondary = Color(0xFF4D616C) +val gray_md_theme_light_onSecondary = Color(0xFFFFFFFF) +val gray_md_theme_light_secondaryContainer = Color(0xFFD0E6F2) +val gray_md_theme_light_onSecondaryContainer = Color(0xFF081E27) +val gray_md_theme_light_tertiary = Color(0xFF5D5B7D) +val gray_md_theme_light_onTertiary = Color(0xFFFFFFFF) +val gray_md_theme_light_tertiaryContainer = Color(0xFFE3DFFF) +val gray_md_theme_light_onTertiaryContainer = Color(0xFF191836) +val gray_md_theme_light_error = Color(0xFFBA1A1A) +val gray_md_theme_light_errorContainer = Color(0xFFFFDAD6) +val gray_md_theme_light_onError = Color(0xFFFFFFFF) +val gray_md_theme_light_onErrorContainer = Color(0xFF410002) +val gray_md_theme_light_background = Color(0xFFFBFCFE) +val gray_md_theme_light_onBackground = Color(0xFF191C1E) +val gray_md_theme_light_outline = Color(0xFF70787D) +val gray_md_theme_light_inverseOnSurface = Color(0xFFEFF1F3) +val gray_md_theme_light_inverseSurface = Color(0xFF2E3132) +val gray_md_theme_light_inversePrimary = Color(0xFF65D3FF) +val gray_md_theme_light_surfaceTint = Color(0xFF006783) +val gray_md_theme_light_outlineVariant = Color(0xFFC0C8CD) +val gray_md_theme_light_scrim = Color(0xFF000000) +val gray_md_theme_light_surface = Color(0xFFF8F9FB) +val gray_md_theme_light_onSurface = Color(0xFF191C1E) +val gray_md_theme_light_surfaceVariant = Color(0xFFDCE4E9) +val gray_md_theme_light_onSurfaceVariant = Color(0xFF40484C) + +val gray_md_theme_dark_primary = Color(0xFF65D3FF) +val gray_md_theme_dark_onPrimary = Color(0xFF003546) +val gray_md_theme_dark_primaryContainer = Color(0xFF004D64) +val gray_md_theme_dark_onPrimaryContainer = Color(0xFFBDE9FF) +val gray_md_theme_dark_secondary = Color(0xFFB4CAD6) +val gray_md_theme_dark_onSecondary = Color(0xFF1F333C) +val gray_md_theme_dark_secondaryContainer = Color(0xFF354A53) +val gray_md_theme_dark_onSecondaryContainer = Color(0xFFD0E6F2) +val gray_md_theme_dark_tertiary = Color(0xFFC6C2EA) +val gray_md_theme_dark_onTertiary = Color(0xFF2E2D4D) +val gray_md_theme_dark_tertiaryContainer = Color(0xFF454364) +val gray_md_theme_dark_onTertiaryContainer = Color(0xFFE3DFFF) +val gray_md_theme_dark_error = Color(0xFFFFB4AB) +val gray_md_theme_dark_errorContainer = Color(0xFF93000A) +val gray_md_theme_dark_onError = Color(0xFF690005) +val gray_md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +val gray_md_theme_dark_background = Color(0xFF191C1E) +val gray_md_theme_dark_onBackground = Color(0xFFE1E2E4) +val gray_md_theme_dark_outline = Color(0xFF8A9297) +val gray_md_theme_dark_inverseOnSurface = Color(0xFF191C1E) +val gray_md_theme_dark_inverseSurface = Color(0xFFE1E2E4) +val gray_md_theme_dark_inversePrimary = Color(0xFF006783) +val gray_md_theme_dark_surfaceTint = Color(0xFF65D3FF) +val gray_md_theme_dark_outlineVariant = Color(0xFF40484C) +val gray_md_theme_dark_scrim = Color(0xFF000000) +val gray_md_theme_dark_surface = Color(0xFF111415) +val gray_md_theme_dark_onSurface = Color(0xFFC5C7C8) +val gray_md_theme_dark_surfaceVariant = Color(0xFF40484C) +val gray_md_theme_dark_onSurfaceVariant = Color(0xFFC0C8CD) + + +val gray_seed = Color(0xFF607D8B) diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Theme.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Theme.kt new file mode 100644 index 000000000..809bfbca2 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Theme.kt @@ -0,0 +1,109 @@ +package com.github.jing332.tts_server_android.compose.theme + +import android.content.Context +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import com.github.jing332.tts_server_android.compose.widgets.SetupSystemBars +import com.github.jing332.tts_server_android.conf.AppConfig +import com.gyf.immersionbar.ImmersionBar + +/** + * 获取当前主题 + */ +@Composable +fun appTheme( + themeType: AppTheme, + darkTheme: Boolean = isSystemInDarkTheme(), + context: Context = LocalContext.current +): ColorScheme = + when (themeType) { + AppTheme.DEFAULT -> defaultTheme(darkTheme) + AppTheme.DYNAMIC_COLOR -> dynamicColorTheme(darkTheme, context) + AppTheme.GREEN -> greenTheme(darkTheme) + AppTheme.RED -> redTheme(darkTheme) + AppTheme.PINK -> pinkTheme(darkTheme) + AppTheme.BLUE -> blueTheme(darkTheme) + AppTheme.CYAN -> cyanTheme(darkTheme) + AppTheme.ORANGE -> orangeTheme(darkTheme) + AppTheme.PURPLE -> purpleTheme(darkTheme) + AppTheme.BROWN -> brownTheme(darkTheme) + AppTheme.GRAY -> grayTheme(darkTheme) + } + +//全局主题状态 +private val themeTypeState: MutableState by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { + mutableStateOf(AppTheme.DEFAULT) +} + +@Composable +private fun InitTheme() { + val theme = try { + AppConfig.theme.value + } catch (e: Exception) { + e.printStackTrace() + AppTheme.DEFAULT + } + setAppTheme(themeType = theme) +} + +/** + * 设置主题 + */ +fun setAppTheme(themeType: AppTheme) { + themeTypeState.value = themeType + AppConfig.theme.value = themeType +} + +/** + * 获取当前主题 + */ +fun getAppTheme(): AppTheme = themeTypeState.value + +/** + * 根Context + */ +@Composable +fun AppTheme( + modifier: Modifier = Modifier, + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + //初始化主题 + InitTheme() + + //获取当前主题 + val targetTheme = appTheme(themeType = themeTypeState.value) + + //沉浸式状态栏 + ImmersionBar.with(LocalView.current.context as ComponentActivity) +// .transparentStatusBar() +// .transparentNavigationBar() // BottomSheet 会有问题 多padding了一个输入法的高度 + .statusBarDarkFont(!darkTheme) + .navigationBarDarkIcon(!darkTheme) + .keyboardEnable(true) +// .keyboardMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + .init() + + SetupSystemBars() + + MaterialTheme( + colorScheme = themeAnimation(targetTheme = targetTheme), + typography = Typography + ) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.background, + content = content + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Type.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Type.kt new file mode 100644 index 000000000..a820cf8ab --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/theme/Type.kt @@ -0,0 +1,34 @@ +package com.github.jing332.tts_server_android.compose.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppBottomSheet.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppBottomSheet.kt new file mode 100644 index 000000000..a1d345159 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppBottomSheet.kt @@ -0,0 +1,44 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.dokar.sheets.BottomSheetValue +import com.dokar.sheets.m3.BottomSheet +import com.dokar.sheets.rememberBottomSheetState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun AppBottomSheet( + value: BottomSheetValue = BottomSheetValue.Peeked, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit +) { + val scope = rememberCoroutineScope() + val state = rememberBottomSheetState(confirmValueChange = { + if (it == BottomSheetValue.Collapsed) { + scope.launch(Dispatchers.Main) { + delay(200) + onDismissRequest() + } + } + + true + }) + LaunchedEffect(value) { + when (value) { + BottomSheetValue.Collapsed -> state.collapse() + BottomSheetValue.Expanded -> state.expand() + BottomSheetValue.Peeked -> state.peek() + } + } + BottomSheet( + modifier = Modifier.fillMaxSize(), + state = state, + content = content, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppDialog.kt new file mode 100644 index 000000000..10b6f75de --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppDialog.kt @@ -0,0 +1,221 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.asActivity +import com.github.jing332.tts_server_android.compose.asAppCompatActivity +import com.github.jing332.tts_server_android.compose.systts.replace.edit.SoftKeyboardInputToolbar +import com.github.jing332.tts_server_android.utils.SoftKeyboardUtils +import kotlin.math.max + +@Preview +@Composable +fun PreviewAppDialog() { + var show by remember { mutableStateOf(true) } + if (show) { + AppDialog(title = { + Text("Title") + }, content = { + Column(Modifier.verticalScroll(rememberScrollState())) { + for (i in 0..50) { + Text("Content") + } + } + }, buttons = { + TextButton(onClick = { + show = false + }) { + Text("Cancel") + } + TextButton(onClick = { + show = false + }) { + Text("OK") + } + }, onDismissRequest = { + show = false + }) + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + title: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit, + dialogContentPadding: PaddingValues = PaddingValues(12.dp), + buttons: @Composable BoxScope.() -> Unit = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = R.string.close)) } + }, +) = BasicAlertDialog(modifier = modifier, onDismissRequest = onDismissRequest, properties = properties) { + Surface( + tonalElevation = 8.dp, shadowElevation = 8.dp, shape = MaterialTheme.shapes.extraLarge + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dialogContentPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(modifier = Modifier.align(Alignment.CenterHorizontally)) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleLarge) { + title() + } + } + + Box( + Modifier + .weight(weight = 1f, fill = false) + .align(Alignment.Start) + ) { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.titleMedium) { + content() + } + } + + + Box(modifier = Modifier.align(Alignment.End)) { + AppDialogFlowRow( + mainAxisSpacing = ButtonsMainAxisSpacing, + crossAxisSpacing = ButtonsCrossAxisSpacing + ) { + buttons() + } + } + } + } +} + + +@Composable +internal fun AppDialogFlowRow( + mainAxisSpacing: Dp, crossAxisSpacing: Dp, content: @Composable () -> Unit +) { + Layout(content) { measurables, constraints -> + val sequences = mutableListOf>() + val crossAxisSizes = mutableListOf() + val crossAxisPositions = mutableListOf() + + var mainAxisSpace = 0 + var crossAxisSpace = 0 + + val currentSequence = mutableListOf() + var currentMainAxisSize = 0 + var currentCrossAxisSize = 0 + + // Return whether the placeable can be added to the current sequence. + fun canAddToCurrentSequence(placeable: Placeable) = + currentSequence.isEmpty() || currentMainAxisSize + mainAxisSpacing.roundToPx() + placeable.width <= constraints.maxWidth + + // Store current sequence information and start a new sequence. + fun startNewSequence() { + if (sequences.isNotEmpty()) { + crossAxisSpace += crossAxisSpacing.roundToPx() + } + // Ensures that confirming actions appear above dismissive actions. + sequences.add(0, currentSequence.toList()) + crossAxisSizes += currentCrossAxisSize + crossAxisPositions += crossAxisSpace + + crossAxisSpace += currentCrossAxisSize + mainAxisSpace = max(mainAxisSpace, currentMainAxisSize) + + currentSequence.clear() + currentMainAxisSize = 0 + currentCrossAxisSize = 0 + } + + for (measurable in measurables) { + // Ask the child for its preferred size. + val placeable = measurable.measure(constraints) + + // Start a new sequence if there is not enough space. + if (!canAddToCurrentSequence(placeable)) startNewSequence() + + // Add the child to the current sequence. + if (currentSequence.isNotEmpty()) { + currentMainAxisSize += mainAxisSpacing.roundToPx() + } + currentSequence.add(placeable) + currentMainAxisSize += placeable.width + currentCrossAxisSize = max(currentCrossAxisSize, placeable.height) + } + + if (currentSequence.isNotEmpty()) startNewSequence() + + val mainAxisLayoutSize = max(mainAxisSpace, constraints.minWidth) + + val crossAxisLayoutSize = max(crossAxisSpace, constraints.minHeight) + + val layoutWidth = mainAxisLayoutSize + + val layoutHeight = crossAxisLayoutSize + + layout(layoutWidth, layoutHeight) { + sequences.forEachIndexed { i, placeables -> + val childrenMainAxisSizes = IntArray(placeables.size) { j -> + placeables[j].width + if (j < placeables.lastIndex) mainAxisSpacing.roundToPx() else 0 + } + val arrangement = Arrangement.End + val mainAxisPositions = IntArray(childrenMainAxisSizes.size) { 0 } + with(arrangement) { + arrange( + mainAxisLayoutSize, + childrenMainAxisSizes, + layoutDirection, + mainAxisPositions + ) + } + placeables.forEachIndexed { j, placeable -> + placeable.place( + x = mainAxisPositions[j], y = crossAxisPositions[i] + ) + } + } + } + } +} + +private val ButtonsMainAxisSpacing = 8.dp +private val ButtonsCrossAxisSpacing = 12.dp \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppLauncherIcon.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppLauncherIcon.kt new file mode 100644 index 000000000..f767bdf80 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppLauncherIcon.kt @@ -0,0 +1,62 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import android.graphics.drawable.AdaptiveIconDrawable +import android.os.Build +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import com.github.jing332.tts_server_android.R + +// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486 +@Composable +fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter { + val res = LocalContext.current.resources + val theme = LocalContext.current.theme + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // A8 + // Android O supports adaptive icons + val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable + if (adaptiveIcon != null) + BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap()) + else + painterResource(id) + } else + painterResource(id) +} + +@Composable +fun AppLauncherIcon(modifier: Modifier) { + Image( + modifier = modifier.clip(CircleShape), + painter = adaptiveIconPainterResource(R.mipmap.ic_app_launcher_round), + contentDescription = "LOGO" + ) +// ResourcesCompat.getDrawable( +// LocalContext.current.resources, +// R.mipmap.ic_app_launcher_round, LocalContext.current.theme +// )?.let { drawable -> +// val bitmap = Bitmap.createBitmap( +// drawable.intrinsicWidth, drawable.intrinsicHeight, +// Bitmap.Config.ARGB_8888 +// ) +// val canvas = Canvas(bitmap) +// drawable.setBounds(0, 0, canvas.width, canvas.height) +// drawable.draw(canvas) +// Image( +// // painter = painterResource(R.mipmap.ic_launcher), +// bitmap = bitmap.asImageBitmap(), +// "LOGO", +// modifier = modifier +// ) +// } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppSelectionDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppSelectionDialog.kt new file mode 100644 index 000000000..3b0c40efe --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppSelectionDialog.kt @@ -0,0 +1,163 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.clickableRipple +import com.github.jing332.tts_server_android.utils.performLongPress +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun AppSelectionDialog( + onDismissRequest: () -> Unit, title: @Composable () -> Unit, + value: Any, + values: List, + entries: List, + isLoading: Boolean = false, + searchEnabled: Boolean = values.size > 5, + + itemContent: @Composable RowScope.(Boolean, String, Any) -> Unit = { isSelected, entry, _ -> + Text( + entry, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(8.dp), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ) + }, + + buttons: @Composable BoxScope.() -> Unit = { + TextButton(onClick = onDismissRequest) { Text(stringResource(id = R.string.close)) } + }, + + onValueSame: (Any, Any) -> Boolean = { a, b -> a == b }, + onClick: (Any, String) -> Unit, +) { + val context = LocalContext.current + val view = LocalView.current + AppDialog( + title = title, + content = { + val state = rememberLazyListState() + LaunchedEffect(values) { + val index = values.indexOfFirst { onValueSame(it, value) } + if (index >= 0 && index < entries.size) + state.scrollToItem(index) + } + Column(modifier = Modifier.fillMaxWidth()) { + var searchText by rememberSaveable { mutableStateOf("") } + + if (searchEnabled) { + val keyboardController = LocalSoftwareKeyboardController.current + + var text by rememberSaveable { mutableStateOf("") } + DenseOutlinedField( + modifier = Modifier.align(Alignment.CenterHorizontally), + value = text, onValueChange = { text = it }, + label = { Text(stringResource(id = R.string.search)) }, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { keyboardController?.hide() } + ) + ) + + LaunchedEffect(Unit) { + while (coroutineContext.isActive) { + delay(500) + searchText = text + } + } + } + + val isEmpty by remember { + derivedStateOf { state.layoutInfo.viewportSize == IntSize.Zero } + } + + if (searchText.isNotBlank() && isEmpty) + Text( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .minimumInteractiveComponentSize() + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.empty_list), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + + LoadingContent( + modifier = Modifier.padding(vertical = 16.dp), + isLoading = isLoading + ) { + LazyColumn(state = state) { + itemsIndexed(entries) { i, entry -> + if (searchEnabled && searchText.isNotBlank() && + !entry.contains(searchText, ignoreCase = true)) return@itemsIndexed + + val current = values[i] + val isSelected = onValueSame(value, current) + Row( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Unspecified) + .clickableRipple( + onClick = { onClick(current, entry) }, + onLongClick = { + view.performLongPress() + ClipboardUtils.copyText(entry) + context.toast(R.string.copied) + } + ) + .minimumInteractiveComponentSize(), + ) { + itemContent(isSelected, entry, value) + } + + } + } + } + } + }, + buttons = buttons, onDismissRequest = onDismissRequest, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppSpinner.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppSpinner.kt new file mode 100644 index 000000000..cf4cc34a8 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppSpinner.kt @@ -0,0 +1,184 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import com.github.jing332.tts_server_android.conf.AppConfig +import kotlin.math.max + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TextFieldSelectionDialog( + modifier: Modifier, + + label: @Composable () -> Unit = {}, + leadingIcon: @Composable (() -> Unit)? = null, + + value: Any, + values: List, + entries: List, + enabled: Boolean = true, + + onSelectedChange: (key: Any, value: String) -> Unit, + onValueSame: (current: Any, new: Any) -> Boolean = { current, new -> current == new }, +) { + val selectedText = entries.getOrNull(max(0, values.indexOf(value))) ?: "" + var expanded by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(values, entries) { + values.getOrNull(entries.indexOf(selectedText))?.let { + onSelectedChange.invoke(it, selectedText) + } + } + if (expanded) { + AppSelectionDialog( + onDismissRequest = { expanded = false }, + title = label, + value = value, + values = values, + entries = entries, + onClick = { v, entry -> + onSelectedChange.invoke(v, entry) + expanded = false + }, + onValueSame = onValueSame, + ) + } + + Box( + modifier = modifier + .clickable( + enabled = enabled, + role = Role.DropdownList, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { expanded = !expanded } + ) { + CompositionLocalProvider( + LocalTextInputService provides null, + LocalTextToolbar provides EmptyTextToolbar, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + enabled = false, + colors = if (enabled) OutlinedTextFieldDefaults.colors( + disabledContainerColor = Color.Transparent, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledLabelColor = MaterialTheme.colorScheme.onSurface, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurface, + + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + + disabledBorderColor = if (expanded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + + disabledPrefixColor = MaterialTheme.colorScheme.onSurface, + disabledSuffixColor = MaterialTheme.colorScheme.onSurface, + ) + else + OutlinedTextFieldDefaults.colors(), + + leadingIcon = leadingIcon, + readOnly = true, + value = selectedText, + onValueChange = { }, + label = label, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + ) + } + } +} + +@Composable +fun AppSpinner( + modifier: Modifier = Modifier, + label: @Composable (() -> Unit), + leadingIcon: @Composable (() -> Unit)? = null, + + value: Any, + values: List, + entries: List, + maxDropDownCount: Int = AppConfig.spinnerMaxDropDownCount.value, + enabled: Boolean = true, + + onValueSame: (current: Any, new: Any) -> Boolean = { current, new -> current == new }, + onSelectedChange: (key: Any, value: String) -> Unit, +) { + if (values.isNotEmpty() && !values.contains(value)) { + onSelectedChange.invoke(values[0], entries[0]) + } + + if (maxDropDownCount > 0 && values.size > maxDropDownCount) { + TextFieldSelectionDialog( + modifier = modifier, + label = label, + leadingIcon = leadingIcon, + value = value, + values = values, + entries = entries, + enabled = enabled, + onValueSame = onValueSame, + onSelectedChange = onSelectedChange, + ) + } else + DropdownTextField( + modifier = modifier, + label = label, + leadingIcon = leadingIcon, + value = value, + values = values, + entries = entries, + enabled = enabled, + onSelectedChange = onSelectedChange, + onValueSame = onValueSame, + ) + +// LaunchedEffect(keys) { +// keys.getOrNull(values.indexOf(selectedText))?.let { +// onSelectedChange.invoke(it, selectedText) +// } +// } + + +} + + +@Preview +@Composable +private fun ExposedDropTextFieldPreview() { + var key by remember { mutableIntStateOf(1) } + val list = 0.rangeTo(10).toList() + AppSpinner( + label = { Text("所属分组") }, + value = key, + values = list, + entries = list.map { it.toString() }, + maxDropDownCount = 2 + ) { k, _ -> + key = k as Int + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppTooltip.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppTooltip.kt new file mode 100644 index 000000000..458f5f147 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/AppTooltip.kt @@ -0,0 +1,31 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppTooltip( + modifier: Modifier = Modifier, + tooltip: String, + content: @Composable (tooltip: String) -> Unit +) { + val state = rememberTooltipState() + TooltipBox( + modifier = modifier, + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(tooltip) + } + }, + state = state, + content = { content(tooltip) }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ButtonToggleGroup.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ButtonToggleGroup.kt new file mode 100644 index 000000000..ce4262af9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ButtonToggleGroup.kt @@ -0,0 +1,371 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun ColumnToggleButtonGroup( + modifier: Modifier = Modifier, + buttonCount: Int, + primarySelection: Int = -1, + selectedColor: Color = Color.White, + unselectedColor: Color = Color.Unspecified, + selectedContentColor: Color = Color.Black, + unselectedContentColor: Color = Color.Gray, + buttonIconTint: Color = selectedContentColor, + unselectedButtonIconTint: Color = unselectedContentColor, + borderColor: Color = selectedColor, + buttonTexts: Array = Array(buttonCount) { "" }, + buttonIcons: Array = Array(buttonCount) { emptyPainter }, + shape: CornerBasedShape = MaterialTheme.shapes.small, + borderSize: Dp = 1.dp, + border: BorderStroke = BorderStroke(borderSize, borderColor), + elevation: ButtonElevation = ButtonDefaults.buttonElevation(), + enabled: Boolean = true, + buttonHeight: Dp = 60.dp, + iconPosition: IconPosition = IconPosition.Start, + onButtonClick: (index: Int) -> Unit, +) { + Column(modifier = modifier) { + val squareCorner = CornerSize(0.dp) + var selectionIndex by rememberSaveable { mutableStateOf(primarySelection) } + + repeat(buttonCount) { index -> + val buttonShape = when (index) { + 0 -> shape.copy(bottomStart = squareCorner, bottomEnd = squareCorner) + buttonCount - 1 -> shape.copy(topStart = squareCorner, topEnd = squareCorner) + else -> shape.copy(all = squareCorner) + } + val isButtonSelected = selectionIndex == index + val backgroundColor = if (isButtonSelected) selectedColor else unselectedColor + val contentColor = + if (isButtonSelected) selectedContentColor else unselectedContentColor + val iconTintColor = if (isButtonSelected) buttonIconTint else unselectedButtonIconTint + val offset = borderSize * -index + + ToggleButton( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = buttonHeight) + .offset(y = offset), + buttonShape = buttonShape, + border = border, + backgroundColor = backgroundColor, + elevation = elevation, + enabled = enabled, + buttonTexts = buttonTexts, + buttonIcons = buttonIcons, + index = index, + contentColor = contentColor, + iconTintColor = iconTintColor, + iconPosition = iconPosition, + onClick = { + selectionIndex = index + onButtonClick.invoke(index) + }, + ) + } + } +} + +@Composable +fun RowToggleButtonGroup( + modifier: Modifier = Modifier, + buttonCount: Int, + selectionIndex: Int, + selectedColor: Color = MaterialTheme.colorScheme.secondaryContainer, + unselectedColor: Color = Color.Unspecified, + selectedContentColor: Color = MaterialTheme.colorScheme.onBackground, + unselectedContentColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f), + buttonIconTint: Color = selectedContentColor, + unselectedButtonIconTint: Color = unselectedContentColor, + borderColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + buttonTexts: Array = Array(buttonCount) { "" }, + buttonIcons: Array = Array(buttonCount) { ColorPainter(Color.Transparent) }, + shape: CornerBasedShape = MaterialTheme.shapes.extraLarge, + borderSize: Dp = 1.dp, + border: BorderStroke = BorderStroke(borderSize, borderColor), + elevation: ButtonElevation = ButtonDefaults.buttonElevation(), + enabled: Boolean = true, + buttonHeight: Dp = 40.dp, + iconPosition: IconPosition = IconPosition.Start, + onButtonClick: (index: Int) -> Unit, +) { + Row(modifier = modifier.focusGroup()) { + val squareCorner = CornerSize(0.dp) + + repeat(buttonCount) { index -> + val buttonShape = when (index) { + 0 -> shape.copy(bottomEnd = squareCorner, topEnd = squareCorner) + buttonCount - 1 -> shape.copy(topStart = squareCorner, bottomStart = squareCorner) + else -> shape.copy(all = squareCorner) + } + val isButtonSelected = selectionIndex == index + val backgroundColor = if (isButtonSelected) selectedColor else unselectedColor + val contentColor = + if (isButtonSelected) selectedContentColor else unselectedContentColor + val iconTintColor = if (isButtonSelected) buttonIconTint else unselectedButtonIconTint + val offset = borderSize * -index + + ToggleButton( + modifier = Modifier + .semantics { +// role = Role.Button + focused = isButtonSelected + selected = isButtonSelected + contentDescription = buttonTexts[index] + stateDescription = buttonTexts[index] + } +// .weight(weight = 1f) + .defaultMinSize(minHeight = buttonHeight) + .offset(x = offset), + buttonShape = buttonShape, + border = border, + backgroundColor = backgroundColor, + elevation = elevation, + enabled = enabled, + buttonTexts = buttonTexts, + buttonIcons = buttonIcons, + index = index, + contentColor = contentColor, + iconTintColor = iconTintColor, + iconPosition = iconPosition, + onClick = { + onButtonClick.invoke(index) + }, + ) + } + } +} + +@Composable +private fun ToggleButton( + modifier: Modifier, + buttonShape: CornerBasedShape, + border: BorderStroke, + backgroundColor: Color, + elevation: ButtonElevation, + enabled: Boolean, + buttonTexts: Array, + buttonIcons: Array, + index: Int, + contentColor: Color, + iconTintColor: Color, + iconPosition: IconPosition, + onClick: () -> Unit, +) { + OutlinedButton( + modifier = modifier, + contentPadding = PaddingValues(), + shape = buttonShape, + border = border, + onClick = onClick, + colors = ButtonDefaults.outlinedButtonColors(containerColor = backgroundColor), + elevation = elevation, + enabled = enabled, + ) { + ButtonContent( + buttonTexts = buttonTexts, + buttonIcons = buttonIcons, + index = index, + contentColor = contentColor, + iconTintColor = iconTintColor, + iconPosition = iconPosition, + ) + } +} + +@Composable +private fun RowScope.ButtonContent( + buttonTexts: Array, + buttonIcons: Array, + index: Int, + contentColor: Color, + iconTintColor: Color, + iconPosition: IconPosition = IconPosition.Start, +) { + when { + buttonTexts.all { it != "" } && buttonIcons.all { it != emptyPainter } -> ButtonWithIconAndText( + iconTintColor = iconTintColor, + buttonIcons = buttonIcons, + buttonTexts = buttonTexts, + index = index, + contentColor = contentColor, + iconPosition = iconPosition, + ) + + buttonTexts.all { it != "" } && buttonIcons.all { it == emptyPainter } -> TextContent( + modifier = Modifier.align(Alignment.CenterVertically), + buttonTexts = buttonTexts, + index = index, + contentColor = contentColor, + ) + + buttonTexts.all { it == "" } && buttonIcons.all { it != emptyPainter } -> IconContent( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = 4.dp), + iconTintColor = iconTintColor, + buttonIcons = buttonIcons, + index = index, + ) + } +} + +@Composable +private fun RowScope.ButtonWithIconAndText( + iconTintColor: Color, + buttonIcons: Array, + buttonTexts: Array, + index: Int, + contentColor: Color, + iconPosition: IconPosition +) { + when (iconPosition) { + IconPosition.Start -> { + IconContent( + Modifier.align(Alignment.CenterVertically), + iconTintColor, + buttonIcons, + index + ) + TextContent( + Modifier.align(Alignment.CenterVertically), + buttonTexts, + index, + contentColor + ) + } + + IconPosition.Top -> Column { + IconContent( + Modifier.align(Alignment.CenterHorizontally), + iconTintColor, + buttonIcons, + index + ) + TextContent( + Modifier.align(Alignment.CenterHorizontally), + buttonTexts, + index, + contentColor + ) + } + + IconPosition.End -> { + TextContent( + Modifier.align(Alignment.CenterVertically), + buttonTexts, + index, + contentColor + ) + IconContent( + Modifier.align(Alignment.CenterVertically), + iconTintColor, + buttonIcons, + index + ) + } + + IconPosition.Bottom -> Column { + TextContent( + Modifier.align(Alignment.CenterHorizontally), + buttonTexts, + index, + contentColor + ) + IconContent( + Modifier.align(Alignment.CenterHorizontally), + iconTintColor, + buttonIcons, + index + ) + } + } +} + +@Composable +private fun IconContent( + modifier: Modifier, + iconTintColor: Color, + buttonIcons: Array, + index: Int +) { + if (iconTintColor == Color.Transparent || iconTintColor == Color.Unspecified) { + Image( + modifier = modifier + .size(24.dp) + .padding(start = 4.dp), + painter = buttonIcons[index], + contentDescription = null, + ) + } else { + Image( + modifier = modifier + .size(24.dp) + .padding(start = 4.dp), + painter = buttonIcons[index], + contentDescription = null, + colorFilter = ColorFilter.tint(iconTintColor), + ) + } +} + +@Composable +private fun TextContent( + modifier: Modifier, + buttonTexts: Array, + index: Int, + contentColor: Color +) { + Text( + modifier = modifier.padding(horizontal = 8.dp), + text = buttonTexts[index], + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +private val emptyPainter = ColorPainter(Color.Transparent) + +enum class IconPosition { + Start, Top, End, Bottom +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/CheckMenuItem.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/CheckMenuItem.kt new file mode 100644 index 000000000..af1bf8294 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/CheckMenuItem.kt @@ -0,0 +1,43 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +@Composable +fun CheckedMenuItem( + modifier: Modifier = Modifier, + text: @Composable () -> Unit, + checked: Boolean, + onClick: (Boolean) -> Unit, + onClickCheckBox: (Boolean) -> Unit = onClick, + leadingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + colors: MenuItemColors = MenuDefaults.itemColors(), + contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + DropdownMenuItem( + onClick = { onClick(!checked) }, + modifier = modifier, + enabled = enabled, + contentPadding = contentPadding, + interactionSource = interactionSource, + colors = colors, + text = text, + leadingIcon = leadingIcon, + trailingIcon = { + Checkbox( + checked = checked, + onCheckedChange = { onClickCheckBox.invoke(it) }, + enabled = enabled, + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/DenseTextField.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/DenseTextField.kt new file mode 100644 index 000000000..e4bc3f7a5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/DenseTextField.kt @@ -0,0 +1,240 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + + +@Composable +private fun textColor( + enabled: Boolean, + isError: Boolean, + interactionSource: InteractionSource +): State { + val focused by interactionSource.collectIsFocusedAsState() + + val targetValue = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.32f) + isError -> MaterialTheme.colorScheme.error + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.95f) + } + return rememberUpdatedState(targetValue) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DenseOutlinedField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = OutlinedTextFieldDefaults.shape, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors() +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + textColor(enabled, isError, interactionSource).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + +// CompositionLocalProvider(LocalTextSelectionColors provides colors.selectionColors) { + BasicTextField( + value = value, + modifier = if (label != null) { + modifier + // Merge semantics at the beginning of the modifier chain to ensure padding is + // considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = 8.dp) + } else { + modifier + }, +// .defaultMinSize( +// minWidth = OutlinedTextFieldDefaults.MinWidth, +// minHeight = OutlinedTextFieldDefaults.MinHeight +// ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, +// cursorBrush = SolidColor(colors.cursorColor(isError).value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + container = { + OutlinedTextFieldDefaults.ContainerBox( + enabled, + isError, + interactionSource, + colors, + shape + ) + }, + contentPadding = OutlinedTextFieldDefaults.contentPadding( + start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp, + ) + ) + } + ) +// } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DenseTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { + textColor(enabled, isError, interactionSource).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + +// CompositionLocalProvider(LocalTextSelectionColors provides colors.selectionColors) { + BasicTextField( + value = value, + modifier = if (label != null) { + modifier + // Merge semantics at the beginning of the modifier chain to ensure padding is + // considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = 8.dp) + } else { + modifier + }, +// .defaultMinSize( +// minWidth = OutlinedTextFieldDefaults.MinWidth, +// minHeight = OutlinedTextFieldDefaults.MinHeight +// ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, +// cursorBrush = SolidColor(colors.cursorColor(isError).value), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + container = { + TextFieldDefaults.ContainerBox( + enabled, + isError, + interactionSource, + colors, + shape + ) + }, + contentPadding = PaddingValues( + start = 12.dp, top = 10.dp, end = 12.dp, bottom = 10.dp, + ) + ) + } + ) +// } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/DropdownTextField.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/DropdownTextField.kt new file mode 100644 index 000000000..626c3d2a2 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/DropdownTextField.kt @@ -0,0 +1,114 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import kotlin.math.max + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun DropdownTextField( + modifier: Modifier = Modifier, + label: @Composable() (() -> Unit), + value: Any, + values: List, + entries: List, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, + onValueSame: (current: Any, new: Any) -> Boolean = { current, new -> current == new }, + onSelectedChange: (value: Any, entry: String) -> Unit, +) { + var selectedText = entries.getOrNull(max(0, values.indexOf(value))) ?: "" + var expanded by remember { mutableStateOf(false) } + + LaunchedEffect(values, entries) { + values.getOrNull(entries.indexOf(selectedText))?.let { + onSelectedChange.invoke(it, selectedText) + } + } + + CompositionLocalProvider( + LocalTextInputService provides null // Disable Keyboard + ) { + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + if (enabled) expanded = !expanded + }, + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + leadingIcon = leadingIcon, + readOnly = true, + enabled = enabled, + value = selectedText, + onValueChange = { }, + label = label, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + entries.forEachIndexed { index, text -> + val checked = onValueSame(value, values[index]) + DropdownMenuItem( + text = { + Text( + text, + fontWeight = if (checked) FontWeight.Bold else FontWeight.Normal + ) + }, + onClick = { + expanded = false + selectedText = text + onSelectedChange.invoke(values[index], text) + }, modifier = Modifier.background( + if (checked) MaterialTheme.colorScheme.secondaryContainer + else Color.Unspecified + ) + ) + } + } + } + } +} + + +@Preview +@Composable +private fun PreviewDropdownTextField() { + var key by remember { mutableIntStateOf(1) } + AppSpinner( + label = { Text("所属分组") }, + value = key, + values = listOf(1, 2, 3), + entries = listOf("1", "2", "3"), + ) { k, _ -> + key = k as Int + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/EmptyTextToolbar.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/EmptyTextToolbar.kt new file mode 100644 index 000000000..90fb82207 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/EmptyTextToolbar.kt @@ -0,0 +1,20 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.TextToolbar +import androidx.compose.ui.platform.TextToolbarStatus + +object EmptyTextToolbar : TextToolbar { + override val status: TextToolbarStatus = TextToolbarStatus.Hidden + + override fun hide() {} + + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + ) { + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ErrorDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ErrorDialog.kt new file mode 100644 index 000000000..f09753433 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ErrorDialog.kt @@ -0,0 +1,86 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ErrorDialog( + t: Throwable? = null, + title: String = stringResource(R.string.error), + message: String = t?.localizedMessage ?: "", + onDismiss: () -> Unit = {} +) { + var isShow by remember { mutableStateOf(true) } + if (isShow) + AlertDialog( + icon = { Icon(Icons.Filled.ErrorOutline, "", tint = MaterialTheme.colorScheme.error) }, + title = { Text(title) }, + text = { + Column { + Text( + text = message, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp) + ) + t?.stackTraceToString()?.let { traceString -> + val lines = traceString.lines() + LazyColumn(modifier = Modifier.horizontalScroll(rememberScrollState())) { + item { + lines.forEach { + Text( + text = it, + fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + }, + onDismissRequest = { + isShow = false + onDismiss.invoke() + }, + confirmButton = { + Button(onClick = { + isShow = false + onDismiss.invoke() + }) { + Text(stringResource(android.R.string.ok)) + } + } + ) +} + +@Preview +@Composable +private fun PreviewErrorDialog() { + ErrorDialog(Throwable("error")) +} + diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ExpandableText.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ExpandableText.kt new file mode 100644 index 000000000..0ed83054b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/ExpandableText.kt @@ -0,0 +1,112 @@ +/* +package com.github.jing332.text_searcher.ui.widgets + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.TextUnit +import com.github.jing332.text_searcher.R + + +// from https://stackoverflow.com/a/72982110/13197001 +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ExpandableText( + modifier: Modifier = Modifier, + textModifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + fontFamily: FontFamily = FontFamily.Default, + fontStyle: FontStyle? = null, + fontSize: TextUnit = LocalTextStyle.current.fontSize, + fontWeight: FontWeight = LocalTextStyle.current.fontWeight ?: FontWeight.Normal, + lineHeight: TextUnit = LocalTextStyle.current.lineHeight, + text: String, + collapsedMaxLine: Int = 2, + showMoreText: String = stringResource(R.string.expandable_text_more), + showMoreStyle: SpanStyle = SpanStyle( + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.primary + ), + showLessText: String = stringResource(R.string.expandable_text_less), + showLessStyle: SpanStyle = showMoreStyle, + textAlign: TextAlign? = null, + + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null +) { + var isExpanded by remember { mutableStateOf(false) } + var clickable by remember { mutableStateOf(false) } + var lastCharIndex by remember { mutableIntStateOf(0) } + + Box( + modifier = Modifier + .combinedClickable( + onClick = { + if (clickable) + isExpanded = !isExpanded + }, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + onClickLabel = if (isExpanded) showLessText else showMoreText + ) + .then(modifier) + ) { + Text( + modifier = textModifier + .fillMaxWidth() + .animateContentSize(), + text = buildAnnotatedString { + if (clickable) { + if (isExpanded) { + append(text) + withStyle(style = showLessStyle) { append(showLessText) } + } else { + val adjustText = text.substring(startIndex = 0, endIndex = lastCharIndex) + .dropLast(showMoreText.length) + .dropLastWhile { Character.isWhitespace(it) || it == '.' } + append(adjustText) + withStyle(style = showMoreStyle) { append(showMoreText) } + } + } else { + append(text) + } + }, + maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLine, + fontStyle = fontStyle, + onTextLayout = { textLayoutResult -> + if (!isExpanded && textLayoutResult.hasVisualOverflow) { + clickable = true + lastCharIndex = textLayoutResult.getLineEnd(collapsedMaxLine - 1) + } + }, + style = style, + textAlign = textAlign, + fontSize = fontSize, + fontWeight = fontWeight, + lineHeight = lineHeight, + fontFamily = fontFamily + ) + } + +}*/ diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/HtmlText.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/HtmlText.kt new file mode 100644 index 000000000..0c5166d9f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/HtmlText.kt @@ -0,0 +1,66 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.core.text.HtmlCompat +import com.github.jing332.tts_server_android.utils.toAnnotatedString + +@Composable +fun HtmlText( + modifier: Modifier = Modifier, + text: String, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + val spanned = remember(text) { + HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString() + } + Text( + modifier = modifier, + text = spanned, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + inlineContent = inlineContent, + onTextLayout = onTextLayout, + style = style + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LabelSlider.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LabelSlider.kt new file mode 100644 index 000000000..c05a64212 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LabelSlider.kt @@ -0,0 +1,201 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.utils.performLongPress + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun LabelSlider( + modifier: Modifier = Modifier, + enabled: Boolean = true, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + + showButton: Boolean = true, + buttonSteps: Float = 0.01f, + buttonLongSteps: Float = 0.1f, + + valueChange: (Float) -> Unit = { + if (it < valueRange.start) onValueChange(valueRange.start) + else if (it > valueRange.endInclusive) onValueChange(valueRange.endInclusive) + else onValueChange(it) + }, + + onValueRemove: (longClick: Boolean) -> Unit = { + valueChange(value - (if (it) buttonLongSteps else buttonSteps)) + }, + onValueAdd: (longClick: Boolean) -> Unit = { + valueChange(value + if (it) buttonLongSteps else buttonSteps) + }, + + text: String, +) { + LabelSlider( + modifier = modifier, + enabled = enabled, + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished, + showButton = showButton, + buttonSteps = buttonSteps, + buttonLongSteps = buttonLongSteps, + valueChange = valueChange, + onValueRemove = onValueRemove, + onValueAdd = onValueAdd, + a11yDescription = text, + ) { + Text(text = text, modifier = Modifier.semantics { invisibleToUser() }) + } +} + +@Composable +fun LabelSlider( + modifier: Modifier = Modifier, + enabled: Boolean = true, + value: Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + + showButton: Boolean = true, + buttonSteps: Float = 0.01f, + buttonLongSteps: Float = 0.1f, + + valueChange: (Float) -> Unit = { + if (it < valueRange.start) onValueChange(valueRange.start) + else if (it > valueRange.endInclusive) onValueChange(valueRange.endInclusive) + else onValueChange(it) + }, + + onValueRemove: (longClick: Boolean) -> Unit = { + valueChange(value - (if (it) buttonLongSteps else buttonSteps)) + }, + onValueAdd: (longClick: Boolean) -> Unit = { + valueChange(value + if (it) buttonLongSteps else buttonSteps) + }, + + a11yDescription: String = "", + text: @Composable BoxScope.() -> Unit, +) { + val view = LocalView.current + ConstraintLayout(modifier) { + val (textRef, sliderRef) = createRefs() + Box( + modifier = Modifier + .constrainAs(textRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + end.linkTo(parent.end) + } + .wrapContentHeight() + ) { + text() + } + Row(Modifier.constrainAs(sliderRef) { + start.linkTo(parent.start) + end.linkTo(parent.end) + top.linkTo(textRef.bottom, margin = (-12).dp) + }) { + if (showButton) + LongClickIconButton( + onClick = { + onValueRemove(false) + }, + onLongClick = { + onValueRemove(true) + }, + enabled = value > valueRange.start, + modifier = Modifier + .semantics { + contentDescription = a11yDescription + } + ) { + Icon(Icons.Default.Remove, stringResource(id = R.string.desc_seekbar_remove)) + } + Slider( + modifier = Modifier + .weight(1f) + .semantics { + stateDescription = a11yDescription + contentDescription = a11yDescription + }, + value = value, + onValueChange = { + onValueChange(it) + + if (it == valueRange.start || it == valueRange.endInclusive) + view.performLongPress() + }, + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished + ) + if (showButton) + LongClickIconButton( + onClick = { + onValueAdd(false) + }, + onLongClick = { + onValueAdd(true) + }, + enabled = value < valueRange.endInclusive, + modifier = Modifier + .semantics { + contentDescription = a11yDescription + } + ) { + Icon(Icons.Default.Add, stringResource(id = R.string.desc_seekbar_add)) + } + + } + } +} + +@Preview +@Composable +fun PreviewSlider() { + var value by remember { mutableFloatStateOf(0f) } + val str = "语速: $value" + LabelSlider( + value = value, + onValueChange = { value = it }, + valueRange = 0.1f..3.0f, + a11yDescription = str, + buttonSteps = 0.1f + ) { + Text(str) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LazyListIndexStateSaver.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LazyListIndexStateSaver.kt new file mode 100644 index 000000000..0c6919ac2 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LazyListIndexStateSaver.kt @@ -0,0 +1,36 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +@Composable +fun LazyListIndexStateSaver( + models: Any?, + listState: LazyListState, + + onIndexUpdate: suspend (Int, Int) -> Unit = { index, offset -> + listState.scrollToItem(index, offset) + }, +) { + var index by rememberSaveable { mutableIntStateOf(0) } + var offset by rememberSaveable { mutableIntStateOf(0) } + + LaunchedEffect(models) { + if (models != null && listState.firstVisibleItemIndex <= 0 && listState.firstVisibleItemScrollOffset <= 0) { + onIndexUpdate(index, offset) + } + } + + DisposableEffect(Unit) { + onDispose { + index = listState.firstVisibleItemIndex + offset = listState.firstVisibleItemScrollOffset + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingAnimation.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingAnimation.kt new file mode 100644 index 000000000..50613ee3b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingAnimation.kt @@ -0,0 +1,140 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.github.jing332.tts_server_android.R +import kotlinx.coroutines.delay + +//@OptIn(ExperimentalMaterial3Api::class) +//@Composable +//fun LoadingDialog(onDismissRequest: () -> Unit) { +// AlertDialog( +// onDismissRequest = onDismissRequest, +// properties = DialogProperties(dismissOnClickOutside = false) +// ) { +// Surface( +// tonalElevation = 8.dp, +// shape = MaterialTheme.shapes.small, +// modifier = Modifier.wrapContentSize() +// ) { +// Column(Modifier.padding(horizontal = 48.dp, vertical = 24.dp)) { +// LoadingAnimation() +// Text( +// stringResource(id = R.string.loading), +// Modifier +// .padding(top = 8.dp) +// .align(Alignment.CenterHorizontally), +// style = MaterialTheme.typography.titleMedium +// ) +// } +// } +// } +//} + +//@Preview +//@Composable +//fun PreviewLoadingDialog() { +// var show by remember { mutableStateOf(true) } +// if (show) { +// LoadingDialog { +// show = false +// } +// } +//} + +// https://github.com/JustAmalll/LoadingAnimation +@Composable +fun LoadingAnimation( + modifier: Modifier = Modifier, + circleSize: Dp = 16.dp, + circleColor: Color = MaterialTheme.colorScheme.primary, + spaceBetween: Dp = 6.dp, + travelDistance: Dp = 10.dp +) { + val circle = listOf( + remember { + androidx.compose.animation.core.Animatable(initialValue = 0f) + }, + remember { + androidx.compose.animation.core.Animatable(initialValue = 0f) + }, + remember { + androidx.compose.animation.core.Animatable(initialValue = 0f) + } + ) + + circle.forEachIndexed { index, animatable -> + LaunchedEffect(key1 = animatable) { + delay(index * 100L) + animatable.animateTo( + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0.0f at 0 with LinearOutSlowInEasing + 1.0f at 300 with LinearOutSlowInEasing + 0.0f at 600 with LinearOutSlowInEasing + 0.0f at 1200 with LinearOutSlowInEasing + }, + repeatMode = RepeatMode.Restart + ) + ) + } + } + val circleValues = circle.map { it.value } + val distance = with(LocalDensity.current) { travelDistance.toPx() } + val lastCircle = circleValues.size - 1 + + Row(modifier = modifier) { + circleValues.forEachIndexed { index, value -> + Box( + modifier = Modifier + .size(circleSize) + .graphicsLayer { + translationY = -value * distance + } + .background( + color = circleColor, + shape = CircleShape + ) + ) + if (index != lastCircle) { + Spacer(modifier = Modifier.width(spaceBetween)) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingContent.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingContent.kt new file mode 100644 index 000000000..46ba2c620 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingContent.kt @@ -0,0 +1,70 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R +import kotlinx.coroutines.delay + +@Composable +fun LoadingContent( + modifier: Modifier = Modifier, + isLoading: Boolean, + content: @Composable () -> Unit +) { + val context = LocalContext.current + Box(modifier) { + Box( + Modifier + .wrapContentSize() + .alpha(if (isLoading) 0.2f else 1f) + ) { + content() + } + + AnimatedVisibility( + visible = isLoading, modifier = Modifier + .size(64.dp) + .align(Alignment.Center) + ) { + CircularProgressIndicator(modifier = Modifier.semantics { + stateDescription = context.getString(R.string.loading) + }, strokeWidth = 8.dp) + } + } +} + +@Preview +@Composable +fun PreviewLoadingContent() { + MaterialTheme { + var loading by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + delay(3000) + loading = false + } + + LoadingContent(Modifier, loading) { + OutlinedTextField(value = "hello", onValueChange = {}, label = { Text("Label") }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingDialog.kt new file mode 100644 index 000000000..66ea10ee7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LoadingDialog.kt @@ -0,0 +1,103 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + + +@Composable +fun ProgressIndicatorLoading(progressIndicatorSize: Dp, progressIndicatorColor: Color) { + val infiniteTransition = rememberInfiniteTransition(label = "") + + val angle by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 600 + } + ), label = "" + ) + + CircularProgressIndicator( + progress = 1f, + modifier = Modifier + .size(progressIndicatorSize) + .rotate(angle) + .border( + 12.dp, + brush = Brush.sweepGradient( + listOf( + Color.Transparent, + progressIndicatorColor.copy(alpha = 0.1f), + progressIndicatorColor + ) + ), + shape = CircleShape + ), + strokeWidth = 1.dp, + color = Color.Transparent + ) +} + +@Composable +fun LoadingDialog(onDismissRequest: () -> Unit, dismissOnBackPress: Boolean = false) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(dismissOnBackPress = dismissOnBackPress) + ) { + Surface( + tonalElevation = 4.dp, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(horizontal = 48.dp, vertical = 12.dp).wrapContentWidth()) { + ProgressIndicatorLoading( + progressIndicatorSize = 64.dp, + progressIndicatorColor = MaterialTheme.colorScheme.primary + ) + Spacer( + modifier = Modifier + .height(16.dp) + ) + Text( + text = "加载中", + ) + } + } + } +} + +@Preview +@Composable +fun PreviewDialog() { + var isShow by remember { mutableStateOf(true) } + if (isShow) + LoadingDialog(onDismissRequest = { isShow = false }) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LongClickIconButton.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LongClickIconButton.kt new file mode 100644 index 000000000..ac3285a2c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/LongClickIconButton.kt @@ -0,0 +1,82 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.utils.performLongPress + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LongClickIconButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + onLongClickLabel: String? = null, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit +) { + val stateLayerSize = 40.0.dp + val context = LocalContext.current + val view = LocalView.current + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(stateLayerSize) + .clip(CircleShape) + .background(color = colors.mContainerColor(enabled).value) + .combinedClickable( + onClick = onClick, + onLongClick = { + view.performLongPress() + onLongClick() + }, + onLongClickLabel = onLongClickLabel, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple( + bounded = false, + radius = stateLayerSize / 2 + ) + ), + contentAlignment = Alignment.Center + ) { + val contentColor = colors.mContentColor(enabled).value + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + content() + } +} + +@Composable +internal fun IconButtonColors.mContainerColor(enabled: Boolean): State { + return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor) +} + +@Composable +internal fun IconButtonColors.mContentColor(enabled: Boolean): State { + return rememberUpdatedState(if (enabled) contentColor else disabledContentColor) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/Markdown.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/Markdown.kt new file mode 100644 index 000000000..10a7b58a5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/Markdown.kt @@ -0,0 +1,93 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.view.View +import android.widget.TextView +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.LinkResolverDef +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.image.AsyncDrawableScheduler +import io.noties.markwon.image.DefaultMediaDecoder +import io.noties.markwon.image.ImagesPlugin +import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler +import io.noties.markwon.image.svg.SvgMediaDecoder +import io.noties.markwon.linkify.LinkifyPlugin + +@Composable +fun Markdown( + content: String, + modifier: Modifier = Modifier, + isSelectable: Boolean = true, + textColor: Color = MaterialTheme.colorScheme.onBackground, + onLinkResolve: (url: String) -> Boolean = { false }, + onTextViewConfiguration: (TextView) -> Unit = {}, +) { + val context = LocalContext.current + + val markwon = remember { + Markwon.builder(context) + .usePlugin(ImagesPlugin.create { + it.addSchemeHandler(OkHttpNetworkSchemeHandler.create()) + it.addMediaDecoder(DefaultMediaDecoder.create()) + it.addMediaDecoder(SvgMediaDecoder.create()) + it.errorHandler { url, throwable -> + throwable.printStackTrace() + println(url) + null + } + }) +// .usePlugin(HtmlPlugin.create()) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { +// builder.linkResolver { view, link -> +// Log.d(TAG, "configureConfiguration: $link") +// } + + // or subclass default instance + builder.linkResolver(object : LinkResolverDef() { + override fun resolve(view: View, link: String) { + if (!onLinkResolve(link)) + super.resolve(view, link) + } + }) + } + }) + .usePlugin(object : AbstractMarkwonPlugin(), MarkwonPlugin { + override fun beforeSetText(textView: TextView, markdown: Spanned) { + AsyncDrawableScheduler.unschedule(textView) + } + + override fun afterSetText(textView: TextView) { + AsyncDrawableScheduler.schedule(textView); + } + }) + .usePlugin(LinkifyPlugin.create()) + .build() + } + + AndroidView( + modifier = modifier, + factory = { + TextView(it).apply { + movementMethod = LinkMovementMethod.getInstance() + onTextViewConfiguration(this) + } + }) { + it.setTextIsSelectable(isSelectable) + + it.setTextColor(textColor.toArgb()) + markwon.setMarkdown(it, content) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/SelectableText.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/SelectableText.kt new file mode 100644 index 000000000..b9d249a64 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/SelectableText.kt @@ -0,0 +1,155 @@ +/* +package com.github.jing332.text_searcher.ui.widgets + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.platform.LocalTextInputService +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.github.jing332.text_searcher.ui.search.texttoolbar.CustomTextToolbar + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectableText( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = LocalTextStyle.current.color, + unfocusedTextColor = LocalTextStyle.current.color + ), + contentPadding: PaddingValues = PaddingValues(0.dp), + selectionColors: TextSelectionColors = TextSelectionColors( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer + ), +) { + // If color is not provided via the text style, use content color as a default + val textColor = textStyle.color.takeOrElse { MaterialTheme.colorScheme.onBackground } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + CompositionLocalProvider( + LocalTextSelectionColors provides selectionColors, + LocalTextInputService provides null, + ) { + BasicTextField( + value = value, + modifier = modifier + .defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = TextFieldDefaults.MinHeight + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(Color.Blue), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = @Composable { innerTextField -> + // places leading icon, text field with label and placeholder, trailing icon + TextFieldDefaults.DecorationBox( + value = value.text, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = contentPadding, + ) + } + ) + } +} + +@Preview +@Composable +private fun SelectableTextPreview() { + val selectedText = remember { mutableStateOf("") } + val localView = LocalView.current + val textToolbar = remember { + CustomTextToolbar(localView, onTtsRequested = { + println("onTtsRequested: ${selectedText.value}") + }) + } + + CompositionLocalProvider(LocalTextToolbar provides textToolbar) { + var text by remember { mutableStateOf(TextFieldValue("Hello world!")) } + + SelectableText( + value = text, + onValueChange = { + text = it + }, + textStyle = MaterialTheme.typography.bodyMedium, + enabled = true, + ) + } +} +*/ diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/SwitchFloatingAction.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/SwitchFloatingAction.kt new file mode 100644 index 000000000..d3d355e92 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/SwitchFloatingAction.kt @@ -0,0 +1,64 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.R + + +@Composable +fun SwitchFloatingButton(modifier: Modifier, switch: Boolean, onSwitchChange: (Boolean) -> Unit) { + val scope = rememberCoroutineScope() + + val targetIcon = + if (switch) Icons.Filled.Stop else Icons.Filled.Send + val rotationAngle by animateFloatAsState(targetValue = if (switch) 360f else 0f, label = "") + + val color = + animateColorAsState( + targetValue = if (switch) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.primaryContainer, + label = "", + animationSpec = tween(500, 0, LinearEasing) + ) + + FloatingActionButton( + modifier = modifier, + elevation = FloatingActionButtonDefaults.elevation(8.dp), + shape = CircleShape, + containerColor = color.value, + onClick = { onSwitchChange(!switch) }) { + + Crossfade(targetState = targetIcon, label = "") { + Icon( + imageVector = it, + contentDescription = stringResource(id = if (switch) R.string.close else R.string.start), + modifier = Modifier + .rotate(rotationAngle) + .graphicsLayer { + rotationZ = rotationAngle + } + .size(if (switch) 42.dp else 32.dp) + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/TextCheckBox.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/TextCheckBox.kt new file mode 100644 index 000000000..0f54528c9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/TextCheckBox.kt @@ -0,0 +1,43 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.github.jing332.tts_server_android.utils.clickableRipple + +@Composable +fun TextCheckBox( + modifier: Modifier = Modifier, + text: @Composable RowScope.() -> Unit, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + + horizontalArrangement: Arrangement.Horizontal = Arrangement.Center, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { + Row( + modifier + .height(48.dp) + .clip(MaterialTheme.shapes.small) + .clickableRipple(role = Role.Checkbox) { onCheckedChange(!checked) } + , + verticalAlignment = verticalAlignment, + horizontalArrangement = horizontalArrangement, + ) { + Row(Modifier.padding(horizontal = 8.dp)) { + Checkbox(checked = checked, onCheckedChange = null) + text() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/TextFieldDialog.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/TextFieldDialog.kt new file mode 100644 index 000000000..50c849569 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/TextFieldDialog.kt @@ -0,0 +1,30 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun TextFieldDialog( + title: String, + text: String, + onTextChange: (String) -> Unit, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog(onDismissRequest = onDismissRequest, + title = { + Text(title) + }, + text = { + OutlinedTextField(value = text, onValueChange = onTextChange) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("确定") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/Widgets.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/Widgets.kt new file mode 100644 index 000000000..ec3acf340 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/Widgets.kt @@ -0,0 +1,86 @@ +package com.github.jing332.tts_server_android.compose.widgets + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.google.accompanist.systemuicontroller.rememberSystemUiController + + +@Composable +fun SetupSystemBars() { + val systemUiController = rememberSystemUiController() + val useDarkIcons = !isSystemInDarkTheme() + SideEffect { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = useDarkIcons, + ) + } +} + +@Composable +fun BasicBroadcastReceiver( + intentFilter: IntentFilter, + onReceive: (Intent?) -> Unit, + onRegister: (BroadcastReceiver, Context) -> Unit, + onUnregister: (BroadcastReceiver, Context) -> Unit +) { + val context = LocalContext.current + val currentReceive by rememberUpdatedState(onReceive) + + DisposableEffect(context, intentFilter) { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + currentReceive(intent) + } + } + onRegister(receiver, context) + + onDispose { + onUnregister(receiver, context) + } + } +} + +@Composable +fun LocalBroadcastReceiver(intentFilter: IntentFilter, onReceive: (Intent?) -> Unit) { + BasicBroadcastReceiver( + intentFilter, + onReceive, + { obj, context -> + LocalBroadcastManager.getInstance(context).registerReceiver(obj, intentFilter) + }, + { obj, context -> LocalBroadcastManager.getInstance(context).unregisterReceiver(obj) } + ) +} + +@Composable +fun SystemBroadcastReceiver( + intentFilter: IntentFilter, + onSystemEvent: (intent: Intent?) -> Unit +) { + BasicBroadcastReceiver( + intentFilter = intentFilter, onReceive = onSystemEvent, + onRegister = { obj, context -> + ContextCompat.registerReceiver( + context, + obj, + intentFilter, + ContextCompat.RECEIVER_EXPORTED + ) + }, + onUnregister = { obj, context -> context.unregisterReceiver(obj) } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/CharacterStyle.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/CharacterStyle.kt new file mode 100644 index 000000000..746205f62 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/CharacterStyle.kt @@ -0,0 +1,17 @@ +package com.github.jing332.tts_server_android.compose.widgets.htmlcompose + +import android.text.style.ForegroundColorSpan +import android.text.style.StrikethroughSpan +import android.text.style.UnderlineSpan +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.style.TextDecoration + +internal fun UnderlineSpan.spanStyle(): SpanStyle = + SpanStyle(textDecoration = TextDecoration.Underline) + +internal fun ForegroundColorSpan.spanStyle(): SpanStyle = + SpanStyle(color = Color(foregroundColor)) + +internal fun StrikethroughSpan.spanStyle(): SpanStyle = + SpanStyle(textDecoration = TextDecoration.LineThrough) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/HtmlText.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/HtmlText.kt new file mode 100644 index 000000000..eb473f3fa --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/HtmlText.kt @@ -0,0 +1,155 @@ +package com.github.jing332.tts_server_android.compose.widgets.htmlcompose + +import android.text.style.BulletSpan +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.SubscriptSpan +import android.text.style.SuperscriptSpan +import android.text.style.TypefaceSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan +import android.widget.TextView +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.core.text.HtmlCompat + +private const val URL_TAG = "url_tag" + +@Composable +fun HtmlText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + inlineContent: Map = mapOf(), + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current, + + linkClicked: ((String) -> Unit)? = null, + + flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT, + urlSpanStyle: SpanStyle = SpanStyle( + color = linkTextColor(), + textDecoration = TextDecoration.Underline + ) +) { + val content = text.asHTML(fontSize, flags, urlSpanStyle) + if (linkClicked != null) { + ClickableText( + modifier = modifier, + text = content, + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = onTextLayout, + onClick = { + content + .getStringAnnotations(URL_TAG, it, it) + .firstOrNull() + ?.let { stringAnnotation -> linkClicked(stringAnnotation.item) } + } + ) + } else { + Text( + modifier = modifier, + text = content, + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = onTextLayout, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + inlineContent = inlineContent, + minLines = minLines + ) + } + +} + +@Composable +private fun linkTextColor() = Color( + TextView(LocalContext.current).linkTextColors.defaultColor +) + +@Composable +private fun String.asHTML( + fontSize: TextUnit, + flags: Int, + URLSpanStyle: SpanStyle +) = buildAnnotatedString { + val spanned = HtmlCompat.fromHtml(this@asHTML, flags) + val spans = spanned.getSpans(0, spanned.length, Any::class.java) + + append(spanned.toString()) + + spans + .filter { it !is BulletSpan } + .forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + when (span) { + is RelativeSizeSpan -> span.spanStyle(fontSize) + is StyleSpan -> span.spanStyle() + is UnderlineSpan -> span.spanStyle() + is ForegroundColorSpan -> span.spanStyle() + is TypefaceSpan -> span.spanStyle() + is StrikethroughSpan -> span.spanStyle() + is SuperscriptSpan -> span.spanStyle() + is SubscriptSpan -> span.spanStyle() + is URLSpan -> { + addStringAnnotation( + tag = URL_TAG, + annotation = span.url, + start = start, + end = end + ) + URLSpanStyle + } + + else -> { + null + } + }?.let { spanStyle -> + addStyle(spanStyle, start, end) + } + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/MetricAffectingSpan.kt b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/MetricAffectingSpan.kt new file mode 100644 index 000000000..23260fded --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/compose/widgets/htmlcompose/MetricAffectingSpan.kt @@ -0,0 +1,47 @@ +package com.github.jing332.tts_server_android.compose.widgets.htmlcompose + +import android.graphics.Typeface +import android.text.style.* +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import java.io.File + +private const val PATH_SYSTEM_FONTS_FILE = "/system/etc/fonts.xml" +private const val PATH_SYSTEM_FONTS_DIR = "/system/fonts/" + +internal fun RelativeSizeSpan.spanStyle(fontSize: TextUnit): SpanStyle = + SpanStyle(fontSize = (fontSize.value * sizeChange).sp) + +internal fun StyleSpan.spanStyle(): SpanStyle? = when (style) { + Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + Typeface.BOLD_ITALIC -> SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic, + ) + else -> null +} + +internal fun SubscriptSpan.spanStyle(): SpanStyle = + SpanStyle(baselineShift = BaselineShift.Subscript) + +internal fun SuperscriptSpan.spanStyle(): SpanStyle = + SpanStyle(baselineShift = BaselineShift.Superscript) + +internal fun TypefaceSpan.spanStyle(): SpanStyle? { + val xmlContent = File(PATH_SYSTEM_FONTS_FILE).readText() + return if (xmlContent.contains("""""") + val fontName = familyChunkXml.substringAfter("""""") + .substringBefore("") + SpanStyle(fontFamily = FontFamily(Typeface.createFromFile("$PATH_SYSTEM_FONTS_DIR$fontName"))) + } else { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/AppConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/AppConfig.kt new file mode 100644 index 000000000..98036e29a --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/AppConfig.kt @@ -0,0 +1,114 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverConverter.registerTypeConverters +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object AppConfig { + @OptIn(ExperimentalSerializationApi::class) + private val json by lazy { + Json { + ignoreUnknownKeys = true + explicitNulls = false + allowStructuredMapKeys = true + } + } + + init { + registerTypeConverters>>( + save = { + json.encodeToString(it) + }, + restore = { + val list: List> = try { + json.decodeFromString(it) + } catch (_: Exception) { + emptyList() + } + list + } + ) + + registerTypeConverters( + save = { it.id }, + restore = { value -> + AppTheme.values().find { it.id == value } ?: AppTheme.DEFAULT + } + ) + } + + private val dataSaverPref = DataSaverPreferences(app.getSharedPreferences("app", 0)) + + val theme = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "theme", + initialValue = AppTheme.DEFAULT + ) + + val limitTagLength = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "limitTagLength", + initialValue = 0 + ) + + val limitNameLength = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "limitNameLength", + initialValue = 0 + ) + + val isSwapListenAndEditButton = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isSwapListenAndEditButton", + initialValue = false + ) + + val isAutoCheckUpdateEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isAutoCheckUpdateEnabled", + initialValue = true + ) + + val isEdgeDnsEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isEdgeDnsEnabled", + initialValue = true + ) + + val testSampleText = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "testSampleText", + initialValue = app.getString(R.string.systts_sample_test_text) + ) + + val fragmentIndex = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "fragmentIndex", + initialValue = 0 + ) + + val filePickerMode = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "filePickerMode", + initialValue = 0 + ) + + val spinnerMaxDropDownCount = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "spinnerMaxDropDownCount", + initialValue = 20 + ) + + val lastReadHelpDocumentVersion = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "lastReadHelpDocumentVersion", + initialValue = 0 + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/CodeEditorConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/CodeEditorConfig.kt new file mode 100644 index 000000000..7b324834d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/CodeEditorConfig.kt @@ -0,0 +1,26 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverConverter.registerTypeConverters +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.constant.CodeEditorTheme + +object CodeEditorConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("code_editor", 0)) + + init { + registerTypeConverters( + save = { it.id }, + restore = { value -> + CodeEditorTheme.values().find { it.id == value } ?: CodeEditorTheme.AUTO + } + ) + } + + val theme = mutableDataSaverStateOf(pref, "codeEditorTheme", CodeEditorTheme.AUTO) + + val isWordWrapEnabled = mutableDataSaverStateOf(pref, "isWordWrapEnabled", false) + val isRemoteSyncEnabled = mutableDataSaverStateOf(pref, "isRemoteSyncEnabled", false) + val remoteSyncPort = mutableDataSaverStateOf(pref, "remoteSyncPort", 4566) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/DirectUploadConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/DirectUploadConfig.kt new file mode 100644 index 000000000..f7c77ba10 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/DirectUploadConfig.kt @@ -0,0 +1,16 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText + +object DirectUploadConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("direct_link_upload", 0)) + + val code = mutableDataSaverStateOf( + pref, + key = "code", + app.assets.open("defaultData/direct_link_upload.js").readAllText() + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/MsForwarderConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/MsForwarderConfig.kt new file mode 100644 index 000000000..3c28aed58 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/MsForwarderConfig.kt @@ -0,0 +1,27 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app + +object MsForwarderConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("server", 0)) + + val port = mutableDataSaverStateOf( + dataSaverInterface = pref, + key = "port", + initialValue = 1233 + ) + + val isWakeLockEnabled = mutableDataSaverStateOf( + dataSaverInterface = pref, + key = "isWakeLockEnabled", + initialValue = false + ) + + val token = mutableDataSaverStateOf( + dataSaverInterface = pref, + key = "token", + initialValue = "", + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/MsTtsForwarderConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/MsTtsForwarderConfig.kt new file mode 100644 index 000000000..5c6818036 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/MsTtsForwarderConfig.kt @@ -0,0 +1,13 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app + +object MsTtsForwarderConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("server", 0)) + + val port = mutableDataSaverStateOf(pref, key = "port", 1233) + val isWakeLockEnabled = mutableDataSaverStateOf(pref, key = "isWakeLockEnabled", false) + val token = mutableDataSaverStateOf(pref, key = "token", "") +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/PluginConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/PluginConfig.kt new file mode 100644 index 000000000..4bbec1706 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/PluginConfig.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app + +object PluginConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("plugin", 0)) + + val textParam = mutableDataSaverStateOf(pref, key = "sampleText", "示例文本。 Sample text.") +// val isSaveRhinoLog = mutableDataSaverStateOf(pref, key = "isSaveRhinoLog", false) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/ReplaceRuleConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/ReplaceRuleConfig.kt new file mode 100644 index 000000000..a50fe2c19 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/ReplaceRuleConfig.kt @@ -0,0 +1,49 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverConverter +import com.funny.data_saver.core.DataSaverMutableState +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.constant.AppConst +import kotlinx.serialization.encodeToString + +object ReplaceRuleConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("replace_rule", 0)) + + val defaultSymbols: LinkedHashMap = listOf( + "(", + ")", + "[", + "]", + "|", + "\\", + "/", + "{", + "}", + "^", + "$", + ".", + "*", + "+", + "?" + ).associateWith { it } as LinkedHashMap + + init { + DataSaverConverter.registerTypeConverters( + save = { AppConst.jsonBuilder.encodeToString(it) }, + restore = { value -> + try { + AppConst.jsonBuilder.decodeFromString>(value) + } catch (_: Exception) { + defaultSymbols + } + } + ) + } + + val symbols: DataSaverMutableState> = mutableDataSaverStateOf( + pref, key = "symbols", + defaultSymbols + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/SpeechRuleConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/SpeechRuleConfig.kt new file mode 100644 index 000000000..5fb1f6b71 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/SpeechRuleConfig.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app + +object SpeechRuleConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("speech_rule", 0)) + + var textParam = mutableDataSaverStateOf(pref, key = "textParam", "这是一个Android系统TTS应用,内置微软演示接口,可自定义HTTP请求,可导入其他本地TTS引擎,以及根据中文双引号的简单旁白/对话识别朗读 ,还有自动重试,备用配置,文本替换等更多功能。") +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/SysTtsConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/SysTtsConfig.kt new file mode 100644 index 000000000..5a0d70068 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/SysTtsConfig.kt @@ -0,0 +1,129 @@ +package com.github.jing332.tts_server_android.conf + +object SysTtsConfig { + var isInAppPlayAudio: Boolean + get() = SystemTtsConfig.isInternalPlayerEnabled.value + set(value) { + SystemTtsConfig.isInternalPlayerEnabled.value = value + } + + var inAppPlaySpeed: Float + get() = SystemTtsConfig.inAppPlaySpeed.value + set(value) { + SystemTtsConfig.inAppPlaySpeed.value = value + } + + var inAppPlayVolume: Float + get() = SystemTtsConfig.inAppPlayVolume.value + set(value) { + SystemTtsConfig.inAppPlayVolume.value = value + } + + var inAppPlayPitch: Float + get() = SystemTtsConfig.inAppPlayPitch.value + set(value) { + SystemTtsConfig.inAppPlayPitch.value = value + } + + var audioParamsSpeed: Float + get() = SystemTtsConfig.audioParamsSpeed.value + set(value) { + SystemTtsConfig.audioParamsSpeed.value = value + } + + var audioParamsPitch: Float + get() = SystemTtsConfig.audioParamsPitch.value + set(value) { + SystemTtsConfig.audioParamsPitch.value = value + } + + var audioParamsVolume: Float + get() = SystemTtsConfig.audioParamsVolume.value + set(value) { + SystemTtsConfig.audioParamsVolume.value = value + } + + var bgmVolume: Float + get() = SystemTtsConfig.bgmVolume.value + set(value) { + SystemTtsConfig.bgmVolume.value = value + } + + var isBgmShuffleEnabled: Boolean + get() = SystemTtsConfig.isBgmShuffleEnabled.value + set(value) { + SystemTtsConfig.isBgmShuffleEnabled.value = value + } + + var isMultiVoiceEnabled: Boolean + get() = SystemTtsConfig.isMultiVoiceEnabled.value + set(value) { + SystemTtsConfig.isMultiVoiceEnabled.value = value + } + + var isWakeLockEnabled: Boolean + get() = SystemTtsConfig.isWakeLockEnabled.value + set(value) { + SystemTtsConfig.isWakeLockEnabled.value = value + } + + var isForegroundServiceEnabled: Boolean + get() = SystemTtsConfig.isForegroundServiceEnabled.value + set(value) { + SystemTtsConfig.isForegroundServiceEnabled.value = value + } + + var isReplaceEnabled: Boolean + get() = SystemTtsConfig.isReplaceEnabled.value + set(value) { + SystemTtsConfig.isReplaceEnabled.value = value + } + + var isSplitEnabled: Boolean + get() = SystemTtsConfig.isSplitEnabled.value + set(value) { + SystemTtsConfig.isSplitEnabled.value = value + } + + var requestTimeout: Int + get() = SystemTtsConfig.requestTimeout.value + set(value) { + SystemTtsConfig.requestTimeout.value = value + } + + var maxRetryCount: Int + get() = SystemTtsConfig.maxRetryCount.value + set(value) { + SystemTtsConfig.maxRetryCount.value = value + } + + var standbyTriggeredRetryIndex: Int + get() = SystemTtsConfig.standbyTriggeredRetryIndex.value + set(value) { + SystemTtsConfig.standbyTriggeredRetryIndex.value = value + } + + var maxEmptyAudioRetryCount: Int + get() = SystemTtsConfig.maxEmptyAudioRetryCount.value + set(value) { + SystemTtsConfig.maxEmptyAudioRetryCount.value = value + } + + var isSkipSilentText: Boolean + get() = SystemTtsConfig.isSkipSilentText.value + set(value) { + SystemTtsConfig.isSkipSilentText.value = value + } + + var isStreamPlayModeEnabled: Boolean + get() = SystemTtsConfig.isStreamPlayModeEnabled.value + set(value) { + SystemTtsConfig.isStreamPlayModeEnabled.value = value + } + + var isExoDecoderEnabled: Boolean + get() = SystemTtsConfig.isExoDecoderEnabled.value + set(value) { + SystemTtsConfig.isExoDecoderEnabled.value = value + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/SystemTtsConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/SystemTtsConfig.kt new file mode 100644 index 000000000..fa4edced5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/SystemTtsConfig.kt @@ -0,0 +1,180 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app + +object SystemTtsConfig { + private val dataSaverPref = DataSaverPreferences(app.getSharedPreferences("systts", 0)) + + //var isInAppPlayAudio by booleanPref(false) + // var inAppPlaySpeed by floatPref(1f) + // var inAppPlayVolume by floatPref(1f) + // var inAppPlayPitch by floatPref(1f) + // + // var audioParamsSpeed by floatPref(1f) + // var audioParamsPitch by floatPref(1f) + // var audioParamsVolume by floatPref(1f) + // + // var bgmVolume by floatPref(1f) + // var isBgmShuffleEnabled by booleanPref(false) + // + // var isMultiVoiceEnabled by booleanPref() + // + // var isVoiceMultipleEnabled by booleanPref() + // var isGroupMultipleEnabled by booleanPref() + // + // var isWakeLockEnabled by booleanPref(true) + // var isForegroundServiceEnabled by booleanPref(true) + // + // var isReplaceEnabled by booleanPref() + // var isSplitEnabled by booleanPref() + // + // var requestTimeout by intPref(5000) + // var maxRetryCount by intPref(3) + // + // var standbyTriggeredRetryIndex by intPref(1) + // var maxEmptyAudioRetryCount by intPref(1) + // + // var isSkipSilentText by booleanPref(true) + // var isStreamPlayModeEnabled by booleanPref(false) + // var isExoDecoderEnabled by booleanPref(false) + + val isInternalPlayerEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isInAppPlayAudio", + initialValue = false + ) + + val inAppPlaySpeed = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "inAppPlaySpeed", + initialValue = 1f + ) + + val inAppPlayVolume = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "inAppPlayVolume", + initialValue = 1f + ) + + val inAppPlayPitch = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "inAppPlayPitch", + initialValue = 1f + ) + + val audioParamsSpeed = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "audioParamsSpeed", + initialValue = 1f + ) + + val audioParamsPitch = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "audioParamsPitch", + initialValue = 1f + ) + + val audioParamsVolume = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "audioParamsVolume", + initialValue = 1f + ) + + val bgmVolume = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "bgmVolume", + initialValue = 1f + ) + + val isBgmShuffleEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isBgmShuffleEnabled", + initialValue = false + ) + + val isMultiVoiceEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isMultiVoiceEnabled", + initialValue = false + ) + + val isVoiceMultipleEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isVoiceMultipleEnabled", + initialValue = false + ) + + val isGroupMultipleEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isGroupMultipleEnabled", + initialValue = false + ) + + val isWakeLockEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isWakeLockEnabled", + initialValue = true + ) + + val isForegroundServiceEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isForegroundServiceEnabled", + initialValue = true + ) + + val isReplaceEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isReplaceEnabled", + initialValue = false + ) + + val isSplitEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isSplitEnabled", + initialValue = false + ) + + val requestTimeout = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "requestTimeout", + initialValue = 5000 + ) + + val maxRetryCount = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "maxRetryCount", + initialValue = 3 + ) + + val standbyTriggeredRetryIndex = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "standbyTriggeredRetryIndex", + initialValue = 1 + ) + + val maxEmptyAudioRetryCount = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "maxEmptyAudioRetryCount", + initialValue = 1 + ) + + val isSkipSilentText = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isSkipSilentText", + initialValue = true + ) + + val isStreamPlayModeEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isStreamPlayModeEnabled", + initialValue = false + ) + + val isExoDecoderEnabled = mutableDataSaverStateOf( + dataSaverInterface = dataSaverPref, + key = "isExoDecoderEnabled", + initialValue = true + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/conf/SysttsForwarderConfig.kt b/app/src/main/java/com/github/jing332/tts_server_android/conf/SysttsForwarderConfig.kt new file mode 100644 index 000000000..7dc1580ea --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/conf/SysttsForwarderConfig.kt @@ -0,0 +1,21 @@ +package com.github.jing332.tts_server_android.conf + +import com.funny.data_saver.core.DataSaverPreferences +import com.funny.data_saver.core.mutableDataSaverStateOf +import com.github.jing332.tts_server_android.app + +object SysttsForwarderConfig { + private val pref = DataSaverPreferences(app.getSharedPreferences("systts_forwarder", 0)) + + val port = mutableDataSaverStateOf( + dataSaverInterface = pref, + key = "port", + initialValue = 1221 + ) + + val isWakeLockEnabled = mutableDataSaverStateOf( + dataSaverInterface = pref, + key = "isWakeLockEnabled", + initialValue = false + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/AppConst.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/AppConst.kt new file mode 100644 index 000000000..138346e7a --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/AppConst.kt @@ -0,0 +1,90 @@ +package com.github.jing332.tts_server_android.constant + +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.BuildConfig +import com.github.jing332.tts_server_android.app +import com.script.javascript.RhinoScriptEngine +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Locale + +@SuppressLint("SimpleDateFormat") +@Suppress("DEPRECATION") +object AppConst { + const val PACKET_NAME = "com.github.jing332.tts_server_android" + + val fileProviderAuthor = BuildConfig.APPLICATION_ID + ".fileprovider" + val localBroadcast by lazy { LocalBroadcastManager.getInstance(App.context) } + + + var isSysTtsLogEnabled = true + var isServerLogEnabled = false + + @OptIn(ExperimentalSerializationApi::class) + val jsonBuilder by lazy { + Json { + allowStructuredMapKeys = true + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + explicitNulls = false //忽略为null的字段 + allowStructuredMapKeys = true + } + } + + val isCnLocale: Boolean + get() = App.context.resources.configuration.locale.language.endsWith("zh") + + + // JS引擎 + val SCRIPT_ENGINE: RhinoScriptEngine by lazy { RhinoScriptEngine() } + + val locale: Locale + get() = App.context.resources.configuration.locale + + val localeCode: String + get() = locale.run { "$language-$country" } + + val timeFormat: SimpleDateFormat by lazy { + SimpleDateFormat("HH:mm") + } + + val dateFormat: SimpleDateFormat by lazy { + SimpleDateFormat("yyyy/MM/dd HH:mm") + } + + val dateFormatSec: SimpleDateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + } + + val fileNameFormat: SimpleDateFormat by lazy { + SimpleDateFormat("yy-MM-dd-HH-mm-ss") + } + + val appInfo: AppInfo by lazy { + val appInfo = AppInfo() + App.context.packageManager.getPackageInfo( + app.packageName, + PackageManager.GET_ACTIVITIES + ) + ?.let { + appInfo.versionName = it.versionName + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + appInfo.versionCode = it.longVersionCode + } else { + @Suppress("DEPRECATION") + appInfo.versionCode = it.versionCode.toLong() + } + } + appInfo + } + + data class AppInfo( + var versionCode: Long = 0L, + var versionName: String = "" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/AppPattern.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/AppPattern.kt new file mode 100644 index 000000000..4d06ba543 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/AppPattern.kt @@ -0,0 +1,5 @@ +package com.github.jing332.tts_server_android.constant + +object AppPattern { + val notReadAloudRegex = Regex("^(\\s|\\p{C}|\\p{P}|\\p{Z}|\\p{S})+$") +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/CnLocalMap.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/CnLocalMap.kt new file mode 100644 index 000000000..72be4455e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/CnLocalMap.kt @@ -0,0 +1,469 @@ +package com.github.jing332.tts_server_android.constant + +object CnLocalMap { + fun getStyleAndRole(key: String): String = "" + + fun getEdgeVoice(key: String): String { + return edgeVoice.getOrDefault(key, key) + } + + private val edgeVoice = mapOf( + "af-ZA-AdriNeural" to "Adri", + "af-ZA-WillemNeural" to "Willem", + "am-ET-AmehaNeural" to "አምሀ", + "am-ET-MekdesNeural" to "መቅደስ", + "ar-AE-FatimaNeural" to "فاطمة", + "ar-AE-HamdanNeural" to "حمدان", + "ar-BH-AliNeural" to "علي", + "ar-BH-LailaNeural" to "ليلى", + "ar-DZ-AminaNeural" to "أمينة", + "ar-DZ-IsmaelNeural" to "إسماعيل", + "ar-EG-SalmaNeural" to "سلمى", + "ar-EG-ShakirNeural" to "شاكر", + "ar-IQ-BasselNeural" to "باسل", + "ar-IQ-RanaNeural" to "رنا", + "ar-JO-SanaNeural" to "سناء", + "ar-JO-TaimNeural" to "تيم", + "ar-KW-FahedNeural" to "فهد", + "ar-KW-NouraNeural" to "نورا", + "ar-LB-LaylaNeural" to "ليلى", + "ar-LB-RamiNeural" to "رامي", + "ar-LY-ImanNeural" to "إيمان", + "ar-LY-OmarNeural" to "أحمد", + "ar-MA-JamalNeural" to "جمال", + "ar-MA-MounaNeural" to "منى", + "ar-OM-AbdullahNeural" to "عبدالله", + "ar-OM-AyshaNeural" to "عائشة", + "ar-QA-AmalNeural" to "أمل", + "ar-QA-MoazNeural" to "معاذ", + "ar-SA-HamedNeural" to "حامد", + "ar-SA-ZariyahNeural" to "زارية", + "ar-SY-AmanyNeural" to "أماني", + "ar-SY-LaithNeural" to "ليث", + "ar-TN-HediNeural" to "هادي", + "ar-TN-ReemNeural" to "ريم", + "ar-YE-MaryamNeural" to "مريم", + "ar-YE-SalehNeural" to "صالح", + "az-AZ-BabekNeural" to "Babək", + "az-AZ-BanuNeural" to "Banu", + "bg-BG-BorislavNeural" to "Борислав", + "bg-BG-KalinaNeural" to "Калина", + "bn-BD-NabanitaNeural" to "নবনীতা", + "bn-BD-PradeepNeural" to "প্রদ্বীপ", + "bn-IN-BashkarNeural" to "ভাস্কর", + "bn-IN-TanishaaNeural" to "তানিশা", + "bs-BA-GoranNeural" to "Goran", + "bs-BA-VesnaNeural" to "Vesna", + "ca-ES-JoanaNeural" to "Joana", + "ca-ES-AlbaNeural" to "Alba", + "ca-ES-EnricNeural" to "Enric", + "cs-CZ-An:ninNeural" to "An:nín", + "cs-CZ-VlastaNeural" to "Vlasta", + "cy-GB-AledNeural" to "Aled", + "cy-GB-NiaNeural" to "Nia", + "da-DK-ChristelNeural" to "Christel", + "da-DK-JeppeNeural" to "Jeppe", + "de-AT-IngridNeural" to "Ingrid", + "de-AT-JonasNeural" to "Jonas", + "de-CH-JanNeural" to "Jan", + "de-CH-LeniNeural" to "Leni", + "de-DE-KatjaNeural" to "Katja", + "de-DE-AmalaNeural" to "Amala", + "de-DE-BerndNeural" to "Bernd", + "de-DE-Chris:phNeural" to "Chris:ph", + "de-DE-ConradNeural" to "Conrad", + "de-DE-ElkeNeural" to "Elke", + "de-DE-GiselaNeural" to "Gisela", + "de-DE-KasperNeural" to "Kasper", + "de-DE-KillianNeural" to "Killian", + "de-DE-KlarissaNeural" to "Klarissa", + "de-DE-KlausNeural" to "Klaus", + "de-DE-LouisaNeural" to "Louisa", + "de-DE-MajaNeural" to "Maja", + "de-DE-RalfNeural" to "Ralf", + "de-DE-TanjaNeural" to "Tanja", + "el-GR-AthinaNeural" to "Αθηνά", + "el-GR-Nes:rasNeural" to "Νέστορας", + "en-AU-NatashaNeural" to "Natasha", + "en-AU-WilliamNeural" to "William", + "en-AU-AnnetteNeural" to "Annette", + "en-AU-CarlyNeural" to "Carly", + "en-AU-DarrenNeural" to "Darren", + "en-AU-DuncanNeural" to "Duncan", + "en-AU-ElsieNeural" to "Elsie", + "en-AU-FreyaNeural" to "Freya", + "en-AU-JoanneNeural" to "Joanne", + "en-AU-KenNeural" to "Ken", + "en-AU-KimNeural" to "Kim", + "en-AU-NeilNeural" to "Neil", + "en-AU-TimNeural" to "Tim", + "en-AU-TinaNeural" to "Tina", + "en-CA-ClaraNeural" to "Clara", + "en-CA-LiamNeural" to "Liam", + "en-GB-LibbyNeural" to "Libby", + "en-GB-AbbiNeural" to "Abbi", + "en-GB-AlfieNeural" to "Alfie", + "en-GB-BellaNeural" to "Bella", + "en-GB-ElliotNeural" to "Elliot", + "en-GB-EthanNeural" to "Ethan", + "en-GB-HollieNeural" to "Hollie", + "en-GB-MaisieNeural" to "Maisie", + "en-GB-NoahNeural" to "Noah", + "en-GB-OliverNeural" to "Oliver", + "en-GB-OliviaNeural" to "Olivia", + "en-GB-ThomasNeural" to "Thomas", + "en-GB-RyanNeural" to "Ryan", + "en-GB-SoniaNeural" to "Sonia", + "en-GB-MiaNeural" to "Mia", + "en-HK-SamNeural" to "Sam", + "en-HK-YanNeural" to "Yan", + "en-IE-ConnorNeural" to "Connor", + "en-IE-EmilyNeural" to "Emily", + "en-IN-NeerjaNeural" to "Neerja", + "en-IN-PrabhatNeural" to "Prabhat", + "en-KE-AsiliaNeural" to "Asilia", + "en-KE-ChilembaNeural" to "Chilemba", + "en-NG-AbeoNeural" to "Abeo", + "en-NG-EzinneNeural" to "Ezinne", + "en-NZ-MitchellNeural" to "Mitchell", + "en-NZ-MollyNeural" to "Molly", + "en-PH-JamesNeural" to "James", + "en-PH-RosaNeural" to "Rosa", + "en-SG-LunaNeural" to "Luna", + "en-SG-WayneNeural" to "Wayne", + "en-TZ-ElimuNeural" to "Elimu", + "en-TZ-ImaniNeural" to "Imani", + "en-US-JennyNeural" to "Jenny", + "en-US-JennyMultilingualNeural" to "Jenny Multilingual", + "en-US-GuyNeural" to "Guy", + "en-US-AmberNeural" to "Amber", + "en-US-AnaNeural" to "Ana", + "en-US-AriaNeural" to "Aria", + "en-US-AshleyNeural" to "Ashley", + "en-US-BrandonNeural" to "Brandon", + "en-US-Chris:pherNeural" to "Chris:pher", + "en-US-CoraNeural" to "Cora", + "en-US-ElizabethNeural" to "Elizabeth", + "en-US-EricNeural" to "Eric", + "en-US-JacobNeural" to "Jacob", + "en-US-MichelleNeural" to "Michelle", + "en-US-MonicaNeural" to "Monica", + "en-US-SaraNeural" to "Sara", + "en-US-AIGenerate1Neural" to "AIGenerate1", + "en-US-AIGenerate2Neural" to "AIGenerate2", + "en-US-DavisNeural" to "Davis", + "en-US-JaneNeural" to "Jane", + "en-US-JasonNeural" to "Jason", + "en-US-NancyNeural" to "Nancy", + "en-US-RogerNeural" to "Roger", + "en-US-SteffanNeural" to "Steffan", + "en-US-:nyNeural" to "tony", + "en-ZA-LeahNeural" to "Leah", + "en-ZA-LukeNeural" to "Luke", + "es-AR-ElenaNeural" to "Elena", + "es-AR-:masNeural" to "tomas", + "es-BO-MarceloNeural" to "Marcelo", + "es-BO-SofiaNeural" to "Sofia", + "es-CL-CatalinaNeural" to "Catalina", + "es-CL-LorenzoNeural" to "Lorenzo", + "es-CO-GonzaloNeural" to "Gonzalo", + "es-CO-SalomeNeural" to "Salome", + "es-CR-JuanNeural" to "Juan", + "es-CR-MariaNeural" to "María", + "es-CU-BelkysNeural" to "Belkys", + "es-CU-ManuelNeural" to "Manuel", + "es-DO-EmilioNeural" to "Emilio", + "es-DO-RamonaNeural" to "Ramona", + "es-EC-AndreaNeural" to "Andrea", + "es-EC-LuisNeural" to "Luis", + "es-ES-ElviraNeural" to "Elvira", + "es-ES-AbrilNeural" to "Abril", + "es-ES-AlvaroNeural" to "Álvaro", + "es-ES-ArnauNeural" to "Arnau", + "es-ES-DarioNeural" to "Dario", + "es-ES-EliasNeural" to "Elias", + "es-ES-EstrellaNeural" to "Estrella", + "es-ES-IreneNeural" to "Irene", + "es-ES-LaiaNeural" to "Laia", + "es-ES-LiaNeural" to "Lia", + "es-ES-NilNeural" to "Nil", + "es-ES-SaulNeural" to "Saul", + "es-ES-TeoNeural" to "Teo", + "es-ES-TrianaNeural" to "Triana", + "es-ES-VeraNeural" to "Vera", + "es-GQ-JavierNeural" to "Javier", + "es-GQ-TeresaNeural" to "Teresa", + "es-GT-AndresNeural" to "Andrés", + "es-GT-MartaNeural" to "Marta", + "es-HN-CarlosNeural" to "Carlos", + "es-HN-KarlaNeural" to "Karla", + "es-MX-DaliaNeural" to "Dalia", + "es-MX-BeatrizNeural" to "Beatriz", + "es-MX-CandelaNeural" to "Candela", + "es-MX-CarlotaNeural" to "Carlota", + "es-MX-CecilioNeural" to "Cecilio", + "es-MX-GerardoNeural" to "Gerardo", + "es-MX-JorgeNeural" to "Jorge", + "es-MX-LarissaNeural" to "Larissa", + "es-MX-Liber:Neural" to "Liber:", + "es-MX-LucianoNeural" to "Luciano", + "es-MX-MarinaNeural" to "Marina", + "es-MX-NuriaNeural" to "Nuria", + "es-MX-PelayoNeural" to "Pelayo", + "es-MX-RenataNeural" to "Renata", + "es-MX-YagoNeural" to "Yago", + "es-NI-FedericoNeural" to "Federico", + "es-NI-YolandaNeural" to "Yolanda", + "es-PA-MargaritaNeural" to "Margarita", + "es-PA-Rober:Neural" to "Rober:", + "es-PE-AlexNeural" to "Alex", + "es-PE-CamilaNeural" to "Camila", + "es-PR-KarinaNeural" to "Karina", + "es-PR-Vic:rNeural" to "Víc:r", + "es-PY-MarioNeural" to "Mario", + "es-PY-TaniaNeural" to "Tania", + "es-SV-LorenaNeural" to "Lorena", + "es-SV-RodrigoNeural" to "Rodrigo", + "es-US-AlonsoNeural" to "Alonso", + "es-US-PalomaNeural" to "Paloma", + "es-UY-MateoNeural" to "Mateo", + "es-UY-ValentinaNeural" to "Valentina", + "es-VE-PaolaNeural" to "Paola", + "es-VE-SebastianNeural" to "Sebastián", + "et-EE-AnuNeural" to "Anu", + "et-EE-KertNeural" to "Kert", + "eu-ES-AinhoaNeural" to "Ainhoa", + "eu-ES-AnderNeural" to "Ander", + "fa-IR-DilaraNeural" to "دلارا", + "fa-IR-FaridNeural" to "فرید", + "fi-FI-SelmaNeural" to "Selma", + "fi-FI-HarriNeural" to "Harri", + "fi-FI-NooraNeural" to "Noora", + "fil-PH-AngeloNeural" to "Angelo", + "fil-PH-BlessicaNeural" to "Blessica", + "fr-BE-CharlineNeural" to "Charline", + "fr-BE-GerardNeural" to "Gerard", + "fr-CA-SylvieNeural" to "Sylvie", + "fr-CA-An:ineNeural" to "An:ine", + "fr-CA-JeanNeural" to "Jean", + "fr-CH-ArianeNeural" to "Ariane", + "fr-CH-FabriceNeural" to "Fabrice", + "fr-FR-AlainNeural" to "Alain", + "fr-FR-BrigitteNeural" to "Brigitte", + "fr-FR-CelesteNeural" to "Celeste", + "fr-FR-ClaudeNeural" to "Claude", + "fr-FR-CoralieNeural" to "Coralie", + "fr-FR-EloiseNeural" to "Eloise", + "fr-FR-JacquelineNeural" to "Jacqueline", + "fr-FR-JeromeNeural" to "Jerome", + "fr-FR-JosephineNeural" to "Josephine", + "fr-FR-MauriceNeural" to "Maurice", + "fr-FR-YvesNeural" to "Yves", + "fr-FR-YvetteNeural" to "Yvette", + "fr-FR-DeniseNeural" to "Denise", + "fr-FR-HenriNeural" to "Henri", + "ga-IE-ColmNeural" to "Colm", + "ga-IE-OrlaNeural" to "Orla", + "gl-ES-RoiNeural" to "Roi", + "gl-ES-SabelaNeural" to "Sabela", + "gu-IN-DhwaniNeural" to "ધ્વની", + "gu-IN-NiranjanNeural" to "નિરંજન", + "he-IL-AvriNeural" to "אברי", + "he-IL-HilaNeural" to "הילה", + "hi-IN-MadhurNeural" to "मधुर", + "hi-IN-SwaraNeural" to "स्वरा", + "hr-HR-GabrijelaNeural" to "Gabrijela", + "hr-HR-SreckoNeural" to "Srećko", + "hu-HU-NoemiNeural" to "Noémi", + "hu-HU-TamasNeural" to "Tamás", + "hy-AM-AnahitNeural" to "Անահիտ", + "hy-AM-HaykNeural" to "Հայկ", + "id-ID-ArdiNeural" to "Ardi", + "id-ID-GadisNeural" to "Gadis", + "is-IS-GudrunNeural" to "Guðrún", + "is-IS-GunnarNeural" to "Gunnar", + "it-IT-IsabellaNeural" to "Isabella", + "it-IT-ElsaNeural" to "Elsa", + "it-IT-BenignoNeural" to "Benigno", + "it-IT-CalimeroNeural" to "Calimero", + "it-IT-CataldoNeural" to "Cataldo", + "it-IT-DiegoNeural" to "Diego", + "it-IT-FabiolaNeural" to "Fabiola", + "it-IT-FiammaNeural" to "Fiamma", + "it-IT-GianniNeural" to "Gianni", + "it-IT-ImeldaNeural" to "Imelda", + "it-IT-IrmaNeural" to "Irma", + "it-IT-LisandroNeural" to "Lisandro", + "it-IT-PalmiraNeural" to "Palmira", + "it-IT-PierinaNeural" to "Pierina", + "it-IT-RinaldoNeural" to "Rinaldo", + "ja-JP-NanamiNeural" to "七海", + "ja-JP-KeitaNeural" to "圭太", + "ja-JP-AoiNeural" to "碧衣", + "ja-JP-DaichiNeural" to "大智", + "ja-JP-MayuNeural" to "真夕", + "ja-JP-NaokiNeural" to "直紀", + "ja-JP-ShioriNeural" to "志織", + "jv-ID-DimasNeural" to "Dimas", + "jv-ID-SitiNeural" to "Siti", + "ka-GE-EkaNeural" to "ეკა", + "ka-GE-GiorgiNeural" to "გიორგი", + "kk-KZ-AigulNeural" to "Айгүл", + "kk-KZ-DauletNeural" to "Дәулет", + "km-KH-PisethNeural" to "ពិសិដ្ឋ", + "km-KH-SreymomNeural" to "ស្រីមុំ", + "kn-IN-GaganNeural" to "ಗಗನ್", + "kn-IN-SapnaNeural" to "ಸಪ್ನಾ", + "ko-KR-SunHiNeural" to "선히", + "ko-KR-InJoonNeural" to "인준", + "ko-KR-BongJinNeural" to "봉진", + "ko-KR-GookMinNeural" to "국민", + "ko-KR-JiMinNeural" to "지민", + "ko-KR-SeoHyeonNeural" to "서현", + "ko-KR-SoonBokNeural" to "순복", + "ko-KR-YuJinNeural" to "유진", + "lo-LA-ChanthavongNeural" to "ຈັນທະວົງ", + "lo-LA-KeomanyNeural" to "ແກ້ວມະນີ", + "lt-LT-LeonasNeural" to "Leonas", + "lt-LT-OnaNeural" to "Ona", + "lv-LV-EveritaNeural" to "Everita", + "lv-LV-NilsNeural" to "Nils", + "mk-MK-AleksandarNeural" to "Александар", + "mk-MK-MarijaNeural" to "Марија", + "ml-IN-MidhunNeural" to "മിഥുൻ", + "ml-IN-SobhanaNeural" to "ശോഭന", + "mn-MN-BataaNeural" to "Батаа", + "mn-MN-YesuiNeural" to "Есүй", + "mr-IN-AarohiNeural" to "आरोही", + "mr-IN-ManoharNeural" to "मनोहर", + "ms-MY-OsmanNeural" to "Osman", + "ms-MY-YasminNeural" to "Yasmin", + "mt-MT-GraceNeural" to "Grace", + "mt-MT-JosephNeural" to "Joseph", + "my-MM-NilarNeural" to "နီလာ", + "my-MM-ThihaNeural" to "သီဟ", + "nb-NO-PernilleNeural" to "Pernille", + "nb-NO-FinnNeural" to "Finn", + "nb-NO-IselinNeural" to "Iselin", + "ne-NP-HemkalaNeural" to "हेमकला", + "ne-NP-SagarNeural" to "सागर", + "nl-BE-ArnaudNeural" to "Arnaud", + "nl-BE-DenaNeural" to "Dena", + "nl-NL-ColetteNeural" to "Colette", + "nl-NL-FennaNeural" to "Fenna", + "nl-NL-MaartenNeural" to "Maarten", + "pl-PL-AgnieszkaNeural" to "Agnieszka", + "pl-PL-MarekNeural" to "Marek", + "pl-PL-ZofiaNeural" to "Zofia", + "ps-AF-GulNawazNeural" to " ګل نواز", + "ps-AF-LatifaNeural" to "لطيفه", + "pt-BR-FranciscaNeural" to "Francisca", + "pt-BR-An:nioNeural" to "Antônio", + "pt-BR-BrendaNeural" to "Brenda", + "pt-BR-Dona:Neural" to "Dona:", + "pt-BR-ElzaNeural" to "Elza", + "pt-BR-FabioNeural" to "Fabio", + "pt-BR-GiovannaNeural" to "Giovanna", + "pt-BR-Humber:Neural" to "Humber:", + "pt-BR-JulioNeural" to "Julio", + "pt-BR-LeilaNeural" to "Leila", + "pt-BR-LeticiaNeural" to "Leticia", + "pt-BR-ManuelaNeural" to "Manuela", + "pt-BR-NicolauNeural" to "Nicolau", + "pt-BR-ValerioNeural" to "Valerio", + "pt-BR-YaraNeural" to "Yara", + "pt-PT-DuarteNeural" to "Duarte", + "pt-PT-FernandaNeural" to "Fernanda", + "pt-PT-RaquelNeural" to "Raquel", + "ro-RO-AlinaNeural" to "Alina", + "ro-RO-EmilNeural" to "Emil", + "ru-RU-SvetlanaNeural" to "Светлана", + "ru-RU-DariyaNeural" to "Дария", + "ru-RU-DmitryNeural" to "Дмитрий", + "si-LK-SameeraNeural" to "සමීර", + "si-LK-ThiliniNeural" to "තිළිණි", + "sk-SK-LukasNeural" to "Lukáš", + "sk-SK-Vik:riaNeural" to "Viktória", + "sl-SI-PetraNeural" to "Petra", + "sl-SI-RokNeural" to "Rok", + "so-SO-MuuseNeural" to "Muuse", + "so-SO-UbaxNeural" to "Ubax", + "sq-AL-AnilaNeural" to "Anila", + "sq-AL-IlirNeural" to "Ilir", + "sr-RS-NicholasNeural" to "Никола", + "sr-RS-SophieNeural" to "Софија", + "su-ID-JajangNeural" to "Jajang", + "su-ID-TutiNeural" to "Tuti", + "sv-SE-SofieNeural" to "Sofie", + "sv-SE-HilleviNeural" to "Hillevi", + "sv-SE-MattiasNeural" to "Mattias", + "sw-KE-RafikiNeural" to "Rafiki", + "sw-KE-ZuriNeural" to "Zuri", + "sw-TZ-DaudiNeural" to "Daudi", + "sw-TZ-RehemaNeural" to "Rehema", + "ta-IN-PallaviNeural" to "பல்லவி", + "ta-IN-ValluvarNeural" to "வள்ளுவர்", + "ta-LK-KumarNeural" to "குமார்", + "ta-LK-SaranyaNeural" to "சரண்யா", + "ta-MY-KaniNeural" to "கனி", + "ta-MY-SuryaNeural" to "சூர்யா", + "ta-SG-AnbuNeural" to "அன்பு", + "ta-SG-VenbaNeural" to "வெண்பா", + "te-IN-MohanNeural" to "మోహన్", + "te-IN-ShrutiNeural" to "శ్రుతి", + "th-TH-PremwadeeNeural" to "เปรมวดี", + "th-TH-AcharaNeural" to "อัจฉรา", + "th-TH-NiwatNeural" to "นิวัฒน์", + "tr-TR-AhmetNeural" to "Ahmet", + "tr-TR-EmelNeural" to "Emel", + "uk-UA-OstapNeural" to "Остап", + "uk-UA-PolinaNeural" to "Поліна", + "ur-IN-GulNeural" to "گل", + "ur-IN-SalmanNeural" to "سلمان", + "ur-PK-AsadNeural" to "اسد", + "ur-PK-UzmaNeural" to "عظمیٰ", + "uz-UZ-MadinaNeural" to "Madina", + "uz-UZ-SardorNeural" to "Sardor", + "vi-VN-HoaiMyNeural" to "Hoài My", + "vi-VN-NamMinhNeural" to "Nam Minh", + "wuu-CN-Xiao:ngNeural" to "晓彤", + "wuu-CN-YunzheNeural" to "云哲", + "yue-CN-XiaoMinNeural" to "晓敏", + "yue-CN-YunSongNeural" to "云松", + "zh-CN-XiaoxiaoNeural" to "晓晓", + "zh-CN-YunyangNeural" to "云扬", + "zh-CN-XiaochenNeural" to "晓辰", + "zh-CN-XiaohanNeural" to "晓涵", + "zh-CN-XiaomoNeural" to "晓墨", + "zh-CN-XiaoqiuNeural" to "晓秋", + "zh-CN-XiaoruiNeural" to "晓睿", + "zh-CN-XiaoshuangNeural" to "晓双", + "zh-CN-XiaoxuanNeural" to "晓萱", + "zh-CN-XiaoyanNeural" to "晓颜", + "zh-CN-XiaoyouNeural" to "晓悠", + "zh-CN-YunxiNeural" to "云希", + "zh-CN-YunyeNeural" to "云野", + "zh-CN-XiaomengNeural" to "晓梦", + "zh-CN-XiaoyiNeural" to "晓伊", + "zh-CN-XiaozhenNeural" to "晓甄", + "zh-CN-YunfengNeural" to "云枫", + "zh-CN-YunhaoNeural" to "云皓", + "zh-CN-YunjianNeural" to "云健", + "zh-CN-YunxiaNeural" to "云夏", + "zh-CN-YunzeNeural" to "云泽", + "zh-CN-henan-YundengNeural" to "云登", + "zh-CN-liaoning-XiaobeiNeural" to "晓北", + "zh-CN-shaanxi-XiaoniNeural" to "晓妮", + "zh-CN-shandong-YunxiangNeural" to "云翔", + "zh-CN-sichuan-YunxiNeural" to "云希", + "zh-HK-HiuMaanNeural" to "曉曼", + "zh-HK-HiuGaaiNeural" to "曉佳", + "zh-HK-WanLungNeural" to "雲龍", + "zh-TW-HsiaoChenNeural" to "曉臻", + "zh-TW-HsiaoYuNeural" to "曉雨", + "zh-TW-YunJheNeural" to "雲哲", + "zu-ZA-ThandoNeural" to "Thando", + "zu-ZA-ThembaNeural" to "Themba" + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/CodeEditorTheme.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/CodeEditorTheme.kt new file mode 100644 index 000000000..e656bcba9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/CodeEditorTheme.kt @@ -0,0 +1,9 @@ +package com.github.jing332.tts_server_android.constant + +enum class CodeEditorTheme(val id: String) { + AUTO(""), + QUIET_LIGHT("quietlight"), + SOLARIZED_DRAK("solarized_drak"), + DARCULA("darcula"), + ABYSS("abyss") +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/ConfigType.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/ConfigType.kt new file mode 100644 index 000000000..dd2a6b638 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/ConfigType.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android.constant + +import androidx.annotation.StringRes +import com.github.jing332.tts_server_android.R + +enum class ConfigType(@StringRes val strId: Int) { + UNKNOWN(R.string.unknown), + LIST(R.string.config_list), + PLUGIN(R.string.plugin), + REPLACE_RULE(R.string.replace_rule), + SPEECH_RULE(R.string.speech_rule); +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/FilePickerMode.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/FilePickerMode.kt new file mode 100644 index 000000000..6324deb81 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/FilePickerMode.kt @@ -0,0 +1,7 @@ +package com.github.jing332.tts_server_android.constant + +object FilePickerMode { + const val PROMPT = 0 + const val SYSTEM = 1 + const val BUILTIN = 2 +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/KeyConst.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/KeyConst.kt new file mode 100644 index 000000000..582f4497d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/KeyConst.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android.constant + +object KeyConst { + const val KEY_DATA = "data" + const val KEY_POSITION = "position" + + const val KEY_BUNDLE = "bundle" + const val KEY_LARGE_DATA_BINDER = "bigData" + + const val RESULT_ADD = 111 + const val RESULT_EDIT = 222 +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/LogLevel.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/LogLevel.kt new file mode 100644 index 000000000..f21ae9b9f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/LogLevel.kt @@ -0,0 +1,54 @@ +package com.github.jing332.tts_server_android.constant + +import android.graphics.Color +import androidx.annotation.IntDef + +@IntDef( + LogLevel.PANIC, + LogLevel.FATIL, + LogLevel.ERROR, + LogLevel.WARN, + LogLevel.INFO, + LogLevel.DEBUG, + LogLevel.TRACE +) +annotation class LogLevel { + companion object { + const val PANIC = 0 + const val FATIL = 1 + const val ERROR = 2 + const val WARN = 3 + const val INFO = 4 + const val DEBUG = 5 + const val TRACE = 6 + + fun toColor(level: Int): Int { + return when { + level == WARN -> { + Color.rgb(255, 215, 0) /* 金色 */ + } + + level <= ERROR -> { + Color.RED + } + + else -> { + Color.GRAY + } + } + } + + fun toString(level: Int): String { + when (level) { + PANIC -> return "宕机" + FATIL -> return "致命" + ERROR -> return "错误" + WARN -> return "警告" + INFO -> return "信息" + DEBUG -> return "调试" + TRACE -> return "详细" + } + return level.toString() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/MsTtsApiType.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/MsTtsApiType.kt new file mode 100644 index 000000000..3b7a2a55d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/MsTtsApiType.kt @@ -0,0 +1,26 @@ +package com.github.jing332.tts_server_android.constant + +import androidx.annotation.IntDef + +@IntDef( + MsTtsApiType.EDGE, MsTtsApiType.AZURE, MsTtsApiType.CREATION, MsTtsApiType.EDGE_OKHTTP +) +@Retention(AnnotationRetention.SOURCE) +annotation class MsTtsApiType { + companion object { + const val EDGE: Int = 0 + const val AZURE = 1 + const val CREATION = 2 + const val EDGE_OKHTTP = 3 + + fun toString(@MsTtsApiType apiType: Int): String { + return when (apiType) { + EDGE -> "Edge" + EDGE_OKHTTP -> "Edge-OkHttp" + AZURE -> "Azure" + CREATION -> "Creation" + else -> "" + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/PreferKey.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/PreferKey.kt new file mode 100644 index 000000000..d7e97e0a1 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/PreferKey.kt @@ -0,0 +1,9 @@ +package com.github.jing332.tts_server_android.constant + +object PreferKey { + const val isSplitEnabled = "isSplitEnabled" + const val isMultiVoiceEnabled = "isMultiVoiceEnabled" + const val isReplaceEnabled = "isReplaceEnabled" + const val requestTimeout = "requestTimeout" + const val minDialogueLength = "minDialogueLength" +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/ReplaceExecution.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/ReplaceExecution.kt new file mode 100644 index 000000000..2a2025a6c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/ReplaceExecution.kt @@ -0,0 +1,9 @@ +package com.github.jing332.tts_server_android.constant + +/** + * 替换规则执行时机 + */ +object ReplaceExecution { + const val BEFORE = 0 // 朗读规则前 + const val AFTER = 1 // 朗读规则后 +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/SpeechTarget.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/SpeechTarget.kt new file mode 100644 index 000000000..a5b8d9090 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/SpeechTarget.kt @@ -0,0 +1,39 @@ +package com.github.jing332.tts_server_android.constant + +import androidx.annotation.IntDef +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app + +@IntDef( + SpeechTarget.ALL, +// SpeechTarget.ASIDE, +// SpeechTarget.DIALOGUE, + SpeechTarget.BGM, + SpeechTarget.CUSTOM_TAG +) +@Retention(AnnotationRetention.SOURCE) +annotation class SpeechTarget { + companion object { + const val ALL = 0 + + @Deprecated("已有自定TAG") + const val ASIDE = 1 //旁白 + + @Deprecated("已有自定TAG") + const val DIALOGUE = 2 //对话 + + const val BGM = 3 //背景音乐 + const val CUSTOM_TAG = 4 // 自定义Tag + + fun toText(@SpeechTarget target: Int): String { + return when (target) { + BGM -> { + app.getString(R.string.bgm) + } + + else -> "" + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/constant/SystemNotificationConst.kt b/app/src/main/java/com/github/jing332/tts_server_android/constant/SystemNotificationConst.kt new file mode 100644 index 000000000..b583878b2 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/constant/SystemNotificationConst.kt @@ -0,0 +1,7 @@ +package com.github.jing332.tts_server_android.constant + +object SystemNotificationConst { + const val ID_FORWARDER_MS = 1 + const val ID_FORWARDER_SYS = 2 + const val ID_SYSTEM_TTS = 3 +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/AppDataBase.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/AppDataBase.kt new file mode 100644 index 000000000..7c16cefca --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/AppDataBase.kt @@ -0,0 +1,72 @@ +package com.github.jing332.tts_server_android.data + +import android.content.Context +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.DeleteColumn +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.AutoMigrationSpec +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.data.dao.PluginDao +import com.github.jing332.tts_server_android.data.dao.SpeechRuleDao +import com.github.jing332.tts_server_android.data.dao.ReplaceRuleDao +import com.github.jing332.tts_server_android.data.dao.SystemTtsDao +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTtsGroup + +val appDb by lazy { AppDatabase.createDatabase(App.context) } + +@Database( + version = 24, + entities = [ + SystemTts::class, + SystemTtsGroup::class, + ReplaceRule::class, + ReplaceRuleGroup::class, + Plugin::class, + SpeechRule::class, + ], + autoMigrations = [ + AutoMigration(from = 7, to = 8), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10), + AutoMigration(from = 10, to = 11), + AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13, AppDatabase.DeleteSystemTtsColumn::class), + AutoMigration(from = 13, to = 14), + AutoMigration(from = 14, to = 15), + // 15-16 + AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), + AutoMigration(from = 19, to = 20), + AutoMigration(from = 20, to = 21), + AutoMigration(from = 21, to = 22), + AutoMigration(from = 22, to = 23), + AutoMigration(from = 23, to = 24), + ] +) +abstract class AppDatabase : RoomDatabase() { + abstract val replaceRuleDao: ReplaceRuleDao + abstract val systemTtsDao: SystemTtsDao + abstract val pluginDao: PluginDao + abstract val speechRuleDao: SpeechRuleDao + + companion object { + private const val DATABASE_NAME = "systts.db" + + fun createDatabase(context: Context) = Room + .databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) + .allowMainThreadQueries() + .addMigrations(*DataBaseMigration.migrations) + .build() + } + + @DeleteColumn(tableName = "sysTts", columnName = "isBgm") + class DeleteSystemTtsColumn : AutoMigrationSpec +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/DataBaseMigration.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/DataBaseMigration.kt new file mode 100644 index 000000000..c61377923 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/DataBaseMigration.kt @@ -0,0 +1,23 @@ +package com.github.jing332.tts_server_android.data + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +object DataBaseMigration { + val migrations: Array by lazy { + arrayOf(migration_15_16) + } + + private val migration_15_16 = object : Migration(15, 16) { + override fun migrate(database: SupportSQLiteDatabase) { + //language=RoomSql + database.apply { + execSQL("ALTER TABLE sysTts ADD COLUMN speechRule_tagRuleId TEXT NOT NULL DEFAULT ''") + execSQL("ALTER TABLE sysTts ADD COLUMN speechRule_tag TEXT NOT NULL DEFAULT ''") + // 移动到嵌套对象 + execSQL("ALTER TABLE sysTts RENAME readAloudTarget TO speechRule_target") + execSQL("ALTER TABLE sysTts RENAME isStandby TO speechRule_isStandby") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/dao/PluginDao.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/PluginDao.kt new file mode 100644 index 000000000..93602a784 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/PluginDao.kt @@ -0,0 +1,46 @@ +package com.github.jing332.tts_server_android.data.dao + +import androidx.room.* +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import kotlinx.coroutines.flow.Flow + +@Dao +interface PluginDao { + @get:Query("SELECT * FROM plugin ORDER BY `order` ASC") + val all: List + + @get:Query("SELECT * FROM plugin WHERE isEnabled = '1' ORDER BY `order` ASC") + val allEnabled: List + + @Query("SELECT * FROM plugin ORDER BY `order` ASC") + fun flowAll(): Flow> + + @get:Query("SELECT count(*) FROM plugin") + val count: Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg data: Plugin) + + @Delete + fun delete(vararg data: Plugin) + + @Update + fun update(vararg data: Plugin) + + @Query("SELECT * FROM plugin WHERE pluginId = :pluginId ") + fun getByPluginId(pluginId: String): Plugin? + + fun insertOrUpdate(vararg args: Plugin) { + for (v in args) { + val old = getByPluginId(v.pluginId) + if (old == null) { + insert(v) + continue + } + + if (v.pluginId == old.pluginId && v.version > old.version) + update(v.copy(id = old.id)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/dao/ReplaceRuleDao.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/ReplaceRuleDao.kt new file mode 100644 index 000000000..c791a6129 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/ReplaceRuleDao.kt @@ -0,0 +1,120 @@ +package com.github.jing332.tts_server_android.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup.Companion.DEFAULT_GROUP_ID +import com.github.jing332.tts_server_android.data.entities.replace.GroupWithReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRuleGroup +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.map +import kotlin.math.min + +@Dao +interface ReplaceRuleDao { + @get:Query("SELECT * FROM ReplaceRule ORDER BY `order` ASC") + val all: List + + @get:Query("SELECT * FROM ReplaceRule WHERE isEnabled = '1' ORDER BY `order` ASC") + val allEnabled: List + + @Query("SELECT * FROM ReplaceRule ORDER BY `order` ASC") + fun flowAll(): Flow> + + @get:Query("SELECT count(*) FROM ReplaceRule") + val count: Int + + @Query("SELECT * FROM ReplaceRule WHERE id = :id") + fun get(id: Long): ReplaceRule? + + @get:Query("SELECT * FROM replaceRuleGroup ORDER by `order` ASC") + val allGroup: List + + @Transaction +// @Query("SELECT * FROM replaceRuleGroup ORDER BY `order` ASC") + fun allGroupWithReplaceRules(): List { + val list = mutableListOf() + allGroup.forEach { + list.add(GroupWithReplaceRule(it, getListInGroup(it.id))) + } + + return list + } + + @Transaction + @Query("SELECT * FROM replaceRuleGroup ORDER BY `order` ASC") + fun internalFlowAllGroupWithReplaceRules(): Flow> + + fun flowAllGroupWithReplaceRules(): Flow> = + internalFlowAllGroupWithReplaceRules().conflate().map { list -> + list.map { groupWithRules -> + GroupWithReplaceRule( + groupWithRules.group, + groupWithRules.list.sortedBy { it.order }) + } + } + + @Query("SELECT * FROM replaceRuleGroup WHERE id = :id") + fun getGroup(id: Long = DEFAULT_GROUP_ID): ReplaceRuleGroup? + + @Query("SELECT * FROM ReplaceRule WHERE groupId = :groupId ORDER BY `order` ASC") + fun getListInGroup(groupId: Long = DEFAULT_GROUP_ID): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertGroup(vararg data: ReplaceRuleGroup) + + @Update + fun updateGroup(vararg data: ReplaceRuleGroup) + + @Delete + fun deleteGroup(vararg data: ReplaceRuleGroup) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg data: ReplaceRule) + + @Delete + fun delete(vararg data: ReplaceRule) + + @Query("DELETE from ReplaceRule WHERE groupId = :groupId") + fun deleteAllByGroup(groupId: Long) + + @Update + fun update(vararg data: ReplaceRule) + + /** + * 更新数据并刷新排序索引 + */ + fun updateDataAndRefreshOrder(data: ReplaceRule) { + val list = getListInGroup(data.groupId).toMutableList() + list.removeIf { it.id == data.id } + list.add(min(data.order, list.size), data) + list.forEachIndexed { index, value -> value.apply { order = index } } + + update(*list.toTypedArray()) + } + + fun updateAllOrder() { + allGroupWithReplaceRules().forEachIndexed { index, groupWithReplaceRule -> + val g = groupWithReplaceRule.group + if (g.order != index) updateGroup(g.copy(order = index)) + + groupWithReplaceRule.list.forEachIndexed { i, replaceRule -> + if (replaceRule.order != i) + update(replaceRule.copy(order = i)) + } + } + } + + fun insertRuleWithGroup(vararg args: GroupWithReplaceRule) { + for (v in args) { + insertGroup(v.group) + insert(*v.list.toTypedArray()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SpeechRuleDao.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SpeechRuleDao.kt new file mode 100644 index 000000000..19ed1b022 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SpeechRuleDao.kt @@ -0,0 +1,48 @@ +package com.github.jing332.tts_server_android.data.dao + +import androidx.room.* +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import kotlinx.coroutines.flow.Flow + +@Dao +interface SpeechRuleDao { + @get:Query("SELECT * FROM speech_rules ORDER BY `order` ASC") + val all: List + + @get:Query("SELECT * FROM speech_rules WHERE isEnabled = '1'") + val allEnabled: List + + @Query("SELECT * FROM speech_rules ORDER BY `order` ASC") + fun flowAll(): Flow> + + @get:Query("SELECT count(*) FROM speech_rules") + val count: Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg data: SpeechRule) + + @Delete + fun delete(vararg data: SpeechRule) + + @Update + fun update(vararg data: SpeechRule) + + @Query("SELECT * FROM speech_rules WHERE ruleId = :ruleId AND isEnabled = :isEnabled LIMIT 1") + fun getByRuleId(ruleId: String, isEnabled: Boolean = true): SpeechRule? + +// @Query("SELECT * FROM speech_rules WHERE ruleId = :ruleId") +// fun getByRuleId(ruleId: String): SpeechRule? + + fun insertOrUpdate(vararg args: SpeechRule) { + for (v in args) { + val old = getByRuleId(v.ruleId) + if (old == null) { + insert(v) + continue + } + + if (v.ruleId == old.ruleId && v.version > old.version) + update(v.copy(id = old.id)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SysTtsDao.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SysTtsDao.kt new file mode 100644 index 000000000..32fd3a7c7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SysTtsDao.kt @@ -0,0 +1,40 @@ +/* +package com.github.jing332.tts_server_android.data.dao + +import androidx.room.* +import com.github.jing332.tts_server_android.constant.ReadAloudTarget +import com.github.jing332.tts_server_android.data.entities.SysTts +import kotlinx.coroutines.flow.Flow + +@Dao +interface SysTtsDao { + @get:Query("select * from SysTts ORDER BY readAloudTarget ASC") + val all: List + + @Query("select * from SysTts ORDER BY readAloudTarget ASC") + fun flowAll(): Flow> + + @get:Query("select count(*) from SysTts") + val count: Int + + @Query("select * from SysTts where id = :id") + fun get(id: Long): SysTts? + + @Query("select * from SysTts where readAloudTarget = :target and isEnabled = '1'") + fun getByReadAloudTarget(target: Int = ReadAloudTarget.ALL): SysTts? + + @Query("select * from sysTts where readAloudTarget = :target and isEnabled = '1'") + fun getAllByReadAloudTarget(target: Int = ReadAloudTarget.ALL): List? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(vararg tts: SysTts) + + @Delete + fun delete(vararg tts: SysTts) + + @Update + fun update(vararg tts: SysTts) + + @Query("delete from SysTts where id < 0") + fun deleteDefault() +}*/ diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SystemTtsDao.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SystemTtsDao.kt new file mode 100644 index 000000000..4a5f34073 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/dao/SystemTtsDao.kt @@ -0,0 +1,147 @@ +package com.github.jing332.tts_server_android.data.dao + +import androidx.room.* +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup.Companion.DEFAULT_GROUP_ID +import com.github.jing332.tts_server_android.data.entities.systts.GroupWithSystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTts +import com.github.jing332.tts_server_android.data.entities.systts.SystemTtsGroup +import kotlinx.coroutines.flow.Flow + +@Dao +interface SystemTtsDao { + @get:Query("SELECT * FROM sysTts") + val allTts: List + + @get:Query("SELECT count(*) FROM sysTts") + val ttsCount: Int + + @get:Query("SELECT * FROM sysTts") + val flowAllTts: Flow> + + @get:Query("SELECT count(speechRule_isStandby = '1') FROM sysTts") + val standbyTtsCount: Int + + @Query("SELECT * FROM sysTts WHERE isEnabled = '1' AND speechRule_target = :target AND speechRule_isStandby = :isStandbyType") + fun getEnabledList( + target: Int = SpeechTarget.ALL, + isStandbyType: Boolean = false + ): List + + @Query("SELECT * FROM sysTts WHERE isEnabled = '1' AND speechRule_target = :target AND speechRule_isStandby = :isStandbyType AND groupId = :groupId") + fun getEnabledListByGroupId( + groupId: Long, + target: Int = SpeechTarget.ALL, + isStandbyType: Boolean = false, + ): List + + @Query("SELECT * FROM sysTts WHERE groupId = :groupId") + fun getTtsByGroup(groupId: Long): List + + @Query("SELECT * FROM sysTts WHERE id = :id") + fun getTts(id: Long): SystemTts? + + @get:Query("SELECT * FROM sysTts WHERE isEnabled = '1'") + val allEnabledTts: List + + @get:Query("SELECT * FROM SystemTtsGroup ORDER by `order` ASC") + val allGroup: List + + @get:Query("SELECT count(*) FROM SystemTtsGroup") + val groupCount: Int + + @Transaction + @Query("SELECT * FROM SystemTtsGroup ORDER BY `order` ASC") + fun getAllGroupWithTts(): List + + @Transaction + @Query("SELECT * FROM SystemTtsGroup ORDER BY `order` ASC") + fun getFlowAllGroupWithTts(): Flow> + + @Query("SELECT * FROM SystemTtsGroup WHERE groupId = :id") + fun getGroup(id: Long = DEFAULT_GROUP_ID): SystemTtsGroup? + + @Query("SELECT * FROM sysTts WHERE groupId = :groupId ORDER BY `order` ASC") + fun getTtsListByGroupId(groupId: Long): List + + /** + * 所有TTS 是否启用 + */ + @Query("UPDATE sysTts SET isEnabled = :isEnabled") + fun setAllTtsEnabled(isEnabled: Boolean) + + /** + * 设置某个组中的所有TTS 是否启用 + */ + @Query("UPDATE sysTts SET isEnabled = :isEnabled WHERE groupId = :groupId") + fun setTtsEnabledInGroup(groupId: Long, isEnabled: Boolean) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertTts(vararg items: SystemTts) + + @Update(onConflict = OnConflictStrategy.REPLACE) + fun updateTts(vararg items: SystemTts) + + @Delete + fun deleteTts(vararg items: SystemTts) + + @Query("DELETE from sysTts WHERE groupId = :groupId") + fun deleteTtsByGroup(groupId: Long) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertGroup(vararg group: SystemTtsGroup) + + @Update(onConflict = OnConflictStrategy.REPLACE) + fun updateGroup(group: SystemTtsGroup) + + @Delete + fun deleteGroup(group: SystemTtsGroup) + + @Transaction + @Query("SELECT * FROM SystemTtsGroup ORDER BY `order` ASC") + fun getSysTtsWithGroups(): List + + /** + * 删除组以及TTS + */ + fun deleteGroupAndTts(group: SystemTtsGroup) { + deleteTtsByGroup(group.id) + deleteGroup(group) + } + + fun insertGroupWithTts(vararg args: GroupWithSystemTts) { + for (v in args) { + insertGroup(v.group) + insertTts(*v.list.toTypedArray()) + } + } + + /** + * 按照分组和分组内进行排序获取 + */ + fun getEnabledListForSort(target: Int, isStandbyType: Boolean = false): List { + val list = mutableListOf() + allGroup.forEach { group -> + list.addAll( + getEnabledListByGroupId( + group.id, + target, + isStandbyType + ).sortedBy { it.order }) + } + + return list + } + + fun updateAllOrder() { + getAllGroupWithTts().forEachIndexed { index, groupWithSystemTts -> + val g = groupWithSystemTts.group + if (g.order != index) updateGroup(g.copy(order = index)) + + groupWithSystemTts.list.sortedBy { it.order }.forEachIndexed { subIndex, systemTts -> + if (systemTts.order != subIndex) updateTts(systemTts.copy(order = subIndex)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/AbstractListGroup.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/AbstractListGroup.kt new file mode 100644 index 000000000..6eb3cbe53 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/AbstractListGroup.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android.data.entities + +interface AbstractListGroup { + val id: Long + var name: String + var order: Int + var isExpanded: Boolean + + companion object { + const val DEFAULT_GROUP_ID = 1L + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/MapConverters.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/MapConverters.kt new file mode 100644 index 000000000..e06a550ec --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/MapConverters.kt @@ -0,0 +1,35 @@ +package com.github.jing332.tts_server_android.data.entities + +import androidx.room.TypeConverter + +object MapConverters { + @TypeConverter + fun toMap(json: String): Map { + return TypeConverterUtils.decodeFromString(json) ?: emptyMap() + } + + @TypeConverter + fun fromMap(tags: Map): String { + return TypeConverterUtils.encodeToString(tags) ?: "" + } + + @TypeConverter + fun toNestMap(json: String): Map> { + return TypeConverterUtils.decodeFromString(json) ?: emptyMap() + } + + @TypeConverter + fun fromNestMap(map: Map>): String { + return TypeConverterUtils.encodeToString(map) ?: "" + } + + @TypeConverter + fun toMapList(json: String): Map>> { + return TypeConverterUtils.decodeFromString(json) ?: emptyMap() + } + + @TypeConverter + fun fromMapList(tags: Map>>): String { + return TypeConverterUtils.encodeToString(tags) ?: "" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/SpeechRule.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/SpeechRule.kt new file mode 100644 index 000000000..5dd7ac1a8 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/SpeechRule.kt @@ -0,0 +1,60 @@ +package com.github.jing332.tts_server_android.data.entities + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import androidx.room.TypeConverters +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.JsonElement + +@Entity(tableName = "speech_rules") +@Parcelize +@TypeConverters(SpeechRule.Converters::class, MapConverters::class) +@Serializable +data class SpeechRule( +// @Transient + @PrimaryKey(autoGenerate = true) + val id: Long = System.currentTimeMillis(), + + var isEnabled: Boolean = false, + + var name: String = "", + var version: Int = 0, + var ruleId: String = "", + var author: String = "", + var code: String = "", + + @ColumnInfo(defaultValue = "") + var tags: Map = mutableMapOf(), + + // 声明的tag的附加 + // 如:为tag为dialogue的声明role附加数据 + // {dialogue: {role: {label: '角色名', "hint": "仅支持前置搜索"}, } } + @ColumnInfo(defaultValue = "") + var tagsData: TagsDataMap = mutableMapOf(), + + // 索引 排序用 + @ColumnInfo(name = "order", defaultValue = "0") + var order: Int = 0, +) : Parcelable { + + class Converters { + @TypeConverter + fun toListMap(json: String): TagsDataMap { + return TypeConverterUtils.decodeFromString(json) ?: emptyMap() + } + + @TypeConverter + fun fromListMap(tags: TagsDataMap): String { + return TypeConverterUtils.encodeToString(tags) ?: "" + } + } +} + +// {dialogue: {role: {label: '角色名', "hint": "仅支持前置搜索"}, } } +typealias TagsDataMap = Map>> \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/TypeConverterUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/TypeConverterUtils.kt new file mode 100644 index 000000000..8d2848b07 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/TypeConverterUtils.kt @@ -0,0 +1,38 @@ +package com.github.jing332.tts_server_android.data.entities + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +internal object TypeConverterUtils { + @OptIn(ExperimentalSerializationApi::class) + val json by lazy { + Json { + ignoreUnknownKeys = true //忽略未知 + explicitNulls = false //忽略为null的字段 + isLenient = true //忽略不符合json规范的字段 + allowStructuredMapKeys = true + } + } + + inline fun decodeFromString(s: String?): T? { + if (s == null) return null + return try { + json.decodeFromString(s.toString()) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + inline fun encodeToString(value :T?): String? { + if (value == null) return null + return try { + json.encodeToString(value) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/plugin/Plugin.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/plugin/Plugin.kt new file mode 100644 index 000000000..22a2a972d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/plugin/Plugin.kt @@ -0,0 +1,53 @@ +package com.github.jing332.tts_server_android.data.entities.plugin + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import com.github.jing332.tts_server_android.data.entities.MapConverters +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +@Parcelize +@Entity +@TypeConverters(MapConverters::class) +data class Plugin( + @Transient + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + var isEnabled: Boolean = false, + + @ColumnInfo(defaultValue = "0") + var version: Int = 0, + var name: String = "", + var pluginId: String = "", + var author: String = "", + var code: String = "", + + // authKey: { label: "验证KEY", hint: "填入用于验证身份的KEY"} + @ColumnInfo(name = "defVars", defaultValue = "{}") + var defVars: Map> = mutableMapOf(), + + + @ColumnInfo(defaultValue = "{}") + var userVars: Map = mutableMapOf(), + + // 索引 排序用 + @ColumnInfo(name = "order", defaultValue = "0") + var order: Int = 0, +) : Parcelable { + val mutableUserVars: MutableMap + get() = userVars as MutableMap + + override fun toString(): String { + return "name: $name, pluginId: $pluginId, author: $author, version: $version, isEnabled: $isEnabled" + } + + override fun equals(other: Any?): Boolean { + return super.equals(other) && other is Plugin && other.userVars == userVars + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/GroupWithReplaceRule.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/GroupWithReplaceRule.kt new file mode 100644 index 000000000..f3bef85ad --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/GroupWithReplaceRule.kt @@ -0,0 +1,16 @@ +package com.github.jing332.tts_server_android.data.entities.replace + +import androidx.room.Embedded +import androidx.room.Relation + +@kotlinx.serialization.Serializable +data class GroupWithReplaceRule( + @Embedded + val group: ReplaceRuleGroup, + + @Relation( + parentColumn = "id", + entityColumn = "groupId" + ) + val list: List +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/ReplaceRule.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/ReplaceRule.kt new file mode 100644 index 000000000..74204f6d9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/ReplaceRule.kt @@ -0,0 +1,38 @@ +package com.github.jing332.tts_server_android.data.entities.replace + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup.Companion.DEFAULT_GROUP_ID +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +@Entity(tableName = "replaceRule") +data class ReplaceRule( + @PrimaryKey(autoGenerate = true) + var id: Long = System.currentTimeMillis(), + + // 所属组的ID + @ColumnInfo(defaultValue = DEFAULT_GROUP_ID.toString()) + var groupId: Long = DEFAULT_GROUP_ID, + + // 显示名称 + var name: String = "", + // 是否启用 + var isEnabled: Boolean = true, + // 是否正则 + var isRegex: Boolean = false, + // 匹配 + var pattern: String = "", + // 替换为 + var replacement: String = "", + // 索引 排序用 + @ColumnInfo(defaultValue = "0") + var order: Int = 0, + + @ColumnInfo(defaultValue = "") + var sampleText: String = "" +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/ReplaceRuleGroup.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/ReplaceRuleGroup.kt new file mode 100644 index 000000000..0390c853c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/replace/ReplaceRuleGroup.kt @@ -0,0 +1,22 @@ +package com.github.jing332.tts_server_android.data.entities.replace + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.github.jing332.tts_server_android.constant.ReplaceExecution +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup + +@Entity("replaceRuleGroup") +@kotlinx.serialization.Serializable +data class ReplaceRuleGroup( + @PrimaryKey + override val id: Long = System.currentTimeMillis(), + override var name: String, + override var order: Int = 0, + + @kotlinx.serialization.Transient + override var isExpanded: Boolean = false, + + @ColumnInfo(defaultValue = ReplaceExecution.BEFORE.toString()) + var onExecution: Int = ReplaceExecution.BEFORE, +) : AbstractListGroup \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/AudioParams.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/AudioParams.kt new file mode 100644 index 000000000..8cf0776e1 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/AudioParams.kt @@ -0,0 +1,41 @@ +package com.github.jing332.tts_server_android.data.entities.systts + +import android.os.Parcelable +import androidx.room.ColumnInfo +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +data class AudioParams( + @ColumnInfo("speed", defaultValue = "$FOLLOW_GLOBAL_VALUE") + var speed: Float = FOLLOW_GLOBAL_VALUE, + + @ColumnInfo("volume", defaultValue = "$FOLLOW_GLOBAL_VALUE") + var volume: Float = FOLLOW_GLOBAL_VALUE, + + @ColumnInfo("pitch", defaultValue = "$FOLLOW_GLOBAL_VALUE") + var pitch: Float = FOLLOW_GLOBAL_VALUE +) : Parcelable { + companion object { + const val FOLLOW_GLOBAL_VALUE = 0f + } + + val isDefaultValue: Boolean + get() = speed == 1f && volume == 1f && pitch == 1f + + fun copyIfFollow(followSpeed: Float, followVolume: Float, followPitch: Float): AudioParams { + return AudioParams( + if (speed == FOLLOW_GLOBAL_VALUE) followSpeed else speed, + if (volume == FOLLOW_GLOBAL_VALUE) followVolume else volume, + if (pitch == FOLLOW_GLOBAL_VALUE) followPitch else pitch + ) + } + + fun reset(v: Float) { + speed = v + volume = v + pitch = v + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/CompatSystemTts.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/CompatSystemTts.kt new file mode 100644 index 000000000..15f240ac3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/CompatSystemTts.kt @@ -0,0 +1,26 @@ +package com.github.jing332.tts_server_android.data.entities.systts + +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import kotlinx.serialization.Serializable + + +@Serializable +data class CompatSystemTts( + @kotlinx.serialization.Transient + val id: Long = 0, + + // 是否启用 + @kotlinx.serialization.Transient + var isEnabled: Boolean = false, + + // UI显示名称 + var displayName: String = "", + + // 朗读目标 + @SpeechTarget var speechTarget: Int = SpeechTarget.ALL, + + // TTS属性 + var tts: ITextToSpeechEngine, +) { +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/GroupWithSystemTts.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/GroupWithSystemTts.kt new file mode 100644 index 000000000..5dd871acc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/GroupWithSystemTts.kt @@ -0,0 +1,16 @@ +package com.github.jing332.tts_server_android.data.entities.systts + +import androidx.room.Embedded +import androidx.room.Relation + +@kotlinx.serialization.Serializable +data class GroupWithSystemTts( + @Embedded + val group: SystemTtsGroup, + + @Relation( + parentColumn = "groupId", + entityColumn = "groupId" + ) + val list: List +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SpeechRuleInfo.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SpeechRuleInfo.kt new file mode 100644 index 000000000..8aab0b545 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SpeechRuleInfo.kt @@ -0,0 +1,64 @@ +package com.github.jing332.tts_server_android.data.entities.systts + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Ignore +import androidx.room.TypeConverters +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.entities.MapConverters +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +@Parcelize +@TypeConverters(MapConverters::class) +data class SpeechRuleInfo( + var target: Int = SpeechTarget.ALL, + var isStandby: Boolean = false, + + @ColumnInfo(defaultValue = "") + var tag: String = "", + @ColumnInfo(defaultValue = "") + var tagRuleId: String = "", + + // 显示在列表右上角的标签名 + @ColumnInfo(defaultValue = "") + var tagName: String = "", + + // 用于存储tag的数据 + // 例: key=role, value=张三 + @ColumnInfo(defaultValue = "") + var tagData: Map = mutableMapOf(), + + // 用于标识tts配置的唯一性,由脚本处理后将 tag 与 id 返回给程序以找到朗读 + @ColumnInfo(defaultValue = "0") + var configId: Long = 0L +) : Parcelable { + val mutableTagData: MutableMap + get() = tagData as MutableMap + + @IgnoredOnParcel + @Ignore + @Transient + var standbyTts: ITextToSpeechEngine? = null + + /** + * 判断tag是否相同 + * @return 相同 + */ + fun isTagSame(rule: SpeechRuleInfo): Boolean { + return tag == rule.tag && tagRuleId == rule.tagRuleId + } + + fun resetTag() { + tag = "" + tagRuleId = "" + tagName = "" + mutableTagData.clear() + } + + fun isTagDataEmpty(): Boolean = tagData.filterValues { it.isNotEmpty() }.isEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SystemTts.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SystemTts.kt new file mode 100644 index 000000000..3c468b8cc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SystemTts.kt @@ -0,0 +1,93 @@ +package com.github.jing332.tts_server_android.data.entities.systts + +import android.os.Parcelable +import androidx.room.* +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import com.github.jing332.tts_server_android.model.speech.tts.MsTTS +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@kotlinx.serialization.Serializable +@kotlinx.parcelize.Parcelize +@TypeConverters(SystemTts.Converters::class) +@Entity(tableName = "sysTts") +data class SystemTts( + @PrimaryKey(autoGenerate = true) + val id: Long = System.currentTimeMillis(), + + // 所属组的ID + @ColumnInfo(defaultValue = AbstractListGroup.DEFAULT_GROUP_ID.toString()) + var groupId: Long = AbstractListGroup.DEFAULT_GROUP_ID, + + var displayName: String? = null, + + var isEnabled: Boolean = false, + + @Embedded("speechRule_") + var speechRule: SpeechRuleInfo = SpeechRuleInfo(), + + var tts: ITextToSpeechEngine, + + // 索引 排序用 + @ColumnInfo(defaultValue = "0") + var order: Int = 0, +) : Parcelable { + // 朗读目标 + @Deprecated("") + @SpeechTarget + @SerialName("readAloudTarget") + @get:Ignore + var speechTarget: Int + get() = speechRule.target + set(value) { + speechRule.target = value + } + + @Deprecated("") + @SpeechTarget + @SerialName("isStandby") + @get:Ignore + var isStandby: Boolean + get() = speechRule.isStandby + set(value) { + speechRule.isStandby = value + } + + // 转换器 + class Converters { + companion object { + @OptIn(ExperimentalSerializationApi::class) + val json by lazy { + Json { + ignoreUnknownKeys = true //忽略未知 + explicitNulls = false //忽略为null的字段 + allowStructuredMapKeys = true + } + } + + private inline fun decodeFromString(s: String?): T? { + if (s == null) return null + return try { + json.decodeFromString(s.toString()) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + + @TypeConverter + fun ttsToString(tts: ITextToSpeechEngine): String { + return json.encodeToString(tts) + } + + @TypeConverter + fun stringToTts(json: String?): ITextToSpeechEngine { + return decodeFromString(json).run { this ?: MsTTS() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SystemTtsGroup.kt b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SystemTtsGroup.kt new file mode 100644 index 000000000..dc07fe635 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/data/entities/systts/SystemTtsGroup.kt @@ -0,0 +1,27 @@ +package com.github.jing332.tts_server_android.data.entities.systts + +import android.os.Parcel +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.github.jing332.tts_server_android.data.entities.AbstractListGroup +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +@Entity +data class SystemTtsGroup( + @ColumnInfo("groupId") + @PrimaryKey + override val id: Long = System.currentTimeMillis(), + override var name: String, + @ColumnInfo(defaultValue = "0") + override var order: Int = 0, + override var isExpanded: Boolean = false, + + @Embedded(prefix = "audioParams_") + var audioParams: AudioParams = AudioParams() +) : AbstractListGroup, Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/ByteArrayBinder.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/ByteArrayBinder.kt new file mode 100644 index 000000000..730703439 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/ByteArrayBinder.kt @@ -0,0 +1,6 @@ +package com.github.jing332.tts_server_android.help + +import android.os.Binder + +// Binder 用于传递大数据 +class ByteArrayBinder(val data: ByteArray) : Binder() \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/ConfigImportHelper.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/ConfigImportHelper.kt new file mode 100644 index 000000000..1821f2099 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/ConfigImportHelper.kt @@ -0,0 +1,39 @@ +package com.github.jing332.tts_server_android.help + +import com.github.jing332.tts_server_android.constant.ConfigType + +object ConfigImportHelper { + private val fieldMap = mapOf( + listOf("pluginId", "code", "version") to ConfigType.PLUGIN, + "pattern" to ConfigType.REPLACE_RULE, + listOf("ruleId", "tags") to ConfigType.SPEECH_RULE, + listOf("list", "tts", "displayName", "#type") to ConfigType.LIST, + ) + + /** + * 检查配置字符串是否符合配置格式 + */ + fun getConfigType(str: String): ConfigType { + fieldMap.forEach { entry -> + when (entry.key) { + is String -> if (str.contains("\"${entry.key}\":")) { + return entry.value + } + + is List<*> -> if (str.contains((entry.key as List<*>).map { "\"${it.toString()}\":" })) { + return entry.value + } + } + } + + return ConfigType.UNKNOWN + } + + private fun String.contains(list: List): Boolean { + var count = 0 + list.forEach { + if (this.contains(it)) count++ + } + return count == list.size + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/LocalTtsEngineHelper.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/LocalTtsEngineHelper.kt new file mode 100644 index 000000000..31f564702 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/LocalTtsEngineHelper.kt @@ -0,0 +1,58 @@ +package com.github.jing332.tts_server_android.help + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.Voice +import kotlinx.coroutines.delay +import java.util.* + +class LocalTtsEngineHelper(val context: Context) { + companion object { + private const val INIT_STATUS_WAITING = -2 + } + + private var tts: TextToSpeech? = null + + private var engineName: String = "" + + /** + * return 是否 初始化成功 + */ + suspend fun setEngine(name: String): Boolean { + if (engineName != name) { + engineName = name + shutdown() + + var status = INIT_STATUS_WAITING + tts = TextToSpeech(context, { status = it }, name) + + for (i in 1..50) { // 5s + if (status == TextToSpeech.SUCCESS) break + else if (i == 50) return false + delay(100) + } + + } + return true + } + + fun shutdown() { + tts?.shutdown() + } + + + val voices: List + get() = try { + tts!!.voices?.toList()!! + } catch (e: NullPointerException) { + emptyList() + } + + val locales: List + get() = try { + tts!!.availableLanguages.toList().sortedBy { it.toString() } + } catch (e: NullPointerException) { + emptyList() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioDecoder.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioDecoder.kt new file mode 100644 index 000000000..5bbc7cf07 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioDecoder.kt @@ -0,0 +1,329 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.media.MediaCodec +import android.media.MediaCodec.BufferInfo +import android.media.MediaExtractor +import android.media.MediaFormat +import android.os.Build +import android.os.SystemClock +import android.text.TextUtils +import android.util.Log +import com.github.jing332.tts_server_android.help.audio.AudioDecoderException.Companion.ERROR_CODE_NO_AUDIO_TRACK +import com.github.jing332.tts_server_android.utils.GcManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import okio.ByteString +import okio.ByteString.Companion.toByteString +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import kotlin.coroutines.coroutineContext + + +class AudioDecoder { + companion object { + const val TAG = "AudioDecode" + + suspend fun InputStream.readPcmChunk( + bufferSize: Int = 4096, + chunkSize: Int = 2048, + onRead: suspend (ByteArray) -> Unit + ) { + var bufferFilledCount = 0 + val buffer = ByteArray(bufferSize) + + while (coroutineContext.isActive) { + val readLen = + this.read(buffer, bufferFilledCount, chunkSize - bufferFilledCount) + if (readLen == -1) { + if (bufferFilledCount > 0) { + val chunkData = buffer.copyOfRange(0, bufferFilledCount) + onRead.invoke(chunkData) + } + break + } + if (readLen == 0) { + delay(100) + continue + } + + bufferFilledCount += readLen + if (bufferFilledCount >= chunkSize) { + val chunkData = buffer.copyOfRange(0, chunkSize) + + onRead.invoke(chunkData) + bufferFilledCount = 0 + } + } + } + + /** + * 获取音频采样率 + */ + private fun getFormats(srcData: ByteArray): List { + kotlin.runCatching { + val mediaExtractor = MediaExtractor() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + mediaExtractor.setDataSource(ByteArrayMediaDataSource(srcData)) + else + mediaExtractor.setDataSource( + "data:" + "" + ";base64," + srcData.toByteString().base64() + ) + + val formats = mutableListOf() + for (i in 0 until mediaExtractor.trackCount) { + formats.add(mediaExtractor.getTrackFormat(i)) + } + return formats + } + return emptyList() + } + + /** + * 获取采样率和MIME + */ + fun getSampleRateAndMime(audio: ByteArray): Pair { + val formats = getFormats(audio) + + var sampleRate = 0 + var mime = "" + if (formats.isNotEmpty()) { + sampleRate = formats[0].getInteger(MediaFormat.KEY_SAMPLE_RATE) + mime = formats[0].getString(MediaFormat.KEY_MIME) ?: "" + } + + return Pair(sampleRate, mime) + } + + } + + private val currentMime: String = "" + private var mediaCodec: MediaCodec? = null + private var oldMime: String? = null + + private fun getMediaCodec(mime: String, mediaFormat: MediaFormat): MediaCodec { + if (mediaCodec == null || mime != oldMime) { + if (null != mediaCodec) { + mediaCodec!!.release() + GcManager.doGC() + } + try { + mediaCodec = MediaCodec.createDecoderByType(mime) + oldMime = mime + } catch (ioException: IOException) { + //设备无法创建,直接抛出 + ioException.printStackTrace() + throw RuntimeException(ioException) + } + } + mediaCodec!!.reset() + mediaCodec!!.configure(mediaFormat, null, null, 0) + return mediaCodec as MediaCodec + } + + suspend fun doDecode( + srcData: ByteArray, + sampleRate: Int, + onRead: suspend (pcmData: ByteArray) -> Unit, + ) { + val mediaExtractor = MediaExtractor() + try { + // RIFF WAVEfmt直接去除文件头即可 + if (srcData.size > 15 && srcData.copyOfRange(0, 15).decodeToString() + .endsWith("WAVEfmt") + ) { + val data = srcData.copyOfRange(44, srcData.size) + onRead.invoke(data) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + mediaExtractor.setDataSource(ByteArrayMediaDataSource(srcData)) + else + mediaExtractor.setDataSource( + "data:" + currentMime + ";base64," + srcData.toByteString().base64() + ) + + decodeInternal(mediaExtractor, sampleRate) { + onRead.invoke(it) + } + } catch (e: Exception) { + mediaCodec?.reset() + throw AudioDecoderException(cause = e, message = "音频解码失败") + } finally { + mediaExtractor.release() + } + } + + + /** + * @param sampleRate opus音频必须设置采样率 + */ + suspend fun doDecode( + ins: InputStream, + sampleRate: Int = 0, + timeoutUs: Long = 5000L, + onRead: suspend (pcmData: ByteArray) -> Unit, + ) { + val mediaExtractor = MediaExtractor() + try { + val bytes = ByteArray(15) + val len = ins.read(bytes) + + if (len == -1) throw AudioDecoderException(message = "读取音频流前15字节失败: len == -1") + if (bytes.decodeToString().endsWith("WAVEfmt")) { + ins.buffered().use { buffered -> + buffered.skip(29) + buffered.readPcmChunk { pcmData -> + onRead.invoke(pcmData) + } + + buffered.close() + } + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + mediaExtractor.setDataSource(InputStreamMediaDataSource(ins)) + else + throw AudioDecoderException( + AudioDecoderException.ERROR_CODE_NOT_SUPPORT_A5, + "音频流解码器不支持 Android 5" + ) + + decodeInternal(mediaExtractor, sampleRate, timeoutUs) { + onRead.invoke(it) + } + } catch (e: Exception) { + mediaCodec?.reset() + throw AudioDecoderException(cause = e, message = "音频解码失败") + } finally { + mediaExtractor.release() + } + } + + private suspend fun decodeInternal( + mediaExtractor: MediaExtractor, + sampleRate: Int, + timeoutUs: Long = 5000L, + onRead: suspend (pcmData: ByteArray) -> Unit + ) { + val trackFormat = mediaExtractor.selectAudioTrack() + val mime = trackFormat.mime + + //opus的音频必须设置这个才能正确的解码 + if ("audio/opus" == mime) trackFormat.compatOpus(sampleRate) + + //创建解码器 + val mediaCodec = getMediaCodec(mime, trackFormat) + mediaCodec.start() + + val bufferInfo = BufferInfo() + var inputBuffer: ByteBuffer? + val startNanos = SystemClock.elapsedRealtimeNanos() + while (coroutineContext.isActive) { + //获取可用的inputBuffer,输入参数-1代表一直等到,0代表不等待,10*1000代表10秒超时 + val inputIndex = mediaCodec.dequeueInputBuffer(timeoutUs) + if (inputIndex < 0) break + + bufferInfo.presentationTimeUs = mediaExtractor.sampleTime + + inputBuffer = mediaCodec.getInputBuffer(inputIndex) + if (inputBuffer != null) { + inputBuffer.clear() + } else + continue + + //从流中读取的采样数量 + val sampleSize = mediaExtractor.readSampleData(inputBuffer, 0) + if (sampleSize > 0) { + bufferInfo.size = sampleSize + //入队解码 + mediaCodec.queueInputBuffer(inputIndex, 0, sampleSize, 0, 0) + //移动到下一个采样点 + if (!mediaExtractor.nextSample(startNanos)) { + Log.d(TAG, "nextSample(): 已到达流末尾EOF") + } + } else + break + + //取解码后的数据 + var outputIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, timeoutUs) + //不一定能一次取完,所以要循环取 + var outputBuffer: ByteBuffer? + val pcmData = ByteArray(bufferInfo.size) + + while (coroutineContext.isActive && outputIndex >= 0) { + outputBuffer = mediaCodec.getOutputBuffer(outputIndex) + if (outputBuffer != null) { + outputBuffer.get(pcmData) + outputBuffer.clear() //用完后清空,复用 + } + + onRead.invoke(pcmData) + mediaCodec.releaseOutputBuffer(/* index = */ outputIndex, /* render = */ false) + outputIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, timeoutUs) + } + } + } + + @Suppress("UNUSED_PARAMETER") + private fun MediaExtractor.nextSample(startNanos: Long): Boolean { +// while (sampleTime > SystemClock.elapsedRealtimeNanos() - startNanos) { +// delay(100) +// } + return advance() + } + + private val MediaFormat.mime: String + get() = getString(MediaFormat.KEY_MIME) ?: "" + + + private fun MediaExtractor.selectAudioTrack(): MediaFormat { + var audioTrackIndex = -1 + var mime: String? + var trackFormat: MediaFormat? = null + for (i in 0 until trackCount) { + trackFormat = getTrackFormat(i) + mime = trackFormat.getString(MediaFormat.KEY_MIME) + if (!TextUtils.isEmpty(mime) && mime!!.startsWith("audio")) { + audioTrackIndex = i + break + } + } + if (audioTrackIndex == -1) + throw AudioDecoderException(ERROR_CODE_NO_AUDIO_TRACK, "没有找到音频流") + + selectTrack(audioTrackIndex) + return trackFormat!! + } + + private fun MediaFormat.compatOpus(sampleRate: Int) { + //Log.d(TAG, ByteString.of(trackFormat.getByteBuffer("csd-0")).hex()); + val buf = okio.Buffer() + // Magic Signature:固定头,占8个字节,为字符串OpusHead + buf.write("OpusHead".toByteArray(StandardCharsets.UTF_8)) + // Version:版本号,占1字节,固定为0x01 + buf.writeByte(1) + // Channel Count:通道数,占1字节,根据音频流通道自行设置,如0x02 + buf.writeByte(1) + // Pre-skip:回放的时候从解码器中丢弃的samples数量,占2字节,为小端模式,默认设置0x00, + buf.writeShortLe(0) + // Input Sample Rate (Hz):音频流的Sample Rate,占4字节,为小端模式,根据实际情况自行设置 + buf.writeIntLe(sampleRate) + //Output Gain:输出增益,占2字节,为小端模式,没有用到默认设置0x00, 0x00就好 + buf.writeShortLe(0) + // Channel Mapping Family:通道映射系列,占1字节,默认设置0x00就好 + buf.writeByte(0) + //Channel Mapping Table:可选参数,上面的Family默认设置0x00的时候可忽略 + val csd1bytes = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val csd2bytes = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val hd: ByteString = buf.readByteString() + val csd0: ByteBuffer = ByteBuffer.wrap(hd.toByteArray()) + setByteBuffer("csd-0", csd0) + val csd1: ByteBuffer = ByteBuffer.wrap(csd1bytes) + setByteBuffer("csd-1", csd1) + val csd2: ByteBuffer = ByteBuffer.wrap(csd2bytes) + setByteBuffer("csd-2", csd2) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioDecoderException.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioDecoderException.kt new file mode 100644 index 000000000..12c30ec4d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioDecoderException.kt @@ -0,0 +1,13 @@ +package com.github.jing332.tts_server_android.help.audio + +class AudioDecoderException( + val errCode: Int = ERROR_CODE_DECODER, override val message: String? = null, + override val cause: Throwable? = null +) : Exception() { + companion object { + const val ERROR_CODE_DECODER = 0 + const val ERROR_CODE_NO_AUDIO_TRACK = 1 + const val ERROR_CODE_NOT_SUPPORT_A5 = 2 + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioPlayer.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioPlayer.kt new file mode 100644 index 000000000..c50f6dfa0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/AudioPlayer.kt @@ -0,0 +1,35 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.content.Context +import java.io.InputStream + +class AudioPlayer(context: Context) { + private val exoAudioPlayer = ExoAudioPlayer(context) + private val pcmAudioPlayer = PcmAudioPlayer() + + suspend fun play(inputStream: InputStream, sampleRate: Int) { + pcmAudioPlayer.play(inputStream, sampleRate) + } + + fun play(bytes: ByteArray, sampleRate: Int) { + pcmAudioPlayer.play(bytes, sampleRate) + } + + suspend fun play(inputStream: InputStream) { + exoAudioPlayer.play(inputStream) + } + + suspend fun play(bytes: ByteArray) { + exoAudioPlayer.play(bytes) + } + + fun stop() { + exoAudioPlayer.stop() + pcmAudioPlayer.stop() + } + + fun release() { + exoAudioPlayer.release() + pcmAudioPlayer.release() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ByteArrayMediaDataSource.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ByteArrayMediaDataSource.kt new file mode 100644 index 000000000..b1314f7a9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ByteArrayMediaDataSource.kt @@ -0,0 +1,25 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.media.MediaDataSource +import android.os.Build +import androidx.annotation.RequiresApi + +@RequiresApi(api = Build.VERSION_CODES.M) +class ByteArrayMediaDataSource(var data: ByteArray) : MediaDataSource() { + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + if (position >= data.size) return -1 + + val endPosition = (position + size).toInt() + val size2 = if (endPosition > data.size) size - (endPosition - data.size) else size + + System.arraycopy(data, position.toInt(), buffer, offset, size2) + return size2 + } + + override fun getSize(): Long { + return data.size.toLong() + } + + override fun close() { + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ExoAudioPlayer.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ExoAudioPlayer.kt new file mode 100644 index 000000000..4dbbbeb89 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ExoAudioPlayer.kt @@ -0,0 +1,113 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.annotation.SuppressLint +import android.content.Context +import androidx.annotation.FloatRange +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.MediaSource +import com.drake.net.utils.runMain +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.help.audio.ExoPlayerHelper.createMediaSourceFromByteArray +import com.github.jing332.tts_server_android.help.audio.ExoPlayerHelper.createMediaSourceFromInputStream +import kotlinx.coroutines.* +import java.io.InputStream + + +class ExoAudioPlayer(val context: Context) { + companion object { + const val TAG = "AudioPlayer" + + const val MSG_STATE_ENDED = "MSG_STATE_ENDED" + const val MSG_PLAYER_ERROR = "MSG_PLAYER_ERROR" + } + + // APP内播放音频Job 用于 job.cancel() 取消播放 + private var mPlayWaitJob: Job? = null + + // APP内音频播放器 必须在主线程调用 + private val exoPlayer by lazy { + ExoPlayer.Builder(context).build().apply { + playWhenReady = true + addListener(object : Player.Listener { + @SuppressLint("SwitchIntDef") + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + ExoPlayer.STATE_ENDED -> { + mPlayWaitJob?.cancel(MSG_STATE_ENDED) + } + } + + super.onPlaybackStateChanged(playbackState) + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + mPlayWaitJob?.cancel("onPlayerError", error) + } + }) + } + } + + suspend fun play(audio: InputStream, speed: Float = 1f, volume: Float = 1f, pitch: Float = 1f) { + playInternal(createMediaSourceFromInputStream(audio), speed, volume, pitch) + } + + suspend fun play(audio: ByteArray, speed: Float = 1f, volume: Float = 1f, pitch: Float = 1f) { + playInternal(createMediaSourceFromByteArray(audio), speed, volume, pitch) + } + + @SuppressLint("UnsafeOptInUsageError") + private suspend fun playInternal( + mediaSource: MediaSource, + speed: Float = 1f, + @FloatRange(from = 0.0, to = 1.0) volume: Float = 1f, + pitch: Float = 1f, + ) = coroutineScope { + var throwable: Throwable? = null + mPlayWaitJob = launch() { + try { + withMain { + exoPlayer.setMediaSource(mediaSource) + exoPlayer.playbackParameters = + PlaybackParameters(speed, pitch) + exoPlayer.volume = volume + exoPlayer.prepare() + } + // 一直等待 直到 job.cancel + awaitCancellation() + } catch (e: CancellationException) { + when (e.message) { + MSG_STATE_ENDED -> { + runMain { exoPlayer.stop() } + } + + MSG_PLAYER_ERROR -> { + throwable = e.cause + } + + else -> { + runMain { exoPlayer.stop() } + } + } + } + } + mPlayWaitJob?.join() + mPlayWaitJob = null + + throwable?.let { throw it } + } + + fun stop() { + mPlayWaitJob?.cancel() + mPlayWaitJob = null + } + + fun release() { + stop() + exoPlayer.release() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ExoPlayerHelper.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ExoPlayerHelper.kt new file mode 100644 index 000000000..5779db435 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/ExoPlayerHelper.kt @@ -0,0 +1,32 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.annotation.SuppressLint +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.ByteArrayDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.MediaSource +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.help.audio.exo.InputStreamDataSource +import java.io.InputStream + + +object ExoPlayerHelper { + @SuppressLint("UnsafeOptInUsageError") + fun createMediaSourceFromInputStream(inputStream: InputStream): MediaSource { + val factory = DataSource.Factory { + InputStreamDataSource(inputStream) + } + return DefaultMediaSourceFactory(App.context).setDataSourceFactory(factory) + .createMediaSource(MediaItem.fromUri("")) + } + + // 创建音频媒体源 + @SuppressLint("UnsafeOptInUsageError") + fun createMediaSourceFromByteArray(data: ByteArray): MediaSource { + val factory = DataSource.Factory { ByteArrayDataSource(data) } + return DefaultMediaSourceFactory(App.context).setDataSourceFactory(factory) + .createMediaSource(MediaItem.fromUri("")) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/InputStreamMediaDataSource.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/InputStreamMediaDataSource.kt new file mode 100644 index 000000000..0f7b314a0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/InputStreamMediaDataSource.kt @@ -0,0 +1,42 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.media.MediaDataSource +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import okio.buffer +import okio.source +import java.io.InputStream + +@RequiresApi(Build.VERSION_CODES.M) +class InputStreamMediaDataSource(private val inputStream: InputStream) : MediaDataSource() { + companion object { + const val TAG = "InputStreamDataSource" + } + + private val bufferedInputStream = inputStream.source().buffer() + + override fun close() { + Log.d(TAG, "close") + bufferedInputStream.close() + } + + override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { + Log.d(TAG, "readAt: pos=$position, offset=$offset, size=$size") + kotlin.runCatching { + return bufferedInputStream.read(buffer, offset, size).apply { + Log.d(TAG, "readAt: readLen=$this") + } + }.onFailure { + Log.d(TAG, it.stackTraceToString()) + } + return -1 + } + + override fun getSize(): Long { + return inputStream.available().toLong().apply { + Log.d(TAG, "getSize: $this") + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/PcmAudioPlayer.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/PcmAudioPlayer.kt new file mode 100644 index 000000000..306b637da --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/PcmAudioPlayer.kt @@ -0,0 +1,75 @@ +package com.github.jing332.tts_server_android.help.audio + +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import android.media.AudioTrack.PLAYSTATE_PLAYING +import android.util.Log +import com.github.jing332.tts_server_android.help.audio.AudioDecoder.Companion.readPcmChunk +import java.io.InputStream + +class PcmAudioPlayer { + companion object { + private const val TAG = "AudioTrackPlayer" + } + + private var audioTrack: AudioTrack? = null + private var currentSampleRate = 16000 + + @Suppress("DEPRECATION") + private fun createAudioTrack(sampleRate: Int = 16000): AudioTrack { + val mSampleRate = if (sampleRate == 0) 16000 else sampleRate + Log.d(TAG, "createAudioTrack: sampleRate=$mSampleRate") + + val bufferSize = AudioTrack.getMinBufferSize( + mSampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + return AudioTrack( + AudioManager.STREAM_MUSIC, + mSampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize, + AudioTrack.MODE_STREAM + ) + } + + suspend fun play(inputStream: InputStream, sampleRate: Int = currentSampleRate) { + val bufferSize = AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + inputStream.readPcmChunk(chunkSize = bufferSize) { data -> + play(data, sampleRate) + } + } + + @Synchronized + fun play(audioData: ByteArray, sampleRate: Int = currentSampleRate) { + if (currentSampleRate == sampleRate) { + audioTrack = audioTrack ?: createAudioTrack(sampleRate) + } else { + audioTrack?.stop() + audioTrack?.release() + audioTrack = createAudioTrack(sampleRate) + currentSampleRate = sampleRate + } + + if (audioTrack!!.playState != PLAYSTATE_PLAYING) audioTrack!!.play() + + audioTrack!!.write(audioData, 0, audioData.size) + println("play done..") + } + + + fun stop() { + audioTrack?.stop() + } + + fun release() { + audioTrack?.release() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/Sonic.java b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/Sonic.java new file mode 100644 index 000000000..6e2c6865c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/Sonic.java @@ -0,0 +1,1085 @@ +/* Sonic library + Copyright 2010, 2011 + Bill Cox + This file is part of the Sonic Library. + + This file is licensed under the Apache 2.0 license. +*/ + +package com.github.jing332.tts_server_android.help.audio; + +public class Sonic { + + public static byte[] upSampling(byte[] data, int inFrequency, int outFrequency) { + if (data.length < 4) { + return data; + } + + int v1, v2; + short value; + double pos = 0; + int length = data.length; + double scale = (double) inFrequency / (double) outFrequency; + byte[] output = new byte[2 * (int) ((length / 2) / scale)]; + for (int i = 0; i < output.length / 2; i++) { + int inPos = (int) pos; + double proportion = pos - inPos; + + int inRealPos = inPos * 2; + if (inRealPos >= length - 3) { + inRealPos = length - 4; + proportion = 1; + } + v1 = ((data[inRealPos] & 255) | (data[inRealPos + 1] << 8)); + v2 = ((data[inRealPos + 2] & 255) | (data[inRealPos + 3] << 8)); + + value = (short) (v1 * (1 - proportion) + v2 * proportion); + + output[i * 2] = (byte) (value & 255); + output[i * 2 + 1] = (byte) ((value >> 8) & 255); + + pos += scale; + } + return output; + } + + private static final int SONIC_MIN_PITCH = 65; + private static final int SONIC_MAX_PITCH = 400; + // This is used to down-sample some inputs to improve speed + private static final int SONIC_AMDF_FREQ = 4000; + // The number of points to use in the sinc FIR filter for resampling. + private static final int SINC_FILTER_POINTS = 12; + private static final int SINC_TABLE_SIZE = 601; + + // Lookup table for windowed sinc function of SINC_FILTER_POINTS points. + // The code to generate this is in the header comment of sonic.c. + private static final short sincTable[] = { + 0, 0, 0, 0, 0, 0, 0, -1, -1, -2, -2, -3, -4, -6, -7, -9, -10, -12, -14, + -17, -19, -21, -24, -26, -29, -32, -34, -37, -40, -42, -44, -47, -48, -50, + -51, -52, -53, -53, -53, -52, -50, -48, -46, -43, -39, -34, -29, -22, -16, + -8, 0, 9, 19, 29, 41, 53, 65, 79, 92, 107, 121, 137, 152, 168, 184, 200, + 215, 231, 247, 262, 276, 291, 304, 317, 328, 339, 348, 357, 363, 369, 372, + 374, 375, 373, 369, 363, 355, 345, 332, 318, 300, 281, 259, 234, 208, 178, + 147, 113, 77, 39, 0, -41, -85, -130, -177, -225, -274, -324, -375, -426, + -478, -530, -581, -632, -682, -731, -779, -825, -870, -912, -951, -989, + -1023, -1053, -1080, -1104, -1123, -1138, -1149, -1154, -1155, -1151, + -1141, -1125, -1105, -1078, -1046, -1007, -963, -913, -857, -796, -728, + -655, -576, -492, -403, -309, -210, -107, 0, 111, 225, 342, 462, 584, 708, + 833, 958, 1084, 1209, 1333, 1455, 1575, 1693, 1807, 1916, 2022, 2122, 2216, + 2304, 2384, 2457, 2522, 2579, 2625, 2663, 2689, 2706, 2711, 2705, 2687, + 2657, 2614, 2559, 2491, 2411, 2317, 2211, 2092, 1960, 1815, 1658, 1489, + 1308, 1115, 912, 698, 474, 241, 0, -249, -506, -769, -1037, -1310, -1586, + -1864, -2144, -2424, -2703, -2980, -3254, -3523, -3787, -4043, -4291, + -4529, -4757, -4972, -5174, -5360, -5531, -5685, -5819, -5935, -6029, + -6101, -6150, -6175, -6175, -6149, -6096, -6015, -5905, -5767, -5599, + -5401, -5172, -4912, -4621, -4298, -3944, -3558, -3141, -2693, -2214, + -1705, -1166, -597, 0, 625, 1277, 1955, 2658, 3386, 4135, 4906, 5697, 6506, + 7332, 8173, 9027, 9893, 10769, 11654, 12544, 13439, 14335, 15232, 16128, + 17019, 17904, 18782, 19649, 20504, 21345, 22170, 22977, 23763, 24527, + 25268, 25982, 26669, 27327, 27953, 28547, 29107, 29632, 30119, 30569, + 30979, 31349, 31678, 31964, 32208, 32408, 32565, 32677, 32744, 32767, + 32744, 32677, 32565, 32408, 32208, 31964, 31678, 31349, 30979, 30569, + 30119, 29632, 29107, 28547, 27953, 27327, 26669, 25982, 25268, 24527, + 23763, 22977, 22170, 21345, 20504, 19649, 18782, 17904, 17019, 16128, + 15232, 14335, 13439, 12544, 11654, 10769, 9893, 9027, 8173, 7332, 6506, + 5697, 4906, 4135, 3386, 2658, 1955, 1277, 625, 0, -597, -1166, -1705, + -2214, -2693, -3141, -3558, -3944, -4298, -4621, -4912, -5172, -5401, + -5599, -5767, -5905, -6015, -6096, -6149, -6175, -6175, -6150, -6101, + -6029, -5935, -5819, -5685, -5531, -5360, -5174, -4972, -4757, -4529, + -4291, -4043, -3787, -3523, -3254, -2980, -2703, -2424, -2144, -1864, + -1586, -1310, -1037, -769, -506, -249, 0, 241, 474, 698, 912, 1115, 1308, + 1489, 1658, 1815, 1960, 2092, 2211, 2317, 2411, 2491, 2559, 2614, 2657, + 2687, 2705, 2711, 2706, 2689, 2663, 2625, 2579, 2522, 2457, 2384, 2304, + 2216, 2122, 2022, 1916, 1807, 1693, 1575, 1455, 1333, 1209, 1084, 958, 833, + 708, 584, 462, 342, 225, 111, 0, -107, -210, -309, -403, -492, -576, -655, + -728, -796, -857, -913, -963, -1007, -1046, -1078, -1105, -1125, -1141, + -1151, -1155, -1154, -1149, -1138, -1123, -1104, -1080, -1053, -1023, -989, + -951, -912, -870, -825, -779, -731, -682, -632, -581, -530, -478, -426, + -375, -324, -274, -225, -177, -130, -85, -41, 0, 39, 77, 113, 147, 178, + 208, 234, 259, 281, 300, 318, 332, 345, 355, 363, 369, 373, 375, 374, 372, + 369, 363, 357, 348, 339, 328, 317, 304, 291, 276, 262, 247, 231, 215, 200, + 184, 168, 152, 137, 121, 107, 92, 79, 65, 53, 41, 29, 19, 9, 0, -8, -16, + -22, -29, -34, -39, -43, -46, -48, -50, -52, -53, -53, -53, -52, -51, -50, + -48, -47, -44, -42, -40, -37, -34, -32, -29, -26, -24, -21, -19, -17, -14, + -12, -10, -9, -7, -6, -4, -3, -2, -2, -1, -1, 0, 0, 0, 0, 0, 0, 0 + }; + + private short inputBuffer[]; + private short outputBuffer[]; + private short pitchBuffer[]; + private short downSampleBuffer[]; + private float speed; + private float volume; + private float pitch; + private float rate; + private int oldRatePosition; + private int newRatePosition; + private boolean useChordPitch; + private int quality; + private int numChannels; + private int inputBufferSize; + private int pitchBufferSize; + private int outputBufferSize; + private int numInputSamples; + private int numOutputSamples; + private int numPitchSamples; + private int minPeriod; + private int maxPeriod; + private int maxRequired; + private int remainingInputToCopy; + private int sampleRate; + private int prevPeriod; + private int prevMinDiff; + private int minDiff; + private int maxDiff; + + // Resize the array. + private short[] resize( + short[] oldArray, + int newLength) { + newLength *= numChannels; + short[] newArray = new short[newLength]; + int length = oldArray.length <= newLength ? oldArray.length : newLength; + + System.arraycopy(oldArray, 0, newArray, 0, length); + return newArray; + } + + // Move samples from one array to another. May move samples down within an array, but not up. + private void move( + short dest[], + int destPos, + short source[], + int sourcePos, + int numSamples) { + System.arraycopy(source, sourcePos * numChannels, dest, destPos * numChannels, numSamples * numChannels); + } + + // Scale the samples by the factor. + private void scaleSamples( + short samples[], + int position, + int numSamples, + float volume) { + // Convert volume to fixed-point, with a 12 bit fraction. + int fixedPointVolume = (int) (volume * 4096.0f); + int start = position * numChannels; + int stop = start + numSamples * numChannels; + + for (int xSample = start; xSample < stop; xSample++) { + // Convert back from fixed point to 16-bit integer. + int value = (samples[xSample] * fixedPointVolume) >> 12; + if (value > 32767) { + value = 32767; + } else if (value < -32767) { + value = -32767; + } + samples[xSample] = (short) value; + } + } + + // Get the speed of the stream. + public float getSpeed() { + return speed; + } + + // Set the speed of the stream. + public void setSpeed( + float speed) { + this.speed = speed; + } + + // Get the pitch of the stream. + public float getPitch() { + return pitch; + } + + // Set the pitch of the stream. + public void setPitch( + float pitch) { + this.pitch = pitch; + } + + // Get the rate of the stream. + public float getRate() { + return rate; + } + + // Set the playback rate of the stream. This scales pitch and speed at the same time. + public void setRate( + float rate) { + this.rate = rate; + this.oldRatePosition = 0; + this.newRatePosition = 0; + } + + // Get the vocal chord pitch setting. + public boolean getChordPitch() { + return useChordPitch; + } + + // Set the vocal chord mode for pitch computation. Default is off. + public void setChordPitch( + boolean useChordPitch) { + this.useChordPitch = useChordPitch; + } + + // Get the quality setting. + public int getQuality() { + return quality; + } + + // Set the "quality". Default 0 is virtually as good as 1, but very much faster. + public void setQuality( + int quality) { + this.quality = quality; + } + + // Get the scaling factor of the stream. + public float getVolume() { + return volume; + } + + // Set the scaling factor of the stream. + public void setVolume( + float volume) { + this.volume = volume; + } + + // Allocate stream buffers. + private void allocateStreamBuffers( + int sampleRate, + int numChannels) { + minPeriod = sampleRate / SONIC_MAX_PITCH; + maxPeriod = sampleRate / SONIC_MIN_PITCH; + maxRequired = 2 * maxPeriod; + inputBufferSize = maxRequired; + inputBuffer = new short[maxRequired * numChannels]; + outputBufferSize = maxRequired; + outputBuffer = new short[maxRequired * numChannels]; + pitchBufferSize = maxRequired; + pitchBuffer = new short[maxRequired * numChannels]; + downSampleBuffer = new short[maxRequired]; + this.sampleRate = sampleRate; + this.numChannels = numChannels; + oldRatePosition = 0; + newRatePosition = 0; + prevPeriod = 0; + } + + // Create a sonic stream. + public Sonic( + int sampleRate, + int numChannels) { + allocateStreamBuffers(sampleRate, numChannels); + speed = 1.0f; + pitch = 1.0f; + volume = 1.0f; + rate = 1.0f; + oldRatePosition = 0; + newRatePosition = 0; + useChordPitch = false; + quality = 0; + } + + // Get the sample rate of the stream. + public int getSampleRate() { + return sampleRate; + } + + // Set the sample rate of the stream. This will cause samples buffered in the stream to be lost. + public void setSampleRate( + int sampleRate) { + allocateStreamBuffers(sampleRate, numChannels); + } + + // Get the number of channels. + public int getNumChannels() { + return numChannels; + } + + // Set the num channels of the stream. This will cause samples buffered in the stream to be lost. + public void setNumChannels( + int numChannels) { + allocateStreamBuffers(sampleRate, numChannels); + } + + // Enlarge the output buffer if needed. + private void enlargeOutputBufferIfNeeded( + int numSamples) { + if (numOutputSamples + numSamples > outputBufferSize) { + outputBufferSize += (outputBufferSize >> 1) + numSamples; + outputBuffer = resize(outputBuffer, outputBufferSize); + } + } + + // Enlarge the input buffer if needed. + private void enlargeInputBufferIfNeeded( + int numSamples) { + if (numInputSamples + numSamples > inputBufferSize) { + inputBufferSize += (inputBufferSize >> 1) + numSamples; + inputBuffer = resize(inputBuffer, inputBufferSize); + } + } + + // Add the input samples to the input buffer. + private void addFloatSamplesToInputBuffer( + float samples[], + int numSamples) { + if (numSamples == 0) { + return; + } + enlargeInputBufferIfNeeded(numSamples); + int xBuffer = numInputSamples * numChannels; + for (int xSample = 0; xSample < numSamples * numChannels; xSample++) { + inputBuffer[xBuffer++] = (short) (samples[xSample] * 32767.0f); + } + numInputSamples += numSamples; + } + + // Add the input samples to the input buffer. + private void addShortSamplesToInputBuffer( + short samples[], + int numSamples) { + if (numSamples == 0) { + return; + } + enlargeInputBufferIfNeeded(numSamples); + move(inputBuffer, numInputSamples, samples, 0, numSamples); + numInputSamples += numSamples; + } + + // Add the input samples to the input buffer. + private void addUnsignedByteSamplesToInputBuffer( + byte samples[], + int numSamples) { + short sample; + + enlargeInputBufferIfNeeded(numSamples); + int xBuffer = numInputSamples * numChannels; + for (int xSample = 0; xSample < numSamples * numChannels; xSample++) { + sample = (short) ((samples[xSample] & 0xff) - 128); // Convert from unsigned to signed + inputBuffer[xBuffer++] = (short) (sample << 8); + } + numInputSamples += numSamples; + } + + // Add the input samples to the input buffer. They must be 16-bit little-endian encoded in a byte array. + private void addBytesToInputBuffer( + byte inBuffer[], + int numBytes) { + int numSamples = numBytes / (2 * numChannels); + short sample; + + enlargeInputBufferIfNeeded(numSamples); + int xBuffer = numInputSamples * numChannels; + for (int xByte = 0; xByte + 1 < numBytes; xByte += 2) { + sample = (short) ((inBuffer[xByte] & 0xff) | (inBuffer[xByte + 1] << 8)); + inputBuffer[xBuffer++] = sample; + } + numInputSamples += numSamples; + } + + // Remove input samples that we have already processed. + private void removeInputSamples( + int position) { + int remainingSamples = numInputSamples - position; + + move(inputBuffer, 0, inputBuffer, position, remainingSamples); + numInputSamples = remainingSamples; + } + + // Just copy from the array to the output buffer + private void copyToOutput( + short samples[], + int position, + int numSamples) { + enlargeOutputBufferIfNeeded(numSamples); + move(outputBuffer, numOutputSamples, samples, position, numSamples); + numOutputSamples += numSamples; + } + + // Just copy from the input buffer to the output buffer. Return num samples copied. + private int copyInputToOutput( + int position) { + int numSamples = remainingInputToCopy; + + if (numSamples > maxRequired) { + numSamples = maxRequired; + } + copyToOutput(inputBuffer, position, numSamples); + remainingInputToCopy -= numSamples; + return numSamples; + } + + // Read data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readFloatFromStream( + float samples[], + int maxSamples) { + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if (numSamples == 0) { + return 0; + } + if (numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + for (int xSample = 0; xSample < numSamples * numChannels; xSample++) { + samples[xSample] = (outputBuffer[xSample]) / 32767.0f; + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return numSamples; + } + + // Read short data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readShortFromStream( + short samples[], + int maxSamples) { + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if (numSamples == 0) { + return 0; + } + if (numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + move(samples, 0, outputBuffer, 0, numSamples); + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return numSamples; + } + + // Read unsigned byte data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readUnsignedByteFromStream( + byte samples[], + int maxSamples) { + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if (numSamples == 0) { + return 0; + } + if (numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + for (int xSample = 0; xSample < numSamples * numChannels; xSample++) { + samples[xSample] = (byte) ((outputBuffer[xSample] >> 8) + 128); + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return numSamples; + } + + public byte[] readBytesFromStream(int maxBytes) { + int maxSamples = maxBytes / (2 * numChannels); + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if (numSamples == 0 || maxSamples == 0) { + return new byte[0]; + } + if (numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + final int outLength = 2 * numSamples * numChannels; + byte[] outBuffer = new byte[outLength]; + for (int xSample = 0; xSample < numSamples * numChannels; xSample++) { + short sample = outputBuffer[xSample]; + outBuffer[xSample << 1] = (byte) (sample & 0xff); + outBuffer[(xSample << 1) + 1] = (byte) (sample >> 8); + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return outBuffer; + } + + // Read unsigned byte data out of the stream. Sometimes no data will be available, and zero + // is returned, which is not an error condition. + public int readBytesFromStream( + byte outBuffer[], + int maxBytes) { + int maxSamples = maxBytes / (2 * numChannels); + int numSamples = numOutputSamples; + int remainingSamples = 0; + + if (numSamples == 0 || maxSamples == 0) { + return 0; + } + if (numSamples > maxSamples) { + remainingSamples = numSamples - maxSamples; + numSamples = maxSamples; + } + for (int xSample = 0; xSample < numSamples * numChannels; xSample++) { + short sample = outputBuffer[xSample]; + outBuffer[xSample << 1] = (byte) (sample & 0xff); + outBuffer[(xSample << 1) + 1] = (byte) (sample >> 8); + } + move(outputBuffer, 0, outputBuffer, numSamples, remainingSamples); + numOutputSamples = remainingSamples; + return 2 * numSamples * numChannels; + } + + // Force the sonic stream to generate output using whatever data it currently + // has. No extra delay will be added to the output, but flushing in the middle of + // words could introduce distortion. + public void flushStream() { + int remainingSamples = numInputSamples; + float s = speed / pitch; + float r = rate * pitch; + int expectedOutputSamples = numOutputSamples + (int) ((remainingSamples / s + numPitchSamples) / r + 0.5f); + + // Add enough silence to flush both input and pitch buffers. + enlargeInputBufferIfNeeded(remainingSamples + 2 * maxRequired); + for (int xSample = 0; xSample < 2 * maxRequired * numChannels; xSample++) { + inputBuffer[remainingSamples * numChannels + xSample] = 0; + } + numInputSamples += 2 * maxRequired; + writeShortToStream(null, 0); + // Throw away any extra samples we generated due to the silence we added. + if (numOutputSamples > expectedOutputSamples) { + numOutputSamples = expectedOutputSamples; + } + // Empty input and pitch buffers. + numInputSamples = 0; + remainingInputToCopy = 0; + numPitchSamples = 0; + } + + // Return the number of samples in the output buffer + public int samplesAvailable() { + return numOutputSamples; + } + + // If skip is greater than one, average skip samples together and write them to + // the down-sample buffer. If numChannels is greater than one, mix the channels + // together as we down sample. + private void downSampleInput( + short samples[], + int position, + int skip) { + int numSamples = maxRequired / skip; + int samplesPerValue = numChannels * skip; + int value; + + position *= numChannels; + for (int i = 0; i < numSamples; i++) { + value = 0; + for (int j = 0; j < samplesPerValue; j++) { + value += samples[position + i * samplesPerValue + j]; + } + value /= samplesPerValue; + downSampleBuffer[i] = (short) value; + } + } + + // Find the best frequency match in the range, and given a sample skip multiple. + // For now, just find the pitch of the first channel. + private int findPitchPeriodInRange( + short samples[], + int position, + int minPeriod, + int maxPeriod) { + int bestPeriod = 0, worstPeriod = 255; + int minDiff = 1, maxDiff = 0; + + position *= numChannels; + for (int period = minPeriod; period <= maxPeriod; period++) { + int diff = 0; + for (int i = 0; i < period; i++) { + short sVal = samples[position + i]; + short pVal = samples[position + period + i]; + diff += sVal >= pVal ? sVal - pVal : pVal - sVal; + } + /* Note that the highest number of samples we add into diff will be less + than 256, since we skip samples. Thus, diff is a 24 bit number, and + we can safely multiply by numSamples without overflow */ + if (diff * bestPeriod < minDiff * period) { + minDiff = diff; + bestPeriod = period; + } + if (diff * worstPeriod > maxDiff * period) { + maxDiff = diff; + worstPeriod = period; + } + } + this.minDiff = minDiff / bestPeriod; + this.maxDiff = maxDiff / worstPeriod; + + return bestPeriod; + } + + // At abrupt ends of voiced words, we can have pitch periods that are better + // approximated by the previous pitch period estimate. Try to detect this case. + private boolean prevPeriodBetter( + int minDiff, + int maxDiff, + boolean preferNewPeriod) { + if (minDiff == 0 || prevPeriod == 0) { + return false; + } + if (preferNewPeriod) { + if (maxDiff > minDiff * 3) { + // Got a reasonable match this period + return false; + } + if (minDiff * 2 <= prevMinDiff * 3) { + // Mismatch is not that much greater this period + return false; + } + } else { + if (minDiff <= prevMinDiff) { + return false; + } + } + return true; + } + + // Find the pitch period. This is a critical step, and we may have to try + // multiple ways to get a good answer. This version uses AMDF. To improve + // speed, we down sample by an integer factor get in the 11KHz range, and then + // do it again with a narrower frequency range without down sampling + private int findPitchPeriod( + short samples[], + int position, + boolean preferNewPeriod) { + int period, retPeriod; + int skip = 1; + + if (sampleRate > SONIC_AMDF_FREQ && quality == 0) { + skip = sampleRate / SONIC_AMDF_FREQ; + } + if (numChannels == 1 && skip == 1) { + period = findPitchPeriodInRange(samples, position, minPeriod, maxPeriod); + } else { + downSampleInput(samples, position, skip); + period = findPitchPeriodInRange(downSampleBuffer, 0, minPeriod / skip, + maxPeriod / skip); + if (skip != 1) { + period *= skip; + int minP = period - (skip << 2); + int maxP = period + (skip << 2); + if (minP < minPeriod) { + minP = minPeriod; + } + if (maxP > maxPeriod) { + maxP = maxPeriod; + } + if (numChannels == 1) { + period = findPitchPeriodInRange(samples, position, minP, maxP); + } else { + downSampleInput(samples, position, 1); + period = findPitchPeriodInRange(downSampleBuffer, 0, minP, maxP); + } + } + } + if (prevPeriodBetter(minDiff, maxDiff, preferNewPeriod)) { + retPeriod = prevPeriod; + } else { + retPeriod = period; + } + prevMinDiff = minDiff; + prevPeriod = period; + return retPeriod; + } + + // Overlap two sound segments, ramp the volume of one down, while ramping the + // other one from zero up, and add them, storing the result at the output. + private void overlapAdd( + int numSamples, + int numChannels, + short out[], + int outPos, + short rampDown[], + int rampDownPos, + short rampUp[], + int rampUpPos) { + for (int i = 0; i < numChannels; i++) { + int o = outPos * numChannels + i; + int u = rampUpPos * numChannels + i; + int d = rampDownPos * numChannels + i; + for (int t = 0; t < numSamples; t++) { + out[o] = (short) ((rampDown[d] * (numSamples - t) + rampUp[u] * t) / numSamples); + o += numChannels; + d += numChannels; + u += numChannels; + } + } + } + + // Overlap two sound segments, ramp the volume of one down, while ramping the + // other one from zero up, and add them, storing the result at the output. + private void overlapAddWithSeparation( + int numSamples, + int numChannels, + int separation, + short out[], + int outPos, + short rampDown[], + int rampDownPos, + short rampUp[], + int rampUpPos) { + for (int i = 0; i < numChannels; i++) { + int o = outPos * numChannels + i; + int u = rampUpPos * numChannels + i; + int d = rampDownPos * numChannels + i; + for (int t = 0; t < numSamples + separation; t++) { + if (t < separation) { + out[o] = (short) (rampDown[d] * (numSamples - t) / numSamples); + d += numChannels; + } else if (t < numSamples) { + out[o] = (short) ((rampDown[d] * (numSamples - t) + rampUp[u] * (t - separation)) / numSamples); + d += numChannels; + u += numChannels; + } else { + out[o] = (short) (rampUp[u] * (t - separation) / numSamples); + u += numChannels; + } + o += numChannels; + } + } + } + + // Just move the new samples in the output buffer to the pitch buffer + private void moveNewSamplesToPitchBuffer( + int originalNumOutputSamples) { + int numSamples = numOutputSamples - originalNumOutputSamples; + + if (numPitchSamples + numSamples > pitchBufferSize) { + pitchBufferSize += (pitchBufferSize >> 1) + numSamples; + pitchBuffer = resize(pitchBuffer, pitchBufferSize); + } + move(pitchBuffer, numPitchSamples, outputBuffer, originalNumOutputSamples, numSamples); + numOutputSamples = originalNumOutputSamples; + numPitchSamples += numSamples; + } + + // Remove processed samples from the pitch buffer. + private void removePitchSamples( + int numSamples) { + if (numSamples == 0) { + return; + } + move(pitchBuffer, 0, pitchBuffer, numSamples, numPitchSamples - numSamples); + numPitchSamples -= numSamples; + } + + // Change the pitch. The latency this introduces could be reduced by looking at + // past samples to determine pitch, rather than future. + private void adjustPitch( + int originalNumOutputSamples) { + int period, newPeriod, separation; + int position = 0; + + if (numOutputSamples == originalNumOutputSamples) { + return; + } + moveNewSamplesToPitchBuffer(originalNumOutputSamples); + while (numPitchSamples - position >= maxRequired) { + period = findPitchPeriod(pitchBuffer, position, false); + newPeriod = (int) (period / pitch); + enlargeOutputBufferIfNeeded(newPeriod); + if (pitch >= 1.0f) { + overlapAdd(newPeriod, numChannels, outputBuffer, numOutputSamples, pitchBuffer, + position, pitchBuffer, position + period - newPeriod); + } else { + separation = newPeriod - period; + overlapAddWithSeparation(period, numChannels, separation, outputBuffer, numOutputSamples, + pitchBuffer, position, pitchBuffer, position); + } + numOutputSamples += newPeriod; + position += period; + } + removePitchSamples(position); + } + + // Approximate the sinc function times a Hann window from the sinc table. + private int findSincCoefficient(int i, int ratio, int width) { + int lobePoints = (SINC_TABLE_SIZE - 1) / SINC_FILTER_POINTS; + int left = i * lobePoints + (ratio * lobePoints) / width; + int right = left + 1; + int position = i * lobePoints * width + ratio * lobePoints - left * width; + int leftVal = sincTable[left]; + int rightVal = sincTable[right]; + + return ((leftVal * (width - position) + rightVal * position) << 1) / width; + } + + // Return 1 if value >= 0, else -1. This represents the sign of value. + private int getSign(int value) { + return value >= 0 ? 1 : -1; + } + + // Interpolate the new output sample. + private short interpolate( + short in[], + int inPos, // Index to first sample which already includes channel offset. + int oldSampleRate, + int newSampleRate) { + // Compute N-point sinc FIR-filter here. Clip rather than overflow. + int i; + int total = 0; + int position = newRatePosition * oldSampleRate; + int leftPosition = oldRatePosition * newSampleRate; + int rightPosition = (oldRatePosition + 1) * newSampleRate; + int ratio = rightPosition - position - 1; + int width = rightPosition - leftPosition; + int weight, value; + int oldSign; + int overflowCount = 0; + + for (i = 0; i < SINC_FILTER_POINTS; i++) { + weight = findSincCoefficient(i, ratio, width); + /* printf("%u %f\n", i, weight); */ + value = in[inPos + i * numChannels] * weight; + oldSign = getSign(total); + total += value; + if (oldSign != getSign(total) && getSign(value) == oldSign) { + /* We must have overflowed. This can happen with a sinc filter. */ + overflowCount += oldSign; + } + } + /* It is better to clip than to wrap if there was a overflow. */ + if (overflowCount > 0) { + return Short.MAX_VALUE; + } else if (overflowCount < 0) { + return Short.MIN_VALUE; + } + return (short) (total >> 16); + } + + // Change the rate. + private void adjustRate( + float rate, + int originalNumOutputSamples) { + int newSampleRate = (int) (sampleRate / rate); + int oldSampleRate = sampleRate; + int position; + int N = SINC_FILTER_POINTS; + + // Set these values to help with the integer math + while (newSampleRate > (1 << 14) || oldSampleRate > (1 << 14)) { + newSampleRate >>= 1; + oldSampleRate >>= 1; + } + if (numOutputSamples == originalNumOutputSamples) { + return; + } + moveNewSamplesToPitchBuffer(originalNumOutputSamples); + // Leave at least N pitch samples in the buffer + for (position = 0; position < numPitchSamples - N; position++) { + while ((oldRatePosition + 1) * newSampleRate > newRatePosition * oldSampleRate) { + enlargeOutputBufferIfNeeded(1); + for (int i = 0; i < numChannels; i++) { + outputBuffer[numOutputSamples * numChannels + i] = interpolate(pitchBuffer, + position * numChannels + i, oldSampleRate, newSampleRate); + } + newRatePosition++; + numOutputSamples++; + } + oldRatePosition++; + if (oldRatePosition == oldSampleRate) { + oldRatePosition = 0; + if (newRatePosition != newSampleRate) { + System.out.printf("Assertion failed: newRatePosition != newSampleRate\n"); + assert false; + } + newRatePosition = 0; + } + } + removePitchSamples(position); + } + + + // Skip over a pitch period, and copy period/speed samples to the output + private int skipPitchPeriod( + short samples[], + int position, + float speed, + int period) { + int newSamples; + + if (speed >= 2.0f) { + newSamples = (int) (period / (speed - 1.0f)); + } else { + newSamples = period; + remainingInputToCopy = (int) (period * (2.0f - speed) / (speed - 1.0f)); + } + enlargeOutputBufferIfNeeded(newSamples); + overlapAdd(newSamples, numChannels, outputBuffer, numOutputSamples, samples, position, + samples, position + period); + numOutputSamples += newSamples; + return newSamples; + } + + // Insert a pitch period, and determine how much input to copy directly. + private int insertPitchPeriod( + short samples[], + int position, + float speed, + int period) { + int newSamples; + + if (speed < 0.5f) { + newSamples = (int) (period * speed / (1.0f - speed)); + } else { + newSamples = period; + remainingInputToCopy = (int) (period * (2.0f * speed - 1.0f) / (1.0f - speed)); + } + enlargeOutputBufferIfNeeded(period + newSamples); + move(outputBuffer, numOutputSamples, samples, position, period); + overlapAdd(newSamples, numChannels, outputBuffer, numOutputSamples + period, samples, + position + period, samples, position); + numOutputSamples += period + newSamples; + return newSamples; + } + + // Resample as many pitch periods as we have buffered on the input. Return 0 if + // we fail to resize an input or output buffer. Also scale the output by the volume. + private void changeSpeed( + float speed) { + int numSamples = numInputSamples; + int position = 0, period, newSamples; + + if (numInputSamples < maxRequired) { + return; + } + do { + if (remainingInputToCopy > 0) { + newSamples = copyInputToOutput(position); + position += newSamples; + } else { + period = findPitchPeriod(inputBuffer, position, true); + if (speed > 1.0) { + newSamples = skipPitchPeriod(inputBuffer, position, speed, period); + position += period + newSamples; + } else { + newSamples = insertPitchPeriod(inputBuffer, position, speed, period); + position += newSamples; + } + } + } while (position + maxRequired <= numSamples); + removeInputSamples(position); + } + + // Resample as many pitch periods as we have buffered on the input. Scale the output by the volume. + private void processStreamInput() { + int originalNumOutputSamples = numOutputSamples; + float s = speed / pitch; + float r = rate; + + if (!useChordPitch) { + r *= pitch; + } + if (s > 1.00001 || s < 0.99999) { + changeSpeed(s); + } else { + copyToOutput(inputBuffer, 0, numInputSamples); + numInputSamples = 0; + } + if (useChordPitch) { + if (pitch != 1.0f) { + adjustPitch(originalNumOutputSamples); + } + } else if (r != 1.0f) { + adjustRate(r, originalNumOutputSamples); + } + if (volume != 1.0f) { + // Adjust output volume. + scaleSamples(outputBuffer, originalNumOutputSamples, numOutputSamples - originalNumOutputSamples, + volume); + } + } + + // Write floating point data to the input buffer and process it. + public void writeFloatToStream( + float samples[], + int numSamples) { + addFloatSamplesToInputBuffer(samples, numSamples); + processStreamInput(); + } + + // Write the data to the input stream, and process it. + public void writeShortToStream( + short samples[], + int numSamples) { + addShortSamplesToInputBuffer(samples, numSamples); + processStreamInput(); + } + + // Simple wrapper around sonicWriteFloatToStream that does the unsigned byte to short + // conversion for you. + public void writeUnsignedByteToStream( + byte samples[], + int numSamples) { + addUnsignedByteSamplesToInputBuffer(samples, numSamples); + processStreamInput(); + } + + // Simple wrapper around sonicWriteBytesToStream that does the byte to 16-bit LE conversion. + public void writeBytesToStream( + byte inBuffer[], + int numBytes) { + addBytesToInputBuffer(inBuffer, numBytes); + processStreamInput(); + } + + // This is a non-stream oriented interface to just change the speed of a sound sample + public static int changeFloatSpeed( + float samples[], + int numSamples, + float speed, + float pitch, + float rate, + float volume, + boolean useChordPitch, + int sampleRate, + int numChannels) { + Sonic stream = new Sonic(sampleRate, numChannels); + + stream.setSpeed(speed); + stream.setPitch(pitch); + stream.setRate(rate); + stream.setVolume(volume); + stream.setChordPitch(useChordPitch); + stream.writeFloatToStream(samples, numSamples); + stream.flushStream(); + numSamples = stream.samplesAvailable(); + stream.readFloatFromStream(samples, numSamples); + return numSamples; + } + + /* This is a non-stream oriented interface to just change the speed of a sound sample */ + public int sonicChangeShortSpeed( + short samples[], + int numSamples, + float speed, + float pitch, + float rate, + float volume, + boolean useChordPitch, + int sampleRate, + int numChannels) { + Sonic stream = new Sonic(sampleRate, numChannels); + + stream.setSpeed(speed); + stream.setPitch(pitch); + stream.setRate(rate); + stream.setVolume(volume); + stream.setChordPitch(useChordPitch); + stream.writeShortToStream(samples, numSamples); + stream.flushStream(); + numSamples = stream.samplesAvailable(); + stream.readShortFromStream(samples, numSamples); + return numSamples; + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/DecoderAudioSink.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/DecoderAudioSink.kt new file mode 100644 index 000000000..a8d0d20e7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/DecoderAudioSink.kt @@ -0,0 +1,118 @@ +package com.github.jing332.tts_server_android.help.audio.exo + +import androidx.media3.common.AudioAttributes +import androidx.media3.common.AuxEffectInfo +import androidx.media3.common.Format +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING +import java.nio.ByteBuffer + +@UnstableApi +/** + * 用于接收从 ExoPlayer 解码后的 PCM 数据,而不是播放到 AudioTrack。 + */ +class DecoderAudioSink(private val onPcmBuffer: (ByteBuffer) -> Unit) : AudioSink { + private var timeUs: Long = 0L + + companion object { + const val TAG = "DecoderAudioSink" + } + + override fun setListener(listener: AudioSink.Listener) { + } + + override fun supportsFormat(format: Format): Boolean { + return format.sampleMimeType == "audio/raw" // 只接收 PCM 格式 + } + + override fun getFormatSupport(format: Format): Int = SINK_FORMAT_SUPPORTED_WITH_TRANSCODING + + override fun getCurrentPositionUs(sourceEnded: Boolean): Long = timeUs + + override fun configure( + inputFormat: Format, + specifiedBufferSize: Int, + outputChannels: IntArray? + ) { + } + + override fun play() { + } + + override fun handleDiscontinuity() { + + } + + override fun handleBuffer( + buffer: ByteBuffer, + presentationTimeUs: Long, + encodedAccessUnitCount: Int + ): Boolean { + onPcmBuffer.invoke(buffer) + timeUs += presentationTimeUs + return true + } + + override fun playToEndOfStream() { + + } + + override fun isEnded(): Boolean = true + + override fun hasPendingData(): Boolean = true + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + + } + + override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters(1f) + + + override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) { + + } + + override fun getSkipSilenceEnabled(): Boolean = false + + override fun setAudioAttributes(audioAttributes: AudioAttributes) { + + } + + override fun getAudioAttributes(): AudioAttributes? = null + + override fun setAudioSessionId(audioSessionId: Int) { + + } + + override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) { + + } + + override fun enableTunnelingV21() { + + } + + override fun disableTunneling() { + + } + + override fun setVolume(volume: Float) { + + } + + override fun pause() { + + } + + override fun flush() { + + } + + + override fun reset() { + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/ExoAudioDecoder.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/ExoAudioDecoder.kt new file mode 100644 index 000000000..bbb3e7f52 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/ExoAudioDecoder.kt @@ -0,0 +1,106 @@ +package com.github.jing332.tts_server_android.help.audio.exo + +import android.annotation.SuppressLint +import android.content.Context +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.source.MediaSource +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.help.audio.AudioDecoderException +import com.github.jing332.tts_server_android.help.audio.ExoPlayerHelper +import kotlinx.coroutines.* +import java.io.InputStream +import java.nio.ByteBuffer + +@SuppressLint("UnsafeOptInUsageError") +class ExoAudioDecoder(val context: Context) { + companion object { + private const val CANCEL_MESSAGE_ENDED = "CANCEL_MESSAGE_ENDED" + private const val CANCEL_MESSAGE_ERROR = "CANCEL_MESSAGE_ERROR" + } + + private var mWaitJob: Job? = null + var callback: Callback? = null + + private val exoPlayer by lazy { + val rendererFactory = object : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean + ): AudioSink? { + return DecoderAudioSink { callback?.onReadPcmAudio(it) } + } + } + + ExoPlayer.Builder(context, rendererFactory).build().apply { + addListener(object : Player.Listener { + @SuppressLint("SwitchIntDef") + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + ExoPlayer.STATE_ENDED -> { + mWaitJob?.cancel(CANCEL_MESSAGE_ENDED) + } + } + + super.onPlaybackStateChanged(playbackState) + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + mWaitJob?.cancel(CANCEL_MESSAGE_ERROR, error) + } + }) + + playWhenReady = true + } + } + + suspend fun doDecode(bytes: ByteArray) { + decodeInternal(ExoPlayerHelper.createMediaSourceFromByteArray(bytes)) + } + + suspend fun doDecode(inputStream: InputStream) { + decodeInternal(ExoPlayerHelper.createMediaSourceFromInputStream(inputStream)) + } + + private suspend fun decodeInternal(mediaSource: MediaSource) { + withMain { + exoPlayer.setMediaSource(mediaSource) + exoPlayer.prepare() + } + + var throwable: Throwable? = null + coroutineScope { + mWaitJob = launch { + try { + awaitCancellation() + } catch (e: CancellationException) { + if (e.message == CANCEL_MESSAGE_ERROR) { + throwable = e.cause + exoPlayer.stop() + } + } + } + } + mWaitJob?.join() + mWaitJob = null + + throwable?.let { + throw AudioDecoderException( + message = "ExoPlayer解码失败:${it.message}", + cause = it + ) + } + } + + + fun interface Callback { + fun onReadPcmAudio(byteBuffer: ByteBuffer) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/InputStreamDataSource.kt b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/InputStreamDataSource.kt new file mode 100644 index 000000000..16c489561 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/help/audio/exo/InputStreamDataSource.kt @@ -0,0 +1,78 @@ +package com.github.jing332.tts_server_android.help.audio.exo + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.datasource.BaseDataSource +import androidx.media3.datasource.DataSpec +import okio.buffer +import okio.source +import java.io.EOFException +import java.io.IOException +import java.io.InputStream +import kotlin.math.min + + +@SuppressLint("UnsafeOptInUsageError") +class InputStreamDataSource( + private val inputStream: InputStream, +) : BaseDataSource(/* isNetwork = */ false) { + private val bufferedSource = inputStream.source().buffer() + private var dataSpec: DataSpec? = null + private var bytesRemaining: Long = 0 + private var opened = false + + @Throws(IOException::class) + override fun open(dataSpec: DataSpec): Long { + this.dataSpec = dataSpec + bufferedSource.skip(dataSpec.position) + + if (dataSpec.length == C.LENGTH_UNSET.toLong()) { + bytesRemaining = inputStream.available().toLong() + if (bytesRemaining == 0L) bytesRemaining = C.LENGTH_UNSET.toLong() + } else { + bytesRemaining = dataSpec.length + } + + opened = true + return bytesRemaining + } + + override fun getUri(): Uri? = dataSpec?.uri + + @Throws(IOException::class) + override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int { + if (readLength == 0) { + return 0 + } else if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + + val bytesToRead = + if (bytesRemaining == C.LENGTH_UNSET.toLong()) readLength + else min(bytesRemaining, readLength.toLong()).toInt() + + val bytesRead = bufferedSource.read(buffer, offset, bytesToRead) + if (bytesRead == -1) { + if (bytesRemaining != C.LENGTH_UNSET.toLong()) { + // End of stream reached having not read sufficient data. + throw IOException(EOFException()) + } + return C.RESULT_END_OF_INPUT + } + if (bytesRemaining != C.LENGTH_UNSET.toLong()) + bytesRemaining -= bytesRead.toLong() + + return bytesRead + } + + @Throws(IOException::class) + override fun close() { + try { + bufferedSource.close() + inputStream.close() + } finally { + opened = false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/AnalyzeUrl.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/AnalyzeUrl.kt new file mode 100644 index 000000000..b16814dbc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/AnalyzeUrl.kt @@ -0,0 +1,68 @@ +package com.github.jing332.tts_server_android.model + +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.AppConst.SCRIPT_ENGINE +import com.script.SimpleBindings +import kotlinx.serialization.decodeFromString +import java.util.regex.Pattern + +class AnalyzeUrl( + val mUrl: String, + var baseUrl: String = "", + var speakText: String? = null, + val speakSpeed: Int? = null, + val speakVolume: Int? = null, +) { + companion object { + val jsPattern: Pattern by lazy { Pattern.compile("\\{\\{.*?\\}\\}") } + } + + fun eval(): UrlOption? { + // 提取js替换变量 + val matcher = jsPattern.matcher(mUrl) + val sb = StringBuffer() + while (matcher.find()) { + val jsCodeStr = + matcher.group().replace("{{", "").replace("}}", "").replace("java.", "") + + kotlin.runCatching { + val result = evalJs(jsCodeStr) + matcher.appendReplacement(sb, result.toString()) + }.onFailure { + throw Exception("执行 $jsCodeStr 时出错", it) + } + + matcher.end() + } + matcher.appendTail(sb) + + val str = sb.toString() + val splitIndex = str.indexOf(",") + if (splitIndex < 0) { // GET + baseUrl = str + return null + } + + // POST + baseUrl = str.substring(0, splitIndex).trim() + val jsonStr = str.substring(splitIndex + 1) + return AppConst.jsonBuilder.decodeFromString(jsonStr) + } + + + // 执行js 替换变量 + private fun evalJs(jsStr: String): Any? { + val bindings = SimpleBindings() + bindings["speakText"] = speakText + bindings["speakSpeed"] = speakSpeed + bindings["speakVolume"] = speakVolume + return SCRIPT_ENGINE.eval(jsStr, bindings) + } + + // url中的参数 + @kotlinx.serialization.Serializable + data class UrlOption( + var method: String? = null, + var body: String? = null + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/EdgeTtsLib.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/EdgeTtsLib.kt new file mode 100644 index 000000000..16d711c42 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/EdgeTtsLib.kt @@ -0,0 +1,75 @@ +package com.github.jing332.tts_server_android.model + +import com.github.jing332.tts_server_android.constant.MsTtsApiType +import com.github.jing332.tts_server_android.model.speech.tts.MsTTS +import tts_server_lib.* + +/* 系统TTS Go库的包装 */ +object SysTtsLib { + private fun toLibProperty(pro: MsTTS): ResultProperty { + val libPro = VoiceProperty() + libPro.api = pro.api.toLong() + libPro.voiceName = pro.voiceName + libPro.voiceId = pro.voiceId + libPro.secondaryLocale = pro.secondaryLocale + val libProsody = VoiceProsody() + libProsody.rate = pro.prosody.rate.toByte() + libProsody.volume = pro.prosody.volume.toByte() + libProsody.pitch = pro.prosody.pitch.toByte() + val libExp = VoiceExpressAs() + libExp.style = pro.expressAs?.style + libExp.styleDegree = pro.expressAs?.styleDegree ?: 1F + libExp.role = pro.expressAs?.role + + return ResultProperty(libPro, libProsody, libExp) + } + + private val mEdgeApi: EdgeApi by lazy { EdgeApi() } + + /** + * 设置超时 + */ + fun setTimeout(ms: Int) { + mEdgeApi.timeout = ms + } + + fun setUseDnsLookup(isEnabled: Boolean) { + mEdgeApi.useDnsLookup = isEnabled + } + + /** + * 获取音频 在Go中生成SSML + */ + fun getAudio( + text: String, + pro: MsTTS, + format: String, + ): ByteArray? { + val libPro = toLibProperty(pro) + when (pro.api) { + MsTtsApiType.EDGE -> { + return mEdgeApi.getEdgeAudio( + text, + format, + libPro.voiceProperty, + libPro.voiceProsody + ) + } + + MsTtsApiType.AZURE -> { + throw Exception("Azure 已失效!") + } + + MsTtsApiType.CREATION -> { + throw Exception("Creation 已失效!") + } + } + return null + } +} + +data class ResultProperty( + var voiceProperty: VoiceProperty, + var voiceProsody: VoiceProsody, + var voiceExpressAs: VoiceExpressAs +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/LocalTtsEngineHelper.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/LocalTtsEngineHelper.kt new file mode 100644 index 000000000..728345ec6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/LocalTtsEngineHelper.kt @@ -0,0 +1,77 @@ +package com.github.jing332.tts_server_android.model + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.Voice +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.App +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.util.Locale + +class LocalTtsEngine(val context: Context) { + companion object { + private const val INIT_STATUS_WAITING = -2 + + fun getEngines(): List { + val tts = TextToSpeech(App.context, null) + val engines = tts.engines + tts.shutdown() + return engines + } + } + + private var tts: TextToSpeech? = null + + + /** + * @return 是否成功 + */ + suspend fun setEngine(name: String): Boolean = coroutineScope { + shutdown() + + var status = INIT_STATUS_WAITING + withMain { tts = TextToSpeech(context, { status = it }, name) } + + while (isActive) { + if (status == TextToSpeech.SUCCESS) break + else if (status != INIT_STATUS_WAITING) { + tts = null + return@coroutineScope false // 初始化失败 + } + + try { + delay(100) + } catch (_: CancellationException) { + } + } + if (!isActive) { // 取消了 + tts = null + return@coroutineScope false + } + + return@coroutineScope true + } + + fun shutdown() { + tts?.shutdown() + tts = null + } + + val voices: List + get() = try { + tts?.voices?.toList()!! + } catch (e: NullPointerException) { + emptyList() + } + + val locales: List + get() = try { + tts!!.availableLanguages.toList().sortedBy { it.toString() } + } catch (e: NullPointerException) { + emptyList() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/MsTtsEditRepository.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/MsTtsEditRepository.kt new file mode 100644 index 000000000..b48d5deda --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/MsTtsEditRepository.kt @@ -0,0 +1,178 @@ +package com.github.jing332.tts_server_android.model + +import com.drake.net.utils.withDefault +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.bean.EdgeVoiceBean +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.CnLocalMap +import com.github.jing332.tts_server_android.constant.MsTtsApiType +import com.github.jing332.tts_server_android.utils.FileUtils +import tts_server_lib.Tts_server_lib +import java.io.File +import java.util.* + +class MsTtsEditRepository() { + companion object { + private val json by lazy { AppConst.jsonBuilder } + private val EDGE_CACHE_PATH by lazy { "${app.cacheDir.path}/edge/voices.json" } + private val AZURE_CACHE_PATH by lazy { "${app.cacheDir.path}/azure/voices.json" } + private val CREATION_CACHE_PATH by lazy { "${app.cacheDir.path}/creation/voices.json" } + } + + // 缓存 + private val mDataCacheMap: MutableMap> = mutableMapOf() + + /** + * 根据api获取数据 + */ + @Suppress("UNUSED_PARAMETER") + suspend fun voicesByApi(@MsTtsApiType api: Int): List { + return withDefault { + edgeVoices() + } + } + + // 帮助 获取并解析数据 + private suspend inline fun getVoicesHelper( + cachePath: String, + crossinline loadData: () -> ByteArray? + ): List { + val file = File(cachePath) + return if (FileUtils.exists(file)) { + json.decodeFromString(withIO { file.readText() }) + } else { + val data = withIO { loadData.invoke() ?: throw Exception("数据为空") } + FileUtils.saveFile(file, data) + json.decodeFromString(data.decodeToString()) + } + } + + private suspend fun edgeVoices(): List { + mDataCacheMap[EDGE_CACHE_PATH]?.let { return it } + + val list = getVoicesHelper(EDGE_CACHE_PATH) { + Tts_server_lib.getEdgeVoices() + } + + return list.map { + GeneralVoiceData( + gender = it.gender, + locale = it.locale, + voiceName = it.shortName, + _localeName = it.friendlyName.split("-").getOrNull(1)?.trim() + ) + }.apply { mDataCacheMap[EDGE_CACHE_PATH] = this } + } +} + +// 通用数据 +data class GeneralVoiceData( + /** + // * 性别 Male:男, Female:女 + */ + val gender: String, + /** + * UUID 仅Creation + */ + val voiceId: String? = null, + /** + * 地区代码 zh-CN + */ + val locale: String, + /** + * Voice zh-CN-XiaoxiaoNeural + */ + val voiceName: String, + + private val _localeName: String? = null, + + /** + * 本地化发音人名称 晓晓 + */ + private val _localVoiceName: String? = null, + + // 二级语言(语言技能) 仅限 en-US-JennyMultilingualNeural + private val _secondaryLocales: Any? = null, + + // 风格列表 azure为List, Creation为string 逗号分割 + private val _styles: Any? = null, + // 角色列表 + private val _roles: Any? = null, +) { + /** + * 地区名 + */ + val localeName: String + get() = Locale.forLanguageTag(locale).run { getDisplayName(this) } + + /** + * 获取发音人本地化名称,edge则为汉化 + */ + val localVoiceName: String + get() { + _localVoiceName?.let { return _localVoiceName } + return CnLocalMap.getEdgeVoice(voiceName) + } + + /** + * 获取风格列表 + */ + private val styleList: List? + get() = transformToList(_styles) + + /** + * 获取本地化的风格列表 + * @return first: 原Key, second: 本地化value + */ + val localStyleList: List>? + get() = ( + if (AppConst.isCnLocale) styleList?.map { Pair(it, CnLocalMap.getStyleAndRole(it)) } + else styleList?.map { Pair(it, it) } + ) + ?.also { if (it.isEmpty()) return null } + + /** + * 获取角色列表 + */ + private val roleList: List? + get() = transformToList(_roles) + + /** + * 获取汉化的角色列表 + * @return first: 原Key, second: 汉化Value + */ + val localRoleList: List>? + get() = ( + if (AppConst.isCnLocale) roleList?.map { Pair(it, CnLocalMap.getStyleAndRole(it)) } + else roleList?.map { Pair(it, it) } + ) + ?.also { if (it.isEmpty()) return null } + + /** + * 二级语言列表 + */ + private val secondaryLocaleList: List? + get() = transformToList(_secondaryLocales) + + /** + * 汉化的二级语言列表 + * @return first: 原Key, second: 汉化Value + */ + val localSecondaryLocaleList: List>? + get() = secondaryLocaleList?.map { Pair(it, Locale.forLanguageTag(it).displayLanguage) } + ?.also { if (it.isEmpty()) return null } + + // 根据类型(String或List) 自动转换 + private fun transformToList(obj: Any?): List? { + obj?.let { v -> + if (v is List<*>) { + return v.map { it.toString() } + } else if (v is String) { + return v.split(",").filter { it.isNotBlank() } + } + } + return null + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/hanlp/HanlpManager.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/hanlp/HanlpManager.kt new file mode 100644 index 000000000..f8a774961 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/hanlp/HanlpManager.kt @@ -0,0 +1,31 @@ +package com.github.jing332.tts_server_android.model.hanlp + +import android.util.Log +import com.hankcs.hanlp.HanLP +import java.io.File + +object HanlpManager { + const val TAG = "HanlpManager" + + fun test(): Boolean { + kotlin.runCatching { + HanLP.newSegment().seg("test, 测试") + return true + } + + return false + } + + fun initDir(dir: String) { + val cfgClz = HanLP.Config::class.java + for (field in cfgClz.declaredFields) { + if (field.type == String::class.java) { + field.isAccessible = true + val value = field.get(null) as String + val newValue = dir + File.separator + value.removePrefix("data/") + field.set(null, newValue) + Log.d(TAG, "set config: $newValue") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/ExceptionExt.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/ExceptionExt.kt new file mode 100644 index 000000000..be880410e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/ExceptionExt.kt @@ -0,0 +1,10 @@ +package com.github.jing332.tts_server_android.model.rhino + +import com.github.jing332.tts_server_android.utils.rootCause +import com.script.ScriptException + +object ExceptionExt { + fun ScriptException.lineMessage(): String { + return "第 $lineNumber 行错误:${rootCause?.message ?: this}" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/BaseScriptEngine.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/BaseScriptEngine.kt new file mode 100644 index 000000000..112e76909 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/BaseScriptEngine.kt @@ -0,0 +1,38 @@ +package com.github.jing332.tts_server_android.model.rhino.core + +import com.script.javascript.RhinoScriptEngine +import org.mozilla.javascript.NativeObject + +open class BaseScriptEngine( + open val rhino: RhinoScriptEngine = RhinoScriptEngine(), + open val ttsrvObject: BaseScriptEngineContext, + open var code: String = "", + open val logger: Logger = Logger.global, +) { + companion object { + const val OBJ_TTSRV = "ttsrv" + const val OBJ_LOGGER = "logger" + } + + open fun findObject(name: String): NativeObject { + return rhino.get(name).run { + if (this == null) throw Exception("Not found object: $name") + else this as NativeObject + } + } + + fun putDefaultObjects() { + rhino.put(OBJ_TTSRV, ttsrvObject) + rhino.put(OBJ_LOGGER, logger) + } + + @Synchronized + open fun eval( + prefixCode: String = "" + ): Any? { + putDefaultObjects() + + return rhino.eval("${prefixCode.removePrefix(";").removeSuffix(";")};$code") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/BaseScriptEngineContext.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/BaseScriptEngineContext.kt new file mode 100644 index 000000000..bc7dba7d9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/BaseScriptEngineContext.kt @@ -0,0 +1,23 @@ +package com.github.jing332.tts_server_android.model.rhino.core + +import android.content.Context +import com.github.jing332.tts_server_android.model.rhino.core.ext.JsExtensions + +/** + * ttsrv 对象类 + */ +open class BaseScriptEngineContext( + override val context: Context, override val engineId: String, + + /*val globalData: Map = BaseScriptEngineContext.globalDataSet.run { + if (!this.containsKey(engineId)) + this[engineId] = mutableMapOf() + + this[engineId]!! + }*/ +) : + JsExtensions(context, engineId) { + /*companion object { + private val globalDataSet = mutableMapOf>() + }*/ +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/Logger.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/Logger.kt new file mode 100644 index 000000000..6210f95f7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/Logger.kt @@ -0,0 +1,87 @@ +package com.github.jing332.tts_server_android.model.rhino.core + +import android.util.Log +import com.github.jing332.tts_server_android.constant.LogLevel +import org.mozilla.javascript.NativeArray +import org.mozilla.javascript.NativeMap +import org.mozilla.javascript.NativeObject + +class Logger { + companion object { + val global: Logger by lazy { Logger() } + } + + private val listenerSet = mutableSetOf() + + fun interface LogListener { + fun log(text: CharSequence, level: Int) + } + + fun addListener(listener: LogListener) { + listenerSet.add(listener) + } + + fun removeListener(listener: LogListener) { + listenerSet.remove(listener) + } + + private fun write(text: CharSequence, @LogLevel level: Int) { + Log.d("RhinoLog", "${LogLevel.toString(level)} $text") + for (listener in listenerSet) { + listener.log(text, level) + } + } + + fun d(obj: Any) { + write(jsObj2String(obj), LogLevel.DEBUG) + } + + fun i(obj: Any) { + write(jsObj2String(obj), LogLevel.INFO) + } + + fun w(obj: Any) { + write(jsObj2String(obj), LogLevel.WARN) + } + + fun e(obj: Any) { + write(jsObj2String(obj), LogLevel.ERROR) + } + + fun jsObj2String(obj: Any): String { + return when (obj) { + is NativeArray -> obj.show + is NativeMap -> obj.toString() + is NativeObject -> obj.show() + is ByteArray -> obj.contentToString() + else -> obj.toString() + } + } + + val NativeArray.show + get() = this.toArray().joinToString(prefix = "[", postfix = "]") + + fun NativeObject.show( + deep: Int = 1 + ): String { + val stringBuilder = StringBuilder("{\n") + this.ids.forEach { id -> + if (id is String) { + val v = this[id] + val value = when (v) { + is NativeObject -> v.show(deep + 1) + is NativeArray -> v.show + else -> v?.javaClass?.name + } + //println("$id->${this[id]?.javaClass?.name}") + for (i in 0 until deep) stringBuilder.append("\t") + stringBuilder.append("${id}:${value},\n") //删去多余的"," + } + } + stringBuilder.deleteCharAt(stringBuilder.length - 2) + for (i in 0 until deep - 1) stringBuilder.append("\t") + stringBuilder.append("}") + return stringBuilder.toString() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsCrypto.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsCrypto.kt new file mode 100644 index 000000000..973d45223 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsCrypto.kt @@ -0,0 +1,175 @@ +@file:Suppress("unused") + +package com.github.jing332.tts_server_android.model.rhino.core.ext + +import cn.hutool.core.codec.Base64 +import cn.hutool.core.util.HexUtil +import cn.hutool.crypto.symmetric.SymmetricCrypto +import com.github.jing332.tts_server_android.constant.AppConst.dateFormat +import com.github.jing332.tts_server_android.utils.EncoderUtils +import com.github.jing332.tts_server_android.utils.MD5Utils +import java.text.SimpleDateFormat +import java.util.* + +interface JsCrypto { + fun md5Encode(str: String): String { + return MD5Utils.md5Encode(str) + } + + fun md5Encode16(str: String): String { + return MD5Utils.md5Encode16(str) + } + + //******************对称加密解密************************// + + /** + * 在js中这样使用 + * java.createSymmetricCrypto(transformation, key, iv).decrypt(data) + * java.createSymmetricCrypto(transformation, key, iv).decryptStr(data) + + * java.createSymmetricCrypto(transformation, key, iv).encrypt(data) + * java.createSymmetricCrypto(transformation, key, iv).encryptBase64(data) + * java.createSymmetricCrypto(transformation, key, iv).encryptHex(data) + */ + + /* 调用SymmetricCrypto key为null时使用随机密钥*/ + fun createSymmetricCrypto( + transformation: String, + key: ByteArray?, + iv: ByteArray? + ): SymmetricCrypto { + val symmetricCrypto = SymmetricCrypto(transformation, key) + return if (iv != null && iv.isNotEmpty()) symmetricCrypto.setIv(iv) else symmetricCrypto + } + + fun createSymmetricCrypto( + transformation: String, + key: ByteArray + ): SymmetricCrypto = createSymmetricCrypto(transformation, key, null) + + fun createSymmetricCrypto( + transformation: String, + key: String + ): SymmetricCrypto = createSymmetricCrypto(transformation, key, null) + + fun createSymmetricCrypto( + transformation: String, + key: String, + iv: String? + ): SymmetricCrypto = + createSymmetricCrypto(transformation, key.encodeToByteArray(), iv?.encodeToByteArray()) + + + fun base64DecodeToBytes(str: String): ByteArray { + return Base64.decode(str) + } + + fun base64DecodeToBytes(bytes: ByteArray): ByteArray { + return Base64.decode(bytes) + } + + + fun base64Decode(str: String, flags: Int): String { + return EncoderUtils.base64Decode(str, flags) + } + + fun base64DecodeToByteArray(str: String?): ByteArray? { + if (str.isNullOrBlank()) { + return null + } + return EncoderUtils.base64DecodeToByteArray(str, 0) + } + + fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? { + if (str.isNullOrBlank()) { + return null + } + return EncoderUtils.base64DecodeToByteArray(str, flags) + } + + fun base64Encode(str: String): String? { + return EncoderUtils.base64Encode(str, 2) + } + + fun base64Encode(str: String, flags: Int): String? { + return EncoderUtils.base64Encode(str, flags) + } + + fun base64Encode(src: ByteArray): String? { + return EncoderUtils.base64Encode(src) + } + + fun base64Encode(src: ByteArray, flags: Int = android.util.Base64.NO_WRAP): String? { + return EncoderUtils.base64Encode(src, flags) + } + + + /* HexString 解码为字节数组 */ + fun hexDecodeToByteArray(hex: String): ByteArray? { + return HexUtil.decodeHex(hex) + } + + /* hexString 解码为utf8String*/ + fun hexDecodeToString(hex: String): String? { + return HexUtil.decodeHexStr(hex) + } + + /* utf8 编码为hexString */ + fun hexEncodeToString(utf8: String): String? { + return HexUtil.encodeHexStr(utf8) + } + + /** + * 格式化时间 + */ + fun timeFormatUTC(time: Long, format: String, sh: Int): String? { + val utc = SimpleTimeZone(sh, "UTC") + return SimpleDateFormat(format, Locale.getDefault()).run { + timeZone = utc + format(Date(time)) + } + } + + /** + * 时间格式化 + */ + fun timeFormat(time: Long): String { + return dateFormat.format(Date(time)) + } + + /** + * utf8编码转gbk编码 + */ + fun utf8ToGbk(str: String): String { + val utf8 = String(str.toByteArray(charset("UTF-8"))) + val unicode = String(utf8.toByteArray(), charset("UTF-8")) + return String(unicode.toByteArray(charset("GBK"))) + } + + /* fun base64Decode(str: String, flags: Int): String { + return EncoderUtils.base64Decode(str, flags) + } + + fun base64DecodeToByteArray(str: String?): ByteArray? { + if (str.isNullOrBlank()) { + return null + } + return EncoderUtils.base64DecodeToByteArray(str, 0) + } + + fun base64DecodeToByteArray(str: String?, flags: Int): ByteArray? { + if (str.isNullOrBlank()) { + return null + } + return EncoderUtils.base64DecodeToByteArray(str, flags) + } + + fun base64Encode(str: String): String? { + return EncoderUtils.base64Encode(str, 2) + } + + fun base64Encode(str: String, flags: Int): String? { + return EncoderUtils.base64Encode(str, flags) + }*/ + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsExtensions.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsExtensions.kt new file mode 100644 index 000000000..fcc08a3c7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsExtensions.kt @@ -0,0 +1,112 @@ +package com.github.jing332.tts_server_android.model.rhino.core.ext + +import android.content.Context +import cn.hutool.core.lang.UUID +import com.github.jing332.tts_server_android.help.audio.AudioDecoder +import com.github.jing332.tts_server_android.utils.FileUtils +import java.io.File +import java.io.InputStream + +@Suppress("unused") +open class JsExtensions(open val context: Context, open val engineId: String) : JsNet(engineId), JsCrypto, + JsUserInterface { + + @Suppress("MemberVisibilityCanBePrivate") + fun getAudioSampleRate(audio: ByteArray): Int { + return AudioDecoder.getSampleRateAndMime(audio).first + } + + fun getAudioSampleRate(ins: InputStream): Int { + return getAudioSampleRate(ins.readBytes()) + } + + /* Str转ByteArray */ + fun strToBytes(str: String): ByteArray { + return str.toByteArray(charset("UTF-8")) + } + + fun strToBytes(str: String, charset: String): ByteArray { + return str.toByteArray(charset(charset)) + } + + /* ByteArray转Str */ + fun bytesToStr(bytes: ByteArray): String { + return String(bytes, charset("UTF-8")) + } + + fun bytesToStr(bytes: ByteArray, charset: String): String { + return String(bytes, charset(charset)) + } + + //****************文件操作******************// + /** + * 获取本地文件 + * @param path 相对路径 + * @return File + */ + fun getFile(path: String): File { + val cachePath = "${context.externalCacheDir!!.absolutePath}/${engineId}" + if (!FileUtils.exists(cachePath)) File(cachePath).mkdirs() + val aPath = if (path.startsWith(File.separator)) { + cachePath + path + } else { + cachePath + File.separator + path + } + return File(aPath) + } + + /** + * 读Bytes文件 + */ + fun readFile(path: String): ByteArray? { + val file = getFile(path) + if (file.exists()) { + return file.readBytes() + } + return null + } + + /** + * 读取文本文件 + */ + fun readTxtFile(path: String): String { + val file = getFile(path) + if (file.exists()) { + return String(file.readBytes(), charset(charsetDetect(file))) + } + return "" + } + + /** + * 获取文件编码 + */ + fun charsetDetect(f: File): String = FileUtils.getFileCharsetSimple(f) + + fun readTxtFile(path: String, charsetName: String): String { + val file = getFile(path) + if (file.exists()) { + return String(file.readBytes(), charset(charsetName)) + } + return "" + } + + @JvmOverloads + fun writeTxtFile(path: String, text: String, charset: String = "UTF-8") { + getFile(path).writeText(text, charset(charset)) + } + + fun fileExist(path: String): Boolean { + return FileUtils.exists(getFile(path)) + } + + /** + * 删除本地文件 + * @return 操作是否成功 + */ + fun deleteFile(path: String): Boolean { + val file = getFile(path) + return file.delete() + } + + fun randomUUID(): String = UUID.randomUUID().toString() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsNet.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsNet.kt new file mode 100644 index 000000000..a175f82a9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsNet.kt @@ -0,0 +1,136 @@ +package com.github.jing332.tts_server_android.model.rhino.core.ext + +import androidx.annotation.Keep +import com.drake.net.Net +import com.drake.net.NetConfig +import com.drake.net.exception.ConvertException +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.File + +@Keep +open class JsNet(private val engineId: String) { + private val groupId by lazy { engineId + hashCode() } + + internal fun cancel() { + Net.cancelGroup(groupId) + } + + @JvmOverloads + fun httpGet(url: CharSequence, headers: Map? = null): Response { + return Net.get(url.toString()) { + setGroup(groupId) + headers?.let { + it.forEach { + setHeader(it.key.toString(), it.value.toString()) + } + } + }.execute() + } + + /** + * HTTP GET + */ + @JvmOverloads + fun httpGetString( + url: CharSequence, headers: Map? = null + ): String? { + return try { + Net.get(url.toString()) { + setGroup(groupId) + headers?.let { + it.forEach { + setHeader(it.key.toString(), it.value.toString()) + } + } + }.execute() + } catch (e: ConvertException) { + throw Exception("Body is not a String, HTTP-${e.response.code}=${e.response.message}") + } + } + + @JvmOverloads + fun httpGetBytes( + url: CharSequence, headers: Map? = null + ): ByteArray? { + return try { + httpGet(url, headers).body?.bytes() + } catch (e: ConvertException) { + throw Exception("Body is not a Bytes, HTTP-${e.response.code}=${e.response.message}") + } + } + + /** + * HTTP POST + */ + @JvmOverloads + fun httpPost( + url: CharSequence, + body: CharSequence? = null, + headers: Map? = null + ): Response { + return Net.post(url.toString()) { + setGroup(groupId) + body?.let { this.body = it.toString().toRequestBody() } + headers?.let { + it.forEach { + setHeader(it.key.toString(), it.value.toString()) + } + } + }.execute() + } + + @Suppress("UNCHECKED_CAST") + private fun postMultipart(type: String, form: Map): MultipartBody.Builder { + val multipartBody = MultipartBody.Builder() + multipartBody.setType(type.toMediaType()) + + form.forEach { entry -> + when (entry.value) { + // 文件表单 + is Map<*, *> -> { + val filePartMap = entry.value as Map + val fileName = filePartMap["fileName"] as? String + val body = filePartMap["body"] + val contentType = filePartMap["contentType"] as? String + + val mediaType = contentType?.toMediaType() + val requestBody = when (body) { + is File -> body.asRequestBody(mediaType) + is ByteArray -> body.toRequestBody(mediaType) + is String -> body.toRequestBody(mediaType) + else -> body.toString().toRequestBody() + } + + multipartBody.addFormDataPart(entry.key, fileName, requestBody) + } + + // 常规表单 + else -> multipartBody.addFormDataPart(entry.key, entry.value as String) + } + } + + return multipartBody + } + + @JvmOverloads + fun httpPostMultipart( + url: CharSequence, + form: Map, + type: String = "multipart/form-data", + headers: Map? = null + ): Response { + return Net.post(url.toString()) { + setGroup(groupId) + headers?.let { + it.forEach { + setHeader(it.key.toString(), it.value.toString()) + } + } + body = postMultipart(type, form).build() + }.execute() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsUserInterface.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsUserInterface.kt new file mode 100644 index 000000000..c21815bf9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/ext/JsUserInterface.kt @@ -0,0 +1,23 @@ +package com.github.jing332.tts_server_android.model.rhino.core.ext + +import android.view.View +import android.view.ViewGroup +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.utils.dp +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.toast + +interface JsUserInterface { + fun toast(msg: String) = app.toast(msg) + fun longToast(msg: String) = app.longToast(msg) + + fun setMargins(v: View, left: Int, top: Int, right: Int, bottom: Int) { + (v.layoutParams as ViewGroup.MarginLayoutParams).setMargins( + left.dp, + top.dp, + right.dp, + bottom.dp + ) + } +// +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/JClass.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/JClass.kt new file mode 100644 index 000000000..e185796df --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/JClass.kt @@ -0,0 +1,14 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type + +abstract class JClass { + var onThrowable: ((t: Throwable) -> Unit)? = null + + fun tryBlock(block: () -> Unit) { + kotlin.runCatching { + block.invoke() + }.onFailure { + it.printStackTrace() + onThrowable?.invoke(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/Item.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/Item.kt new file mode 100644 index 000000000..6ec8024c7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/Item.kt @@ -0,0 +1,3 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ui + +data class Item(val name: CharSequence, val value: Any) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JSeekBar.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JSeekBar.kt new file mode 100644 index 000000000..7d2ce4c0a --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JSeekBar.kt @@ -0,0 +1,83 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import com.github.jing332.tts_server_android.compose.widgets.LabelSlider +import com.github.jing332.tts_server_android.utils.ThrottleUtil +import com.github.jing332.tts_server_android.utils.toScale + +@Suppress("unused") +@SuppressLint("ViewConstructor") +class JSeekBar(context: Context, val hint: CharSequence) : FrameLayout(context) { + + interface OnSeekBarChangeListener { + fun onStartTrackingTouch(seekBar: JSeekBar) + fun onProgressChanged(seekBar: JSeekBar, progress: Int, fromUser: Boolean) + fun onStopTrackingTouch(seekBar: JSeekBar) + } + + private var mListener: OnSeekBarChangeListener? = null + + fun setOnChangeListener(listener: OnSeekBarChangeListener?) { + mListener = listener + } + + init { + val compose = ComposeView(context) + addView(compose) + compose.setContent { + Content() + } + + } + + @JvmField + var max = 0 + + + private var n = 0 + private var x = 1f + fun setFloatType(n: Int) { + this.n = n + x = 1f + for (i in 1..n) { + x *= 10f + } + } + + var value: Float + get() = mValue / x + set(value) { + mValue = value * x + mListener?.onProgressChanged(this@JSeekBar, value.toInt(), false) + } + + private var mValue by mutableFloatStateOf(0f) + private val throttleUtils = ThrottleUtil() + + @Composable + fun Content() { + LabelSlider( + value = mValue, + valueRange = 0f..max.toFloat(), + buttonSteps = 1f, + buttonLongSteps = 10f, + onValueChange = { + mValue = it + mListener?.onProgressChanged(this@JSeekBar, value.toInt(), true) + + throttleUtils.runAction { + mListener?.onStopTrackingTouch(this@JSeekBar) + } + }, + text = hint.toString() + value.toScale(n) + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JSpinner.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JSpinner.kt new file mode 100644 index 000000000..0bb1123cf --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JSpinner.kt @@ -0,0 +1,100 @@ +@file:Suppress("unused") + +package com.github.jing332.tts_server_android.model.rhino.core.type.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.widgets.AppSpinner +import kotlin.math.max + +@Suppress("MemberVisibilityCanBePrivate") +@SuppressLint("ViewConstructor") +class JSpinner(context: Context, val hint: CharSequence) : FrameLayout(context) { + companion object { + const val TAG = "JSpinner" + } + + private val mItems = mutableStateListOf() + var items: List + get() = mItems + set(value) { + mItems.clear() + mItems += value + } + + private var mSelectedPosition by mutableIntStateOf(0) + + var selectedPosition: Int + get() = mSelectedPosition + set(value) { + mSelectedPosition = value + } + + var value: Any + get() = items[selectedPosition] + set(value) { + selectedPosition = max(0, mItems.indexOfFirst { it.value == value }) + } + + private var mListener: OnItemSelectedListener? = null + + interface OnItemSelectedListener { + fun onItemSelected(spinner: JSpinner, position: Int, item: Item) + } + + fun setOnItemSelected(listener: OnItemSelectedListener?) { + mListener = listener + } + + init { + val composeView = ComposeView(context) + addView(composeView) + composeView.setContent { + Content() + } + } + + @Composable + fun Content() { + val item = mItems.getOrElse(mSelectedPosition) { mItems.getOrNull(0) } ?: Item("null", Unit) + if (mItems.isEmpty()){ + AppSpinner( + label = { Text(hint.toString()) }, + value = item.value, + values = listOf(Unit), + entries = listOf(stringResource(id = R.string.empty_list)), + enabled = false, + onSelectedChange = { _, _ -> } + ) + } + else + AppSpinner( + label = { Text(hint.toString()) }, + value = item.value, + values = mItems.map { it.value }, + entries = mItems.map { it.name.toString() }, + onSelectedChange = { value, _ -> + val index = mItems.indexOfFirst { it.value == value } + mSelectedPosition = index + mListener?.onItemSelected( + spinner = this@JSpinner, position = index, item = mItems[index] + ) + } + ) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JTextInput.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JTextInput.kt new file mode 100644 index 000000000..e14d73490 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ui/JTextInput.kt @@ -0,0 +1,180 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ui + +import android.annotation.SuppressLint +import android.content.Context +import android.text.Editable +import android.text.InputFilter +import android.widget.FrameLayout +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView + +@Suppress("unused") +@SuppressLint("ViewConstructor") +class JTextInput(context: Context, val hint: CharSequence? = null) : FrameLayout(context) { + + interface OnTextChangedListener { + fun onChanged(text: CharSequence) + } + + private val listeners = mutableSetOf() + + fun addTextChangedListener(listener: OnTextChangedListener) { + listeners.add(listener) + } + + fun removeTextChangedListener(listener: OnTextChangedListener) { + listeners.remove(listener) + } + + private var mOnTextChangedListeners: OnTextChangedListener? = null + + fun setOnTextChangedListener(listener: OnTextChangedListener) { + listeners.add(listener) + } + + init { + val compose = ComposeView(context) + this.addView(compose) + compose.setContent { + Content() + } + + } + + val text: Editable by lazy { MyEditable() } + private var mText by mutableStateOf("") + + private var mMaxLines by mutableIntStateOf(Int.MAX_VALUE) + var maxLines: Int + get() = mMaxLines + set(value) { + mMaxLines = value + } + + + @Composable + fun Content() { + OutlinedTextField( + value = mText, + onValueChange = { + mText = it + for (listener in listeners) { + listener.onChanged(it) + } + }, + maxLines = mMaxLines, + label = { Text(hint.toString()) } + ) + } + + inner class MyEditable() : Editable { + override val length = mText.length + + override fun get(index: Int): Char { + return mText[index] + } + + fun set(text: CharSequence) { + mText = text.toString() + } + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { + TODO("Not yet implemented") + } + + override fun getChars(start: Int, end: Int, dest: CharArray?, destoff: Int) { + TODO("Not yet implemented") + } + + override fun getSpans(start: Int, end: Int, type: Class?): Array { + TODO("Not yet implemented") + } + + override fun getSpanStart(tag: Any?): Int { + TODO("Not yet implemented") + } + + override fun getSpanEnd(tag: Any?): Int { + TODO("Not yet implemented") + } + + override fun getSpanFlags(tag: Any?): Int { + TODO("Not yet implemented") + } + + override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int { + TODO("Not yet implemented") + } + + override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) { + TODO("Not yet implemented") + } + + override fun removeSpan(what: Any?) { + TODO("Not yet implemented") + } + + override fun append(text: CharSequence?): Editable { + mText += text + return this + } + + override fun append(text: CharSequence?, start: Int, end: Int): Editable { + TODO("Not yet implemented") + } + + override fun append(text: Char): Editable { + mText += text + return this + } + + override fun replace( + st: Int, + en: Int, + source: CharSequence?, + start: Int, + end: Int + ): Editable { + TODO("Not yet implemented") + } + + override fun replace(st: Int, en: Int, text: CharSequence?): Editable { + TODO("Not yet implemented") + } + + override fun insert(where: Int, text: CharSequence?, start: Int, end: Int): Editable { + TODO("Not yet implemented") + } + + override fun insert(where: Int, text: CharSequence?): Editable { + TODO("Not yet implemented") + } + + override fun delete(st: Int, en: Int): Editable { + TODO("Not yet implemented") + } + + override fun clear() { + mText = "" + } + + override fun clearSpans() { + TODO("Not yet implemented") + } + + override fun setFilters(filters: Array?) { + TODO("Not yet implemented") + } + + override fun getFilters(): Array { + TODO("Not yet implemented") + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/JWebSocket.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/JWebSocket.kt new file mode 100644 index 000000000..207a0a0e3 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/JWebSocket.kt @@ -0,0 +1,81 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ws + +import com.github.jing332.tts_server_android.model.rhino.core.type.JClass +import com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal.IWebSocketEvent +import com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal.WebSocketClient +import com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal.WebSocketClient.* +import okhttp3.Request +import okhttp3.Response +import okio.ByteString + +@Suppress("unused") +class JWebSocket( + @JvmField val url: String, + @JvmField val headers: Map? = null +) : JClass() { + companion object { + const val CONNECTING = 0 + const val OPEN = 1 + const val CLOSING = 2 + const val CLOSED = 3 + const val FAILURE = 4 + } + + fun connect() { + val req = Request.Builder().url(url) + headers?.forEach { req.header(it.key.toString(), it.value.toString()) } + ws.connect(req.build()) + + ws.event = object : IWebSocketEvent { + override fun onOpen(response: Response) { + tryBlock { onOpen?.invoke(response) } + } + + override fun onMessage(text: String) { + tryBlock { onTextMessage?.invoke(text) } + } + + override fun onMessage(bytes: ByteString) { + tryBlock { onByteMessage?.invoke(bytes) } + } + + override fun onClosed(code: Int, reason: String) { + tryBlock { onClosed?.invoke(code, reason) } + } + + override fun onClosing(code: Int, reason: String) { + tryBlock { onClosing?.invoke(code, reason) } + } + + override fun onFailure(t: Throwable) { + tryBlock { onFailure?.invoke(t) } + } + } + } + + fun send(text: String): Boolean = ws.send(text) + fun send(bytes: ByteString): Boolean = ws.send(bytes) + + @JvmOverloads + fun close(code: Int, reason: String? = "") = ws.close(code, reason) + + fun cancel() = ws.cancel() + + private var ws: WebSocketClient = WebSocketClient() + + var onByteMessage: ((bytes: ByteString) -> Unit)? = null + var onTextMessage: ((text: String) -> Unit)? = null + var onOpen: ((Response) -> Unit)? = null + var onClosed: ((code: Int, reason: String) -> Unit)? = null + var onClosing: ((code: Int, reason: String) -> Unit)? = null + var onFailure: ((t: Throwable) -> Unit)? = null + + val state: Int + get() = when (ws.connectStatus) { + Status.Connecting -> CONNECTING + Status.Opened -> OPEN + Status.Closing -> CLOSING + Status.Closed -> CLOSED + else -> FAILURE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/IWebSocketEvent.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/IWebSocketEvent.kt new file mode 100644 index 000000000..ae46c23fa --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/IWebSocketEvent.kt @@ -0,0 +1,13 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal + +import okhttp3.Response +import okio.ByteString + +interface IWebSocketEvent { + fun onOpen(response: Response) {} + fun onMessage(text: String) + fun onMessage(bytes: ByteString) + fun onClosed(code: Int, reason: String) + fun onClosing(code: Int, reason: String) + fun onFailure(t: Throwable) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/WebSocketClient.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/WebSocketClient.kt new file mode 100644 index 000000000..1f4be5041 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/WebSocketClient.kt @@ -0,0 +1,123 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal + +import android.util.Log +import com.drake.net.utils.withIO +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import okhttp3.* +import okio.ByteString +import java.util.concurrent.TimeUnit + +class WebSocketClient : WebSocketListener() { + private lateinit var webSocket: WebSocket + + companion object { + private val client = OkHttpClient.Builder().writeTimeout(1, TimeUnit.SECONDS).build() + private const val TAG = "WebSocketClient" + } + + var event: IWebSocketEvent? = null + + var connectStatus: Status = Status.Closed + private set + + fun connect(req: Request) { + webSocket = client.newWebSocket(req, this@WebSocketClient) + connectStatus = Status.Connecting + } + + suspend fun connectSync(req: Request): Boolean = withIO { + connect(req) + while (isActive) { + delay(100) + when (connectStatus) { + Status.Opened -> return@withIO true + is Status.Failure -> + (connectStatus as Status.Failure).apply { + throw WebSocketException(response).initCause(t) + } + + else -> {} + } + } + if (connectStatus != Status.Connecting) return@withIO true + + return@withIO false + } + + /** + * 同步连接WS + * @return 是否成功 + */ + suspend fun connectSync(url: String): Boolean = connectSync(Request.Builder().url(url).build()) + + /** + * 重连WS + * @return 是否成功 + */ + suspend fun reConnect() = connectSync(webSocket.request()) + + fun send(text: String): Boolean { + Log.d(TAG, "send: $text") + return webSocket.send(text) + } + + fun send(bytes: ByteString): Boolean { + Log.d(TAG, "send: $bytes") + return webSocket.send(bytes) + } + + fun cancel() = webSocket.cancel() + fun close() = webSocket.close(1000, null) + + fun close(code: Int, reason: String? = null) = webSocket.close(code, reason) + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "onClosed: $code, $reason") + super.onClosed(webSocket, code, reason) + connectStatus = Status.Closed + event?.onClosed(code, reason) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "onClosing: $code, $reason") + super.onClosing(webSocket, code, reason) + connectStatus = Status.Closing + event?.onClosing(code, reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.d(TAG, "onFailure: $t, $response ${response?.body?.string()}") + super.onFailure(webSocket, t, response) + connectStatus = Status.Failure(t, response) + event?.onFailure(t) + } + + override fun onOpen(webSocket: WebSocket, response: Response) { + super.onOpen(webSocket, response) + connectStatus = Status.Opened + event?.onOpen(response) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "onMessage: $text") + super.onMessage(webSocket, text) + + event?.onMessage(text) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + Log.d(TAG, "onMessage: $bytes") + super.onMessage(webSocket, bytes) + + event?.onMessage(bytes) + } + + sealed class Status { + object Connecting : Status() + object Opened : Status() + object Closed : Status() + object Closing : Status() + data class Failure(val t: Throwable, val response: Response?) : Status() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/WebSocketException.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/WebSocketException.kt new file mode 100644 index 000000000..44066d0ff --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/core/type/ws/internal/WebSocketException.kt @@ -0,0 +1,5 @@ +package com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal + +import okhttp3.Response + +data class WebSocketException(val response: Response? = null) : Exception() \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/direct_link_upload/DirectUploadEngine.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/direct_link_upload/DirectUploadEngine.kt new file mode 100644 index 000000000..25fb677ba --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/direct_link_upload/DirectUploadEngine.kt @@ -0,0 +1,35 @@ +package com.github.jing332.tts_server_android.model.rhino.direct_link_upload + +import android.content.Context +import com.github.jing332.tts_server_android.conf.DirectUploadConfig +import com.github.jing332.tts_server_android.model.rhino.core.BaseScriptEngine +import com.github.jing332.tts_server_android.model.rhino.core.BaseScriptEngineContext +import com.github.jing332.tts_server_android.model.rhino.core.Logger +import com.script.javascript.RhinoScriptEngine +import org.mozilla.javascript.NativeObject + +class DirectUploadEngine( + override val rhino: RhinoScriptEngine = RhinoScriptEngine(), + private val context: Context, + override val logger: Logger = Logger(), + override var code: String = DirectUploadConfig.code.value, +) : BaseScriptEngine(rhino, BaseScriptEngineContext(context, "DirectUpload"), code, logger) { + companion object { + private const val TAG = "DirectUploadEngine" + const val OBJ_DIRECT_UPLOAD = "DirectUploadJS" + } + + private val jsObject: NativeObject + get() = findObject(OBJ_DIRECT_UPLOAD) + + /** + * 获取所有方法 + */ + fun obtainFunctionList(): List { + eval() + return jsObject.map { + DirectUploadFunction(rhino, jsObject, it.key as String) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/direct_link_upload/DirectUploadFunction.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/direct_link_upload/DirectUploadFunction.kt new file mode 100644 index 000000000..b51a72237 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/direct_link_upload/DirectUploadFunction.kt @@ -0,0 +1,16 @@ +package com.github.jing332.tts_server_android.model.rhino.direct_link_upload + +import com.script.javascript.RhinoScriptEngine +import org.mozilla.javascript.NativeObject + +data class DirectUploadFunction( + val scriptEngine: RhinoScriptEngine, + val thisObj: NativeObject, + val funcName: String, +) { + fun invoke(config: String): String? { + return scriptEngine.invokeMethod(thisObj, funcName, config) + ?.run { this as String } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/speech_rule/ScriptEngineContext.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/speech_rule/ScriptEngineContext.kt new file mode 100644 index 000000000..821679d33 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/speech_rule/ScriptEngineContext.kt @@ -0,0 +1,26 @@ +package com.github.jing332.tts_server_android.model.rhino.speech_rule + +import android.content.Context +import com.github.jing332.tts_server_android.model.rhino.core.BaseScriptEngineContext +import com.hankcs.hanlp.HanLP +import com.hankcs.hanlp.seg.Segment + +class ScriptEngineContext( + override val context: Context, override val engineId: String +) : BaseScriptEngineContext(context, engineId) { + + /** + * 创建一个分词器, 这是一个工厂方法 + * Params: + * algorithm – 分词算法,传入算法的中英文名都可以,可选列表: + * 维特比 (viterbi):效率和效果的最佳平衡 + * 双数组trie树 (dat):极速词典分词,千万字符每秒 + * 条件随机场 (crf):分词、词性标注与命名实体识别精度都较高,适合要求较高的NLP任务 + * 感知机 (perceptron):分词、词性标注与命名实体识别,支持在线学习 + * N最短路 (nshort):命名实体识别稍微好一些,牺牲了速度 + * Returns: + * 一个分词器 + */ + @JvmOverloads + fun newSegment(algorithm: String = "viterbi"): Segment = HanLP.newSegment(algorithm)!! +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/speech_rule/SpeechRuleEngine.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/speech_rule/SpeechRuleEngine.kt new file mode 100644 index 000000000..3ceb46ed6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/speech_rule/SpeechRuleEngine.kt @@ -0,0 +1,131 @@ +package com.github.jing332.tts_server_android.model.rhino.speech_rule + +import android.content.Context +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.data.entities.TagsDataMap +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.model.rhino.core.BaseScriptEngine +import com.github.jing332.tts_server_android.model.rhino.core.Logger + +class SpeechRuleEngine( + val context: Context, + private val rule: SpeechRule, + override var code: String = rule.code, + override val logger: Logger = Logger.global +) : + BaseScriptEngine(ttsrvObject = ScriptEngineContext(context = context, "ReadRule")) { + companion object { + const val OBJ_JS = "SpeechRuleJS" + + const val FUNC_GET_TAG_NAME = "getTagName" + const val FUNC_HANDLE_TEXT = "handleText" + const val FUNC_SPLIT_TEXT = "splitText" + + fun getTagName(context: Context, speechRule: SpeechRule, info: SpeechRuleInfo): String { + val engine = SpeechRuleEngine(context, speechRule) + engine.eval() + + val tagName = try { + engine.getTagName(info.tag, info.tagData) + } catch (_: NoSuchMethodException) { + speechRule.tags[info.tag] ?: "" + } + + return tagName + } + } + + private val objJS + get() = findObject(OBJ_JS) + + @Suppress("UNCHECKED_CAST") + fun evalInfo() { + eval() + objJS.apply { + rule.name = get("name").toString() + rule.ruleId = get("id").toString() + rule.author = get("author").toString() + + rule.tags = get("tags") as Map + + rule.tagsData = + getOrDefault( + "tagsData", + emptyMap>>() + ) as TagsDataMap + + runCatching { + rule.version = (get("version") as Double).toInt() + }.onFailure { + throw NumberFormatException(context.getString(R.string.plugin_bad_format)) + } + } + } + + fun getTagName(tag: String, tagMap: Map): String { + return rhino.invokeMethod(objJS, FUNC_GET_TAG_NAME, tag, tagMap).toString() + } + + data class TagData(val id: String, val value: String) + + fun handleText(text: String, list: List = emptyList()): List { + val tagsDataMap: MutableMap>>> = + mutableMapOf() + list.forEach { info -> + if (tagsDataMap[info.tag] == null) + tagsDataMap[info.tag] = mutableMapOf() + + info.tagData.forEach { + if (tagsDataMap[info.tag]!![it.key] == null) + tagsDataMap[info.tag]!![it.key] = mutableListOf() + + tagsDataMap[info.tag]!![it.key]!!.add( + mapOf( + "id" to info.configId.toString(), + "value" to it.value + ) + ) + } + } + return handleText(text, tagsDataMap) + } + + /** ['dialogue']['role']= List + *@param tagsDataSet 例: key: dialogue, value: map(key: role, value: [{tagDataId:111, 张三, 李四]) + */ + fun handleText( + text: String, + tagsDataMap: Map>>> + ): List { + val resultList: MutableList = mutableListOf() + rhino.invokeMethod(objJS, FUNC_HANDLE_TEXT, text, tagsDataMap) + ?.run { this as List<*> } + ?.let { list -> + list.forEach { + if (it is Map<*, *>) { + resultList.add( + TextWithTag( + it["text"].toString(), + it["tag"].toString(), + it.getOrDefault("id", 0).toString().toLong(), + ) + ) + } + } + + } + return resultList + } + + @Suppress("UNCHECKED_CAST") + fun splitText(text: String): List { + return rhino.invokeMethod( + objJS, + FUNC_SPLIT_TEXT, + text + ) as List + } + + data class TextWithTag(val text: String, val tag: String, val id: Long) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/EngineContext.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/EngineContext.kt new file mode 100644 index 000000000..493776b30 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/EngineContext.kt @@ -0,0 +1,19 @@ +package com.github.jing332.tts_server_android.model.rhino.tts + +import android.content.Context +import androidx.annotation.Keep +import com.github.jing332.tts_server_android.model.rhino.core.BaseScriptEngineContext +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS + +/** + * @param tts 在JS中用 `ttsrv.tts` 访问 + */ +@Keep +data class EngineContext( + var tts: PluginTTS, + val userVars: Map = mutableMapOf(), + override val context: Context, + override val engineId: String +) : + BaseScriptEngineContext(context, engineId) { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/TtsPluginEngine.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/TtsPluginEngine.kt new file mode 100644 index 000000000..fe2e9a92b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/TtsPluginEngine.kt @@ -0,0 +1,135 @@ +package com.github.jing332.tts_server_android.model.rhino.tts + +import android.content.Context +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.model.rhino.core.BaseScriptEngine +import com.github.jing332.tts_server_android.model.rhino.core.Logger +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.script.javascript.RhinoScriptEngine +import org.mozilla.javascript.NativeObject +import java.io.ByteArrayInputStream +import java.io.InputStream + +open class TtsPluginEngine( + val pluginTTS: PluginTTS, + private val context: Context, + override val rhino: RhinoScriptEngine = RhinoScriptEngine(), + override val logger: Logger = Logger(), +) : BaseScriptEngine( + rhino = rhino, logger = logger, + code = pluginTTS.requirePlugin.code, + ttsrvObject = EngineContext( + pluginTTS, + pluginTTS.plugin!!.userVars, + context, + pluginTTS.requirePlugin.pluginId + ) +) { + private var mPlugin: Plugin + inline get() = pluginTTS.plugin!! + inline set(value) { + pluginTTS.plugin = value + } + + companion object { + const val OBJ_PLUGIN_JS = "PluginJS" + + const val FUNC_GET_AUDIO = "getAudio" + const val FUNC_ON_LOAD = "onLoad" + const val FUNC_ON_STOP = "onStop" + } + + @Synchronized + override fun eval(prefixCode: String): Any? { + return super.eval("$prefixCode ;importPackage(${AppConst.PACKET_NAME}.model.rhino.core.type.ws)") + } + + // 已弃用, 占位 + @Suppress("unused") + var extraData: String = "" + + private val pluginJsObject: NativeObject + get() = findObject(OBJ_PLUGIN_JS) + + @Suppress("UNCHECKED_CAST") + @Synchronized + fun evalPluginInfo(): Plugin { + logger.d("evalPluginInfo()...") + eval() + + pluginJsObject.apply { + mPlugin.name = get("name").toString() + mPlugin.pluginId = get("id").toString() + mPlugin.author = get("author").toString() + + try { + mPlugin.defVars = get("vars") as Map> + } catch (_: NullPointerException) { + mPlugin.defVars = emptyMap() + } catch (t: Throwable) { + mPlugin.defVars = emptyMap() + + throw ClassCastException("\"vars\" bad format" ).initCause(t) + } + + runCatching { + mPlugin.version = (get("version") as Double).toInt() + }.onFailure { + throw NumberFormatException(context.getString(R.string.plugin_bad_format)) + } + } + + return mPlugin + } + + @Synchronized + fun onLoad(): Any? { + logger.d("onLoad()...") + eval() + try { + return rhino.invokeMethod(pluginJsObject, FUNC_ON_LOAD) + } catch (_: NoSuchMethodException) { + } + return null + } + + @Synchronized + fun onStop(): Any? { + logger.d("onStop()...") + ttsrvObject.cancel() + try { + return rhino.invokeMethod(pluginJsObject, FUNC_ON_STOP) + } catch (_: NoSuchMethodException) { + } + return null + } + + @Synchronized + fun getAudio( + text: String, rate: Int = 1, pitch: Int = 1 + ): InputStream? { + logger.d("getAudio()... $pluginTTS") + + return rhino.invokeMethod( + pluginJsObject, + FUNC_GET_AUDIO, + text, + pluginTTS.locale, + pluginTTS.voice, + rate, + pluginTTS.volume, + pitch + )?.run { + when (this) { + is ArrayList<*> -> { + ByteArrayInputStream(this.map { (it as Double).toInt().toByte() }.toByteArray()) + } + + is InputStream -> this + else -> ByteArrayInputStream(this as ByteArray) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/TtsPluginUiEngine.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/TtsPluginUiEngine.kt new file mode 100644 index 000000000..c0f9badae --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/rhino/tts/TtsPluginUiEngine.kt @@ -0,0 +1,154 @@ +package com.github.jing332.tts_server_android.model.rhino.tts + +import android.content.Context +import android.widget.LinearLayout +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.model.speech.tts.PluginTTS +import com.github.jing332.tts_server_android.utils.dp +import org.mozilla.javascript.NativeObject +import java.util.Locale + +class TtsPluginUiEngine( + private val pluginTts: PluginTTS, + val context: Context, +) : TtsPluginEngine(pluginTTS = pluginTts, context = context) { + companion object { + const val FUNC_SAMPLE_RATE = "getAudioSampleRate" + const val FUNC_IS_NEED_DECODE = "isNeedDecode" + + const val FUNC_LOCALES = "getLocales" + const val FUNC_VOICES = "getVoices" + + const val FUNC_ON_LOAD_UI = "onLoadUI" + const val FUNC_ON_LOAD_DATA = "onLoadData" + const val FUNC_ON_VOICE_CHANGED = "onVoiceChanged" + + const val OBJ_UI_JS = "EditorJS" + } + + fun dp(px: Int): Int { + return px.dp + } + + private val editUiJsObject: NativeObject by lazy { + val importCode = "importPackage(${AppConst.PACKET_NAME}.model.rhino.core.type.ui);" + + "importPackage(android.view);" + + "importPackage(android.widget);" + + eval(importCode) + findObject(OBJ_UI_JS) + } + + + fun getSampleRate(locale: String, voice: String): Int? { + logger.d("getSampleRate()...") + + return rhino.invokeMethod( + editUiJsObject, + FUNC_SAMPLE_RATE, + locale, + voice + )?.run { + return if (this is Int) this + else (this as Double).toInt() + } + } + + fun isNeedDecode(locale: String, voice: String): Boolean { + logger.d("isNeedDecode()...") + + return try { + rhino.invokeMethod( + editUiJsObject, + FUNC_IS_NEED_DECODE, + locale, + voice + )?.run { + if (this is Boolean) this + else (this as Double).toInt() == 1 + } ?: true + } catch (_: NoSuchMethodException) { + true + } + } + + fun getLocales(): Map { + return rhino.invokeMethod(editUiJsObject, FUNC_LOCALES).run { + when (this) { + is List<*> -> this.associate { + it.toString() to Locale.forLanguageTag(it.toString()).run { + this.getDisplayName(this) + } + } + + is Map<*, *> -> { + this.map { (key, value) -> + key.toString() to value.toString() + }.toMap() + } + + else -> emptyMap() + } + } + } + + fun getVoices(locale: String): Map { + return rhino.invokeMethod(editUiJsObject, FUNC_VOICES, locale).run { + when (this) { + is Map<*, *> -> { + this.map { (key, value) -> + key.toString() to value.toString() + }.toMap() + } + /* is NativeMap -> { + val entries = NativeMap::class.java.getDeclaredField("entries").apply { + isAccessible = true + }.get(this) as Hashtable + entries.forEach { + println(it) + } + + emptyMap() + }*/ + else -> emptyMap() + } + } + } + + fun onLoadData() { + logger.d("onLoadData()...") + + try { + rhino.invokeMethod( + editUiJsObject, + FUNC_ON_LOAD_DATA + ) + } catch (_: NoSuchMethodException) { + } + } + + fun onLoadUI(context: Context, container: LinearLayout) { + logger.d("onLoadUI()...") + + try { + rhino.invokeMethod( + editUiJsObject, + FUNC_ON_LOAD_UI, + context, + container + ) + } catch (_: NoSuchMethodException) { + } + } + + fun onVoiceChanged(locale: String, voice: String) { + logger.d("onVoiceChanged()...") + + rhino.invokeMethod( + editUiJsObject, + FUNC_ON_VOICE_CHANGED, + locale, + voice + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/ITextToSpeechSynthesizer.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/ITextToSpeechSynthesizer.kt new file mode 100644 index 000000000..8858fdff9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/ITextToSpeechSynthesizer.kt @@ -0,0 +1,125 @@ +package com.github.jing332.tts_server_android.model.speech + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import java.io.InputStream + +abstract class ITextToSpeechSynthesizer { + suspend fun retry( + times: Int = 3, + initialDelayMillis: Long = 200, + factor: Float = 2F, + maxDelayMillis: Long = 5000, + onCatch: suspend (times: Int, t: Throwable) -> Boolean, + block: suspend () -> T?, + ): T? { + var currentDelay = initialDelayMillis + for (i in 1..times) { + return try { + block() + } catch (t: Throwable) { + delay(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMillis) + if (onCatch.invoke(i, t)) continue + else null + } + } + return null + } + + open fun load() {} + open fun stop() {} + open fun destroy() {} + + open suspend fun handleText(text: String): List = emptyList() + + open suspend fun getAudio( + tts: ITextToSpeechEngine, + text: String, + sysRate: Int, + sysPitch: Int + ): AudioResult? = + null + + suspend fun synthesizeText( + tts: ITextToSpeechEngine, + text: String, + sysRate: Int, + sysPitch: Int, + onAudioAvailable: suspend (AudioData) -> Unit + ){ + getAudio(tts, text, sysRate, sysPitch)?.let { + onAudioAvailable.invoke(AudioData(txtTts = TtsTextSegment(tts, text), audio = it, done = {})) + } + } + + suspend fun synthesizeText( + text: String, + sysRate: Int, + sysPitch: Int, + onAudioAvailable: suspend (AudioData) -> Unit + ) { + val channel = Channel>(10) + coroutineScope { + launch(Dispatchers.IO) { + val textList = handleText(text) + textList.forEach { subTxtTts -> + val audioResult = getAudio(subTxtTts.tts, subTxtTts.text, sysRate, sysPitch) + val waitJob = launch { awaitCancellation() }.job + channel.send(AudioData(txtTts = subTxtTts, audio = audioResult, done = { + waitJob.cancel() + })) + waitJob.join() + } + channel.close() + } + + for (data in channel) { + onAudioAvailable.invoke(data) + } + } + } + + + data class AudioResult( + var inputStream: InputStream? = null, + var bytes: ByteArray? = null, + var data: Any? = null + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AudioResult + + if (inputStream != other.inputStream) return false + if (bytes != null) { + if (other.bytes == null) return false + if (!bytes.contentEquals(other.bytes)) return false + } else if (other.bytes != null) return false + if (data != other.data) return false + + return true + } + + override fun hashCode(): Int { + var result = inputStream?.hashCode() ?: 0 + result = 31 * result + (bytes?.contentHashCode() ?: 0) + result = 31 * result + (data?.hashCode() ?: 0) + return result + } + } + + data class AudioData( + val txtTts: TtsTextSegment, + val audio: AudioResult? = null, + val done: () -> Unit, + ) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/SynthesizerException.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/SynthesizerException.kt new file mode 100644 index 000000000..af6b1319c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/SynthesizerException.kt @@ -0,0 +1,4 @@ +package com.github.jing332.tts_server_android.model.speech + +class SynthesizerException : Exception() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/TtsTextSegment.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/TtsTextSegment.kt new file mode 100644 index 000000000..fc0054eb5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/TtsTextSegment.kt @@ -0,0 +1,5 @@ +package com.github.jing332.tts_server_android.model.speech + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +data class TtsTextSegment(val tts: ITextToSpeechEngine, val text: String) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/BaseAudioFormat.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/BaseAudioFormat.kt new file mode 100644 index 000000000..e5fb7a529 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/BaseAudioFormat.kt @@ -0,0 +1,21 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.media.AudioFormat +import android.os.Parcelable +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +open class BaseAudioFormat( + var sampleRate: Int = 16000, + var bitRate: Int = AudioFormat.ENCODING_PCM_16BIT, + var isNeedDecode: Boolean = true +) : Parcelable { + override fun toString(): String { + val str = if (isNeedDecode) " | " + app.getString(R.string.decode) else "" + return "${sampleRate}hz" + str + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/BgmTTS.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/BgmTTS.kt new file mode 100644 index 000000000..20ebecb1d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/BgmTTS.kt @@ -0,0 +1,49 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.app.Activity +import android.content.Context +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.utils.toHtmlBold +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.io.InputStream + +@Parcelize +@Serializable +@SerialName("bgm") +data class BgmTTS( + var musicList: MutableSet = mutableSetOf(), + + override var pitch: Int = 0, + override var volume: Int = 0, + override var rate: Int = 0, + override var audioFormat: BaseAudioFormat = BaseAudioFormat(), + override var audioPlayer: PlayerParams = PlayerParams(), + + @Transient + @IgnoredOnParcel + override var audioParams: AudioParams = AudioParams(), + @Transient + override var speechRule: SpeechRuleInfo = SpeechRuleInfo(), + + override var locale: String = "" +) : ITextToSpeechEngine() { + override fun getType() = "BGM" + + override fun getDescription(): String { + val volStr = if (volume == 0) context.getString(R.string.follow) else volume.toString() + return context.getString(R.string.systts_bgm_description, volStr.toHtmlBold()) + } + + override fun getBottomContent(): String = + context.getString(R.string.total_n_folders, musicList.size.toString()) + + override suspend fun getAudio(speakText: String, rate: Int, pitch: Int): InputStream? { + throw Exception("请在编辑界面中点击音乐路径进行测试播放") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/EdgeTtsWS.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/EdgeTtsWS.kt new file mode 100644 index 000000000..b3ac5fe7f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/EdgeTtsWS.kt @@ -0,0 +1,234 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.util.Log +import cn.hutool.core.lang.UUID +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.model.rhino.core.type.ws.internal.WebSocketException +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import java.io.InputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.TimeUnit + +class EdgeTtsWS : WebSocketListener() { + companion object { + const val TAG = "EdgeTtsWS" + + private const val wssUrl = + "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1?TrustedClientToken=6A5AA1D4EAFF4E9FB37E23D68491D6F4&ConnectionId=" + + private val simpleDateFormat by lazy { + SimpleDateFormat( + "EEE MMM dd yyyy HH:mm:ss", + Locale.getDefault() + ) + } + } + + private lateinit var ws: WebSocket + private var uuid: String = "" + private var waitJob: Job? = null + + var connectStatus: Status = Status.Closed + private set + + private fun connect(req: Request) { + connectStatus = Status.Connecting + val client = OkHttpClient.Builder().writeTimeout(5, TimeUnit.SECONDS).build() + ws = client.newWebSocket(req, this) + } + + private suspend fun connectSync(): Boolean = withIO { + val req = Request.Builder().url(wssUrl + uuid).apply { + header("Accept-Encoding", "gzip, deflate, br") + header("Origin", "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold") + header( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.66 Safari/537.36 Edg/103.0.1264.44" + ) + }.build() + + connect(req) + while (isActive) { + delay(50) + when (connectStatus) { + Status.Opened -> return@withIO true + is Status.Failure -> + (connectStatus as Status.Failure).apply { + throw WebSocketException(response).initCause(t) + } + + else -> {} + } + } + + return@withIO connectStatus != Status.Connecting + } + + private var outputStream: PipedOutputStream? = null + suspend fun getAudio( + text: String, + voice: String, + rate: Int, + volume: Int, + pitch: Int, + format: String + ): InputStream = getAudio(generateSSML(text, voice, rate, volume, pitch), format) + + suspend fun getAudio(ssml: String, format: String): InputStream = coroutineScope { + uuid = UUID.randomUUID().toString(true) + outputStream = PipedOutputStream() + + if (connectStatus != Status.Opened) connectSync() + + sendConfig(format) + sendSSML(ssml) + + waitJob = launch { awaitCancellation() }.job + waitJob?.join() // 等待响应: Path:turn.start + + if (outputStream == null) { + when (connectStatus) { + is Status.Failure -> + (connectStatus as Status.Failure).apply { + throw WebSocketException(response).initCause(t) + } + + is Status.Closing -> + (connectStatus as Status.Closing).apply { + throw Exception("WebSocket is closing: $code $reason") + } + + else -> { + throw Exception("outputStream is null") + } + } + } + + return@coroutineScope PipedInputStream(outputStream) + } + + /** + * 取消并关闭 Websocket 连接 + */ + fun cancelConnect() { + ws.cancel() + connectStatus = Status.Closed + } + + private val currentISOTime: String + get() = simpleDateFormat.format(System.currentTimeMillis()) + + private fun sendSSML(ssml: String) { + Log.d(TAG, "sendSSML: $ssml") + val msg = + "Path: ssml\r\nX-RequestId: $uuid\r\nX-Timestamp: $currentISOTime\r\nContent-Type: application/ssml+xml\r\n\r\n$ssml" + ws.send(msg) + } + + private fun sendConfig(format: String) { + Log.d(TAG, "sendConfig: $format") + val msg = + "X-Timestamp:$currentISOTime\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n" + + """{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"false"},"outputFormat":"$format"}}}}""".trimIndent() + + ws.send(msg) + } + + private fun generateSSML( + text: String, + voice: String, + rate: Int, + volume: Int, + pitch: Int + ): String { + return """ + + + + ${xmlEscape(text)} + + + + """.trimIndent() + } + + private fun xmlEscape(s: String): String { + return s.replace("'", "'").replace("\"", """).replace("<", "<") + .replace(">", ">").replace("&", "&").replace("/", "").replace("\\", "") + } + + override fun onOpen(webSocket: WebSocket, response: Response) { + connectStatus = Status.Opened + Log.d(TAG, "onOpen: $response") + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + super.onClosing(webSocket, code, reason) + Log.d(TAG, "onClosing: $code $reason") + + connectStatus = Status.Closing(code, reason) + outputStream?.close() + outputStream = null + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + connectStatus = Status.Closed + Log.d(TAG, "onClosed: $code $reason") + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.w(TAG, "onFailure: $response", t) + connectStatus = Status.Failure(t, response) + outputStream?.close() + outputStream = null + waitJob?.cancel() + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + Log.d(TAG, "onMessage: $bytes") + + val index = bytes.indexOf("Path:audio".toByteArray()) + val data = bytes.substring(index + 12); + + outputStream?.write(data.toByteArray()) + outputStream?.flush() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "onMessage: $text") + + if (text.contains("Path:turn.end")) { + Log.d(TAG, "turn.end") + outputStream?.close() + outputStream = null + } else if (text.contains("Path:turn.start")) { + waitJob?.cancel() + waitJob = null + } + + } + + sealed class Status { + data object Connecting : Status() + data object Opened : Status() + data object Closed : Status() + data class Closing(val code: Int, val reason: String) : Status() + data class Failure(val t: Throwable, val response: Response?) : Status() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/HttpTTS.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/HttpTTS.kt new file mode 100644 index 000000000..02223ddfa --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/HttpTTS.kt @@ -0,0 +1,109 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.app.Activity +import android.content.Context +import android.os.Parcelable +import android.os.SystemClock +import com.drake.net.Net +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.model.AnalyzeUrl +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import okhttp3.Headers.Companion.toHeaders +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.InputStream + +@Parcelize +@Serializable +@SerialName("http") +data class HttpTTS( + var url: String = "", + var header: String? = null, + + override var pitch: Int = 1, + override var volume: Int = 1, + override var rate: Int = 1, + + override var audioFormat: BaseAudioFormat = BaseAudioFormat(), + override var audioPlayer: PlayerParams = PlayerParams(), + override var audioParams: AudioParams = AudioParams(), + @Transient + override var speechRule: SpeechRuleInfo = SpeechRuleInfo(), + + @Transient + override var locale: String = "", + + ) : Parcelable, ITextToSpeechEngine() { + override fun isRateFollowSystem(): Boolean { + return VALUE_FOLLOW_SYSTEM == rate + } + + override fun isPitchFollowSystem(): Boolean { + return false + } + + + override fun getType(): String { + return app.getString(R.string.custom) + } + + override fun getBottomContent(): String { + return audioFormat.toString() + } + + @IgnoredOnParcel + private var requestId: String = "" + + override fun onStop() { + Net.cancelId(requestId) + } + + override fun onLoad() { + runCatching { parseHeaders() }.onFailure { throw Throwable("解析请求头失败:$it") } + } + + @IgnoredOnParcel + private var httpHeaders: MutableMap = mutableMapOf() + + private fun parseHeaders() { + if (!header.isNullOrEmpty()) { + httpHeaders = AppConst.jsonBuilder.decodeFromString(header.toString()) + } + } + + @Synchronized + fun getAudioResponse(speakText: String): Response { + requestId = "HTTP_TTS_${SystemClock.elapsedRealtime()}" + val a = + AnalyzeUrl(mUrl = url, speakText = speakText, speakSpeed = rate, speakVolume = volume) + val urlOption = a.eval() + return if (urlOption == null) { //GET + Net.get(a.baseUrl) { + setId(requestId) + setHeaders(httpHeaders.toHeaders()) + }.execute() + } else { + Net.post(a.baseUrl) { + setId(requestId) + setHeaders(httpHeaders.toHeaders()) + body = urlOption.body.toString().toRequestBody(null) + }.execute() + } + } + + override suspend fun getAudio(speakText: String, rate: Int, pitch: Int): InputStream? { + val resp = getAudioResponse(speakText) + return if (resp.isSuccessful) { + resp.body?.byteStream() + } else + throw Exception("HTTP TTS 请求失败:${resp.code}, ${resp.message}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/ITextToSpeechEngine.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/ITextToSpeechEngine.kt new file mode 100644 index 000000000..8a8f6ff99 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/ITextToSpeechEngine.kt @@ -0,0 +1,151 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.content.Context +import android.os.Parcelable +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.utils.toHtmlBold +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.JsonClassDiscriminator +import java.io.ByteArrayInputStream +import java.io.InputStream + +@OptIn(ExperimentalSerializationApi::class) +@Parcelize +@Serializable +@JsonClassDiscriminator("#type") +sealed class ITextToSpeechEngine( + @Transient + @IgnoredOnParcel + var context: Context = app, + + ) : Parcelable { + companion object { + const val VALUE_FOLLOW_SYSTEM = 0 + } + + abstract var pitch: Int + abstract var volume: Int + abstract val rate: Int + + abstract var locale: String + + abstract var speechRule: SpeechRuleInfo + abstract var audioFormat: BaseAudioFormat + abstract var audioPlayer: PlayerParams + abstract var audioParams: AudioParams + + + /** + * 语速是否跟随系统 + */ + open fun isRateFollowSystem(): Boolean = rate == VALUE_FOLLOW_SYSTEM + + /** + * 音高是否跟随系统 + */ + open fun isPitchFollowSystem(): Boolean = pitch == VALUE_FOLLOW_SYSTEM + + /** + * UI 右下角类型 + */ + abstract fun getType(): String + + /** + * UI 底部的格式 + */ + open fun getBottomContent(): String { + return audioFormat.toString() + } + + /** + * UI 显示名称下方的描述,如音量语速等 + */ + open fun getDescription(): String { + val followStr = context.getString(R.string.follow).toHtmlBold() + return context.getString( + R.string.systts_play_params_description, + if (isRateFollowSystem()) followStr else "$rate".toHtmlBold(), + "$volume".toHtmlBold(), + if (isPitchFollowSystem()) followStr else "$pitch".toHtmlBold() + ) + } + + open fun onLoad() {} + + open fun onStop() {} + + open fun onDestroy() {} + + /** + * 是否为 直接播放 + */ + open fun isDirectPlay(): Boolean = false + + protected open suspend fun startPlay(text: String, rate: Int = 0, pitch: Int = 0): Boolean = + false + + /** + * 播放音频 参数自动判断是否随系统 + */ + open suspend fun startPlayWithSystemParams( + text: String, + sysRate: Int = 0, + sysPitch: Int = 0 + ): Boolean { + val r = if (isRateFollowSystem()) sysRate else this.rate + val p = if (isPitchFollowSystem()) sysPitch else this.pitch + return startPlay(text, r, p) + } + + open suspend fun getAudio( + speakText: String, + rate: Int = 50, + pitch: Int = 0 + ): InputStream? = null + + suspend fun getAudioWithSystemParams( + text: String, + sysRate: Int = 50, + sysPitch: Int = 0 + ): InputStream? { + + val r = if (isRateFollowSystem()) sysRate else this.rate + val p = if (isPitchFollowSystem()) sysPitch else this.pitch + return getAudio(text, r, p) + } + +// open suspend fun getAudioStream(text: String, rate: Int, pitch: Int): InputStream? = null +// +// suspend fun getAudioStreamSysParams( +// text: String, +// sysRate: Int = 50, +// sysPitch: Int = 0 +// ): InputStream? { +// val r = if (isRateFollowSystem()) sysRate else this.rate +// val p = if (isPitchFollowSystem()) sysPitch else this.pitch +// return getAudioStream(text, r, p) +// } + + /** + * 获取PCM音频流 + */ + @Deprecated("暂时不用") + open suspend fun getAudioStream( + speakText: String, + chunkSize: Int = 0, + onData: (ByteArray?) -> Unit + ) { + } + + + protected fun ByteArray?.toStream(): ByteArrayInputStream? { + return if (this == null) null else ByteArrayInputStream(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/LocalTTS.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/LocalTTS.kt new file mode 100644 index 000000000..84f8ae800 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/LocalTTS.kt @@ -0,0 +1,250 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.os.Bundle +import android.os.Parcelable +import android.os.SystemClock +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.utils.toHtmlBold +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import java.util.Locale + +@Parcelize +@Serializable +@SerialName("local") +data class LocalTTS( + var engine: String? = null, + override var locale: String = "", + var voiceName: String? = null, + + var extraParams: MutableList? = null, + + var isDirectPlayMode: Boolean = true, + + override var pitch: Int = 0, + override var volume: Int = 0, + override var rate: Int = 0, + + override var audioPlayer: PlayerParams = PlayerParams(), + override var audioFormat: BaseAudioFormat = BaseAudioFormat(isNeedDecode = true), + override var audioParams: AudioParams = AudioParams(), + @Transient + override var speechRule: SpeechRuleInfo = SpeechRuleInfo(), +) : Parcelable, ITextToSpeechEngine() { + companion object { + private const val TAG = "LocalTTS" + private const val STOP_MESSAGE = "STOP" + private const val STATUS_INITIALIZING = -2 + + private val saveDir by lazy { + App.context.cacheDir.absolutePath + "/local_tts_audio" + } + + init { + kotlin.runCatching { + File(saveDir).deleteRecursively() + } + } + } + + init { + audioFormat.isNeedDecode = true + } + + override fun getType() = App.context.getString(R.string.local) + + override fun getBottomContent() = audioFormat.toString() + + override fun getDescription(): String { + val rateStr = if (isRateFollowSystem()) App.context.getString(R.string.follow) else rate + val pitchStr = + if (isPitchFollowSystem()) App.context.getString(R.string.follow) else pitch / 100f + return "${voiceName ?: App.context.getString(R.string.default_str)}
" + App.context.getString( + R.string.systts_play_params_description, + "$rateStr".toHtmlBold(), + "0", + "$pitchStr".toHtmlBold() + ) + } + + @IgnoredOnParcel + @Transient + private var mTtsEngine: TextToSpeech? = null + + @IgnoredOnParcel + @Transient + private var engineInitStatus: Int = STATUS_INITIALIZING + + @IgnoredOnParcel + @Transient + private var waitJob: Job? = null + + @IgnoredOnParcel + private var isInitialized = false + + private fun initEngineIf() { + if (isInitialized) return + Log.i(TAG, "onLoad") + + mTtsEngine?.shutdown() + mTtsEngine = null + engineInitStatus = STATUS_INITIALIZING + mTtsEngine = TextToSpeech(App.context, { + engineInitStatus = it + if (it == TextToSpeech.SUCCESS) { + mTtsEngine!!.setOnUtteranceProgressListener(object : + UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { + engineListener?.onStart() + } + + override fun onDone(utteranceId: String?) { + engineListener?.onDone() + waitJob?.cancel("onDone") + } + + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + } + + }) + } + }, engine) + isInitialized = true + } + + @IgnoredOnParcel + @Transient + var engineListener: EngineProgressListener? = null + + interface EngineProgressListener { + fun onStart() + fun onDone() + } + + override fun onLoad() {} + + override fun onStop() { + Log.i(TAG, "onStop") + mTtsEngine?.stop() + waitJob?.cancel(STOP_MESSAGE) + } + + override fun onDestroy() { + Log.i(TAG, "onDestroy") + mTtsEngine?.shutdown() + mTtsEngine = null + isInitialized = false + } + + // return 是否成功 + private suspend fun checkInitAndWait(): Boolean { + for (i in 1..100) { //5s + if (mTtsEngine != null && engineInitStatus == TextToSpeech.SUCCESS) + break + else if (i == 100) + return false + delay(50) + } + return true + } + + private fun setEnginePlayParams(engine: TextToSpeech, rate: Int, pitch: Int): Bundle { + engine.apply { + locale.let { language = Locale.forLanguageTag(it) } + voiceName?.let { selectedVoice -> + voices?.toList()?.find { it.name == selectedVoice }?.let { + Log.d(TAG, "setVoice: ${it.name}") + voice = it + } + } + + val r = rate / 10f - 5f // r = -5 ~ +5 + val p = if (pitch <= 0) 1f else pitch / 100f // normal = 1.0 + Log.d(TAG, "setSpeechRate: $r, setPitch: $p") + setSpeechRate(r) + setPitch(p) + return Bundle().apply { + extraParams?.forEach { it.putValueFromBundle(this) } + } + } + } + + override suspend fun startPlay(text: String, rate: Int, pitch: Int): Boolean = coroutineScope { + initEngineIf() + if (!checkInitAndWait()) return@coroutineScope false + + waitJob = launch { + mTtsEngine?.apply { + speak( + text, TextToSpeech.QUEUE_FLUSH, setEnginePlayParams(this, rate, pitch), + "" + ) + } + awaitCancellation() + }.job + waitJob?.start() + return@coroutineScope true + } + + fun getAudioFile(text: String, rate: Int, pitch: Int = 0): File { + initEngineIf() + val currentJobId = SystemClock.elapsedRealtime().toString() + + File(saveDir).apply { if (!exists()) mkdirs() } + val file = File("$saveDir/$engine.wav") + runBlocking { + if (!checkInitAndWait()) throw Exception("Engine initialize failed") + + waitJob = launch { + mTtsEngine?.apply { + synthesizeToFile( + text, + setEnginePlayParams(this, rate, pitch), + file, + currentJobId + ) + // 等待完毕 + try { + awaitCancellation() + } catch (e: CancellationException) { + if (e.message == STOP_MESSAGE) // 用户暂停或超时后 删除文件 + kotlin.runCatching { file.delete() } + } + } + }.job + waitJob?.start() + } + return file + } + + + override suspend fun getAudio(speakText: String, rate: Int, pitch: Int): InputStream { + return ByteArrayInputStream( + getAudioFile(speakText, rate, pitch).run { if (exists()) readBytes() else null } + ) + } + + override fun isDirectPlay() = isDirectPlayMode +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/LocalTtsParameter.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/LocalTtsParameter.kt new file mode 100644 index 000000000..30117a865 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/LocalTtsParameter.kt @@ -0,0 +1,28 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.os.Bundle +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +data class LocalTtsParameter(var type: String, var key: String, var value: String) : Parcelable { + companion object { + val typeList = listOf( + "Boolean", + "Int", + "Float", + "String" + ) + } + + fun putValueFromBundle(b: Bundle) { + when (type) { + "Boolean" -> b.putBoolean(key, value.toBoolean()) + "Int" -> b.putInt(key, value.toInt()) + "Float" -> b.putFloat(key, value.toFloat()) + else -> b.putString(key, value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTTS.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTTS.kt new file mode 100644 index 000000000..d61f0b347 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTTS.kt @@ -0,0 +1,199 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.os.Parcelable +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.app +import com.github.jing332.tts_server_android.conf.SystemTtsConfig +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.CnLocalMap +import com.github.jing332.tts_server_android.constant.MsTtsApiType +import com.github.jing332.tts_server_android.constant.MsTtsApiType.Companion.EDGE_OKHTTP +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.model.SysTtsLib +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.io.ByteArrayInputStream +import java.io.InputStream + +@Parcelize +@Serializable +@SerialName("internal") +data class MsTTS( + @MsTtsApiType var api: Int = MsTtsApiType.EDGE, + var format: String = MsTtsAudioFormat.DEFAULT, + override var locale: String = DEFAULT_LOCALE, + // 二级语言(语言技能)仅限en-US-JennyMultilingualNeural + var secondaryLocale: String? = null, + var voiceName: String = DEFAULT_VOICE, + var voiceId: String? = null, + var prosody: Prosody = Prosody(), + var expressAs: ExpressAs? = null, + + override var audioPlayer: PlayerParams = PlayerParams(), + override var audioParams: AudioParams = AudioParams(), + @Transient + override var audioFormat: BaseAudioFormat = MsTtsFormatManger.getFormatOrDefault(format), + @Transient + override var speechRule: SpeechRuleInfo = SpeechRuleInfo(), +) : Parcelable, ITextToSpeechEngine() { + companion object { + const val RATE_FOLLOW_SYSTEM = -100 + const val PITCH_FOLLOW_SYSTEM = -50 + + const val DEFAULT_LOCALE = "zh-CN" + const val DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" + } + + @IgnoredOnParcel + override var pitch: Int + get() { + return prosody.pitch + } + set(value) { + prosody.pitch = value + } + + @IgnoredOnParcel + override var volume: Int + get() { + return prosody.volume + } + set(value) { + prosody.volume = value + } + + @IgnoredOnParcel + override var rate: Int + get() = prosody.rate + set(value) { + prosody.rate = value + } + + override fun isRateFollowSystem(): Boolean { + return RATE_FOLLOW_SYSTEM == rate + } + + override fun isPitchFollowSystem(): Boolean { + return PITCH_FOLLOW_SYSTEM == pitch + } + + override fun getDescription(): String { + val strFollow by lazy { app.getString(R.string.follow) } + val strNone by lazy { app.getString(R.string.none) } + + val rateStr = if (isRateFollowSystem()) strFollow else rate + val pitchStr = if (isPitchFollowSystem()) strFollow else pitch + + var style = strNone + val styleDegree = expressAs?.styleDegree ?: 1F + var role = strNone + expressAs?.also { exp -> + exp.style?.let { + style = if (AppConst.isCnLocale) CnLocalMap.getStyleAndRole(it) else it + } + exp.role?.let { role = if (AppConst.isCnLocale) CnLocalMap.getStyleAndRole(it) else it } + } + + val expressAs = + if (api == MsTtsApiType.EDGE) "" + else App.context.getString( + R.string.systts_ms_express_as_description, + "${style}", "${role}", "${styleDegree}" + ) + "
" + + return expressAs + App.context.getString( + R.string.systts_play_params_description, + "${rateStr}", + "${volume}", + "${pitchStr}" + ) + } + + @IgnoredOnParcel + private var lastLoadTime: Long = 0 + + override fun onLoad() { + // 500ms 内只可加载一次 + val currentTime = System.currentTimeMillis() + if (currentTime - lastLoadTime > 500) { + SysTtsLib.setUseDnsLookup(true) + SysTtsLib.setTimeout(SystemTtsConfig.requestTimeout.value) + lastLoadTime = System.currentTimeMillis() + } + } + + override fun getType(): String { + return MsTtsApiType.toString(api) + } + + override fun getBottomContent(): String { + return audioFormat.toString() + } + + override fun toString(): String { + var s = + "api=${MsTtsApiType.toString(api)}, format=${format}, voiceName=${voiceName}, prosody=${prosody}, expressAs=${expressAs}" + secondaryLocale?.let { s += ", secondaryLocale=$it" } + return s + } + + override fun onStop() { + if (api == EDGE_OKHTTP) tts.cancelConnect() + } + + @IgnoredOnParcel + private val tts by lazy { EdgeTtsWS() } + + override suspend fun getAudio(speakText: String, rate: Int, pitch: Int): InputStream { + return if (api == EDGE_OKHTTP) { + tts.getAudio( + speakText, + voiceName, + rate, + volume, + pitch, + format.ifBlank { "audio-24khz-48kbitrate-mono-mp3" }) + } else + ByteArrayInputStream( + SysTtsLib.getAudio( + speakText, + this.copy(prosody = prosody.copy(rate = rate, pitch = pitch)), + format + ) + ) + } + +// override suspend fun getAudioStream( +// speakText: String, +// chunkSize: Int, +// onData: (ByteArray?) -> Unit +// ) { +// SysTtsLib.getAudioStream(speakText, this@MsTTS) { +// onData(it) +// } +// } +} + +@Serializable +@Parcelize +data class ExpressAs( + var style: String? = null, + var styleDegree: Float = 1F, + var role: String? = null +) : Parcelable { + constructor() : this("", 1F, "") +} + +/* Prosody 基本数值参数 单位: %百分比 */ +@Serializable +@Parcelize +data class Prosody( + var rate: Int = MsTTS.RATE_FOLLOW_SYSTEM, + var volume: Int = 0, + var pitch: Int = MsTTS.PITCH_FOLLOW_SYSTEM +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTtsAudioFormat.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTtsAudioFormat.kt new file mode 100644 index 000000000..ddbeec29d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTtsAudioFormat.kt @@ -0,0 +1,48 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import androidx.annotation.IntDef +import com.github.jing332.tts_server_android.constant.MsTtsApiType + +data class MsTtsAudioFormat( + val name: String, + val value: String, + @SupportedApi val supportedApi: Int = 0, +) : BaseAudioFormat() { + companion object { + const val DEFAULT = "audio-24khz-48kbitrate-mono-mp3" + } + + constructor( + value: String, + sampleRate: Int, + bitRate: Int, + @SupportedApi supportedApi: Int, + isNeedDecode: Boolean = true + ) : this(value, value, supportedApi) { + this.bitRate = bitRate + this.sampleRate = sampleRate + this.isNeedDecode = isNeedDecode + } + + override fun toString(): String { + return value + } + + @IntDef(flag = true, value = [SupportedApi.AZURE, SupportedApi.EDGE, SupportedApi.CREATION]) + @Retention(AnnotationRetention.SOURCE) + annotation class SupportedApi { + companion object { + const val EDGE: Int = 1 + const val AZURE = 1 shl 1 + const val CREATION = 1 shl 2 + + fun fromApiType(@MsTtsApiType api: Int): Int { + return when (api) { + MsTtsApiType.EDGE -> EDGE + MsTtsApiType.AZURE -> AZURE + else -> CREATION + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTtsFormatManger.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTtsFormatManger.kt new file mode 100644 index 000000000..a90ce4746 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/MsTtsFormatManger.kt @@ -0,0 +1,143 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.media.AudioFormat +import com.github.jing332.tts_server_android.constant.MsTtsApiType + +object MsTtsFormatManger { + private val formats = arrayListOf( + /* MsTtsAudioFormat( + "raw-8khz-16bit-mono-pcm", + 8000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + false + ), + MsTtsAudioFormat( + "raw-16khz-16bit-mono-pcm", + 16000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + false + ), + MsTtsAudioFormat( + "raw-24khz-16bit-mono-pcm", + 24000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + false + ), + MsTtsAudioFormat( + "raw-48khz-16bit-mono-pcm", + 48000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + false + ),*/ + MsTtsAudioFormat( + "webm-16khz-16bit-mono-opus", + 24000 * 2, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + true + ), + MsTtsAudioFormat( + "webm-24khz-16bit-mono-opus", + 24000 * 2, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.EDGE or MsTtsAudioFormat.SupportedApi.AZURE, + true + ), + MsTtsAudioFormat( + "webm-24khz-16bit-24kbps-mono-opus", + 24000 * 2, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + true + ), + MsTtsAudioFormat( + "audio-16khz-32kbitrate-mono-mp3", + 16000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE or MsTtsAudioFormat.SupportedApi.CREATION, + true + ), + MsTtsAudioFormat( + "audio-24khz-48kbitrate-mono-mp3", + 24000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.EDGE or MsTtsAudioFormat.SupportedApi.AZURE or MsTtsAudioFormat.SupportedApi.CREATION, + true + ), + MsTtsAudioFormat( + "audio-24khz-96kbitrate-mono-mp3", + 24000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.EDGE or MsTtsAudioFormat.SupportedApi.AZURE or MsTtsAudioFormat.SupportedApi.CREATION, + true + ), + MsTtsAudioFormat( + "audio-48khz-96kbitrate-mono-mp3", + 48000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE or MsTtsAudioFormat.SupportedApi.CREATION, + true + ), + + MsTtsAudioFormat( + "ogg-16khz-16bit-mono-opus", + 16000 * 2, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + true + ), + MsTtsAudioFormat( + "ogg-24khz-16bit-mono-opus", + 24000 * 2, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + true + ), + MsTtsAudioFormat( + "ogg-48khz-16bit-mono-opus", + 48000, + AudioFormat.ENCODING_PCM_16BIT, + MsTtsAudioFormat.SupportedApi.AZURE, + true + ) + ) + + fun getDefault(): MsTtsAudioFormat { + return formats[4] //audio-24khz-48kbitrate-mono-mp3 + } + + /* 通过name查找格式Item */ + fun getFormat(name: String): MsTtsAudioFormat? { + formats.forEach { v -> + if (v.name == name) { + return v + } + } + return null + } + + fun getFormatOrDefault(name: String?): MsTtsAudioFormat { + if (name == null) return getDefault() + var f = getFormat(name) + if (f == null) f = getDefault() + return f + } + + fun getFormatsBySupportedApi(@MsTtsAudioFormat.SupportedApi api: Int): ArrayList { + val list = arrayListOf() + formats.forEach { + if (api and it.supportedApi != 0) + list.add(it.value) + } + + return list + } + + fun getFormatsByApiType(@MsTtsApiType api: Int): List { + return getFormatsBySupportedApi(MsTtsAudioFormat.SupportedApi.fromApiType(api)) + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/PlayerParams.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/PlayerParams.kt new file mode 100644 index 000000000..1dac3c6b9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/PlayerParams.kt @@ -0,0 +1,25 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +data class PlayerParams( + var rate: Float = VALUE_FOLLOW_GLOBAL, + var pitch: Float = VALUE_FOLLOW_GLOBAL, + var volume: Float = VALUE_FOLLOW_GLOBAL, +) : Parcelable { + companion object { + const val VALUE_FOLLOW_GLOBAL = 0f + } + + fun setParamsIfFollow(gRate: Float, gVolume: Float, gPitch: Float): PlayerParams { + if (this.rate == VALUE_FOLLOW_GLOBAL) this.rate = gRate + if (this.volume == VALUE_FOLLOW_GLOBAL) this.volume = gVolume + if (this.pitch == VALUE_FOLLOW_GLOBAL) this.pitch = gPitch + + return this + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/PluginTTS.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/PluginTTS.kt new file mode 100644 index 000000000..132c3bf1c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/PluginTTS.kt @@ -0,0 +1,103 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import androidx.annotation.Keep +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.plugin.Plugin +import com.github.jing332.tts_server_android.data.entities.systts.AudioParams +import com.github.jing332.tts_server_android.data.entities.systts.SpeechRuleInfo +import com.github.jing332.tts_server_android.model.rhino.tts.EngineContext +import com.github.jing332.tts_server_android.model.rhino.tts.TtsPluginEngine +import com.script.javascript.RhinoScriptEngine +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Transient +import java.io.InputStream + +@Keep +@Parcelize +@kotlinx.serialization.Serializable +@SerialName("plugin") +data class PluginTTS( + val pluginId: String = "", + override var locale: String = "", + var voice: String = "", + // 插件附加数据 + var data: MutableMap = mutableMapOf(), + + override var pitch: Int = 50, + override var volume: Int = 50, + override var rate: Int = 50, + + override var audioFormat: BaseAudioFormat = BaseAudioFormat(), + override var audioPlayer: PlayerParams = PlayerParams(), + override var audioParams: AudioParams = AudioParams(), + @Transient + override var speechRule: SpeechRuleInfo = SpeechRuleInfo(), + @Transient + var plugin: Plugin? = null, +) : ITextToSpeechEngine() { + init { + if (pluginId.isNotEmpty()) + plugin = appDb.pluginDao.getByPluginId(pluginId) + } + + val requirePlugin: Plugin + get() { + plugin?.let { return it } + throw Exception(context.getString(R.string.not_found_plugin, pluginId)) + } + + override fun getDescription(): String { + return "$voice
${super.getDescription()}" + } + + override fun getType(): String { + return try { + requirePlugin.name + } catch (e: Exception) { + e.message ?: e.cause?.message + }.toString() + } + + @IgnoredOnParcel + @Transient + var pluginEngine: TtsPluginEngine? = null + + companion object { + // 复用 + private val engineMap = mutableMapOf() + } + + override fun onLoad() { + if (engineMap.containsKey(pluginId)) { + } else { + engineMap[pluginId] = RhinoScriptEngine() + } + pluginEngine = pluginEngine ?: TtsPluginEngine( + pluginTTS = this, + context = context, + rhino = engineMap[pluginId]!! + ) + + pluginEngine?.onLoad() + } + + override fun onStop() { + pluginEngine?.onStop() + } + + override suspend fun getAudio(speakText: String, rate: Int, pitch: Int): InputStream? { + synchronized(engineMap) { + // 重新更新 ttsrv.tts 对象 + val ctx = (pluginEngine?.ttsrvObject as EngineContext) + ctx.tts = this + pluginEngine?.putDefaultObjects() + + return pluginEngine?.getAudio( + speakText, rate, pitch + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/TtsInfo.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/TtsInfo.kt new file mode 100644 index 000000000..581b9743e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/speech/tts/TtsInfo.kt @@ -0,0 +1,17 @@ +package com.github.jing332.tts_server_android.model.speech.tts + +import android.os.Parcelable +import com.github.jing332.tts_server_android.constant.SpeechTarget +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +data class TtsInfo( + @SpeechTarget var target: Int = SpeechTarget.ALL, + var standbyTts: ITextToSpeechEngine? = null, + + var tag: String = "" +) : + Parcelable { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/updater/AppUpdateChecker.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/AppUpdateChecker.kt new file mode 100644 index 000000000..8dd07b10b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/AppUpdateChecker.kt @@ -0,0 +1,84 @@ +package com.github.jing332.tts_server_android.model.updater + +import android.os.Build +import android.util.Log +import com.github.jing332.tts_server_android.BuildConfig +import com.github.jing332.tts_server_android.utils.toNumberInt +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + + +object AppUpdateChecker { + const val TAG = "AppUpdateChecker" + + fun checkUpdate(): UpdateResult { + val rel = Github.getLatestRelease() + + val latest = rel.tagName.toNumberInt() + val current = BuildConfig.VERSION_NAME.toNumberInt() + Log.i(TAG, "checkUpdate: current=$current, latest=$latest") + + if (current < latest) { + val ass = getApkDownloadUrl(rel.assets) + return UpdateResult( + version = rel.tagName, + content = rel.body, + downloadUrl = ass.browserDownloadUrl + ).apply { + Log.i(TAG, "checkUpdate: hasUpdate=${this.downloadUrl}") + } + } + + return UpdateResult() + } + + private fun getApkDownloadUrl(assets: List): Github.Release.Asset { + val abi = Build.SUPPORTED_ABIS[0] + + // 最大的为全量apk + val apkUniversal = assets.sortedByDescending { it.size }[0] + // 根据CPU ABI判断精简版apk + val liteApk = + assets.find { it.name.endsWith("${abi}.apk") } + + return liteApk ?: apkUniversal + } + + private fun toTimestamp(str: String): Long { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + val dateTime = LocalDateTime.parse(str, formatter) + + // 将LocalDateTime转换为时间戳(秒) + val instant = dateTime.toInstant(ZoneOffset.UTC) + return instant.epochSecond + } + + + fun checkUpdateFromActions(path: String = ".github/workflows/test.yml"): ActionResult? { + val workflowRuns = Github.getActions() + val run = workflowRuns.workflowRuns.find { it.path == path } + + if (run != null && run.status == "completed" && run.conclusion == "success") { + val actionTs = toTimestamp(run.createdAt) + Log.i( + TAG, + "checkUpdateFromActions: actionTs=$actionTs, buildTs=${BuildConfig.BUILD_TIME}" + ) + if (actionTs <= BuildConfig.BUILD_TIME) return null + return ActionResult( + url = run.htmlUrl, + title = run.displayTitle, + time = actionTs + ) + } + + return null + } + + data class ActionResult( + val url: String, + val title: String, + val time: Long, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/updater/Github.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/Github.kt new file mode 100644 index 000000000..6943248c8 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/Github.kt @@ -0,0 +1,177 @@ +package com.github.jing332.tts_server_android.model.updater + +import com.drake.net.Net +import com.github.jing332.tts_server_android.constant.AppConst +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +object Github { + private const val repo = "jing332/tts-server-android" + + fun getActions(repo: String = this.repo): WorkflowRuns { + val str = Net.get("https://api.github.com/repos/${repo}/actions/runs") { + }.execute() + + return AppConst.jsonBuilder.decodeFromString(str) + } + + fun getLatestRelease(repo: String = this.repo): Release { + val str = Net.get("https://api.github.com/repos/$repo/releases/latest") { + }.execute() + + return AppConst.jsonBuilder.decodeFromString(str) + } + + + @Serializable + data class Release( + @SerialName("assets") + val assets: List = listOf(), +// @SerialName("assets_url") +// val assetsUrl: String = "", // https://api.github.com/repos/jing332/frpandroid/releases/117392218/assets +// @SerialName("author") +// val author: Author = Author(), +// @SerialName("body") + val body: String = "", // > 未知CPU架构?请优先选择体积最大的APK### 更新内容:- 系统通知内容中支持显示局域网IP +// @SerialName("created_at") +// val createdAt: String = "", // 2023-08-16T03:02:15Z +// @SerialName("draft") +// val draft: Boolean = false, // false +// @SerialName("html_url") +// val htmlUrl: String = "", // https://github.com/jing332/frpandroid/releases/tag/1.23.081611 +// @SerialName("id") +// val id: Int = 0, // 117392218 +// @SerialName("name") +// val name: String = "", // 1.23.081611 +// @SerialName("node_id") +// val nodeId: String = "", // RE_kwDOKGSbbc4G_0Na +// @SerialName("prerelease") +// val prerelease: Boolean = false, // false +// @SerialName("published_at") +// val publishedAt: String = "", // 2023-08-16T03:29:36Z + @SerialName("tag_name") + val tagName: String = "", // 1.23.081611 +// @SerialName("tarball_url") +// val tarballUrl: String = "", // https://api.github.com/repos/jing332/frpandroid/tarball/1.23.081611 +// @SerialName("target_commitish") +// val targetCommitish: String = "", // master +// @SerialName("upload_url") +// val uploadUrl: String = "", // https://uploads.github.com/repos/jing332/frpandroid/releases/117392218/assets{?name,label} +// @SerialName("url") +// val url: String = "", // https://api.github.com/repos/jing332/frpandroid/releases/117392218 +// @SerialName("zipball_url") +// val zipballUrl: String = "" // https://api.github.com/repos/jing332/frpandroid/zipball/1.23.081611 + ) { + @Serializable + data class Asset( + @SerialName("browser_download_url") + val browserDownloadUrl: String = "", // https://github.com/jing332/frpandroid/releases/download/1.23.081611/AList-v1.23.081611.apk +// @SerialName("content_type") +// val contentType: String = "", // application/vnd.android.package-archive +// @SerialName("created_at") +// val createdAt: String = "", // 2023-08-16T03:29:37Z +// @SerialName("download_count") +// val downloadCount: Int = 0, // 28 +// @SerialName("id") +// val id: Long = 0, // 121683040 +// @SerialName("label") +// val label: String = "", + @SerialName("name") + val name: String = "", // AList-v1.23.081611.apk +// @SerialName("node_id") +// val nodeId: String = "", // RA_kwDOKGSbbc4HQLxg + @SerialName("size") + val size: Long = 0, // 71948726 +// @SerialName("state") +// val state: String = "", // uploaded +// @SerialName("updated_at") +// val updatedAt: String = "", // 2023-08-16T03:29:39Z +// @SerialName("uploader") +// val uploader: Uploader = Uploader(), +// @SerialName("url") +// val url: String = "" // https://api.github.com/repos/jing332/frpandroid/releases/assets/121683040 + ) { + @Serializable + data class Uploader( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/in/15368?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/apps/github-actions + @SerialName("id") + val id: Int = 0, // 41898282 + @SerialName("login") + val login: String = "", // github-actions[bot] + @SerialName("node_id") + val nodeId: String = "", // MDM6Qm90NDE4OTgyODI= + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/subscriptions + @SerialName("type") + val type: String = "", // Bot + @SerialName("url") + val url: String = "" // https://api.github.com/users/github-actions%5Bbot%5D + ) + } + + @Serializable + data class Author( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/in/15368?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/apps/github-actions + @SerialName("id") + val id: Int = 0, // 41898282 + @SerialName("login") + val login: String = "", // github-actions[bot] + @SerialName("node_id") + val nodeId: String = "", // MDM6Qm90NDE4OTgyODI= + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/github-actions%5Bbot%5D/subscriptions + @SerialName("type") + val type: String = "", // Bot + @SerialName("url") + val url: String = "" // https://api.github.com/users/github-actions%5Bbot%5D + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/updater/UpdateResult.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/UpdateResult.kt new file mode 100644 index 000000000..4878aa613 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/UpdateResult.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.model.updater + +data class UpdateResult( + val version: String = "", + val time: String = "", + val content: String = "", + val downloadUrl: String = "", + val size: Long = 0, +) { + fun hasUpdate() = version.isNotBlank() && downloadUrl.isNotBlank() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/model/updater/WorkflowRuns.kt b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/WorkflowRuns.kt new file mode 100644 index 000000000..86744c803 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/model/updater/WorkflowRuns.kt @@ -0,0 +1,472 @@ +package com.github.jing332.tts_server_android.model.updater + + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WorkflowRuns( + @SerialName("total_count") + val totalCount: Int = 0, // 468 + @SerialName("workflow_runs") + val workflowRuns: List = listOf() +) { + @Serializable + data class WorkflowRun( +// @SerialName("actor") + val actor: Actor = Actor(), +// @SerialName("artifacts_url") +// val artifactsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7605412793/artifacts +// @SerialName("cancel_url") +// val cancelUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7605412793/cancel +// @SerialName("check_suite_id") +// val checkSuiteId: Long = 0, // 19988906797 +// @SerialName("check_suite_node_id") +// val checkSuiteNodeId: String = "", // CS_kwDOH_7t188AAAAEp26DLQ +// @SerialName("check_suite_url") +// val checkSuiteUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/check-suites/19988906797 + @SerialName("conclusion") + val conclusion: String? = "", // success + @SerialName("created_at") + val createdAt: String = "", // 2024-01-22T01:49:46Z + @SerialName("display_title") + val displayTitle: String = "", // refactor: 插件UI 滑动条 +// @SerialName("event") +// val event: String = "", // push +// @SerialName("head_branch") +// val headBranch: String = "", // compose +// @SerialName("head_commit") +// val headCommit: HeadCommit = HeadCommit(), +// @SerialName("head_repository") +// val headRepository: HeadRepository = HeadRepository(), +// @SerialName("head_sha") +// val headSha: String = "", // 3f23a7603a75b9218e61554fd5ec69fe6377abc4 + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332/tts-server-android/actions/runs/7605412793 +// @SerialName("id") +// val id: Long = 0, // 7605412793 +// @SerialName("jobs_url") +// val jobsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7605412793/jobs +// @SerialName("logs_url") +// val logsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7605412793/logs +// @SerialName("name") +// val name: String = "", // Build Test +// @SerialName("node_id") +// val nodeId: String = "", // WFR_kwLOH_7t188AAAABxVFjuQ + @SerialName("path") + val path: String = "", // .github/workflows/test.yml +// @SerialName("previous_attempt_url") +// val previousAttemptUrl: String? = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7583634607/attempts/1 +// @SerialName("pull_requests") +// val pullRequests: List = listOf(), +// @SerialName("referenced_workflows") +// val referencedWorkflows: List = listOf(), +// @SerialName("repository") +// val repository: Repository = Repository(), +// @SerialName("rerun_url") +// val rerunUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7605412793/rerun +// @SerialName("run_attempt") +// val runAttempt: Int = 0, // 1 +// @SerialName("run_number") +// val runNumber: Int = 0, // 603 +// @SerialName("run_started_at") +// val runStartedAt: String = "", // 2024-01-22T01:49:46Z + @SerialName("status") + val status: String = "", // in_progress +// @SerialName("triggering_actor") +// val triggeringActor: TriggeringActor = TriggeringActor(), + @SerialName("updated_at") + val updatedAt: String = "", // 2024-01-22T01:49:54Z +// @SerialName("url") +// val url: String = "", // https://api.github.com/repos/jing332/tts-server-android/actions/runs/7605412793 +// @SerialName("workflow_id") +// val workflowId: Int = 0, // 35098665 +// @SerialName("workflow_url") +// val workflowUrl: String = "" // https://api.github.com/repos/jing332/tts-server-android/actions/workflows/35098665 + ) { + @Serializable + data class Actor( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/u/42014615?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/jing332/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/jing332/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/jing332/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/jing332/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332 + @SerialName("id") + val id: Int = 0, // 42014615 + @SerialName("login") + val login: String = "", // jing332 + @SerialName("node_id") + val nodeId: String = "", // MDQ6VXNlcjQyMDE0NjE1 + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/jing332/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/jing332/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/jing332/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/jing332/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/jing332/subscriptions + @SerialName("type") + val type: String = "", // User + @SerialName("url") + val url: String = "" // https://api.github.com/users/jing332 + ) + + /* @Serializable + data class HeadCommit( + @SerialName("author") + val author: Author = Author(), + @SerialName("committer") + val committer: Committer = Committer(), + @SerialName("id") + val id: String = "", // 3f23a7603a75b9218e61554fd5ec69fe6377abc4 + @SerialName("message") + val message: String = "", // refactor: 插件UI 滑动条 + @SerialName("timestamp") + val timestamp: String = "", // 2024-01-22T01:49:33Z + @SerialName("tree_id") + val treeId: String = "" // 9029bfee62475a1a775e31862dce31cbe571da5d + ) { + @Serializable + data class Author( + @SerialName("email") + val email: String = "", // 42014615+jing332@users.noreply.github.com + @SerialName("name") + val name: String = "" // Jing + ) + + @Serializable + data class Committer( + @SerialName("email") + val email: String = "", // 42014615+jing332@users.noreply.github.com + @SerialName("name") + val name: String = "" // Jing + ) + } + + @Serializable + data class HeadRepository( + @SerialName("archive_url") + val archiveUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/{archive_format}{/ref} + @SerialName("assignees_url") + val assigneesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/assignees{/user} + @SerialName("blobs_url") + val blobsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/blobs{/sha} + @SerialName("branches_url") + val branchesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/branches{/branch} + @SerialName("collaborators_url") + val collaboratorsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/collaborators{/collaborator} + @SerialName("comments_url") + val commentsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/comments{/number} + @SerialName("commits_url") + val commitsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/commits{/sha} + @SerialName("compare_url") + val compareUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/compare/{base}...{head} + @SerialName("contents_url") + val contentsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/contents/{+path} + @SerialName("contributors_url") + val contributorsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/contributors + @SerialName("deployments_url") + val deploymentsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/deployments + @SerialName("description") + val description: String = "", // 这是一个Android系统TTS应用,内置微软演示接口,可自定义HTTP请求,可导入其他本地TTS引擎,以及根据中文双引号的简单旁白/对话识别朗读 ,还有自动重试,备用配置,文本替换等更多功能。 + @SerialName("downloads_url") + val downloadsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/downloads + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/events + @SerialName("fork") + val fork: Boolean = false, // false + @SerialName("forks_url") + val forksUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/forks + @SerialName("full_name") + val fullName: String = "", // jing332/tts-server-android + @SerialName("git_commits_url") + val gitCommitsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/commits{/sha} + @SerialName("git_refs_url") + val gitRefsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/refs{/sha} + @SerialName("git_tags_url") + val gitTagsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/tags{/sha} + @SerialName("hooks_url") + val hooksUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/hooks + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332/tts-server-android + @SerialName("id") + val id: Int = 0, // 536800727 + @SerialName("issue_comment_url") + val issueCommentUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/issues/comments{/number} + @SerialName("issue_events_url") + val issueEventsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/issues/events{/number} + @SerialName("issues_url") + val issuesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/issues{/number} + @SerialName("keys_url") + val keysUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/keys{/key_id} + @SerialName("labels_url") + val labelsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/labels{/name} + @SerialName("languages_url") + val languagesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/languages + @SerialName("merges_url") + val mergesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/merges + @SerialName("milestones_url") + val milestonesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/milestones{/number} + @SerialName("name") + val name: String = "", // tts-server-android + @SerialName("node_id") + val nodeId: String = "", // R_kgDOH_7t1w + @SerialName("notifications_url") + val notificationsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/notifications{?since,all,participating} + @SerialName("owner") + val owner: Owner = Owner(), + @SerialName("private") + val `private`: Boolean = false, // false + @SerialName("pulls_url") + val pullsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/pulls{/number} + @SerialName("releases_url") + val releasesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/releases{/id} + @SerialName("stargazers_url") + val stargazersUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/stargazers + @SerialName("statuses_url") + val statusesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/statuses/{sha} + @SerialName("subscribers_url") + val subscribersUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/subscribers + @SerialName("subscription_url") + val subscriptionUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/subscription + @SerialName("tags_url") + val tagsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/tags + @SerialName("teams_url") + val teamsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/teams + @SerialName("trees_url") + val treesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/trees{/sha} + @SerialName("url") + val url: String = "" // https://api.github.com/repos/jing332/tts-server-android + ) { + @Serializable + data class Owner( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/u/42014615?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/jing332/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/jing332/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/jing332/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/jing332/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332 + @SerialName("id") + val id: Int = 0, // 42014615 + @SerialName("login") + val login: String = "", // jing332 + @SerialName("node_id") + val nodeId: String = "", // MDQ6VXNlcjQyMDE0NjE1 + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/jing332/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/jing332/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/jing332/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/jing332/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/jing332/subscriptions + @SerialName("type") + val type: String = "", // User + @SerialName("url") + val url: String = "" // https://api.github.com/users/jing332 + ) + } + + @Serializable + data class Repository( + @SerialName("archive_url") + val archiveUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/{archive_format}{/ref} + @SerialName("assignees_url") + val assigneesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/assignees{/user} + @SerialName("blobs_url") + val blobsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/blobs{/sha} + @SerialName("branches_url") + val branchesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/branches{/branch} + @SerialName("collaborators_url") + val collaboratorsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/collaborators{/collaborator} + @SerialName("comments_url") + val commentsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/comments{/number} + @SerialName("commits_url") + val commitsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/commits{/sha} + @SerialName("compare_url") + val compareUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/compare/{base}...{head} + @SerialName("contents_url") + val contentsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/contents/{+path} + @SerialName("contributors_url") + val contributorsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/contributors + @SerialName("deployments_url") + val deploymentsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/deployments + @SerialName("description") + val description: String = "", // 这是一个Android系统TTS应用,内置微软演示接口,可自定义HTTP请求,可导入其他本地TTS引擎,以及根据中文双引号的简单旁白/对话识别朗读 ,还有自动重试,备用配置,文本替换等更多功能。 + @SerialName("downloads_url") + val downloadsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/downloads + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/events + @SerialName("fork") + val fork: Boolean = false, // false + @SerialName("forks_url") + val forksUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/forks + @SerialName("full_name") + val fullName: String = "", // jing332/tts-server-android + @SerialName("git_commits_url") + val gitCommitsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/commits{/sha} + @SerialName("git_refs_url") + val gitRefsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/refs{/sha} + @SerialName("git_tags_url") + val gitTagsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/tags{/sha} + @SerialName("hooks_url") + val hooksUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/hooks + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332/tts-server-android + @SerialName("id") + val id: Int = 0, // 536800727 + @SerialName("issue_comment_url") + val issueCommentUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/issues/comments{/number} + @SerialName("issue_events_url") + val issueEventsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/issues/events{/number} + @SerialName("issues_url") + val issuesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/issues{/number} + @SerialName("keys_url") + val keysUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/keys{/key_id} + @SerialName("labels_url") + val labelsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/labels{/name} + @SerialName("languages_url") + val languagesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/languages + @SerialName("merges_url") + val mergesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/merges + @SerialName("milestones_url") + val milestonesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/milestones{/number} + @SerialName("name") + val name: String = "", // tts-server-android + @SerialName("node_id") + val nodeId: String = "", // R_kgDOH_7t1w + @SerialName("notifications_url") + val notificationsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/notifications{?since,all,participating} + @SerialName("owner") + val owner: Owner = Owner(), + @SerialName("private") + val `private`: Boolean = false, // false + @SerialName("pulls_url") + val pullsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/pulls{/number} + @SerialName("releases_url") + val releasesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/releases{/id} + @SerialName("stargazers_url") + val stargazersUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/stargazers + @SerialName("statuses_url") + val statusesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/statuses/{sha} + @SerialName("subscribers_url") + val subscribersUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/subscribers + @SerialName("subscription_url") + val subscriptionUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/subscription + @SerialName("tags_url") + val tagsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/tags + @SerialName("teams_url") + val teamsUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/teams + @SerialName("trees_url") + val treesUrl: String = "", // https://api.github.com/repos/jing332/tts-server-android/git/trees{/sha} + @SerialName("url") + val url: String = "" // https://api.github.com/repos/jing332/tts-server-android + ) { + @Serializable + data class Owner( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/u/42014615?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/jing332/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/jing332/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/jing332/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/jing332/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332 + @SerialName("id") + val id: Int = 0, // 42014615 + @SerialName("login") + val login: String = "", // jing332 + @SerialName("node_id") + val nodeId: String = "", // MDQ6VXNlcjQyMDE0NjE1 + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/jing332/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/jing332/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/jing332/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/jing332/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/jing332/subscriptions + @SerialName("type") + val type: String = "", // User + @SerialName("url") + val url: String = "" // https://api.github.com/users/jing332 + ) + } + + @Serializable + data class TriggeringActor( + @SerialName("avatar_url") + val avatarUrl: String = "", // https://avatars.githubusercontent.com/u/42014615?v=4 + @SerialName("events_url") + val eventsUrl: String = "", // https://api.github.com/users/jing332/events{/privacy} + @SerialName("followers_url") + val followersUrl: String = "", // https://api.github.com/users/jing332/followers + @SerialName("following_url") + val followingUrl: String = "", // https://api.github.com/users/jing332/following{/other_user} + @SerialName("gists_url") + val gistsUrl: String = "", // https://api.github.com/users/jing332/gists{/gist_id} + @SerialName("gravatar_id") + val gravatarId: String = "", + @SerialName("html_url") + val htmlUrl: String = "", // https://github.com/jing332 + @SerialName("id") + val id: Int = 0, // 42014615 + @SerialName("login") + val login: String = "", // jing332 + @SerialName("node_id") + val nodeId: String = "", // MDQ6VXNlcjQyMDE0NjE1 + @SerialName("organizations_url") + val organizationsUrl: String = "", // https://api.github.com/users/jing332/orgs + @SerialName("received_events_url") + val receivedEventsUrl: String = "", // https://api.github.com/users/jing332/received_events + @SerialName("repos_url") + val reposUrl: String = "", // https://api.github.com/users/jing332/repos + @SerialName("site_admin") + val siteAdmin: Boolean = false, // false + @SerialName("starred_url") + val starredUrl: String = "", // https://api.github.com/users/jing332/starred{/owner}{/repo} + @SerialName("subscriptions_url") + val subscriptionsUrl: String = "", // https://api.github.com/users/jing332/subscriptions + @SerialName("type") + val type: String = "", // User + @SerialName("url") + val url: String = "" // https://api.github.com/users/jing332 + ) + */ + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/TtsIntentService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/TtsIntentService.kt deleted file mode 100644 index 435221715..000000000 --- a/app/src/main/java/com/github/jing332/tts_server_android/service/TtsIntentService.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.github.jing332.tts_server_android.service - -import android.annotation.SuppressLint -import android.app.* -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.graphics.Color -import android.os.Build -import android.os.PowerManager -import android.util.Log -import android.widget.Toast -import com.github.jing332.tts_server_android.GoLog -import com.github.jing332.tts_server_android.R -import com.github.jing332.tts_server_android.ui.MainActivity -import tts_server_lib.LogCallback -import tts_server_lib.Tts_server_lib - - -class TtsIntentService(name: String = "TtsIntentService") : IntentService(name) { - companion object { - const val TAG = "TtsIntentService" - var ACTION_SEND = "service.send_log" /* 广播ID */ - const val ACTION_ON_LOG = "service.on_log" - const val ACTION_ON_CLOSED = "service.on_closed" - const val ACTION_ON_STARTED = "service.on_started" - - private var isWakeLock = false /* 是否使用唤醒锁 */ - var IsRunning = false /* 服务是否在运行 */ - var Isinited = false /* 已经初始化GoLib */ - var port: Int = 1233 /* 监听端口 */ - - /*关闭服务,如有Http请求需要等待*/ - fun closeServer(context: Context): Boolean { - val err = Tts_server_lib.closeServer()/* 5s */ - if (err.isNotEmpty()) { - Toast.makeText(context, "关闭失败:$err", Toast.LENGTH_SHORT).show() - return false - } - return true - } - } - - private lateinit var mWakeLock: PowerManager.WakeLock /* 唤醒锁 */ - - @Deprecated("Deprecated in Java") - @SuppressLint("WakelockTimeout") - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - IsRunning = true - port = intent?.getIntExtra("port", 1233)!! - isWakeLock = intent.getBooleanExtra("isWakeLock", false) - - val notification: Notification - /*Android 12(S)+ 必须指定PendingIntent.FLAG_*/ - val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_IMMUTABLE - } else { - 0 - } - /*点击通知跳转*/ - val pendingIntent = - PendingIntent.getActivity( - this, 0, Intent( - this, - MainActivity::class.java - ), pendingIntentFlags - ) - - /*当点击退出按钮时发送广播*/ - val quitIntent = Intent(this, Receiver::class.java).apply { action = "quit_action" } - val closePendingIntent: PendingIntent = - PendingIntent.getBroadcast(this, 0, quitIntent, pendingIntentFlags) - - val chanId = "server_status" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {/*Android 8.0+ 要求必须设置通知信道*/ - val chan = NotificationChannel(chanId, "前台服务", NotificationManager.IMPORTANCE_NONE) - chan.lightColor = Color.BLUE - chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE - val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - service.createNotificationChannel(chan) - - val builder = Notification.Builder(applicationContext, chanId) - notification = - builder - .setContentTitle("TTS Server正在运行中...") - .setContentText("监听地址: localhost:$port") - .setSmallIcon(R.drawable.ic_app_notification) - .setContentIntent(pendingIntent) - .addAction(R.mipmap.ic_app_notification, "退出", closePendingIntent) - .build() - - } else { /*SDK < Android 8*/ - val action = Notification.Action(0, "退出", closePendingIntent) - val builder = Notification.Builder(applicationContext) - notification = builder - .setContentTitle("TTS Server正在运行中...") - .setContentText("监听地址: localhost:$port") - .setSmallIcon(R.mipmap.ic_app_notification) - .setContentIntent(pendingIntent) - .addAction(action) - .build() - } - startForeground(1, notification) //启动前台服务 - - if (isWakeLock) { /* 启动唤醒锁 */ - val powerManager = getSystemService(POWER_SERVICE) as PowerManager - mWakeLock = powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "tts_server:ttsTag" - ) - mWakeLock.acquire() - } - Toast.makeText(this, "服务已启动", Toast.LENGTH_SHORT).show() - return super.onStartCommand(intent, flags, startId) - } - - @Deprecated("Deprecated in Java") - override fun onDestroy() { - if (isWakeLock) { /* 释放唤醒锁 */ - mWakeLock.release() - } - Toast.makeText(this, "服务已关闭", Toast.LENGTH_SHORT).show() - super.onDestroy() - } - - @Deprecated("Deprecated in Java") - override fun onHandleIntent(intent: Intent?) { - if (!Isinited) { /* 初始化Go: 设置日志转发,注册Http.Server */ - /* 来自Go的日志 */ - val cb = LogCallback { level, msg -> - Log.d("LogCallback", "$level $msg") - sendLog(GoLog(level, msg)) - } - Tts_server_lib.init(cb) - Isinited = true - } - - sendStartedMsg() - /*启动Go服务并阻塞等待,直到关闭*/ - Tts_server_lib.runServer(port.toLong()) - IsRunning = false - sendClosedMsg() - } - - - /* 广播日志消息 */ - private fun sendLog(data: GoLog) { - val i = Intent(ACTION_ON_LOG) - i.putExtra("data", data) - sendBroadcast(i) - } - - /* 广播启动消息 */ - private fun sendStartedMsg() { - val i = Intent(ACTION_ON_STARTED) - sendBroadcast(i) - } - - /* 广播关闭消息 */ - private fun sendClosedMsg() { - val i = Intent(ACTION_ON_CLOSED) - i.putExtra("isClosed", true) - sendBroadcast(i) - } - - class Receiver : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) {/*点击通知上的退出按钮*/ - Log.d("TtsIntentService", "onReceive") - closeServer(ctx!!) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/AbsForwarderService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/AbsForwarderService.kt new file mode 100644 index 000000000..6f44971dc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/AbsForwarderService.kt @@ -0,0 +1,211 @@ +@file:Suppress("OVERRIDE_DEPRECATION") + +package com.github.jing332.tts_server_android.service.forwarder + +import android.annotation.SuppressLint +import android.app.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Build +import android.os.PowerManager +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.KeyConst +import com.github.jing332.tts_server_android.ui.AppLog +import com.github.jing332.tts_server_android.constant.LogLevel +import com.github.jing332.tts_server_android.ui.ImportConfigActivity +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.registerGlobalReceiver +import com.github.jing332.tts_server_android.utils.startForegroundCompat +import com.github.jing332.tts_server_android.utils.toast +import splitties.systemservices.powerManager +import tts_server_lib.Tts_server_lib + +@Suppress("DEPRECATION") +abstract class AbsForwarderService( + private val name: String, + private val id: Int, + private val actionLog: String, + private val actionStarting: String, + private val actionClosed: String, + private val notificationChanId: String, + @StringRes val notificationChanTitle: Int, + @StringRes val notificationTitle: Int, + @DrawableRes val notificationIcon: Int +) : IntentService(name) { + private val notificationActionCopyUrl = "ACTION_NOTIFICATION_COPY_URL_$name" + private val notificationActionClose = "ACTION_NOTIFICATION_CLOSE_$name" + + abstract fun initServer() + abstract fun startServer() + abstract fun closeServer() + + fun close() { + if (isRunning) + closeServer() + } + + private var wakeLock: PowerManager.WakeLock? = null + + protected var isRunning: Boolean = false + abstract val port: Int + abstract val isWakeLockEnabled: Boolean + + private val mNotificationReceiver = NotificationActionReceiver() + + fun listenAddress(): String { + return Tts_server_lib.getOutboundIP() + ":" + port + } + + @SuppressLint("WakelockTimeout", "UnspecifiedRegisterReceiverFlag") + override fun onCreate() { + super.onCreate() + isRunning = true + + initNotification() + + registerGlobalReceiver( + listOf(notificationActionCopyUrl, notificationActionClose), + mNotificationReceiver + ) + + if (isWakeLockEnabled) { + wakeLock = + powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "TTS_SERVER_ANDROID::$name" + ) + wakeLock?.acquire() + } + initServer() + } + + override fun onHandleIntent(intent: Intent?) { + synchronized(this) { + notifiStarted() + kotlin.runCatching { + startServer() + }.onFailure { + sendLog(LogLevel.ERROR, it.localizedMessage ?: it.toString()) + } + notifiClosed() + } + } + + private fun notifiStarted() { + val intent = Intent(actionStarting) + AppConst.localBroadcast.sendBroadcast(intent) + } + + private fun notifiClosed() { + val intent = Intent(actionClosed) + AppConst.localBroadcast.sendBroadcast(intent) + } + + protected fun sendLog(log: AppLog) { + val intent = Intent(actionLog).apply { putExtra(KeyConst.KEY_DATA, log) } + AppConst.localBroadcast.sendBroadcast(intent) + } + + protected fun sendLog(@LogLevel level: Int, msg: String) { + sendLog(AppLog(level, msg)) + } + + override fun onDestroy() { + super.onDestroy() + isRunning = false + unregisterReceiver(mNotificationReceiver) + + wakeLock?.release() + wakeLock = null + } + + private fun initNotification() { + /*Android 12(S)+ 必须指定PendingIntent.FLAG_*/ + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_IMMUTABLE + else + 0 + + /*点击通知跳转*/ + val pendingIntent = + PendingIntent.getActivity( + this, 0, Intent( + this, + ImportConfigActivity::class.java + ).apply { +// putExtra( +// ImportConfigActivity.KEY_FRAGMENT_INDEX, +// ImportConfigActivity.INDEX_FORWARDER_SYS +// ) + }, + pendingIntentFlags + ) + /*当点击退出按钮时发送广播*/ + val closePendingIntent: PendingIntent = + PendingIntent.getBroadcast( + this, + 0, + Intent(notificationActionClose), + pendingIntentFlags + ) + val copyAddressPendingIntent = + PendingIntent.getBroadcast( + this, + 0, + Intent(notificationActionCopyUrl), + pendingIntentFlags + ) + + val smallIconRes: Int + val builder = Notification.Builder(applicationContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {/*Android 8.0+ 要求必须设置通知信道*/ + val chan = NotificationChannel( + notificationChanId, + getString(notificationChanTitle), + NotificationManager.IMPORTANCE_NONE + ) + chan.lightColor = Color.BLUE + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + smallIconRes = notificationIcon + builder.setChannelId(notificationChanId) + } else { + smallIconRes = R.mipmap.ic_app_notification + } + val notification = builder + .setColor(ContextCompat.getColor(this, R.color.md_theme_light_primary)) + .setContentTitle(getString(notificationTitle)) + .setContentText(getString(R.string.server_listen_address_local, listenAddress())) + .setSmallIcon(smallIconRes) + .setContentIntent(pendingIntent) + .addAction(0, getString(R.string.exit), closePendingIntent) + .addAction(0, getString(R.string.copy_address), copyAddressPendingIntent) + .build() + + // 前台服务 + startForegroundCompat(id, notification) + } + + inner class NotificationActionReceiver : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + notificationActionCopyUrl -> { + ClipboardUtils.copyText(listenAddress()) + toast(R.string.copied) + } + + notificationActionClose -> { + closeServer() + } + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ForwarderServiceManager.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ForwarderServiceManager.kt new file mode 100644 index 000000000..7f993d3eb --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ForwarderServiceManager.kt @@ -0,0 +1,40 @@ +package com.github.jing332.tts_server_android.service.forwarder + +import android.content.Context +import android.content.Intent +import com.github.jing332.tts_server_android.service.forwarder.ms.MsTtsForwarderService +import com.github.jing332.tts_server_android.service.forwarder.system.SysTtsForwarderService + +object ForwarderServiceManager { + fun Context.switchMsTtsForwarder() { + if (MsTtsForwarderService.isRunning) { + closeMsTtsForwarder() + } else { + startMsTtsForwarder() + } + } + + fun Context.switchSysTtsForwarder() { + if (SysTtsForwarderService.isRunning) { + closeSysTtsForwarder() + } else { + startSysTtsForwarder() + } + } + + fun Context.startMsTtsForwarder() { + startService(Intent(this, MsTtsForwarderService::class.java)) + } + + fun closeMsTtsForwarder() { + MsTtsForwarderService.instance?.close() + } + + fun Context.startSysTtsForwarder() { + startService(Intent(this, SysTtsForwarderService::class.java)) + } + + fun closeSysTtsForwarder() { + SysTtsForwarderService.instance?.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ms/MsTtsForwarderService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ms/MsTtsForwarderService.kt new file mode 100644 index 000000000..c07ea40e5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ms/MsTtsForwarderService.kt @@ -0,0 +1,64 @@ +package com.github.jing332.tts_server_android.service.forwarder.ms + +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.conf.MsTtsForwarderConfig +import com.github.jing332.tts_server_android.service.forwarder.AbsForwarderService +import com.github.jing332.tts_server_android.ui.AppLog +import tts_server_lib.LogCallback +import tts_server_lib.Tts_server_lib + +class MsTtsForwarderService( + override val port: Int = MsTtsForwarderConfig.port.value, + override val isWakeLockEnabled: Boolean = MsTtsForwarderConfig.isWakeLockEnabled.value +) : + AbsForwarderService( + name = "MsTtsForwarderService", + id = 1233, + actionLog = ACTION_ON_LOG, + actionStarting = ACTION_ON_STARTING, + actionClosed = ACTION_ON_CLOSED, + notificationChanId = "server_status", + notificationChanTitle = R.string.forwarder_ms, + notificationTitle = R.string.forwarder_ms, + notificationIcon = R.drawable.ic_microsoft + ) { + companion object { + const val ACTION_ON_STARTING = "ACTION_ON_STARTED" + const val ACTION_ON_CLOSED = "ACTION_ON_CLOSED" + const val ACTION_ON_LOG = "ACTION_ON_LOG" + + val isRunning: Boolean + get() = instance?.isRunning ?: false + + var instance: MsTtsForwarderService? = null + } + + @Deprecated("Deprecated in Java") + override fun onCreate() { + super.onCreate() + instance = this + } + + @Deprecated("Deprecated in Java") + override fun onDestroy() { + super.onDestroy() + instance = null + } + + override fun initServer() { + val cb = LogCallback { level, msg -> + sendLog(AppLog(level, msg)) + } + Tts_server_lib.init(cb) + } + + override fun startServer() { + Tts_server_lib.runServer( + port.toLong(), MsTtsForwarderConfig.token.value, true + ) + } + + override fun closeServer() { + Tts_server_lib.closeServer() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/QSTileService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ms/QSTileService.kt similarity index 62% rename from app/src/main/java/com/github/jing332/tts_server_android/service/QSTileService.kt rename to app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ms/QSTileService.kt index a52d82007..7a01e0940 100644 --- a/app/src/main/java/com/github/jing332/tts_server_android/service/QSTileService.kt +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/ms/QSTileService.kt @@ -1,25 +1,22 @@ -package com.github.jing332.tts_server_android.service +package com.github.jing332.tts_server_android.service.forwarder.ms import android.content.Intent import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import android.util.Log import androidx.annotation.RequiresApi -import com.github.jing332.tts_server_android.utils.SharedPrefsUtils +import com.github.jing332.tts_server_android.service.forwarder.ForwarderServiceManager.startMsTtsForwarder -/* 快捷开关 */ +/* 快捷开关(Android 7+) */ @RequiresApi(Build.VERSION_CODES.N) class QSTileService : TileService() { override fun startActivity(intent: Intent?) { - Log.e("TAG", intent.toString()) super.startActivity(intent) } override fun onStartListening() { super.onStartListening() - - if (TtsIntentService.IsRunning) { + if (MsTtsForwarderService.isRunning) { qsTile.state = Tile.STATE_ACTIVE } else { qsTile.state = Tile.STATE_INACTIVE @@ -29,20 +26,15 @@ class QSTileService : TileService() { override fun onClick() { super.onClick() - if (qsTile.state == Tile.STATE_ACTIVE) { /* 关闭 */ - if (TtsIntentService.IsRunning) { - TtsIntentService.closeServer(this) + if (MsTtsForwarderService.isRunning) { + MsTtsForwarderService.instance?.close() } qsTile.state = Tile.STATE_INACTIVE } else {/* 打开 */ - val i = Intent(this.applicationContext, TtsIntentService::class.java) - i.putExtra("isWakeLock", SharedPrefsUtils.getWakeLock(this)) - startService(i) + startMsTtsForwarder() qsTile.state = Tile.STATE_ACTIVE } qsTile.updateTile() - } - } \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/EngineInfo.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/EngineInfo.kt new file mode 100644 index 000000000..a66021f81 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/EngineInfo.kt @@ -0,0 +1,6 @@ +package com.github.jing332.tts_server_android.service.forwarder.system + +import kotlinx.serialization.Serializable + +@Serializable +data class EngineInfo(val name: String, val label: String) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/QSTileService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/QSTileService.kt new file mode 100644 index 000000000..814d5a102 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/QSTileService.kt @@ -0,0 +1,37 @@ +package com.github.jing332.tts_server_android.service.forwarder.system + +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import com.github.jing332.tts_server_android.service.forwarder.ForwarderServiceManager +import com.github.jing332.tts_server_android.service.forwarder.ForwarderServiceManager.startSysTtsForwarder + +@RequiresApi(Build.VERSION_CODES.N) +class QSTileService : TileService() { + override fun onStartListening() { + super.onStartListening() + if (SysTtsForwarderService.isRunning) { + qsTile.state = Tile.STATE_ACTIVE + } else { + qsTile.state = Tile.STATE_INACTIVE + } + qsTile.updateTile() + } + + override fun onClick() { + super.onClick() + if (qsTile.state == Tile.STATE_ACTIVE) { /* 关闭 */ + if (SysTtsForwarderService.isRunning) { + ForwarderServiceManager.closeSysTtsForwarder() + } + qsTile.state = Tile.STATE_INACTIVE + } else {/* 打开 */ + startSysTtsForwarder() + qsTile.state = Tile.STATE_ACTIVE + } + qsTile.updateTile() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/SysTtsForwarderService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/SysTtsForwarderService.kt new file mode 100644 index 000000000..c85150d67 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/SysTtsForwarderService.kt @@ -0,0 +1,133 @@ +@file:Suppress("OVERRIDE_DEPRECATION") + +package com.github.jing332.tts_server_android.service.forwarder.system + +import android.speech.tts.TextToSpeech +import com.github.jing332.tts_server_android.App +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.conf.SysttsForwarderConfig +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.LogLevel +import com.github.jing332.tts_server_android.help.LocalTtsEngineHelper +import com.github.jing332.tts_server_android.model.speech.tts.LocalTTS +import com.github.jing332.tts_server_android.service.forwarder.AbsForwarderService +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.encodeToString +import tts_server_lib.SysTtsForwarder + +class SysTtsForwarderService( + override val port: Int = SysttsForwarderConfig.port.value, + override val isWakeLockEnabled: Boolean = SysttsForwarderConfig.isWakeLockEnabled.value +) : + AbsForwarderService( + "SysTtsForwarderService", + id = 1221, + actionLog = ACTION_ON_LOG, + actionStarting = ACTION_ON_STARTING, + actionClosed = ACTION_ON_CLOSED, + notificationChanId = "systts_forwarder_status", + notificationChanTitle = R.string.forwarder_systts, + notificationIcon = R.drawable.ic_baseline_compare_arrows_24, + notificationTitle = R.string.forwarder_systts, + ) { + companion object { + const val TAG = "SysTtsServerService" + const val ACTION_ON_CLOSED = "ACTION_ON_CLOSED" + const val ACTION_ON_STARTING = "ACTION_ON_STARTING" + const val ACTION_ON_LOG = "ACTION_ON_LOG" + + val isRunning: Boolean + get() = instance?.isRunning == true + + var instance: SysTtsForwarderService? = null + } + + private var mServer: SysTtsForwarder? = null + private var mLocalTTS: LocalTTS? = null + private val mLocalTtsHelper by lazy { LocalTtsEngineHelper(this) } + + override fun onCreate() { + super.onCreate() + instance = this + } + + override fun initServer() { + mServer = SysTtsForwarder().apply { + initCallback(object : tts_server_lib.SysTtsForwarderCallback { + override fun log(level: Int, msg: String) { + sendLog(level, msg) + } + + override fun cancelAudio(engine: String) { + if (mLocalTTS?.engine == engine) { + mLocalTTS?.onStop() + sendLog(LogLevel.WARN, "Canceled: $engine") + } + } + + override fun getAudio( + engine: String, + voice: String, + text: String, + rate: Int, + pitch: Int + ): String { + if (mLocalTTS?.engine != engine) { + mLocalTTS?.onDestroy() + mLocalTTS = LocalTTS(engine) + } + + mLocalTTS?.let { + it.voiceName = voice + val file = it.getAudioFile(text, rate, pitch) + if (file.exists()) return file.absolutePath + } + throw Exception(getString(R.string.forwarder_sys_fail_audio_file)) + } + + override fun getEngines(): String { + val data = getSysTtsEngines().map { EngineInfo(it.name, it.label) } + return AppConst.jsonBuilder.encodeToString(data) + } + + override fun getVoices(engine: String): String { + return runBlocking { + val ok = mLocalTtsHelper.setEngine(engine) + if (!ok) throw Exception(getString(R.string.systts_engine_init_failed_timeout)) + + val data = mLocalTtsHelper.voices.map { + VoiceInfo( + it.name, + it.locale.toLanguageTag(), + it.locale.getDisplayName(it.locale), + it.features?.toList() + ) + } + + return@runBlocking AppConst.jsonBuilder.encodeToString(data) + } + } + }) + } + } + + override fun startServer() { + mServer?.start(port.toLong()) + } + + override fun closeServer() { + mServer?.let { + it.close() + mLocalTTS?.onDestroy() + mLocalTTS = null + } + } + + private fun getSysTtsEngines(): List { + val tts = TextToSpeech(App.context, null) + val engines = tts.engines + tts.shutdown() + return engines + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/VoiceInfo.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/VoiceInfo.kt new file mode 100644 index 000000000..3a0d5e08c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/forwarder/system/VoiceInfo.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.service.forwarder.system + +import kotlinx.serialization.Serializable + +@Serializable +data class VoiceInfo( + val name: String, + val locale: String, + val localeName: String, + val features: List? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/CheckVoiceData.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/CheckVoiceData.kt new file mode 100644 index 000000000..1bca452bf --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/CheckVoiceData.kt @@ -0,0 +1,25 @@ +package com.github.jing332.tts_server_android.service.systts + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.speech.tts.TextToSpeech + +class CheckVoiceData : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val result = TextToSpeech.Engine.CHECK_VOICE_DATA_PASS + val returnData = Intent() + + val available: ArrayList = arrayListOf("zho-CHN") + val unavailable: ArrayList = arrayListOf() + + returnData.putStringArrayListExtra(TextToSpeech.Engine.EXTRA_AVAILABLE_VOICES, available) + returnData.putStringArrayListExtra( + TextToSpeech.Engine.EXTRA_UNAVAILABLE_VOICES, + unavailable + ) + setResult(result, returnData) + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/SystemTtsService.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/SystemTtsService.kt new file mode 100644 index 000000000..5859dda0a --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/SystemTtsService.kt @@ -0,0 +1,531 @@ +package com.github.jing332.tts_server_android.service.systts + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Color +import android.net.wifi.WifiManager +import android.os.Build +import android.os.PowerManager +import android.speech.tts.SynthesisCallback +import android.speech.tts.SynthesisRequest +import android.speech.tts.TextToSpeech +import android.speech.tts.TextToSpeechService +import android.speech.tts.Voice +import android.util.Log +import androidx.core.content.ContextCompat +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.MainActivity +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.constant.KeyConst +import com.github.jing332.tts_server_android.constant.LogLevel +import com.github.jing332.tts_server_android.constant.SystemNotificationConst +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.help.audio.AudioDecoderException +import com.github.jing332.tts_server_android.conf.SysTtsConfig +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import com.github.jing332.tts_server_android.service.systts.help.TextToSpeechManager +import com.github.jing332.tts_server_android.service.systts.help.exception.ConfigLoadException +import com.github.jing332.tts_server_android.service.systts.help.exception.PlayException +import com.github.jing332.tts_server_android.service.systts.help.exception.RequestException +import com.github.jing332.tts_server_android.service.systts.help.exception.SpeechRuleException +import com.github.jing332.tts_server_android.service.systts.help.exception.TextReplacerException +import com.github.jing332.tts_server_android.service.systts.help.exception.TtsManagerException +import com.github.jing332.tts_server_android.ui.AppLog +import com.github.jing332.tts_server_android.ui.ImportConfigActivity +import com.github.jing332.tts_server_android.utils.GcManager +import com.github.jing332.tts_server_android.utils.StringUtils.limitLength +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.registerGlobalReceiver +import com.github.jing332.tts_server_android.utils.rootCause +import com.github.jing332.tts_server_android.utils.runOnUI +import com.github.jing332.tts_server_android.utils.startForegroundCompat +import com.github.jing332.tts_server_android.utils.toHtmlBold +import com.github.jing332.tts_server_android.utils.toHtmlItalic +import com.github.jing332.tts_server_android.utils.toHtmlSmall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.util.Locale +import kotlin.system.exitProcess + + +@Suppress("DEPRECATION") +class SystemTtsService : TextToSpeechService(), TextToSpeechManager.Listener { + companion object { + const val TAG = "SysTtsService" + const val ACTION_ON_LOG = "SYS_TTS_ON_LOG" + const val ACTION_UPDATE_CONFIG = "on_config_changed" + const val ACTION_UPDATE_REPLACER = "on_replacer_changed" + + const val ACTION_NOTIFY_CANCEL = "SYS_TTS_NOTIFY_CANCEL" + const val ACTION_NOTIFY_KILL_PROCESS = "SYS_TTS_NOTIFY_EXIT_0" + const val NOTIFICATION_CHAN_ID = "system_tts_service" + + const val DEFAULT_VOICE_NAME = "DEFAULT_默认" + + /** + * 更新配置 + */ + fun notifyUpdateConfig(isOnlyReplacer: Boolean = false) { + if (isOnlyReplacer) + AppConst.localBroadcast.sendBroadcast(Intent(ACTION_UPDATE_REPLACER)) + else + AppConst.localBroadcast.sendBroadcast(Intent(ACTION_UPDATE_CONFIG)) + } + } + + private val mCurrentLanguage: MutableList = mutableListOf("zho", "CHN", "") + + + private val mTtsManager: TextToSpeechManager by lazy { + TextToSpeechManager(this).also { it.listener = this } + } + + private val mNotificationReceiver: NotificationReceiver by lazy { NotificationReceiver() } + private val mLocalReceiver: LocalReceiver by lazy { LocalReceiver() } + + private val mScope = CoroutineScope(Job()) + + // WIFI 锁 + private val mWifiLock by lazy { + val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "tts-server:wifi_lock") + } + + // 唤醒锁 + private var mWakeLock: PowerManager.WakeLock? = null + + override fun onCreate() { + super.onCreate() + + registerGlobalReceiver( + listOf(ACTION_NOTIFY_KILL_PROCESS, ACTION_NOTIFY_CANCEL), mNotificationReceiver + ) + + AppConst.localBroadcast.registerReceiver( + mLocalReceiver, + IntentFilter(ACTION_UPDATE_CONFIG) + ) + + if (SysTtsConfig.isWakeLockEnabled) + mWakeLock = (getSystemService(POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ON_AFTER_RELEASE, + "tts-server:wake_lock" + ) + + mWakeLock?.acquire(60 * 20 * 100) + mWifiLock.acquire() + + mTtsManager.load() + } + + override fun onDestroy() { + super.onDestroy() + + mTtsManager.destroy() + unregisterReceiver(mNotificationReceiver) + AppConst.localBroadcast.unregisterReceiver(mLocalReceiver) + + mWakeLock?.release() + mWifiLock.release() + + stopForeground(/* removeNotification = */ true) + } + + override fun onIsLanguageAvailable(lang: String?, country: String?, variant: String?): Int { + return if (Locale.SIMPLIFIED_CHINESE.isO3Language == lang || Locale.US.isO3Language == lang) { + if (Locale.SIMPLIFIED_CHINESE.isO3Country == country || Locale.US.isO3Country == country) TextToSpeech.LANG_COUNTRY_AVAILABLE else TextToSpeech.LANG_AVAILABLE + } else TextToSpeech.LANG_NOT_SUPPORTED + } + + override fun onGetLanguage(): Array { + return mCurrentLanguage.toTypedArray() + } + + override fun onLoadLanguage(lang: String?, country: String?, variant: String?): Int { + val result = onIsLanguageAvailable(lang, country, variant) + mCurrentLanguage.clear() + mCurrentLanguage.addAll( + mutableListOf( + lang.toString(), + country.toString(), + variant.toString() + ) + ) + + return result + } + + override fun onGetDefaultVoiceNameFor( + lang: String?, + country: String?, + variant: String? + ): String { + return DEFAULT_VOICE_NAME + } + + + override fun onGetVoices(): MutableList { + val list = + mutableListOf(Voice(DEFAULT_VOICE_NAME, Locale.getDefault(), 0, 0, true, emptySet())) + + appDb.systemTtsDao.getSysTtsWithGroups().forEach { + it.list.forEach { tts -> + list.add( + Voice( + /* name = */ "${tts.displayName}_${tts.id}", + /* locale = */ Locale.forLanguageTag(tts.tts.locale), + /* quality = */ 0, + /* latency = */ 0, + /* requiresNetworkConnection = */true, + /* features = */mutableSetOf().apply { + add(tts.order.toString()) + add(tts.id.toString()) + } + ) + ) + + } + } + + return list + } + + override fun onIsValidVoiceName(voiceName: String?): Int { + val isDefault = voiceName == DEFAULT_VOICE_NAME + if (isDefault) return TextToSpeech.SUCCESS + + val index = + appDb.systemTtsDao.allTts.indexOfFirst { "${it.displayName}_${it.id}" == voiceName } + + return if (index == -1) TextToSpeech.ERROR else TextToSpeech.SUCCESS + } + + override fun onStop() { + Log.d(TAG, "onStop") + mTtsManager.stop() + synthesizerJob?.cancel() + updateNotification(getString(R.string.systts_state_idle), "") + } + + private lateinit var mCurrentText: String + private var synthesizerJob: Job? = null + private var mNotificationJob: Job? = null + + override fun onSynthesizeText(request: SynthesisRequest, callback: SynthesisCallback) { + mNotificationJob?.cancel() + reNewWakeLock() + startForegroundService() + val text = request.charSequenceText.toString().trim() + mCurrentText = text + updateNotification(getString(R.string.systts_state_synthesizing), text) + + // 调用者指定ID + var ttsId = -1L + if (!request.voiceName.isNullOrEmpty()) { + val voiceSplitList = request.voiceName?.split("_") ?: emptyList() + if (voiceSplitList.isEmpty()) { + longToast(R.string.voice_name_bad_format) + voiceSplitList.getOrNull(voiceSplitList.size - 1)?.let { idStr -> + ttsId = idStr.toLongOrNull() ?: -1L + } + } + } + + runBlocking { + synthesizerJob = launch { + mTtsManager.textToAudio( + ttsId = ttsId, + text = text, + sysRate = (request.speechRate * 100) / 500, // < 100 + sysPitch = request.pitch - 100, // 默认0, + onStart = { sampleRate, bitRate -> + callback.start(sampleRate, bitRate, 1) + } + ) { + writeToCallBack(callback, it) + } + }.job + synthesizerJob!!.join() + } + callback.done() + Log.i(TAG, "done...................") + + mNotificationJob = mScope.launch { + delay(5000) + stopForeground(true) + mNotificationDisplayed = false + } + } + + private fun writeToCallBack(callback: SynthesisCallback, pcmData: ByteArray) { + try { + val maxBufferSize: Int = callback.maxBufferSize + var offset = 0 + while (offset < pcmData.size && mTtsManager.isSynthesizing) { + val bytesToWrite = maxBufferSize.coerceAtMost(pcmData.size - offset) + callback.audioAvailable(pcmData, offset, bytesToWrite) + offset += bytesToWrite + } + } catch (e: Exception) { + logE("writeToCallBack: ${e.toString()}") + e.printStackTrace() + } + } + + private fun reNewWakeLock() { + if (mWakeLock != null && mWakeLock?.isHeld == false) { + mWakeLock?.acquire(60 * 20 * 1000) + } + GcManager.doGC() + } + + private var mNotificationBuilder: Notification.Builder? = null + private lateinit var mNotificationManager: NotificationManager + + // 通知是否显示中 + private var mNotificationDisplayed = false + + /* 启动前台服务通知 */ + private fun startForegroundService() { + if (SysTtsConfig.isForegroundServiceEnabled && !mNotificationDisplayed) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val chan = NotificationChannel( + NOTIFICATION_CHAN_ID, + getString(R.string.systts_service), + NotificationManager.IMPORTANCE_NONE + ) + chan.lightColor = Color.CYAN + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + mNotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + mNotificationManager.createNotificationChannel(chan) + } + startForegroundCompat(SystemNotificationConst.ID_SYSTEM_TTS, getNotification()) + mNotificationDisplayed = true + } + } + + /* 更新通知 */ + private fun updateNotification(title: String, content: String? = null) { + if (SysTtsConfig.isForegroundServiceEnabled) + runOnUI { + mNotificationBuilder?.let { builder -> + content?.let { + val bigTextStyle = + Notification.BigTextStyle().bigText(it).setSummaryText("TTS") + builder.style = bigTextStyle + builder.setContentText(it) + } + + builder.setContentTitle(title) + startForegroundCompat( + SystemNotificationConst.ID_SYSTEM_TTS, + builder.build() + ) + } + } + } + + /* 获取通知 */ + @Suppress("DEPRECATION") + private fun getNotification(): Notification { + val notification: Notification + /*Android 12(S)+ 必须指定PendingIntent.FLAG_*/ + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } + /*点击通知跳转*/ + val pendingIntent = + PendingIntent.getActivity( + this, 1, Intent( + this, + MainActivity::class.java + ).apply { /*putExtra(KEY_FRAGMENT_INDEX, INDEX_SYS_TTS)*/ }, pendingIntentFlags + ) + + val killProcessPendingIntent = PendingIntent.getBroadcast( + this, 0, Intent( + ACTION_NOTIFY_KILL_PROCESS + ), pendingIntentFlags + ) + val cancelPendingIntent = + PendingIntent.getBroadcast(this, 0, Intent(ACTION_NOTIFY_CANCEL), pendingIntentFlags) + + mNotificationBuilder = Notification.Builder(applicationContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mNotificationBuilder?.setChannelId(NOTIFICATION_CHAN_ID) + } + notification = mNotificationBuilder!! + .setSmallIcon(R.mipmap.ic_app_notification) + .setContentIntent(pendingIntent) + .setColor(ContextCompat.getColor(this, R.color.md_theme_light_primary)) + .addAction(0, getString(R.string.kill_process), killProcessPendingIntent) + .addAction(0, getString(R.string.cancel), cancelPendingIntent) + .build() + + return notification + } + + @Suppress("DEPRECATION") + inner class NotificationReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_NOTIFY_KILL_PROCESS -> { // 通知按钮{结束进程} + stopForeground(true) + exitProcess(0) + } + + ACTION_NOTIFY_CANCEL -> { // 通知按钮{取消} + if (mTtsManager.isSynthesizing) + onStop() /* 取消当前播放 */ + else /* 无播放,关闭通知 */ { + stopForeground(true) + mNotificationDisplayed = false + } + } + } + } + } + + inner class LocalReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_UPDATE_CONFIG -> mTtsManager.load() + ACTION_UPDATE_REPLACER -> mTtsManager.loadReplacer() + } + } + } + + override fun onRequestStarted(text: String, tts: ITextToSpeechEngine) { + if (!AppConst.isSysTtsLogEnabled) return + logD( + "
" + getString( + R.string.systts_log_request_audio, + "${text.toHtmlBold()}
${tts.toString().toHtmlSmall().toHtmlItalic()}" + ) + ) + } + + override fun onError(e: TtsManagerException) { + if (!AppConst.isSysTtsLogEnabled) return + when (e) { + is RequestException -> { + when (e.errorCode) { + RequestException.ERROR_CODE_AUDIO_NULL -> { + logE(getString(R.string.systts_log_audio_empty, e.text)) + } + + RequestException.ERROR_CODE_TIMEOUT -> { + logE(getString(R.string.failed_timed_out, SysTtsConfig.requestTimeout)) + } + + else -> { + logE( + getString( + R.string.systts_log_failed, + "(${e.times}) ${e.rootCause ?: e.toString()}" + ) + ) + } + } + + updateNotification( + getString(R.string.systts_log_failed, ""), + e.rootCause?.toString() ?: e.toString() + ) + } + + is TextReplacerException -> { + logE( + getString( + R.string.systts_log_replace_failed, + "${e.replaceRule}, ${e.localizedMessage}" + ) + ) + } + + is SpeechRuleException -> { + logE(getString(R.string.systts_log_text_handle_failed, e.localizedMessage)) + } + + is ConfigLoadException -> { + logE("配置加载失败: ${e.localizedMessage}") + } + + is PlayException -> { + if (e.cause is AudioDecoderException) { + logE("解码失败: ${e.cause?.localizedMessage}") + } else + logE("播放失败: ${e.localizedMessage}") + } + + else -> { + logE("错误: ${e.localizedMessage}") + e.printStackTrace() + } + } + } + + override fun onStartRetry(times: Int) { + logW(getString(R.string.systts_log_start_retry, times)) + } + + override fun onRequestSuccess( + text: String, + tts: ITextToSpeechEngine, + size: Int, + costTime: Long, + retryTimes: Int + ) { + if (!AppConst.isSysTtsLogEnabled) return + + val sizeStr = if (size == -1) getString(R.string.unknown) else "${(size / 1024)}kb" + logI( + getString( + R.string.systts_log_success, + sizeStr.toHtmlBold(), + "${costTime}ms".toHtmlBold() + ) + ) + // 重试成功 + if (retryTimes > 0) updateNotification( + getString(R.string.systts_state_synthesizing), + mCurrentText + ) + } + + override fun onPlayFinished(text: String, tts: ITextToSpeechEngine) { + if (!AppConst.isSysTtsLogEnabled) return + logI( + getString( + R.string.systts_log_finished_playing, + text.limitLength(suffix = "...").toHtmlBold() + ) + ) + } + + private fun logD(msg: String) = sendLog(LogLevel.DEBUG, msg) + private fun logI(msg: String) = sendLog(LogLevel.INFO, msg) + private fun logW(msg: String) = sendLog(LogLevel.WARN, msg) + private fun logE(msg: String) = sendLog(LogLevel.ERROR, msg) + + private fun sendLog(@LogLevel level: Int, msg: String) { + Log.d(TAG, "$level, $msg") + val intent = + Intent(ACTION_ON_LOG).putExtra(KeyConst.KEY_DATA, AppLog(level, msg)) + AppConst.localBroadcast.sendBroadcast(intent) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/BgmPlayer.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/BgmPlayer.kt new file mode 100644 index 000000000..018b73a3c --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/BgmPlayer.kt @@ -0,0 +1,96 @@ +package com.github.jing332.tts_server_android.service.systts.help + +import android.content.Context +import android.util.Log +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.github.jing332.tts_server_android.conf.SysTtsConfig +import com.github.jing332.tts_server_android.utils.FileUtils +import com.github.jing332.tts_server_android.utils.FileUtils.mimeType +import com.github.jing332.tts_server_android.utils.runOnUI +import java.io.File + + +class BgmPlayer(val context: Context) { + companion object { + const val TAG = "BgmPlayer" + } + + private val exoPlayer by lazy { + ExoPlayer.Builder(context).build().apply { + addListener(object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + val volume = mediaItem?.localConfiguration?.tag + if (volume != null && volume is Float && volume != this@apply.volume) + this@apply.volume = volume + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + + removeMediaItem(currentMediaItemIndex) + seekToNextMediaItem() + prepare() + } + }) + repeatMode = Player.REPEAT_MODE_ALL + shuffleModeEnabled = SysTtsConfig.isBgmShuffleEnabled + } + } + private val currentPlaySet = mutableSetOf>() + + fun release() { + exoPlayer.release() + } + + fun pause() { + Log.d(TAG, "stop()...") + runOnUI { exoPlayer.pause() } + } + + fun play() { + Log.d(TAG, "play()...") + runOnUI { + if (!exoPlayer.isPlaying) exoPlayer.play() + } + } + + fun setPlayList(shuffleMode: Boolean, list: Set>) { + if (list == currentPlaySet) return + currentPlaySet.clear() + currentPlaySet.addAll(list) + + exoPlayer.stop() + exoPlayer.clearMediaItems() + for (path in list) { + val file = File(path.second) + if (file.isDirectory) { + val allFiles = FileUtils.getAllFilesInFolder(file) + .run { if (shuffleMode) this.shuffled() else this } + for (subFile in allFiles) { + if (!addMediaItem(path.first, subFile)) continue + } + } else if (file.isFile) { + addMediaItem(path.first, file) + } + } + exoPlayer.prepare() + } + + private fun addMediaItem(tag: Any, file: File): Boolean { + val mime = file.mimeType + // 非audio或未知则跳过 + if (mime == null || !mime.startsWith("audio")) return false + + Log.d(TAG, file.absolutePath) + val item = + MediaItem.Builder().setTag(tag).setUri(file.absolutePath).build() + exoPlayer.addMediaItem(item) + + return true + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/SpeechRuleHelper.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/SpeechRuleHelper.kt new file mode 100644 index 000000000..5c30e7982 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/SpeechRuleHelper.kt @@ -0,0 +1,47 @@ +package com.github.jing332.tts_server_android.service.systts.help + +import android.content.Context +import android.os.SystemClock +import com.github.jing332.tts_server_android.data.entities.SpeechRule +import com.github.jing332.tts_server_android.model.rhino.speech_rule.SpeechRuleEngine +import com.github.jing332.tts_server_android.model.speech.TtsTextSegment +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import java.util.Random + +class SpeechRuleHelper { + private val random: Random by lazy { Random(SystemClock.elapsedRealtime()) } + lateinit var engine: SpeechRuleEngine + + fun init(context: Context, rule: SpeechRule) { + engine = SpeechRuleEngine(context, rule) + engine.eval() + random.setSeed(SystemClock.elapsedRealtime()) + } + + fun splitText(text: String): List { + return engine.splitText(text).map { it.toString() } + } + + fun handleText( + text: String, + config: Map>, + defaultConfig: ITextToSpeechEngine, + ): List { + if (!this::engine.isInitialized) return listOf(TtsTextSegment(defaultConfig, text)) + val resultList = mutableListOf() + + val list = config.entries.map { it.value }.flatten().map { it.speechRule } + engine.handleText(text, list).forEach { txtWithTag -> + if (txtWithTag.text.isNotBlank()) { + val sameTagList = config[txtWithTag.tag] ?: listOf(defaultConfig) + val ttsFromId = sameTagList.find { it.speechRule.configId == txtWithTag.id } + + val tts = ttsFromId ?: sameTagList[random.nextInt(sameTagList.size)] + resultList.add(TtsTextSegment(text = txtWithTag.text, tts = tts)) + } + } + + return resultList + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/TextReplacer.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/TextReplacer.kt new file mode 100644 index 000000000..bb5817e35 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/TextReplacer.kt @@ -0,0 +1,48 @@ +package com.github.jing332.tts_server_android.service.systts.help + +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule +import com.github.jing332.tts_server_android.service.systts.help.exception.TextReplacerException + +class TextReplacer { + companion object { + const val TAG = "ReplaceHelper" + } + + private var map: MutableMap> = mutableMapOf() + + fun load() { + map.clear() + appDb.replaceRuleDao.allGroupWithReplaceRules().forEach { groupWithRules -> + if (map[groupWithRules.group.onExecution] == null) + map[groupWithRules.group.onExecution] = mutableListOf() + + map[groupWithRules.group.onExecution]?.addAll( + groupWithRules.list.filter { it.isEnabled } + ) + } + } + + /** + * 执行替换 + */ + fun replace( + text: String, + onExecution: Int, + onReplaceError: (t: TextReplacerException) -> Unit + ): String { + var s = text + map[onExecution]?.forEach { rule -> + kotlin.runCatching { + s = if (rule.isRegex) + s.replace(Regex(rule.pattern), rule.replacement) + else + s.replace(rule.pattern, rule.replacement) + }.onFailure { + onReplaceError.invoke(TextReplacerException(rule, it)) + } + } + + return s + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/TextToSpeechManager.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/TextToSpeechManager.kt new file mode 100644 index 000000000..30a3e9717 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/TextToSpeechManager.kt @@ -0,0 +1,601 @@ +package com.github.jing332.tts_server_android.service.systts.help + +import android.content.Context +import android.media.AudioFormat +import android.media.AudioTrack +import android.util.Log +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.constant.AppPattern +import com.github.jing332.tts_server_android.constant.ReplaceExecution +import com.github.jing332.tts_server_android.constant.SpeechTarget +import com.github.jing332.tts_server_android.data.appDb +import com.github.jing332.tts_server_android.help.audio.AudioDecoder +import com.github.jing332.tts_server_android.help.audio.AudioDecoder.Companion.readPcmChunk +import com.github.jing332.tts_server_android.help.audio.ExoAudioPlayer +import com.github.jing332.tts_server_android.help.audio.Sonic +import com.github.jing332.tts_server_android.help.audio.exo.ExoAudioDecoder +import com.github.jing332.tts_server_android.conf.SysTtsConfig +import com.github.jing332.tts_server_android.model.SysTtsLib +import com.github.jing332.tts_server_android.model.speech.ITextToSpeechSynthesizer +import com.github.jing332.tts_server_android.model.speech.TtsTextSegment +import com.github.jing332.tts_server_android.model.speech.tts.BaseAudioFormat +import com.github.jing332.tts_server_android.model.speech.tts.BgmTTS +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine +import com.github.jing332.tts_server_android.model.speech.tts.MsTTS +import com.github.jing332.tts_server_android.model.speech.tts.PlayerParams +import com.github.jing332.tts_server_android.service.systts.help.exception.ConfigLoadException +import com.github.jing332.tts_server_android.service.systts.help.exception.PlayException +import com.github.jing332.tts_server_android.service.systts.help.exception.RequestException +import com.github.jing332.tts_server_android.service.systts.help.exception.SpeechRuleException +import com.github.jing332.tts_server_android.service.systts.help.exception.TtsManagerException +import com.github.jing332.tts_server_android.utils.StringUtils +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.IOException +import kotlin.coroutines.coroutineContext +import kotlin.random.Random +import kotlin.system.measureTimeMillis + +class TextToSpeechManager(val context: Context) : ITextToSpeechSynthesizer() { + companion object { + const val TAG = "TextToSpeechManager" + + private val defaultTtsConfig by lazy { MsTTS() } + } + + var listener: Listener? = null + + var isSynthesizing: Boolean = false + private set + + private var audioFormat: BaseAudioFormat = BaseAudioFormat() + + init { + SysTtsLib.setTimeout(SysTtsConfig.requestTimeout) + } + + private val mConfigMap: MutableMap> = mutableMapOf() + private val mLoadedTtsMap = mutableSetOf() + private val mSpeechRuleHelper = SpeechRuleHelper() + private val mRandom = Random(System.currentTimeMillis()) + + override suspend fun handleText(text: String): List { + kotlin.runCatching { + return if (SysTtsConfig.isMultiVoiceEnabled) { + val tagTtsMap = mutableMapOf>() + mConfigMap[SpeechTarget.CUSTOM_TAG]?.forEach { tts -> + if (tagTtsMap[tts.speechRule.tag] == null) + tagTtsMap[tts.speechRule.tag] = mutableListOf() + + tagTtsMap[tts.speechRule.tag]?.add(tts) + } + + mSpeechRuleHelper.handleText(text, tagTtsMap, defaultTtsConfig) + } else { + val list = mConfigMap[SpeechTarget.ALL] ?: listOf(defaultTtsConfig) + listOf(TtsTextSegment(list[mRandom.nextInt(list.size)], text)) + }.run { + (if (SysTtsConfig.isSplitEnabled) { + val list = mutableListOf() + forEach { ttsText -> + splitText(list, ttsText.text, ttsText.tts, SysTtsConfig.isMultiVoiceEnabled) + } + list + } else this).run { + val list = if (SysTtsConfig.isReplaceEnabled) { + val l = mutableListOf() + this.forEach { + mTextReplacer.replace(it.text, ReplaceExecution.AFTER) { e -> + listener?.onError(e) + }.also { text -> + l.add(TtsTextSegment(it.tts, text)) + } + } + + l + } else this + + if (SysTtsConfig.isSkipSilentText) list.filterNot { + AppPattern.notReadAloudRegex.matches(it.text) + } else list + } + + } + }.onFailure { + listener?.onError( + SpeechRuleException(text = text, tts = null, message = it.message, cause = it) + ) + } + + return emptyList() + } + + private fun splitText( + list: MutableList, + text: String, + tts: ITextToSpeechEngine, + isMultiVoice: Boolean + ) { + if (isMultiVoice) { + val texts = try { + mSpeechRuleHelper.splitText(text) + } catch (e: NoSuchMethodException) { + return splitText(list, text, tts, false) + } + if (texts.isEmpty()) + listener?.onError( + SpeechRuleException(text = text, tts = tts, message = "splittedTexts is empty.") + ) + else + texts.forEach { list.add(TtsTextSegment(tts, it)) } + } else { + Log.d(TAG, "使用内置分割规则...") + StringUtils.splitSentences(text).forEach { + list.add(TtsTextSegment(tts, it)) + } + } + } + + private fun getBufferSize(sampleRate: Int): Int = + AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + + override suspend fun getAudio( + tts: ITextToSpeechEngine, + text: String, + sysRate: Int, + sysPitch: Int + ): AudioResult? { + Log.i(TAG, "请求音频:$tts, $text") + if (!coroutineContext.isActive) return null + + var audioResult: AudioResult? = AudioResult(null, null) + var retryTimes = 0 + retry(times = 50, + onCatch = { times, e -> + retryTimes = times + Log.w(TAG, "请求失败: times=$times, $text, $tts, $e") + listener?.onError( + if (e is RequestException) e else RequestException( + text = text, tts = tts, cause = e, times = times + ) + ) + + if (times > SysTtsConfig.maxRetryCount) { + return@retry false // 超过最大重试次数,跳过 + } + + // 备用TTS + if (SysTtsConfig.standbyTriggeredRetryIndex == times) + tts.speechRule.standbyTts?.let { sbyTts -> + Log.i(TAG, "使用备用TTS:$sbyTts") + audioResult = if (sbyTts.isDirectPlay()) { + null + } else + getAudio(sbyTts, text, sysRate, sysPitch) + return@retry false // 取消重试 + } + + // 第 maxEmptyAudioRetryCount 音频为空失败后退出 return false + val canContinue = if (SysTtsConfig.maxEmptyAudioRetryCount == 0) false else + !(e is RequestException && e.errorCode == RequestException.ERROR_CODE_AUDIO_NULL + && times >= SysTtsConfig.maxEmptyAudioRetryCount) + + if (canContinue) listener?.onStartRetry(times) + return@retry canContinue + }, + block = { + if (!coroutineContext.isActive) return@retry + listener?.onRequestStarted(text, tts) + + val costTime = measureTimeMillis { + if (!mLoadedTtsMap.contains(tts)) { + Log.i(TAG, "加载TTS:$tts") + tts.onLoad() + mLoadedTtsMap.add(tts) + } + // 直接播放 不用获取音频, 在接收者处播放 + if (tts.isDirectPlay()) { + audioResult = AudioResult() + return@retry + } + coroutineScope { + var hasTimeout = false + val timeoutJob = launch { + delay(SysTtsConfig.requestTimeout.toLong()) + hasTimeout = true + } + + audioResult?.inputStream = + try { + tts.getAudioWithSystemParams(text, sysRate, sysPitch) + ?: throw RequestException( + errorCode = RequestException.ERROR_CODE_AUDIO_NULL, + tts = tts, text = text + ) + } catch (e: IOException) { + if (e.message != "Canceled") throw e + null + } + + if (!SysTtsConfig.isStreamPlayModeEnabled) { + audioResult?.bytes = audioResult?.inputStream?.readBytes() + audioResult?.inputStream?.close() + if (audioResult?.bytes == null || audioResult?.bytes?.size!! < 1024) + throw RequestException( + errorCode = RequestException.ERROR_CODE_AUDIO_NULL, + tts = tts, text = text + ) + } + + if (hasTimeout) { + tts.onStop() + throw RequestException( + tts = tts, + text = text, + errorCode = RequestException.ERROR_CODE_TIMEOUT + ) + } else timeoutJob.cancelAndJoin() + } + } + audioResult?.data = costTime to retryTimes + }) + + return audioResult + } + + private val mTextReplacer = TextReplacer() + private val mAudioDecoder = AudioDecoder() + private val mExoDecoder by lazy { ExoAudioDecoder(context) } + private var mAudioPlayer: ExoAudioPlayer? = null + private var mBgmPlayer: BgmPlayer? = null + + private fun getEnabledList(target: Int, isStandby: Boolean): List { + return appDb.systemTtsDao.getEnabledListForSort(target, isStandby).map { + appDb.systemTtsDao.getGroup(it.groupId)?.let { group -> + val groupAudioParams = group.audioParams.copyIfFollow( + SysTtsConfig.audioParamsSpeed, + SysTtsConfig.audioParamsVolume, SysTtsConfig.audioParamsPitch + ) + it.tts.audioParams = it.tts.audioParams.copyIfFollow( + followSpeed = groupAudioParams.speed, + followVolume = groupAudioParams.volume, + followPitch = groupAudioParams.pitch, + ) + } + it.tts.speechRule = it.speechRule + it.tts.speechRule.configId = it.id + return@map it.tts + } + } + + private fun initConfig(target: Int): Boolean { + Log.i(TAG, "initConfig: $target") + var isOk = true + mConfigMap[target] = getEnabledList(target, isStandby = false) + if (mConfigMap[target]?.isEmpty() == true) { + isOk = false + Log.w(TAG, "缺少朗读目标$target, 使用内置MsTTS!") + mConfigMap[target] = listOf(MsTTS()) + } + + val sbyList = getEnabledList(target, isStandby = true) + + mConfigMap[target]?.forEach { tts -> + sbyList.find { it.speechRule.isTagSame(tts.speechRule) } + ?.let { sbyTts -> + Log.i(TAG, "找到备用TTS:$sbyTts") + tts.speechRule.standbyTts = sbyTts + } + } + + return isOk + } + + private fun initBgm() { + val list = mutableSetOf>() + appDb.systemTtsDao.getEnabledListForSort(SpeechTarget.BGM).forEach { + val tts = (it.tts as BgmTTS) + val volume = if (tts.volume == 0) SysTtsConfig.bgmVolume else it.tts.volume / 1000f + list.addAll(tts.musicList.map { path -> + Pair(volume, path) + }) + } + if (list.isEmpty()) { + mBgmPlayer?.release() + mBgmPlayer = null + } else { + mBgmPlayer = mBgmPlayer ?: BgmPlayer(context) + mBgmPlayer?.setPlayList(SysTtsConfig.isBgmShuffleEnabled, list) + } + } + + private fun initAudioFormat(@SpeechTarget target: Int) { + mConfigMap[target]?.maxBy { it.audioFormat.sampleRate }?.audioFormat?.also { + audioFormat = it + } + } + + fun loadReplacer() { + if (SysTtsConfig.isReplaceEnabled) + mTextReplacer.load() + } + + override fun load() { + try { + loadReplacer() + initBgm() + if (SysTtsConfig.isMultiVoiceEnabled) { + val ok = initConfig(SpeechTarget.CUSTOM_TAG) + initAudioFormat(SpeechTarget.CUSTOM_TAG) + if (ok) { + mConfigMap[SpeechTarget.CUSTOM_TAG]?.getOrNull(0)?.also { + appDb.speechRuleDao.getByRuleId(it.speechRule.tagRuleId)?.let { rule -> + return@also mSpeechRuleHelper.init(context, rule) + } + throw ConfigLoadException() + } + } else + context.toast(R.string.systts_no_custom_tag_config_warn) + + } else { + if (!initConfig(SpeechTarget.ALL)) + context.toast(R.string.systts_no_speech_target_all) + initAudioFormat(SpeechTarget.ALL) + } + } catch (e: Exception) { + e.printStackTrace() + listener?.onError(ConfigLoadException(cause = e)) + } + } + + override fun stop() { + isSynthesizing = false + for (engine in mLoadedTtsMap) engine.onStop() + mBgmPlayer?.pause() + } + + override fun destroy() { + for (engine in mLoadedTtsMap) engine.onDestroy() + mBgmPlayer?.release() + mAudioPlayer?.release() + } + + suspend fun textToAudio( + ttsId: Long = -1L, + text: String, + sysRate: Int, + sysPitch: Int, + onStart: (sampleRate: Int, bitRate: Int) -> Unit, + onPcmAudio: (pcmAudio: ByteArray) -> Unit, + ) { + isSynthesizing = true + + val replaced = if (SysTtsConfig.isReplaceEnabled) + mTextReplacer.replace(text, ReplaceExecution.BEFORE) { listener?.onError(it) } else text + + if (ttsId == -1L) { + mBgmPlayer?.play() + onStart(audioFormat.sampleRate, audioFormat.bitRate) + synthesizeText(replaced, sysRate, sysPitch) { data -> // 音频获取完毕 + data.receiver(sysRate, sysPitch, audioFormat, onPcmAudio) + } + } else { + val specifiedTts = appDb.systemTtsDao.getTts(ttsId) + if (specifiedTts == null) { + context.longToast(context.getString(R.string.tts_config_not_exist)) + delay(3000) + return + } + + val group = appDb.systemTtsDao.getGroup(specifiedTts.groupId) + if (group != null) { + val groupAudioParams = group.audioParams.copyIfFollow( + SysTtsConfig.audioParamsSpeed, + SysTtsConfig.audioParamsVolume, + SysTtsConfig.audioParamsPitch + ) + specifiedTts.tts.audioParams = specifiedTts.tts.audioParams.copyIfFollow( + followSpeed = groupAudioParams.speed, + followVolume = groupAudioParams.volume, + followPitch = groupAudioParams.pitch, + ) + } + onStart(specifiedTts.tts.audioFormat.sampleRate, specifiedTts.tts.audioFormat.bitRate) + synthesizeText(specifiedTts.tts, text, sysRate, sysPitch) { + it.receiver(sysRate, sysPitch, it.txtTts.tts.audioFormat, onPcmAudio) + } + } + + + isSynthesizing = false + } + + private suspend fun AudioData.receiver( + sysRate: Int, + sysPitch: Int, + audioFormat: BaseAudioFormat, + onPcmAudio: (pcmAudio: ByteArray) -> Unit + ) { + val data = this + kotlin.runCatching { + val txtTts = data.txtTts + val audioParams = txtTts.tts.audioParams + + val srcSampleRate = txtTts.tts.audioFormat.sampleRate + val targetSampleRate = audioFormat.sampleRate + + val sonic = + if (audioParams.isDefaultValue && srcSampleRate == targetSampleRate) null + else Sonic(txtTts.tts.audioFormat.sampleRate, 1) + txtTts.playAudio( + sysRate, sysPitch, data.audio, + onDone = { data.done.invoke() }) + { pcmAudio -> + if (sonic == null) onPcmAudio.invoke(pcmAudio) + else { + sonic.volume = audioParams.volume + sonic.speed = audioParams.speed + sonic.pitch = audioParams.pitch + sonic.rate = srcSampleRate.toFloat() / targetSampleRate.toFloat() + + sonic.writeBytesToStream(pcmAudio, pcmAudio.size) + onPcmAudio.invoke(sonic.readBytesFromStream(sonic.samplesAvailable())) + } + } + listener?.onPlayFinished(txtTts.text, txtTts.tts) + }.onFailure { + listener?.onError(TtsManagerException(cause = it, message = it.message)) + } + } + + @Suppress("UNCHECKED_CAST") + private suspend fun TtsTextSegment.playAudio( + sysRate: Int, + sysPitch: Int, + audioResult: AudioResult?, + onDone: suspend () -> Unit, + onPcmAudio: (pcmAudio: ByteArray) -> Unit, + ) { + var costTime = 0L + var retryTimes = 0 + if (audioResult?.data is Pair<*, *>) { + costTime = (audioResult.data as Pair).first + retryTimes = (audioResult.data as Pair).second + } + + // audioResult == null 说明请求失败 + if (audioResult == null && tts.speechRule.standbyTts?.isDirectPlay() == true) { // 直接播放备用TTS + tts.speechRule.standbyTts?.startPlayWithSystemParams(text, sysRate, sysPitch) + onDone.invoke() + return + } else if (tts.isDirectPlay()) { // 直接播放 + tts.startPlayWithSystemParams(text, sysRate, sysPitch) + onDone.invoke() + return + } else if (audioResult?.inputStream == null) { // 无音频 + Log.w(TAG, "audio == null, $this") + onDone.invoke() + return + } + + if (SysTtsConfig.isStreamPlayModeEnabled) { + listener?.onRequestSuccess(text, tts, -1, costTime, retryTimes) + if (tts.audioFormat.isNeedDecode) { + if (SysTtsConfig.isInAppPlayAudio) { + onDone.invoke() + audioResult.builtinPlayAudio(tts.audioPlayer) + audioResult.inputStream?.close() + } else { + try { + audioResult.decodeAudio { onPcmAudio.invoke(it) } + } catch (e: Exception) { + throw PlayException( + tts = tts, + cause = e, + message = "流播放下的音频解码失败" + ) + } finally { + onDone.invoke() + } + } + } else { + val bufferSize = getBufferSize(tts.audioFormat.sampleRate) + Log.d(TAG, "raw buffer: $bufferSize") +// val buffer = ByteArray(min(1024, bufferSize * 2)) + audioResult.inputStream?.use { ins -> + ins.buffered().use { + it.readPcmChunk( + bufferSize = bufferSize * 2, + chunkSize = bufferSize + ) { pcm -> + println("pcm ${pcm.size}") + onPcmAudio(pcm) + } + } + } + onDone.invoke() + } + } else { // 全部加载到内存 + val audio = audioResult.bytes + onDone.invoke() + if (audio == null || audio.size < 48) return + + listener?.onRequestSuccess(text, tts, audio.size, costTime, retryTimes) + + if (tts.audioFormat.isNeedDecode) { + if (SysTtsConfig.isInAppPlayAudio) + audioResult.builtinPlayAudio(tts.audioPlayer) + else + try { + audioResult.decodeAudio { onPcmAudio.invoke(it) } + } catch (e: Exception) { + throw PlayException(tts = tts, cause = e, message = "音频解码失败") + } + } else + onPcmAudio.invoke(audio) + } + } + + // 内置播放器 + private suspend fun AudioResult.builtinPlayAudio(audioParams: PlayerParams) { + val params = audioParams.copy().setParamsIfFollow( + SysTtsConfig.inAppPlaySpeed, + SysTtsConfig.inAppPlayVolume, + SysTtsConfig.inAppPlayPitch + ) + + mAudioPlayer = mAudioPlayer ?: ExoAudioPlayer(context) + if (SysTtsConfig.isStreamPlayModeEnabled) + mAudioPlayer?.play(inputStream!!, params.rate, params.volume, params.pitch) + else + mAudioPlayer?.play(bytes!!, params.rate, params.volume, params.pitch) + } + + // 解码音频 + private suspend fun AudioResult.decodeAudio( + isStream: Boolean = SysTtsConfig.isStreamPlayModeEnabled, + useExoDecoder: Boolean = SysTtsConfig.isExoDecoderEnabled, + onPcmAudio: (pcmAudio: ByteArray) -> Unit, + ) { + if (useExoDecoder) { + mExoDecoder.callback = ExoAudioDecoder.Callback { byteBuffer -> + val buffer = ByteArray(byteBuffer.remaining()) + byteBuffer.get(buffer) + onPcmAudio.invoke(buffer) + } + if (isStream) mExoDecoder.doDecode(inputStream!!) + else mExoDecoder.doDecode((bytes!!)) + } else { + if (isStream) mAudioDecoder.doDecode( + inputStream!!, + audioFormat.sampleRate + ) { pcmData -> + onPcmAudio.invoke(pcmData) + } + else + mAudioDecoder.doDecode(bytes!!, audioFormat.sampleRate) { pcmData -> + onPcmAudio.invoke(pcmData) + } + } + + } + + interface Listener { + fun onError(e: TtsManagerException) + fun onStartRetry(times: Int) + fun onRequestStarted(text: String, tts: ITextToSpeechEngine) + fun onPlayFinished(text: String, tts: ITextToSpeechEngine) + fun onRequestSuccess( + text: String, tts: ITextToSpeechEngine, size: Int, costTime: Long, retryTimes: Int + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/ConfigLoadException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/ConfigLoadException.kt new file mode 100644 index 000000000..13939173d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/ConfigLoadException.kt @@ -0,0 +1,9 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +class ConfigLoadException( + override val message: String? = null, + override val cause: Throwable? = null +) : TtsManagerException() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/PlayException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/PlayException.kt new file mode 100644 index 000000000..c8527bbd5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/PlayException.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +class PlayException( + override val text: String? = null, + override val tts: ITextToSpeechEngine?, + override val cause: Throwable?, + override val message: String? = null +) : + SynthesisException() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/RequestException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/RequestException.kt new file mode 100644 index 000000000..a4f5e0511 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/RequestException.kt @@ -0,0 +1,28 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +class RequestException( + val errorCode: Int = 0, + val times: Int = 1, + override val tts: ITextToSpeechEngine?, + override val text: String?, + override val message: String? = null, + override val cause: Throwable? = null +) : + SynthesisException() { + companion object { + const val ERROR_CODE_REQUEST = 0 + const val ERROR_CODE_AUDIO_NULL = 1 + const val ERROR_CODE_TIMEOUT = 2 + } + + override fun getLocalizedMessage(): String? { + return when (errorCode) { + ERROR_CODE_REQUEST -> return "Request error" + ERROR_CODE_AUDIO_NULL -> return "Audio is null" + ERROR_CODE_TIMEOUT -> return "Request timeout" + else -> super.getLocalizedMessage() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/SpeechRuleException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/SpeechRuleException.kt new file mode 100644 index 000000000..2b3241a51 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/SpeechRuleException.kt @@ -0,0 +1,8 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +class SpeechRuleException(override val text: String?, override val tts: ITextToSpeechEngine?, + override val message: String? = null, override val cause: Throwable? = null) : + SynthesisException() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/SynthesisException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/SynthesisException.kt new file mode 100644 index 000000000..7f4aa0e37 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/SynthesisException.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +open class SynthesisException( + open val tts: ITextToSpeechEngine? = null, + open val text: String? = null, + override val message: String? = null, + override val cause: Throwable? = null +) : TtsManagerException() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/TextReplacerException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/TextReplacerException.kt new file mode 100644 index 000000000..f59788558 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/TextReplacerException.kt @@ -0,0 +1,11 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.data.entities.replace.ReplaceRule + +data class TextReplacerException( + val replaceRule: ReplaceRule? = null, + override val cause: Throwable?, + override val message: String? = null, +) : + TtsManagerException() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/TtsManagerException.kt b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/TtsManagerException.kt new file mode 100644 index 000000000..7eec3d411 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/service/systts/help/exception/TtsManagerException.kt @@ -0,0 +1,10 @@ +package com.github.jing332.tts_server_android.service.systts.help.exception + +import com.github.jing332.tts_server_android.model.speech.tts.ITextToSpeechEngine + +open class TtsManagerException( + override val message: String? = null, + override val cause: Throwable? = null +) : + Exception() { +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/AppActivityResultContracts.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/AppActivityResultContracts.kt new file mode 100644 index 000000000..43d4981a5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/AppActivityResultContracts.kt @@ -0,0 +1,57 @@ +package com.github.jing332.tts_server_android.ui + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import androidx.activity.result.contract.ActivityResultContract +import com.github.jing332.tts_server_android.constant.KeyConst +import com.github.jing332.tts_server_android.help.ByteArrayBinder +import com.github.jing332.tts_server_android.utils.setBinder + +object AppActivityResultContracts { + /** + * 用于传递Parcelable数据 + */ + @Suppress("DEPRECATION") + fun parcelableDataActivity(clz: Class) = + object : ActivityResultContract() { + override fun createIntent(context: Context, input: T?): Intent { + return Intent(context, clz).apply { + if (input != null) putExtra(KeyConst.KEY_DATA, input) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): T? { + return intent?.getParcelableExtra(KeyConst.KEY_DATA) + } + } + + fun filePickerActivity() = + object : + ActivityResultContract>() { + override fun createIntent( + context: Context, + input: FilePickerActivity.IRequestData + ): Intent { + return Intent(context, FilePickerActivity::class.java).apply { + if (input is FilePickerActivity.RequestSaveFile) + setBinder(ByteArrayBinder(input.fileBytes!!)) + + putExtra(FilePickerActivity.KEY_REQUEST_DATA, input) + } + } + + @Suppress("DEPRECATION") + override fun parseResult( + resultCode: Int, + intent: Intent? + ): Pair { + return intent?.getParcelableExtra( + FilePickerActivity.KEY_REQUEST_DATA + ) to intent?.data + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/AppHelpDocumentActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/AppHelpDocumentActivity.kt new file mode 100644 index 000000000..a70b7d8f6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/AppHelpDocumentActivity.kt @@ -0,0 +1,147 @@ +package com.github.jing332.tts_server_android.ui + +import android.os.Bundle +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.util.Log +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.setPadding +import androidx.core.widget.NestedScrollView +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText +import com.github.jing332.tts_server_android.utils.dp +import com.github.jing332.tts_server_android.utils.toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.noties.markwon.AbstractMarkwonPlugin +import io.noties.markwon.LinkResolverDef +import io.noties.markwon.Markwon +import io.noties.markwon.MarkwonConfiguration +import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.image.AsyncDrawableScheduler +import io.noties.markwon.image.DefaultMediaDecoder +import io.noties.markwon.image.ImagesPlugin +import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler +import io.noties.markwon.image.svg.SvgMediaDecoder +import io.noties.markwon.linkify.LinkifyPlugin + +@OptIn(ExperimentalMaterial3Api::class) +class AppHelpDocumentActivity : AppCompatActivity() { + companion object { + const val TAG = "AppHelpDocumentActivity" + } + + private val markwon by lazy { + Markwon.builder(this) + .usePlugin(ImagesPlugin.create { + it.addSchemeHandler(OkHttpNetworkSchemeHandler.create()) + it.addMediaDecoder(DefaultMediaDecoder.create()) + it.addMediaDecoder(SvgMediaDecoder.create()) + it.errorHandler { url, throwable -> + throwable.printStackTrace() + println(url) + null + } + }) +// .usePlugin(HtmlPlugin.create()) + .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { + builder.linkResolver { view, link -> + Log.d(TAG, "configureConfiguration: $link") + } + + // or subclass default instance + builder.linkResolver(object : LinkResolverDef() { + override fun resolve(view: View, link: String) { + Log.d(TAG, "resolve: $link") + MaterialAlertDialogBuilder(this@AppHelpDocumentActivity) + .setTitle("是否跳转?") + .setMessage("是否跳转到 $link ?") + .setPositiveButton(android.R.string.ok) { _, _ -> + super.resolve(view, link) + } + .setNegativeButton(R.string.cancel, null) + .setNeutralButton(R.string.copy_address) { _, _ -> + ClipboardUtils.copyText(link) + toast(R.string.copied) + } + .show() + } + }) + } + }) + .usePlugin(object : AbstractMarkwonPlugin(), MarkwonPlugin { + override fun beforeSetText(textView: TextView, markdown: Spanned) { + AsyncDrawableScheduler.unschedule(textView) + } + + override fun afterSetText(textView: TextView) { + AsyncDrawableScheduler.schedule(textView); + } + }) + .usePlugin(LinkifyPlugin.create()) + .build() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val scrollView = NestedScrollView(this) + scrollView.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + scrollView.setPadding(8.dp) + + val text = TextView(this) + text.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + text.setTextIsSelectable(true) + text.movementMethod = LinkMovementMethod.getInstance() + scrollView.addView(text) + + setContent { + AppTheme { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.app_help_document)) }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + } + ) + } + ) { + AndroidView(modifier = Modifier.padding(it), + factory = { + scrollView + } + ) + } + } + } + + markwon.setMarkdown(text, assets.open("help/app.md").readAllText()) + } + +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/AppLog.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/AppLog.kt new file mode 100644 index 000000000..5a70e550f --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/AppLog.kt @@ -0,0 +1,13 @@ +package com.github.jing332.tts_server_android.ui + +import android.os.Parcelable +import com.github.jing332.tts_server_android.constant.LogLevel +import com.github.jing332.tts_server_android.constant.LogLevel.Companion.toColor +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AppLog(@LogLevel val level: Int, val msg: String) : Parcelable { + fun toColor(): Int { + return toColor(level) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/ExoPlayerActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/ExoPlayerActivity.kt new file mode 100644 index 000000000..1b111f3d2 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/ExoPlayerActivity.kt @@ -0,0 +1,113 @@ +package com.github.jing332.tts_server_android.ui + +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.ASFUriUtils.getPath + +@kotlin.OptIn(ExperimentalMaterial3Api::class) +class ExoPlayerActivity : AppCompatActivity(), Player.Listener { + private val exoPlayer by lazy { + ExoPlayer.Builder(this).build().apply { + addListener(this@ExoPlayerActivity) + } + } + + @OptIn(UnstableApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + var text by mutableStateOf("") + setContent { + AppTheme { + Scaffold(topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.exo_player_title)) }, + navigationIcon = { + IconButton(onClick = { finish() }) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + stringResource(id = R.string.nav_back) + ) + } + }) + }) { + Column(Modifier.padding(it)) { + SelectionContainer { + Text(text = text, style = MaterialTheme.typography.bodyMedium) + } + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + PlayerView(context).apply { + player = exoPlayer + val transparentDrawable = ShapeDrawable(OvalShape()) + transparentDrawable.paint.color = + ContextCompat.getColor(context, android.R.color.transparent) + + background = transparentDrawable + setShutterBackgroundColor( + ContextCompat.getColor( + context, + android.R.color.transparent + ) + ) + } + }) + } + } + } + } + + intent.data?.let { uri -> + text = try { + getPath(uri, isTree = false) ?: "" + } catch (e: Exception) { + uri.toString() + } + exoPlayer.addMediaItem(MediaItem.fromUri(uri)) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + } + } + + override fun onDestroy() { + super.onDestroy() + exoPlayer.release() + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + displayErrorDialog(error) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/FilePickerActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/FilePickerActivity.kt new file mode 100644 index 000000000..a5f299ec0 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/FilePickerActivity.kt @@ -0,0 +1,348 @@ +package com.github.jing332.tts_server_android.ui + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import com.drake.net.utils.withMain +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.AppSelectionDialog +import com.github.jing332.tts_server_android.conf.AppConfig +import com.github.jing332.tts_server_android.constant.FilePickerMode +import com.github.jing332.tts_server_android.help.ByteArrayBinder +import com.github.jing332.tts_server_android.ui.view.AppDialogs.displayErrorDialog +import com.github.jing332.tts_server_android.utils.FileUtils +import com.github.jing332.tts_server_android.utils.FileUtils.mimeType +import com.github.jing332.tts_server_android.utils.getBinder +import com.github.jing332.tts_server_android.utils.grantReadWritePermission +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import me.rosuh.filepicker.bean.FileItemBeanImpl +import me.rosuh.filepicker.config.AbstractFileFilter +import me.rosuh.filepicker.config.FilePickerManager +import java.io.File + + +@Suppress("DEPRECATION") +class FilePickerActivity : AppCompatActivity() { + companion object { + const val KEY_REQUEST_DATA = "KEY_REQUEST_DATA" + private const val REQUEST_CODE_SAVE_FILE = 123321 + } + + private lateinit var requestData: IRequestData + + private val reqSaveFile: RequestSaveFile + get() = requestData as RequestSaveFile + + private val reqSelectDir: RequestSelectDir + get() = requestData as RequestSelectDir + + private val reqSelectFile: RequestSelectFile + get() = requestData as RequestSelectFile + + private lateinit var docCreate: ActivityResultLauncher + + private val docTreeSelector = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { + it?.grantReadWritePermission(contentResolver) + resultAndFinish(it) + } + + private val docSelector = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { + it?.grantReadWritePermission(contentResolver) + resultAndFinish(it) + } + + private fun resultAndFinish(uri: Uri?) { + setResult(RESULT_OK, Intent().apply { + putExtra(KEY_REQUEST_DATA, requestData) + data = uri + }) + finish() + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + FilePickerManager.REQUEST_CODE -> { + if (resultCode == Activity.RESULT_OK) { + val list = FilePickerManager.obtainData() + resultAndFinish(list.getOrNull(0)?.toUri()) + } + finish() + } + + REQUEST_CODE_SAVE_FILE -> { + if (resultCode == Activity.RESULT_OK) { + val fileDir = FilePickerManager.obtainData().getOrNull(0) + if (fileDir == null) { + toast(R.string.path_is_empty) + } else { + if (FileUtils.saveFile( + fileDir + "/${reqSaveFile.fileName}", + reqSaveFile.fileBytes!! + ) + ) toast(R.string.save_success) + else + toast(getString(R.string.file_save_failed, "")) + + } + } + finish() + } + + else -> super.onActivityResult(requestCode, resultCode, data) + } + + } + + private fun checkPermission(permission: String): Boolean { + val extPermission = ActivityCompat.checkSelfPermission( + this, permission + ) + if (extPermission != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this, arrayOf( + permission, + ), 1 + ) + return false + } + + return true + } + + private var useSystem = false + + @Suppress("DEPRECATION") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val lp = window.attributes + lp.alpha = 0.0f + window.attributes = lp + + requestData = intent.getParcelableExtra(KEY_REQUEST_DATA)!! + + checkPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkPermission(Manifest.permission.READ_MEDIA_AUDIO) + } + + if (requestData is RequestSaveFile) { + val permission = ActivityCompat.checkSelfPermission( + this, Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + if (permission != PackageManager.PERMISSION_GRANTED) + ActivityCompat.requestPermissions( + this, arrayOf( + Manifest.permission.WRITE_EXTERNAL_STORAGE, + ), 1 + ) + + docCreate = + registerForActivityResult(ActivityResultContracts.CreateDocument(reqSaveFile.fileMime)) { uri -> + if (uri == null) { + finish() + return@registerForActivityResult + } + uri.grantReadWritePermission(contentResolver) + lifecycleScope.launch(Dispatchers.IO) { + kotlin.runCatching { + contentResolver.openOutputStream(uri) + .use { it?.write(reqSaveFile.fileBytes) } + toast(R.string.save_success) + }.onFailure { + displayErrorDialog(it) + }.onSuccess { + withMain { finish() } + } + } + } + } + + var showPromptDialog by mutableStateOf(false) + setContent { + AppTheme { + if (showPromptDialog) + AppSelectionDialog( + onDismissRequest = { + showPromptDialog = false + finish() + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.FileOpen, null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.file_picker)) + } + }, + value = Unit, + values = listOf(0, 1), + entries = listOf( + stringResource(id = R.string.file_picker_mode_system), + stringResource(id = R.string.file_picker_mode_builtin) + ), + onClick = { index, _ -> + showPromptDialog = false + useSystem = index == 0 + doAction() + } + ) + } + } + + when (AppConfig.filePickerMode.value) { + FilePickerMode.PROMPT -> { + showPromptDialog = true + } + + FilePickerMode.SYSTEM -> { + useSystem = true + doAction() + } + + FilePickerMode.BUILTIN -> { + useSystem = false + doAction() + } + + } + } + + private fun doAction() { + when (requestData) { + is RequestSaveFile -> { + val binder = intent.getBinder() + if (binder is ByteArrayBinder) { + reqSaveFile.fileBytes = binder.data + saveFile() + } + } + + is RequestSelectFile -> selectFile() + is RequestSelectDir -> selectDir() + } + } + + private fun saveFile() { + if (useSystem) + kotlin.runCatching { + docCreate.launch(reqSaveFile.fileName) + }.onFailure { + it.printStackTrace() + toast(R.string.sys_doc_picker_error) + useSystem = false + return saveFile() + } + else { + pickerDir(REQUEST_CODE_SAVE_FILE) + } + } + + private fun selectFile() { + if (useSystem) { + kotlin.runCatching { + docSelector.launch(reqSelectFile.fileMimes.toTypedArray()) + }.onFailure { + toast(R.string.sys_doc_picker_error) + useSystem = true + return selectFile() + } + } else { + FilePickerManager + .from(this) + .maxSelectable(1) + .showCheckBox(false) + .enableSingleChoice() + .filter(object : AbstractFileFilter() { + override fun doFilter(listData: ArrayList): ArrayList { + return ArrayList(listData.filter { item -> + item.isDir || reqSelectFile.fileMimes.contains(File(item.filePath).mimeType) + }) + } + }) + .forResult(FilePickerManager.REQUEST_CODE) + } + } + + private fun selectDir() { + if (useSystem) { + kotlin.runCatching { + docTreeSelector.launch(Uri.EMPTY) + }.onFailure { + toast(R.string.sys_doc_picker_error) + useSystem = true + return selectDir() + } + } else { + pickerDir() + } + } + + private fun pickerDir(requestCode: Int = FilePickerManager.REQUEST_CODE) { + FilePickerManager + .from(this) + .maxSelectable(1) + .filter(object : AbstractFileFilter() { + override fun doFilter(listData: ArrayList): ArrayList { + return ArrayList(listData.filter { item -> + item.isDir + }) + } + }) + .enableSingleChoice() + .skipDirWhenSelect(false) + .forResult(requestCode) + } + + + interface IRequestData : Parcelable {} + + @Parcelize + data class RequestSaveFile( + val fileName: String = "ttsrv-file.json", + val fileMime: String = "text/*", + + // 大数据使用Binder传递 这里只是负责临时存取 + @IgnoredOnParcel + @Suppress("ArrayInDataClass") + var fileBytes: ByteArray? = null + ) : IRequestData + + @Parcelize + data class RequestSelectDir(val rootUri: Uri = Uri.EMPTY) : IRequestData + + @Parcelize + data class RequestSelectFile(val fileMimes: List = listOf("*")) : IRequestData +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/ImportConfigActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/ImportConfigActivity.kt new file mode 100644 index 000000000..3ce364500 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/ImportConfigActivity.kt @@ -0,0 +1,139 @@ +package com.github.jing332.tts_server_android.ui + +import android.content.ContentResolver +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.drake.net.utils.fileName +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.LocalImportFilePath +import com.github.jing332.tts_server_android.compose.systts.LocalImportRemoteUrl +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.AppSelectionDialog +import com.github.jing332.tts_server_android.ui.systts.ImportConfigFactory +import com.github.jing332.tts_server_android.ui.systts.ImportConfigFactory.gotoEditorFromJS +import com.github.jing332.tts_server_android.ui.systts.ImportType +import com.github.jing332.tts_server_android.utils.FileUtils.readAllText +import com.github.jing332.tts_server_android.utils.longToast + + +class ImportConfigActivity : AppCompatActivity() { + companion object { + const val TAG = "ImportConfigActivity" + } + + private var showFileTypeSelectDialog = mutableStateOf("") + + private var type = mutableStateOf("") + private var url = mutableStateOf("") + private var path = mutableStateOf("") + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + AppTheme { + if (showFileTypeSelectDialog.value.isNotEmpty()) { + AppSelectionDialog( + onDismissRequest = { showFileTypeSelectDialog.value = "" }, + title = { + Text(stringResource(id = R.string.import_file_as)) + }, + value = Any(), + values = ImportType.values().toList(), + entries = ImportType.values().map { stringResource(id = it.strResId) }, + onClick = { v, _ -> + type.value = (v as ImportType).id + path.value = showFileTypeSelectDialog.value + + showFileTypeSelectDialog.value = "" + } + ) + } + + val sheet = remember(type.value) { + ImportConfigFactory.getBottomSheet( + type = type.value, + onBadFormat = { + if (type.value.isNotEmpty()) { + longToast(R.string.import_config_type_unknown_msg) + finish() + } + } + ) + } + + CompositionLocalProvider( + LocalImportRemoteUrl provides url, + LocalImportFilePath provides path + ) { + sheet { finish() } + } + + + } + } + + importConfigFromIntent(intent) + } + + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + importConfigFromIntent(intent) + } + + private fun importConfigFromIntent(intent: Intent?) { + intent?.data?.let { + when (it.scheme) { + ContentResolver.SCHEME_CONTENT -> importFileFromIntent(intent) + ContentResolver.SCHEME_FILE -> importFileFromIntent(intent) + "ttsrv" -> importUrlFromIntent(intent) + else -> longToast(getString(R.string.invalid_scheme_msg)) + } + } + } + + private fun importUrlFromIntent(intent: Intent?) { + intent?.data?.let { uri -> + if (uri.scheme == "ttsrv") { + val path = uri.host ?: "" + val url = uri.path?.removePrefix("/") ?: "" + if (url.isBlank()) { + longToast(getString(R.string.invalid_url_msg, url)) + intent.data = null + return + } + + type.value = path + this.url.value = url + + intent.data = null + } + } + } + + private fun importFileFromIntent(intent: Intent?) { + if (intent?.data != null) { + if (intent.data?.fileName()?.endsWith("js", true) == true) { + val txt = intent.data?.readAllText(this) + if (txt.isNullOrBlank()) { + longToast(R.string.js_file_type_not_recognized) + } else + if (!gotoEditorFromJS(txt)) longToast(R.string.js_file_type_not_recognized) + + } else + showFileTypeSelectDialog.value = intent.data.toString() + + intent.data = null + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/LogViewAdapter.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/LogViewAdapter.kt deleted file mode 100644 index 1db3f5941..000000000 --- a/app/src/main/java/com/github/jing332/tts_server_android/ui/LogViewAdapter.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.jing332.tts_server_android.ui - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.github.jing332.tts_server_android.GoLog -import com.github.jing332.tts_server_android.R - -//显示日志的适配器 -class LogViewAdapter(private val dataSet: ArrayList) : - RecyclerView.Adapter() { - //追加日志 - fun append(data: GoLog) { - if (itemCount > 100) { //日志条目超过便移除第2行日志Item - dataSet.removeAt(1) - notifyItemRemoved(1) - } - dataSet.add(data) - notifyItemInserted(dataSet.size) - } - - fun removeAll() { - dataSet.removeAll(dataSet) - notifyDataSetChanged() - } - - //用来构建Item - class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val textView: TextView - - init { - textView = view.findViewById(R.id.textView) - } - } - - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.log_itemlist, viewGroup, false) - - return ViewHolder(view) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val data = dataSet[position] - viewHolder.textView.text = data.msg - viewHolder.textView.setTextColor(data.toColor()) - } - - override fun getItemCount() = dataSet.size -} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/MainActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/MainActivity.kt deleted file mode 100644 index b532ce711..000000000 --- a/app/src/main/java/com/github/jing332/tts_server_android/ui/MainActivity.kt +++ /dev/null @@ -1,255 +0,0 @@ -package com.github.jing332.tts_server_android.ui - -import android.annotation.SuppressLint -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.PowerManager -import android.provider.Settings -import android.text.Html -import android.text.method.LinkMovementMethod -import android.view.Gravity -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import android.widget.Button -import android.widget.EditText -import android.widget.TextView -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.github.jing332.tts_server_android.GoLog -import com.github.jing332.tts_server_android.GoLogLevel -import com.github.jing332.tts_server_android.R -import com.github.jing332.tts_server_android.service.TtsIntentService -import com.github.jing332.tts_server_android.utils.MyTools -import com.github.jing332.tts_server_android.utils.SharedPrefsUtils - - -class MainActivity : AppCompatActivity() { - companion object { - val TAG = "MainActivity" - } - - lateinit var etPort: EditText - lateinit var btnStart: Button - lateinit var btnClose: Button - - lateinit var rvLog: RecyclerView - lateinit var logList: ArrayList - lateinit var adapter: LogViewAdapter - - lateinit var myReceiver: MyReceiver - var mLastPosition = -1 - var mLastItemCount = -1 - - var isWakeLock = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) - rvLog = findViewById(R.id.rv_log) - etPort = findViewById(R.id.et_port) - btnStart = findViewById(R.id.btn_start) - btnClose = findViewById(R.id.btn_close) - - logList = ArrayList() - if (TtsIntentService.IsRunning) { //服务在运行 - etPort.setText(TtsIntentService.port.toString()) //设置监听端口 - setControlStatus(false) - val msg = "服务已在运行, 监听地址: localhost:${TtsIntentService.port}" - logList.add(GoLog(GoLogLevel.WarnLevel, msg)) - } else { - val msg = "请点击启动按钮\n然后右上角菜单打开网页版↗️\n" + - "随后生成链接导入阅读APP即可使用\n" + - "\n关闭请点关闭按钮, 并等待响应。\n" + - "⚠️注意: 本APP需常驻后台运行!⚠️" - logList.add(GoLog(GoLogLevel.InfoLevel, msg)) - } - - adapter = LogViewAdapter(logList) - rvLog.adapter = adapter - val layoutManager = LinearLayoutManager(this@MainActivity) - layoutManager.stackFromEnd = true - rvLog.layoutManager = layoutManager - - /* 用来判断是否在日志列表最底部 以确认是否自动滚动 */ - rvLog.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - mLastItemCount = recyclerView.layoutManager!!.itemCount - /* 当前状态为停止滑动状态SCROLL_STATE_IDLE时 */ - if (newState == RecyclerView.SCROLL_STATE_IDLE) { - if (recyclerView.layoutManager is LinearLayoutManager) { - mLastPosition = layoutManager.findLastVisibleItemPosition() - } - } - } - }) - - /*注册广播*/ - myReceiver = MyReceiver() - val intentFilter = IntentFilter(TtsIntentService.ACTION_SEND) - intentFilter.addAction(TtsIntentService.ACTION_ON_STARTED) - intentFilter.addAction(TtsIntentService.ACTION_ON_CLOSED) - intentFilter.addAction(TtsIntentService.ACTION_ON_LOG) - registerReceiver(myReceiver, intentFilter) - /*启动按钮*/ - btnStart.setOnClickListener { - /*启动服务*/ - val i = Intent(this.applicationContext, TtsIntentService::class.java) - i.putExtra("port", etPort.text.toString().toInt()) - i.putExtra("isWakeLock", isWakeLock) - startService(i) - } - /*关闭按钮*/ - btnClose.setOnClickListener { - if (TtsIntentService.IsRunning) { /*服务运行中*/ - TtsIntentService.closeServer(this) /*关闭服务 然后将通过广播通知MainActivity*/ - } - } - - MyTools.checkUpdate(this) - } - - override fun onDestroy() { - super.onDestroy() - unregisterReceiver(myReceiver) - } - - /*右上角更多菜单*/ - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - val inflation: MenuInflater = menuInflater - inflation.inflate(R.menu.menu_main, menu) - return true - } - - /* 准备菜单 */ - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - super.onPrepareOptionsMenu(menu) - val item = menu?.findItem(R.id.menu_wakeLock) - item?.isCheckable = true /* 设置{唤醒锁}菜单为可选中的 */ - /* 从配置文件读取并更新isWakeLock */ - isWakeLock = SharedPrefsUtils.getWakeLock(this) - item?.isChecked = isWakeLock - return true - } - - /*菜单点击事件*/ - @SuppressLint("BatteryLife") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_about -> { /*{关于}按钮*/ - val dlg = AlertDialog.Builder(this) - val tv = TextView(this) - tv.movementMethod = LinkMovementMethod() - - val htmlStr = - "Github开源地址: tts-server-android
" + - "特别感谢(他们的代码对我帮助很大):
" + - " asters1/tts(Go实现)" + - " ms-ra-forwarder" + - " TTS APP" + - " 阅读APP" - - tv.text = Html.fromHtml(htmlStr) - tv.gravity = Gravity.CENTER /* 居中 */ - dlg.setView(tv) - - dlg.setTitle("关于") - .setMessage("本应用界面使用Kotlin开发,底层服务由Go开发.") - .create().show() - true - } - R.id.menu_checkUpdate -> { /* {检查更新}按钮 */ - MyTools.checkUpdate(this) - true - } - R.id.menu_openWeb -> { /* {打开网页版} 按钮 */ - if (TtsIntentService.IsRunning) { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse("http://localhost:${TtsIntentService.port}") - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - startActivity(intent) - } else { - Toast.makeText(this, "请先启动服务!", Toast.LENGTH_LONG).show() - } - - true - } - R.id.menu_killBattery -> { /* {电池优化}按钮 */ - val intent = Intent() - val pm = getSystemService(POWER_SERVICE) as PowerManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (pm.isIgnoringBatteryOptimizations(packageName)) { - Toast.makeText(this, "已忽略电池优化", Toast.LENGTH_SHORT).show() - } else { - try { - intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS - intent.data = Uri.parse("package:$packageName") - startActivity(intent) - } catch (e: Exception) { - Toast.makeText(this, "系统不支持 请手动操作", Toast.LENGTH_SHORT).show() - e.printStackTrace() - } - } - } - true - } - R.id.menu_wakeLock -> { /* 唤醒锁 */ - item.isChecked = !item.isChecked /* 更新选中状态 */ - isWakeLock = item.isChecked - SharedPrefsUtils.setWakeLock(this, item.isChecked) - Toast.makeText(this, "${item.isChecked} 重启服务以生效", Toast.LENGTH_SHORT).show() - true - } - R.id.menu_shortcut -> { - MyTools.addShortcut(this, "开关") - true - } - - else -> super.onOptionsItemSelected(item) - } - } - - /* 监听广播 */ - inner class MyReceiver : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - when (intent?.action) { - TtsIntentService.ACTION_ON_LOG -> { - val data = intent.getSerializableExtra("data") as GoLog - adapter.append(data) - /* 判断是否在最底部 */ - if (mLastPosition == mLastItemCount - 1 || mLastPosition == mLastItemCount - 2) { - rvLog.scrollToPosition(adapter.itemCount - 1) - } - } - TtsIntentService.ACTION_ON_STARTED -> { - adapter.removeAll() /* 清空日志 */ - setControlStatus(false) - } - TtsIntentService.ACTION_ON_CLOSED -> { - setControlStatus(true) /* 设置运行按钮可点击 */ - } - } - } - } - - /* 设置底部按钮、端口 是否可点击 */ - fun setControlStatus(enable: Boolean) { - if (enable) { //可点击{运行}按钮,编辑 - etPort.isEnabled = true - btnStart.isEnabled = true - btnClose.isEnabled = false - } else { //禁用按钮,编辑 - etPort.isEnabled = false - btnStart.isEnabled = false - btnClose.isEnabled = true - } - } -} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/ScSwitchActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/ScSwitchActivity.kt deleted file mode 100644 index f8397ebc5..000000000 --- a/app/src/main/java/com/github/jing332/tts_server_android/ui/ScSwitchActivity.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.jing332.tts_server_android.ui - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import com.github.jing332.tts_server_android.R -import com.github.jing332.tts_server_android.utils.SharedPrefsUtils -import com.github.jing332.tts_server_android.service.TtsIntentService - -class ScSwitchActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_none) - - if (TtsIntentService.IsRunning) { - TtsIntentService.closeServer(this) - } else { - val i = Intent(this.applicationContext, TtsIntentService::class.java) - i.putExtra("isWakeLock", SharedPrefsUtils.getWakeLock(this)) - startService(i) - } - - finish() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/forwarder/MsForwarderSwitchActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/forwarder/MsForwarderSwitchActivity.kt new file mode 100644 index 000000000..d69030601 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/forwarder/MsForwarderSwitchActivity.kt @@ -0,0 +1,20 @@ +package com.github.jing332.tts_server_android.ui.forwarder + +import android.app.Activity +import android.os.Bundle +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.service.forwarder.ForwarderServiceManager.startMsTtsForwarder +import com.github.jing332.tts_server_android.service.forwarder.ms.MsTtsForwarderService + +/* 桌面长按菜单{开关} */ +class MsForwarderSwitchActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (MsTtsForwarderService.isRunning) + MsTtsForwarderService.instance?.close() + else + startMsTtsForwarder() + + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/forwarder/SystemForwarderSwitchActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/forwarder/SystemForwarderSwitchActivity.kt new file mode 100644 index 000000000..ba88d4300 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/forwarder/SystemForwarderSwitchActivity.kt @@ -0,0 +1,18 @@ +package com.github.jing332.tts_server_android.ui.forwarder + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.github.jing332.tts_server_android.service.forwarder.system.SysTtsForwarderService + +class SystemForwarderSwitchActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (SysTtsForwarderService.isRunning) + SysTtsForwarderService.instance?.close() + else + startService(Intent(this, SysTtsForwarderService::class.java)) + + finish() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/systts/ImportConfigFactory.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/systts/ImportConfigFactory.kt new file mode 100644 index 000000000..5ed03711b --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/systts/ImportConfigFactory.kt @@ -0,0 +1,71 @@ +package com.github.jing332.tts_server_android.ui.systts + +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.systts.list.ListImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.plugin.PluginImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.plugin.PluginManagerActivity +import com.github.jing332.tts_server_android.compose.systts.replace.ReplaceRuleImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.speechrule.SpeechRuleImportBottomSheet +import com.github.jing332.tts_server_android.compose.systts.speechrule.SpeechRuleManagerActivity + +enum class ImportType(val id: String, @StringRes val strResId: Int) { + LIST("list", R.string.config_list), + PLUGIN("plugin", R.string.plugin), + REPLACE_RULE("replaceRule", R.string.replace_rule), + SPEECH_RULE("speechRule", R.string.speech_rule) +} + +object ImportConfigFactory { + fun getBottomSheet(type: String, onBadFormat: () -> Unit): @Composable (() -> Unit) -> Unit { + return when (ImportType.values().find { it.id == type }) { + ImportType.LIST -> { + { ListImportBottomSheet(it) } + } + + ImportType.PLUGIN -> { + { PluginImportBottomSheet(it) } + } + + ImportType.REPLACE_RULE -> { + { ReplaceRuleImportBottomSheet(it) } + } + + ImportType.SPEECH_RULE -> { + { SpeechRuleImportBottomSheet(it) } + } + + else -> { + onBadFormat() + + return { println("bad format") } + } + } + } + + /** + * @return 是否识别成功 + */ + fun Context.gotoEditorFromJS(js: String): Boolean { + if (js.contains("PluginJS")) { + startActivity(Intent(this, PluginManagerActivity::class.java).apply { + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("js", js) + }) + + } else if (js.contains("SpeechRuleJS")) { + startActivity(Intent(this, SpeechRuleManagerActivity::class.java).apply { + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra("js", js) + }) + } else + return false + + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/systts/KeyBoardToolPop.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/systts/KeyBoardToolPop.kt new file mode 100644 index 000000000..4e96dc8df --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/systts/KeyBoardToolPop.kt @@ -0,0 +1,64 @@ +package com.github.jing332.tts_server_android.ui.systts + +import android.content.Context +import android.graphics.Rect +import android.view.* +import android.widget.PopupWindow +import androidx.core.content.ContextCompat +import com.github.jing332.tts_server_android.ui.view.Attributes.colorOnBackground +import com.github.jing332.tts_server_android.utils.dp +import com.github.jing332.tts_server_android.utils.windowSize +import splitties.systemservices.windowManager +import kotlin.math.abs + +class KeyBoardToolPop( + val context: Context, + private val rootView: View, + customView: View? = null, +) : PopupWindow( + customView, + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT +), ViewTreeObserver.OnGlobalLayoutListener { + + private var mIsSoftKeyBoardShowing: Boolean = false + + init { + isTouchable = true + isOutsideTouchable = false + isFocusable = false + inputMethodMode = INPUT_METHOD_NEEDED // 避免遮盖输入法 +// setBackgroundDrawable(ContextCompat.getDrawable(context, context.colorOnBackground)) + } + + fun attachToWindow(window: Window) { + window.decorView.viewTreeObserver.addOnGlobalLayoutListener(this) + contentView.measure( + View.MeasureSpec.UNSPECIFIED, + View.MeasureSpec.UNSPECIFIED, + ) + } + + override fun onGlobalLayout() { + val rect = Rect() + // 获取当前页面窗口的显示范围 + rootView.getWindowVisibleDisplayFrame(rect) + val screenHeight = windowManager.windowSize.heightPixels + val keyboardHeight = screenHeight - rect.bottom // 输入法的高度 + val preShowing = mIsSoftKeyBoardShowing + if (abs(keyboardHeight) > screenHeight / 5) { + mIsSoftKeyBoardShowing = true // 超过屏幕五分之一则表示弹出了输入法 + rootView.setPadding(0, 0, 0, contentView.measuredHeight) + if (!isShowing) + showAtLocation(rootView, Gravity.BOTTOM, 0, 0) + } else { + mIsSoftKeyBoardShowing = false + rootView.setPadding(0, 0, 0, 0) + if (preShowing) { + dismiss() + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/view/AppDialogs.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/AppDialogs.kt new file mode 100644 index 000000000..7859aae3e --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/AppDialogs.kt @@ -0,0 +1,12 @@ +package com.github.jing332.tts_server_android.ui.view + +import android.content.Context +import com.github.jing332.tts_server_android.utils.runOnUI + +object AppDialogs { + fun Context.displayErrorDialog(t: Throwable, title: String? = null) { + runOnUI { + ErrorDialogActivity.start(this, title ?: "Error", t) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/view/Attributes.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/Attributes.kt new file mode 100644 index 000000000..2b014b749 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/Attributes.kt @@ -0,0 +1,56 @@ +package com.github.jing332.tts_server_android.ui.view + +import android.content.Context +import android.util.TypedValue +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes + +object Attributes { + @ColorInt + fun Context.colorAttr(resId: Int): Int { + val typedValue = TypedValue() + this.theme.resolveAttribute( + resId, + typedValue, + true + ) + return typedValue.data + } + + + @DrawableRes + fun Context.drawableAttr(resId: Int): Int { + val typedValue = TypedValue() + this.theme.resolveAttribute( + resId, + typedValue, + true + ) + return typedValue.resourceId + } + + @get:ColorInt + val Context.colorChipStroke: Int + get() = colorAttr(com.google.android.material.R.attr.chipStrokeColor) + + @get:ColorInt + val Context.colorControlHighlight: Int + get() = colorAttr(com.google.android.material.R.attr.colorControlHighlight) + + @get:ColorInt + val Context.colorSurface: Int + get() = colorAttr(com.google.android.material.R.attr.colorSurface) + + @get:ColorInt + val Context.colorOnBackground: Int + get() = colorAttr(com.google.android.material.R.attr.colorOnBackground) + + @get:DrawableRes + val Context.selectableItemBackground: Int + get() = drawableAttr(android.R.attr.selectableItemBackground) + + @get:DrawableRes + val Context.selectableItemBackgroundBorderless: Int + get() = drawableAttr(android.R.attr.selectableItemBackgroundBorderless) + +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/view/BigTextView.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/BigTextView.kt new file mode 100644 index 000000000..907397440 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/BigTextView.kt @@ -0,0 +1,27 @@ +package com.github.jing332.tts_server_android.ui.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import com.github.jing332.tts_server_android.databinding.BigTextViewBinding +import splitties.systemservices.layoutInflater + +class BigTextView(context: Context, attrs: AttributeSet?, defaultStyle: Int) : + FrameLayout(context, attrs, defaultStyle) { + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) + constructor(context: Context) : this(context, null, 0) + + private val mBinding by lazy { + BigTextViewBinding.inflate(layoutInflater, this, true).apply { + tvLog.setTextIsSelectable(true) + } + } + + fun setText(text: CharSequence) { + mBinding.tvLog.text = text + } + + fun append(text: CharSequence) { + mBinding.tvLog.append(text) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/view/ErrorDialogActivity.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/ErrorDialogActivity.kt new file mode 100644 index 000000000..b9ed0ff96 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/ErrorDialogActivity.kt @@ -0,0 +1,230 @@ +package com.github.jing332.tts_server_android.ui.view + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Typeface +import android.os.Bundle +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.style.StyleSpan +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.lifecycleScope +import com.drake.net.utils.withIO +import com.github.jing332.tts_server_android.R +import com.github.jing332.tts_server_android.compose.theme.AppTheme +import com.github.jing332.tts_server_android.compose.widgets.AppDialog +import com.github.jing332.tts_server_android.compose.widgets.LoadingContent +import com.github.jing332.tts_server_android.constant.AppConst +import com.github.jing332.tts_server_android.utils.ClipboardUtils +import com.github.jing332.tts_server_android.utils.longToast +import com.github.jing332.tts_server_android.utils.rootCause +import com.github.jing332.tts_server_android.utils.toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import tts_server_lib.Tts_server_lib +import java.util.UUID + +@Suppress("DEPRECATION") +class ErrorDialogActivity : AppCompatActivity() { + companion object { + const val ACTION_FINISH = "com.github.jing332.tts_server_android.ui.view.ErrorDialogActivity.ACTION_FINISH" + + const val KEY_T_DATA = "throwable" + const val KEY_TITLE = "title" + private const val KEY_ID = "id" + + val vm by lazy { ErrorDialogViewModel() } + + fun start(context: Context, title: String, t: Throwable) { + val id = UUID.randomUUID().toString() + vm.throwableList[id] = t + context.startActivity(Intent(context, ErrorDialogActivity::class.java).apply { + putExtra(KEY_TITLE, title) + putExtra(KEY_ID, id) + }) + } + } + + private val mReceiver by lazy { MyReceiver() } + + inner class MyReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_FINISH) { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + val id = intent.getStringExtra(KEY_ID) ?: return + vm.throwableList.remove(id) + + AppConst.localBroadcast.unregisterReceiver(mReceiver) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val lp = window.attributes + lp.alpha = 0.0f + window.attributes = lp + + AppConst.localBroadcast.registerReceiver(mReceiver, IntentFilter(ACTION_FINISH)) + + val title = intent.getStringExtra(KEY_TITLE) ?: getString(R.string.error) + val t = vm.throwableList[intent.getStringExtra(KEY_ID) ?: ""] + ?: intent.getSerializableExtra(KEY_T_DATA) as? Throwable + + if (t == null) { + toast(R.string.error) + finish() + return + } + + val str = t.stackTraceToString() + setContent { + AppTheme { + var showDialog by remember { mutableStateOf(true) } + var isLoading by remember { mutableStateOf(false) } + AppDialog( + onDismissRequest = { + showDialog = false + finish() + }, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(title) + } + }, + content = { + LoadingContent(isLoading = isLoading) { + Column { + SelectionContainer { + Text( + text = t.localizedMessage ?: t.rootCause?.message ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + ThrowableText(t = str) + } + } + }, + buttons = { + Row(Modifier.fillMaxWidth()) { + TextButton(modifier = Modifier.padding(end = 8.dp), onClick = { + ClipboardUtils.copyText(str) + toast(R.string.copied) + showDialog = false + finish() + }) { + Text(stringResource(id = R.string.copy)) + } + + Row(Modifier.weight(1f)) { + Spacer(modifier = Modifier.weight(1f)) + TextButton(enabled = !isLoading, + onClick = { + lifecycleScope.launch(Dispatchers.Main) { + isLoading = true + kotlin.runCatching { + val url = withIO { Tts_server_lib.uploadLog(str) } + ClipboardUtils.copyText(url) + longToast(R.string.copied) + }.onFailure { + longToast( + getString( + R.string.upload_failed, + it.message + ) + ) + } + isLoading = false + } + }) { + Text(stringResource(id = R.string.upload_to_url)) + } + + TextButton(onClick = { + showDialog = false + finish() + }) { + Text(stringResource(id = R.string.confirm)) + } + } + } + } + ) + } + } + } + + @Composable + fun ThrowableText(modifier: Modifier = Modifier, t: String) { + AndroidView( + modifier = modifier, + factory = { ctx -> + val tv = BigTextView(ctx) + + t.lines().forEach { + val span = if (it.trimStart().startsWith("at")) { + SpannableStringBuilder(it).apply { + setSpan( + StyleSpan(Typeface.ITALIC), + 0, + it.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } else { + SpannableStringBuilder(it).apply { + setSpan( + StyleSpan(Typeface.BOLD), + 0, + it.length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + tv.append(span) + tv.append("\n") + } + + tv + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/view/ErrorDialogViewModel.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/ErrorDialogViewModel.kt new file mode 100644 index 000000000..00ef4549d --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/ErrorDialogViewModel.kt @@ -0,0 +1,7 @@ +package com.github.jing332.tts_server_android.ui.view + +import androidx.lifecycle.ViewModel + +class ErrorDialogViewModel : ViewModel() { + internal val throwableList = mutableMapOf() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/ui/view/widget/AppTextInputLayout.kt b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/widget/AppTextInputLayout.kt new file mode 100644 index 000000000..1914dbc30 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/ui/view/widget/AppTextInputLayout.kt @@ -0,0 +1,33 @@ +package com.github.jing332.tts_server_android.ui.view.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import com.google.android.material.R +import com.google.android.material.textfield.TextInputLayout + +open class AppTextInputLayout(context: Context, attrs: AttributeSet? = null, defaultStyle: Int = R.attr.textInputStyle) : + TextInputLayout(context, attrs, defaultStyle) { + constructor(context: Context, attrs: AttributeSet?) : this( + context, + attrs, + R.attr.textInputStyle + ) + + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + setTextInputAccessibilityDelegate(TextInputLayoutDelegate(this)) + } + + class TextInputLayoutDelegate(private val til: TextInputLayout) : AccessibilityDelegate(til) { + override fun onInitializeAccessibilityNodeInfo( + host: View, + info: AccessibilityNodeInfoCompat + ) { + super.onInitializeAccessibilityNodeInfo(host, info) + info.text = "${til.hint} ${til.editText?.text}" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ASFUriUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ASFUriUtils.kt new file mode 100644 index 000000000..9ec2cb0c7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ASFUriUtils.kt @@ -0,0 +1,140 @@ +package com.github.jing332.tts_server_android.utils + +import android.content.ContentUris +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.MediaStore + + +@Suppress("MemberVisibilityCanBePrivate") +object ASFUriUtils { + fun Context.getPath(uri: Uri?, isTree: Boolean = false): String? { + if (uri == null) return null + + if (uri.toString().startsWith("/")) return uri.toString() + return if (isTree) + getPathFromTree(this, uri) + else + ASFUriUtils.getPath(this, uri) + } + + fun getPathFromTree(context: Context, uri: Uri?): String? { + if (uri == null) return null + + val docUri = DocumentsContract.buildDocumentUriUsingTree( + uri, DocumentsContract.getTreeDocumentId(uri) + ) + return getPath(context, docUri) + } + + fun getPath(context: Context, uri: Uri?): String? { + if (uri == null) return null + + val isKitKat = true + // DocumentProvider + if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) { + // ExternalStorageProvider + if (isExternalStorageDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":").toTypedArray() + val type = split[0] + if ("primary".equals(type, ignoreCase = true)) { + return Environment.getExternalStorageDirectory().toString() + "/" + split[1] + } + + // TODO handle non-primary volumes + } else if (isDownloadsDocument(uri)) { + val id = DocumentsContract.getDocumentId(uri) + val contentUri: Uri = ContentUris.withAppendedId( + Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id) + ) + return getDataColumn(context, contentUri, null, null) + } else if (isMediaDocument(uri)) { + val docId = DocumentsContract.getDocumentId(uri) + val split = docId.split(":").toTypedArray() + val type = split[0] + val contentUri: Uri? = if ("image" == type) { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } else if ("video" == type) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else if ("audio" == type) { + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + } else + MediaStore.Files.getContentUri("external") + + val selection = "_id=?" + val selectionArgs = arrayOf( + split[1] + ) + return getDataColumn(context, contentUri, selection, selectionArgs) + } + } else if ("content".equals(uri.getScheme(), ignoreCase = true)) { + + // Return the remote address + return if (isGooglePhotosUri(uri)) uri.getLastPathSegment() else getDataColumn( + context, uri, null, null + ) + } else if ("file".equals(uri.getScheme(), ignoreCase = true)) { + return uri.getPath() + } + return null + } + + fun getDataColumn( + context: Context, uri: Uri?, selection: String?, selectionArgs: Array? + ): String? { + var cursor: Cursor? = null + val column = "_data" + val projection = arrayOf( + column + ) + try { + cursor = context.contentResolver.query( + uri!!, projection, selection, selectionArgs, null + ) + if (cursor != null && cursor.moveToFirst()) { + val index: Int = cursor.getColumnIndexOrThrow(column) + return cursor.getString(index) + } + } finally { + cursor?.close() + } + return null + } + + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is ExternalStorageProvider. + */ + fun isExternalStorageDocument(uri: Uri): Boolean { + return "com.android.externalstorage.documents" == uri.getAuthority() + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is DownloadsProvider. + */ + fun isDownloadsDocument(uri: Uri): Boolean { + return "com.android.providers.downloads.documents" == uri.getAuthority() + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is MediaProvider. + */ + fun isMediaDocument(uri: Uri): Boolean { + return "com.android.providers.media.documents" == uri.getAuthority() + } + + /** + * @param uri The Uri to check. + * @return Whether the Uri authority is Google Photos. + */ + fun isGooglePhotosUri(uri: Uri): Boolean { + return "com.google.android.apps.photos.content" == uri.getAuthority() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ClipBoardUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ClipBoardUtils.kt new file mode 100644 index 000000000..166abe067 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ClipBoardUtils.kt @@ -0,0 +1,96 @@ +package com.github.jing332.tts_server_android.utils + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.ClipboardManager.OnPrimaryClipChangedListener +import android.content.Context +import com.github.jing332.tts_server_android.App + + +/** + *
+ * author: Blankj
+ * blog  : http://blankj.com
+ * time  : 2016/09/25
+ * desc  : utils about clipboard
+
* + */ +object ClipboardUtils { + /** + * Copy the text to clipboard. + * + * The label equals name of package. + * + * @param text The text. + */ + fun copyText(text: CharSequence?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(App.instance.getPackageName(), text)) + } + + /** + * Copy the text to clipboard. + * + * @param label The label. + * @param text The text. + */ + fun copyText(label: CharSequence?, text: CharSequence?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(label, text)) + } + + /** + * Clear the clipboard. + */ + fun clear() { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText(null, "")) + } + + /** + * Return the label for clipboard. + * + * @return the label for clipboard + */ + fun getLabel(): CharSequence { + val cm = App.instance + .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val des = cm.primaryClipDescription ?: return "" + return des.label ?: return "" + } + + /** + * Return the text for clipboard. + * + * @return the text for clipboard + */ + val text: CharSequence + get() { + val cm = + App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = cm.primaryClip + if (clip != null && clip.itemCount > 0) { + val text = clip.getItemAt(0).coerceToText(App.instance) + if (text != null) { + return text + } + } + return "" + } + + /** + * Add the clipboard changed listener. + */ + fun addChangedListener(listener: OnPrimaryClipChangedListener?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.addPrimaryClipChangedListener(listener) + } + + /** + * Remove the clipboard changed listener. + */ + fun removeChangedListener(listener: OnPrimaryClipChangedListener?) { + val cm = App.instance.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.removePrimaryClipChangedListener(listener) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ConfigurationExtensions.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ConfigurationExtensions.kt new file mode 100644 index 000000000..669eab1d9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ConfigurationExtensions.kt @@ -0,0 +1,14 @@ +@file:Suppress("unused") + +package com.github.jing332.tts_server_android.utils + +import android.content.res.Configuration +import android.content.res.Resources + +val sysConfiguration: Configuration = Resources.getSystem().configuration + +val Configuration.isNightMode: Boolean + get() { + val mode = uiMode and Configuration.UI_MODE_NIGHT_MASK + return mode == Configuration.UI_MODE_NIGHT_YES + } \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/DecimalUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/DecimalUtils.kt new file mode 100644 index 000000000..ef3c05d25 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/DecimalUtils.kt @@ -0,0 +1,10 @@ +package com.github.jing332.tts_server_android.utils + +import java.math.BigDecimal +import java.math.RoundingMode + +object DecimalUtils { +} + +fun Float.toScale(scale: Int = 2) = + BigDecimal(this.toDouble()).setScale(scale, RoundingMode.HALF_UP).toFloat() \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/EncoderUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/EncoderUtils.kt new file mode 100644 index 000000000..8eeb5ade2 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/EncoderUtils.kt @@ -0,0 +1,50 @@ +package com.github.jing332.tts_server_android.utils + +import android.util.Base64 + +/** + * 编码工具 escape base64 + */ +@Suppress("unused") +object EncoderUtils { + + fun escape(src: String): String { + val tmp = StringBuilder() + for (char in src) { + val charCode = char.code + if (charCode in 48..57 || charCode in 65..90 || charCode in 97..122) { + tmp.append(char) + continue + } + + val prefix = when { + charCode < 16 -> "%0" + charCode < 256 -> "%" + else -> "%u" + } + tmp.append(prefix).append(charCode.toString(16)) + } + return tmp.toString() + } + + @JvmOverloads + fun base64Decode(str: String, flags: Int = Base64.DEFAULT): String { + val bytes = Base64.decode(str, flags) + return String(bytes) + } + + @JvmOverloads + fun base64Encode(str: String, flags: Int = Base64.NO_WRAP): String? { + return base64Encode(str.toByteArray(), flags) + } + + fun base64Encode(src: ByteArray, flags: Int = Base64.NO_WRAP): String? { + return Base64.encodeToString(src, flags) + } + + @JvmOverloads + fun base64DecodeToByteArray(str: String, flags: Int = Base64.DEFAULT): ByteArray { + return Base64.decode(str, flags) + } + +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ExtensionUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ExtensionUtils.kt new file mode 100644 index 000000000..1d13e65b5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ExtensionUtils.kt @@ -0,0 +1,355 @@ +package com.github.jing332.tts_server_android.utils + +import android.app.Activity +import android.app.Notification +import android.app.Service +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.ServiceInfo +import android.content.res.Resources +import android.graphics.Rect +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.os.SystemClock +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import android.util.DisplayMetrics +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.WindowManager +import android.view.WindowMetrics +import androidx.activity.OnBackPressedCallback +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import androidx.viewpager2.widget.ViewPager2 +import com.github.jing332.tts_server_android.constant.KeyConst +import java.lang.reflect.ParameterizedType + +fun Service.startForegroundCompat( + notificationId: Int, + notification: Notification +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // A14 + startForeground( + notificationId, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(notificationId, notification) + } +} + +@Composable +fun Modifier.simpleVerticalScrollbar( + state: LazyListState, + width: Dp = 8.dp, + color: Color = MaterialTheme.colorScheme.secondary +): Modifier { + val targetAlpha = if (state.isScrollInProgress) 1f else 0f + val duration = if (state.isScrollInProgress) 150 else 500 + + val alpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis = duration), label = "" + ) + + return drawWithContent { + drawContent() + + val firstVisibleElementIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index + val needDrawScrollbar = state.isScrollInProgress || alpha > 0.0f + + // Draw scrollbar if scrolling or if the animation is still running and lazy column has content + if (needDrawScrollbar && firstVisibleElementIndex != null) { + val elementHeight = this.size.height / state.layoutInfo.totalItemsCount + + val scrollbarOffsetY = + firstVisibleElementIndex * elementHeight + state.firstVisibleItemScrollOffset / 4 + +// val scrollbarOffsetY = firstVisibleElementIndex * elementHeight + val scrollbarHeight = state.layoutInfo.visibleItemsInfo.size * elementHeight + + drawRect( + color = color, + topLeft = Offset(this.size.width - width.toPx(), scrollbarOffsetY), + size = Size(width.toPx(), scrollbarHeight), + alpha = alpha + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Modifier.clickableRipple( + enabled: Boolean = true, + role: Role? = null, + onLongClick: (() -> Unit)? = null, + onLongClickLabel: String? = null, + onClickLabel: String? = null, + onClick: () -> Unit, +) = + this.combinedClickable( + enabled = enabled, + role = role, + indication = rememberRipple(), + interactionSource = remember { MutableInteractionSource() }, + onClickLabel = onClickLabel, + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = onLongClickLabel, + ) + +fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString { + val spanned = this@toAnnotatedString + append(spanned.toString()) + getSpans(0, spanned.length, Any::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + when (span) { + is StyleSpan -> when (span.style) { + Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), start, end + ) + } + + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(span.foregroundColor)), + start, + end + ) + } + } +} + +fun Context.registerGlobalReceiver( + actions: List, + receiver: BroadcastReceiver +) { + ContextCompat.registerReceiver(this, receiver, IntentFilter().apply { + actions.forEach { addAction(it) } + }, ContextCompat.RECEIVER_EXPORTED) +} + +fun View.performLongPress() { + this.isHapticFeedbackEnabled = true + this.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) +} + +fun Context.startActivity(clz: Class<*>) { + startActivity(Intent(this, clz).apply { action = Intent.ACTION_VIEW }) +} + +fun Uri.grantReadWritePermission(contentResolver: ContentResolver) { + contentResolver.takePersistableUriPermission( + this, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) +} + +fun Intent.getBinder(): IBinder? { + val bundle = getBundleExtra(KeyConst.KEY_BUNDLE) + return bundle?.getBinder(KeyConst.KEY_LARGE_DATA_BINDER) +} + +fun Intent.setBinder(binder: IBinder) { + putExtra( + KeyConst.KEY_BUNDLE, + Bundle().apply { + putBinder(KeyConst.KEY_LARGE_DATA_BINDER, binder) + }) +} + +val Int.dp: Int get() = SizeUtils.dp2px(this.toFloat()) + +val Int.px: Int get() = SizeUtils.px2dp(this.toFloat()) + +val Context.layoutInflater: LayoutInflater + get() = LayoutInflater.from(this) + +fun ViewGroup.setMarginMatchParent() { + this.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT +} + +/** + * 重启当前 Activity + */ +fun Activity.restart() { + finish() + ContextCompat.startActivity(this, intent, null) +} + +@Suppress("UNCHECKED_CAST") +fun Any.inflateBinding( + inflater: LayoutInflater, + root: ViewGroup? = null, + attachToParent: Boolean = false +): T { + return (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments + .filterIsInstance>() + .first() + .getDeclaredMethod( + "inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java + ) + .also { it.isAccessible = true } + .invoke(null, inflater, root, attachToParent) as T +} + +val WindowManager.windowSize: DisplayMetrics + get() { + val displayMetrics = DisplayMetrics() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics: WindowMetrics = currentWindowMetrics + val insets = windowMetrics.windowInsets + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) + displayMetrics.widthPixels = windowMetrics.bounds.width() - insets.left - insets.right + displayMetrics.heightPixels = windowMetrics.bounds.height() - insets.top - insets.bottom + } else { + @Suppress("DEPRECATION") + defaultDisplay.getMetrics(displayMetrics) + } + return displayMetrics + } + +@Suppress("DEPRECATION") +val Activity.displayHeight: Int + get() { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager.currentWindowMetrics + val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() + ) + windowMetrics.bounds.height() - insets.bottom - insets.top + } else + windowManager.defaultDisplay.height + } + +/** + * 点击防抖动 + */ +fun View.clickWithThrottle(throttleTime: Long = 600L, action: (v: View) -> Unit) { + this.setOnClickListener(object : View.OnClickListener { + private var lastClickTime: Long = 0 + + override fun onClick(v: View) { + if (SystemClock.elapsedRealtime() - lastClickTime < throttleTime) return + else action(v) + + lastClickTime = SystemClock.elapsedRealtime() + } + }) +} + +/** + * View 是否在屏幕上可见 + */ +fun View.isVisibleOnScreen(): Boolean { + if (!isShown) { + return false + } + val actualPosition = Rect() + val isGlobalVisible = getGlobalVisibleRect(actualPosition) + val screenWidth = Resources.getSystem().displayMetrics.widthPixels + val screenHeight = Resources.getSystem().displayMetrics.heightPixels + val screen = Rect(0, 0, screenWidth, screenHeight) + return isGlobalVisible && Rect.intersects(actualPosition, screen) +} + +fun ViewPager2.reduceDragSensitivity(f: Int = 4) { + val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") + recyclerViewField.isAccessible = true + val recyclerView = recyclerViewField.get(this) as RecyclerView + + val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") + touchSlopField.isAccessible = true + val touchSlop = touchSlopField.get(recyclerView) as Int + touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally +} + +/** + * 绑定返回键回调(建议使用该方法) + * @param owner Receive callbacks to a new OnBackPressedCallback when the given LifecycleOwner is at least started. + * This will automatically call addCallback(OnBackPressedCallback) and remove the callback as the lifecycle state changes. As a corollary, if your lifecycle is already at least started, calling this method will result in an immediate call to addCallback(OnBackPressedCallback). + * When the LifecycleOwner is destroyed, it will automatically be removed from the list of callbacks. The only time you would need to manually call OnBackPressedCallback.remove() is if you'd like to remove the callback prior to destruction of the associated lifecycle. + * @param onBackPressed 回调方法;返回true则表示消耗了按键事件,事件不会继续往下传递,相反返回false则表示没有消耗,事件继续往下传递 + * @return 注册的回调对象,如果想要移除注册的回调,直接通过调用[OnBackPressedCallback.remove]方法即可。 + */ +fun androidx.activity.ComponentActivity.addOnBackPressed( + owner: LifecycleOwner, + onBackPressed: () -> Boolean +): OnBackPressedCallback { + return backPressedCallback(onBackPressed).also { + onBackPressedDispatcher.addCallback(owner, it) + } +} + +/** + * 绑定返回键回调,未关联生命周期,建议使用关联生命周期的办法(尤其在fragment中使用,应该关联fragment的生命周期) + */ +fun androidx.activity.ComponentActivity.addOnBackPressed(onBackPressed: () -> Boolean): OnBackPressedCallback { + return backPressedCallback(onBackPressed).also { + onBackPressedDispatcher.addCallback(it) + } +} + +private fun androidx.activity.ComponentActivity.backPressedCallback(onBackPressed: () -> Boolean): OnBackPressedCallback { + return object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!onBackPressed()) { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/FileUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/FileUtils.kt new file mode 100644 index 000000000..e86c3b1ee --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/FileUtils.kt @@ -0,0 +1,305 @@ +package com.github.jing332.tts_server_android.utils + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.core.net.toFile +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.URLConnection + + +object FileUtils { + fun File.audioList(): List { + return if ( isFile) + listOf(this) + else + FileUtils.getAllFilesInFolder(this) + .filter { it.mimeType?.startsWith("audio") == true } + } + + /** + * 复制文件夹 + */ + fun copyFilesFromDir(src: File, target: File, overwrite: Boolean = true) { + target.mkdirs() + src.listFiles()?.forEach { + val newFile = File(target.absolutePath + File.separator + it.name) + it.copyTo(newFile, overwrite) + } + } + + fun copyFolder(src: File, target: File, overwrite: Boolean = true) { + val folder = File(target.absolutePath + File.separator + src.name) + folder.mkdirs() + + src.listFiles()?.forEach { + if (it.isFile) { + val newFile = File(folder.absolutePath + File.separator + it.name) + it.copyTo(newFile, overwrite) + } else if (it.isDirectory) { + copyFolder(it, folder) + } + } + + } + + /** + * 按行读取txt + */ + fun InputStream.readAllText(): String { + this.use { + bufferedReader().use { reader -> + val buffer = StringBuffer("") + var str: String? + while (reader.readLine().also { str = it } != null) { + buffer.appendLine(str) + } + return buffer.toString() + } + } + } + + fun exists(file: File): Boolean { + runCatching { + if (file.isFile) + return file.exists() + }.onFailure { + it.printStackTrace() + } + return false + } + + fun exists(filePath: String): Boolean { + return exists(File(filePath)) + } + + fun saveFile(path: String, data: ByteArray): Boolean { + return saveFile(File(path), data) + } + + fun saveFile(file: File, data: ByteArray): Boolean { + try { + if (!exists(file)) { + if (file.parentFile?.exists() == false) /* 文件夹不存在则创建 */ + file.parentFile?.mkdirs() + } + val fos = FileOutputStream(file) + fos.write(data) + fos.close() + return true + } catch (e: Exception) { + e.printStackTrace() + } + return false + } + + fun getAllFilesInFolder(folder: File): List { + val fileList = mutableListOf() + val files = folder.listFiles() ?: return fileList + for (file in files) { + if (file.isFile) { + fileList.add(file) + } else if (file.isDirectory) { + // 如果是文件夹,则递归调用该方法 + fileList.addAll(getAllFilesInFolder(file)) + } + } + return fileList + } + + /** + * 通过拓展名判断MIME + */ + val File.mimeType: String? + get() { + val fileNameMap = URLConnection.getFileNameMap() + return fileNameMap.getContentTypeFor(name) + } + + fun Uri.readBytes(context: Context): ByteArray { + return when (scheme) { + ContentResolver.SCHEME_CONTENT -> { + val input = context.contentResolver.openInputStream(this) + val bytes = input!!.readBytes() + input.close() + bytes + } + + ContentResolver.SCHEME_FILE -> toFile().readBytes() + else -> File(this.toString()).readBytes() + } + } + + fun Uri.readAllText(context: Context): String { + return readBytes(context).decodeToString() + } + + + /** + * Return the charset of file simply. + * + * @param filePath The path of file. + * @return the charset of file simply + */ + fun getFileCharsetSimple(filePath: String): String? { + return getFileCharsetSimple(File(filePath)) + } + + /** + * Return the charset of file simply. + * + * @param file The file. + * @return the charset of file simply + */ + fun getFileCharsetSimple(file: File?): String { + if (file == null) return "" + if (isUtf8(file)) return "UTF-8" + var p = 0 + var `is`: InputStream? = null + try { + `is` = BufferedInputStream(FileInputStream(file)) + p = (`is`.read() shl 8) + `is`.read() + } catch (e: IOException) { + e.printStackTrace() + } finally { + try { + `is`?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + return when (p) { + 0xfffe -> "Unicode" + 0xfeff -> "UTF-16BE" + else -> "GBK" + } + } + + /** + * Return whether the charset of file is utf8. + * + * @param filePath The path of file. + * @return `true`: yes

`false`: no + */ + fun isUtf8(filePath: String): Boolean { + return isUtf8(File(filePath)) + } + + /** + * Return whether the charset of file is utf8. + * + * @param file The file. + * @return `true`: yes

`false`: no + */ + fun isUtf8(file: File?): Boolean { + if (file == null) return false + var `is`: InputStream? = null + try { + val bytes = ByteArray(24) + `is` = BufferedInputStream(FileInputStream(file)) + val read = `is`.read(bytes) + return if (read != -1) { + val readArr = ByteArray(read) + System.arraycopy(bytes, 0, readArr, 0, read) + isUtf8(readArr) == 100 + } else { + false + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + try { + `is`?.close() + } catch (e: IOException) { + e.printStackTrace() + } + } + return false + } + + /** + * UTF-8编码方式 + * ---------------------------------------------- + * 0xxxxxxx + * 110xxxxx 10xxxxxx + * 1110xxxx 10xxxxxx 10xxxxxx + * 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx + */ + private fun isUtf8(raw: ByteArray): Int { + var utf8 = 0 + var ascii = 0 + if (raw.size > 3) { + if (raw[0].toInt() == 0xEF && raw[1].toInt() == 0xBB && raw[2].toInt() == 0xBF) { + return 100 + } + } + val len: Int = raw.size + var child = 0 + var i: Int = 0 + while (i < len) { + + // UTF-8 byte shouldn't be FF and FE + if (raw[i].toInt() and 0xFF.toByte().toInt() == 0xFF.toByte() + .toInt() || raw[i].toInt() and 0xFE.toByte().toInt() == 0xFE.toByte().toInt() + ) { + return 0 + } + if (child == 0) { + // ASCII format is 0x0******* + if (raw[i].toInt() and 0x7F.toByte() + .toInt() == raw[i].toInt() && raw[i].toInt() != 0 + ) { + ascii++ + } else if (raw[i].toInt() and 0xC0.toByte().toInt() == 0xC0.toByte().toInt()) { + // 0x11****** maybe is UTF-8 + for (bit in 0..7) { + child = if ((0x80 shr bit).toByte() + .toInt() and raw[i].toInt() == (0x80 shr bit).toByte() + .toInt() + ) { + bit + } else { + break + } + } + utf8++ + } + i++ + } else { + child = if (raw.size - i > child) child else raw.size - i + var currentNotUtf8 = false + for (children in 0 until child) { + // format must is 0x10****** + if (raw[i + children].toInt() and 0x80.toByte().toInt() != 0x80.toByte() + .toInt() + ) { + if (raw[i + children].toInt() and 0x7F.toByte() + .toInt() == raw[i + children].toInt() && raw[i].toInt() != 0 + ) { + // ASCII format is 0x0******* + ascii++ + } + currentNotUtf8 = true + } + } + if (currentNotUtf8) { + utf8-- + i++ + } else { + utf8 += child + i += child + } + child = 0 + } + } + // UTF-8 contains ASCII + return if (ascii == len) { + 100 + } else (100 * ((utf8 + ascii).toFloat() / len.toFloat())).toInt() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/GcManager.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/GcManager.kt new file mode 100644 index 000000000..b18c74fcc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/GcManager.kt @@ -0,0 +1,21 @@ +package com.github.jing332.tts_server_android.utils + +import android.os.SystemClock + + +object GcManager { + var last: Long = 0 + + /** + * 避免频繁GC + */ + @Synchronized + fun doGC() { + if (SystemClock.elapsedRealtime() - last > 10000) { + Runtime.getRuntime().gc() + last = SystemClock.elapsedRealtime() + } + + } + +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/HandlerUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/HandlerUtils.kt new file mode 100644 index 000000000..0466ebed5 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/HandlerUtils.kt @@ -0,0 +1,51 @@ +@file:Suppress("unused") +/* https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/utils/HandlerUtils.kt */ +package com.github.jing332.tts_server_android.utils + +import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +/** This main looper cache avoids synchronization overhead when accessed repeatedly. */ +private val mainLooper: Looper = Looper.getMainLooper() + +private val mainThread: Thread = mainLooper.thread + +private val isMainThread: Boolean inline get() = mainThread === Thread.currentThread() + +fun buildMainHandler(): Handler { + return if (SDK_INT >= 28) Handler.createAsync(mainLooper) else try { + Handler::class.java.getDeclaredConstructor( + Looper::class.java, + Handler.Callback::class.java, + Boolean::class.javaPrimitiveType // async + ).newInstance(mainLooper, null, true) + } catch (ignored: NoSuchMethodException) { + // Hidden constructor absent. Fall back to non-async constructor. + Handler(mainLooper) + } +} + +private val mainHandler by lazy { buildMainHandler() } + +fun runOnUI(function: () -> Unit) { + if (isMainThread) { + function() + } else { + mainHandler.post(function) + } +} + +fun CoroutineScope.runOnIO(function: suspend () -> Unit) { + if (isMainThread) { + launch(IO) { + function() + } + } else { + runBlocking { function() } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/LiveDataNoStickyExt.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/LiveDataNoStickyExt.kt new file mode 100644 index 000000000..cb93d06e6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/LiveDataNoStickyExt.kt @@ -0,0 +1,148 @@ +package com.github.jing332.tts_server_android.utils + + +import androidx.lifecycle.* + +/** + * 零入侵零反射解决 [LiveData] 在 event 场景下的粘性数据问题 + * + * 原理:既然不能入侵(魔改) [LiveData],那就对 observer 做文章,以静态代理的方式包装 [Observer],当 [Observer.onChanged] 被回调时判定其参数是否是粘性数据,如果是就忽略不处理 + * + * 判定依据:liveData 订阅(即执行 [LiveData.observe])前一刻 liveData 有了数据(即被执行过 setValue,[LiveData.mVersion] 不再是 [LiveData.START_VERSION]),且 observer 处于 active 状态(详见 [LiveData.ObserverWrapper.shouldBeActive]), + * 那么订阅时会立即触发 [Observer.onChanged] 执行,此时认为第一次在 onChanged 收到的参数就是粘滞数据 + * + * ‼️ 该方法中 [owner] 通过 [LifecycleOwner.activeWhenCreated] 扩宽了 [LiveData.LifecycleBoundObserver.shouldBeActive] 判定为 true 的范围; + * 目的在于实时接收并处理 event. 即当 [androidx.lifecycle.Lifecycle.getCurrentState] 处于 [androidx.lifecycle.Lifecycle.State.CREATED] 就开始接收 LiveData 数据, + * 不用等到 [androidx.lifecycle.Lifecycle.State.STARTED] 才开始处理; + * + * 实时接收并处理 Event 的原因: + * + * 1、此处防止数据粘滞的方案原理是识别粘滞数据并跳过对它的处理,如果在 inactive 时间区间内(即 observer observe ~ observer 状态达到 active 这个时间区间)[LiveData.setValue] 被触发,那么该行为不能够被 [NoStickyObserverWrapper] 有效感知 + * (因为处于 inactive 状态,[LiveData.dispatchingValue] -> [LiveData.considerNotify] -> [LiveData.ObserverWrapper.shouldBeActive] 为 false,所以不会回调 [Observer.onChanged]) + * 进而会导致跳过了非粘性数据的 bug 发生。 + * + * 2、event 的场景有别于 state 的场景,前者更加倾向于即时消费数据,不用延迟到 owner 处于 STARTED 状态再接收数据。虽然可以通过更加复杂的判定方案实现更加完美的粘滞数据判定, + * 但 activeWhenCreated 方案原理简单直接,契合 event 场景,且避免了过度复杂的设计 + * + * (复杂方案之一:NoStickyObserverWrapper 构造时持有 liveData 引用,observe 时读取一次 [LiveData.mVersion] 的值并记录(反射读取),[NoStickyObserverWrapper.onChanged] 第一次执行时读取 [LiveData.mVersion] 的值, + * 两者进行比较,根据变化就能知道 inactive 期间是否有被 setValue) + * (复杂方案之二:如果不使用反射,那么在 NoStickyObserverWrapper inactive 期间通过额外执行一次 observeForever 来辅助监视 liveData 的数据变化,当 NoStickyObserverWrapper 状态变更为 active 状态后移除 observeForever 相关配置。。。不推荐,复杂度急剧升高) + * + * ⚠️ 在试图通过 [LiveData.removeObservers] 方法移除 observer 时不能将 [NoStickyObserverWrapper] 真正移除。通常情况下 observer 会随着 owner 持有的 lifecycle 的 destroy 自动移除; + * 非要手动移除 observer 合理的做法是通过 [observeNoSticky](或 [observeForeverNoSticky]) 的返回值得到最终的 observer,再通过 [LiveData.removeObserver] 进行手动移除 + * + * @return 返回实际传入 LiveData 的 Observer,用于进行后续的 [LiveData.removeObserver] 操作 + */ +fun LiveData.observeNoSticky( + owner: LifecycleOwner, observer: Observer +): Observer { + // generate observer + val noStickyObserverWrapper = NoStickyObserverWrapper(this.hasValue(), observer) + // do observe + this.observe(owner.activeWhenCreated(), noStickyObserverWrapper) + return noStickyObserverWrapper +} + +/** + * 零入侵零反射解决 [LiveData] 在 event 场景下的粘性数据问题 + * + * 通过 [LiveData.observeForever] 订阅数据的 observer 是一直处于 active 状态的,详见 [LiveData.AlwaysActiveObserver] + * + * @return 返回实际传入 LiveData 的 observer,用于进行后续的 [LiveData.removeObserver] 操作 + */ +fun LiveData.observeForeverNoSticky( + observer: Observer +): Observer { + // generate observer + val noStickyObserverWrapper = NoStickyObserverWrapper(this.hasValue(), observer) + // do observe + this.observeForever(noStickyObserverWrapper) + return noStickyObserverWrapper +} + +/** + * 检测 LiveData 是否设置过数据(粘滞数据判定的关键方法) + * + * 如果 LiveData 已经有了有效数据([LiveData.mData] 已经不是 [LiveData.NOT_SET], 或 [LiveData.mVersion] 已经不是 [LiveData.START_VERSION]), + * 那么执行 [LiveData.observeForever] 方法时其内部会立即触发 [Observer.onChanged] 执行, + * 利用这个特性得以实现在不魔改 LiveData 的前提下感知 liveData 内部数据状态 + * + * (如果能接受使用反射,那么也可以直接反射读取 [LiveData.mVersion] 看它是不是 [LiveData.START_VERSION]) + */ +fun LiveData<*>.hasValue(): Boolean { + var hasValue = false + val observer = Observer { + hasValue = true + }/* 如果 liveData 设置过数据,会在 observeForever 执行时同步执行 onChanged 回调。*/ + observeForever(observer)/* 用完 observer 后立即移除. */ + removeObserver(observer) + return hasValue +} + +/////////////////////////////////////////////////////////////////////////// +// NoStickyObserverWrapper +/////////////////////////////////////////////////////////////////////////// + +/** + * 对 [Observer] 进行静态代理包装,包装类 [NoStickyObserverWrapper] 拿到数据后识别是否是粘滞数据,如果是那么不将其传递给 [originObserver] + * + * @param hasValueBeforeObserve [LiveData] 在被本 Observer 观察前(即执行 observe)是否已经被设置了数据(没有被设置过数据那么 [LiveData.mData] 是 [LiveData.NOT_SET]) + * @param originObserver 原始的 observer + */ +private class NoStickyObserverWrapper( + private val hasValueBeforeObserve: Boolean, + val originObserver: Observer, +) : Observer { + private var firstTime = true + + override fun onChanged(value: T) { + if (firstTime) { + firstTime = false/* 第一次执行. */ + when {/* 如果 observe 前有了数据,那么跳过该(粘滞数据)*/ + hasValueBeforeObserve -> Unit/* 如果 observe 前还没有数据,那么传递该数据. */ + else -> originObserver.onChanged(value) + } + } else {/* 不是第一次执行则直接传递数据. */ + originObserver.onChanged(value) + } + } + +} + +/** + * 将 owner 的 active 状态从 STARTED 状态扩宽到 CREATED 状态 + * + * Fragment 或 Activity 处于 CREATED 状态时依然要接收 LiveData 的实时数据时,可以使用此方案解决 + */ +fun LifecycleOwner.activeWhenCreated(): LifecycleOwner = ActiveWhenCreateLifecycleOwner(this) + +/** + * 根据当前 owner,构造一个新的 owner,源 owner 处于 CREATED 状态时,新 owner 处于 STARTED 状态(对应 LiveData 的 active 状态) + */ +private class ActiveWhenCreateLifecycleOwner( + origin: LifecycleOwner, +) : LifecycleOwner { + override val lifecycle = LifecycleRegistry(this) + + init { + origin.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + } + + override fun onResume(owner: LifecycleOwner) { + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + override fun onPause(owner: LifecycleOwner) { + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } + + override fun onDestroy(owner: LifecycleOwner) { + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + }) + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/MD5Utils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/MD5Utils.kt new file mode 100644 index 000000000..213447d31 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/MD5Utils.kt @@ -0,0 +1,24 @@ +package com.github.jing332.tts_server_android.utils + +import cn.hutool.crypto.digest.DigestUtil +import java.io.InputStream + +/** + * 将字符串转化为MD5 + */ +@Suppress("unused") +object MD5Utils { + fun md5Encode(str: String?): String { + return DigestUtil.digester("MD5").digestHex(str) + } + + fun md5Encode(inputStream: InputStream): String { + return DigestUtil.digester("MD5").digestHex(inputStream) + } + + fun md5Encode16(str: String): String { + var reStr = md5Encode(str) + reStr = reStr.substring(8, 24) + return reStr + } +} diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/MyTools.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/MyTools.kt index 0f2845252..88f7cad55 100644 --- a/app/src/main/java/com/github/jing332/tts_server_android/utils/MyTools.kt +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/MyTools.kt @@ -1,80 +1,67 @@ package com.github.jing332.tts_server_android.utils -import android.app.Activity +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context -import android.content.DialogInterface import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager import android.graphics.drawable.Icon import android.net.Uri import android.os.Build +import android.provider.Settings import android.util.Log import android.widget.Toast -import androidx.appcompat.app.AlertDialog +import com.github.jing332.tts_server_android.BuildConfig import com.github.jing332.tts_server_android.R -import com.github.jing332.tts_server_android.ui.MainActivity -import com.github.jing332.tts_server_android.ui.ScSwitchActivity -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.IOException -import org.json.JSONObject +import com.github.jing332.tts_server_android.bean.GithubReleaseApiBean +import com.github.jing332.tts_server_android.constant.AppConst +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import splitties.systemservices.powerManager import java.math.BigDecimal object MyTools { - val TAG = "MyTools" + const val TAG = "MyTools" - /*从Github检查更新*/ - fun checkUpdate(act: Activity) { - val client = OkHttpClient() - val request = Request.Builder() - .url("https://api.github.com/repos/jing332/tts-server-android/releases/latest") - .get() - .build() - client.newCall(request).enqueue(object : okhttp3.Callback { - override fun onFailure(call: okhttp3.Call, e: IOException) { - Log.d(MainActivity.TAG, "check update onFailure: ${e.message}") - act.runOnUiThread { - Toast.makeText(act, "检查更新失败 请检查网络", Toast.LENGTH_SHORT).show() + @SuppressLint("BatteryLife") + fun Context.killBattery() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + if (powerManager.isIgnoringBatteryOptimizations(packageName)) { + toast(R.string.added_battery_optimization_whitelist) + } else { + kotlin.runCatching { + startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + }) + }.onFailure { + toast(R.string.system_not_support_please_manual_set) } } - - override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) { - try { - val s = response.body?.string() - act.runOnUiThread { - checkVersionFromJson(act, s.toString()) - } - } catch (e: Exception) { - act.runOnUiThread { - Toast.makeText(act, "检查更新失败", Toast.LENGTH_SHORT).show() - e.printStackTrace() - } - } - } - }) + } } - fun checkVersionFromJson(ctx: Context, s: String) { - val json = JSONObject(s) - val tag: String = json.getString("tag_name") - val downloadUrl: String = - json.getJSONArray("assets").getJSONObject(0) - .getString("browser_download_url") - val body: String = json.getString("body") /*本次更新内容*/ - /* 远程版本号 */ - val versionName = BigDecimal(tag.split("_")[1].trim()) - val pi = ctx.packageManager.getPackageInfo(ctx.packageName, 0) - val appVersionName = /* 本地版本号 */ - BigDecimal(pi.versionName.split("_").toTypedArray()[1].trim { it <= ' ' }) - Log.d(TAG, "appVersionName: $appVersionName, versionName: $versionName") - if (appVersionName < versionName) {/* 需要更新 */ - downLoadAndInstall(ctx, body, downloadUrl, tag) - } else { - Toast.makeText(ctx, "当前已是最新版", Toast.LENGTH_SHORT).show() - } + private fun checkVersionFromJson(ctx: Context, s: String, isFromUser: Boolean) { + val cpuAbi = Build.SUPPORTED_ABIS[0] + val bean = AppConst.jsonBuilder.decodeFromString(s) + // 最大的为全量apk + val apkUniversalUrl = bean.assets.sortedByDescending { it.size }[0].browserDownloadUrl + // 根据CPU ABI判断精简版apk + val liteApk = + bean.assets.find { it.name.endsWith("${cpuAbi}.apk") }?.browserDownloadUrl ?: "" + val apkUrl = liteApk.ifBlank { apkUniversalUrl } + + val tag = bean.tagName + val body = bean.body + + val remoteVersion = BigDecimal(tag.split("_")[1].trim()) + val appVersion = BigDecimal(BuildConfig.VERSION_NAME.split("_")[1].trim()) + Log.d(TAG, "appVersionName: $appVersion, versionName: $remoteVersion") + + if (remoteVersion > appVersion) /* 需要更新 */ + runOnUI { downLoadAndInstall(ctx, body, apkUrl, tag) } + else if (isFromUser) + ctx.toast(R.string.current_is_last_version) } private fun downLoadAndInstall( @@ -83,32 +70,43 @@ object MyTools { downloadUrl: String, tag: String ) { - AlertDialog.Builder(ctx) - .setTitle("有新版本") - .setMessage("版本号: $tag\n\n$body") - .setPositiveButton( - "Github下载" - ) { dialog: DialogInterface?, which: Int -> - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse(downloadUrl) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - ctx.startActivity(intent) - } + MaterialAlertDialogBuilder(ctx) + .setTitle(ctx.getString(R.string.new_version_available, tag)) + .setMessage(body) + .setNeutralButton( + "Github" + ) { _, _ -> startDownload(ctx, downloadUrl) } .setNegativeButton( - "Github加速" - ) { dialog: DialogInterface?, which: Int -> - val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse("https://ghproxy.com/$downloadUrl") - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - ctx.startActivity(intent) + "GhProxy" + ) { _, _ -> startDownload(ctx, "https://ghproxy.com/$downloadUrl") } + .setPositiveButton("FastGit") { _, _ -> + startDownload(ctx, downloadUrl.replace("github.com", "download.fastgit.org")) } - .create().show() + .show() + } + + private fun startDownload(context: Context, url: String) { + ClipboardUtils.copyText(url) + context.toast(R.string.copied) + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(url) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) } + /* 添加快捷方式 */ - fun addShortcut(ctx: Context, name: String) { + @SuppressLint("UnspecifiedImmutableFlag") + @Suppress("DEPRECATION") + fun addShortcut( + ctx: Context, + name: String, + id: String, + iconResId: Int, + launcherIntent: Intent + ) { + ctx.longToast(R.string.add_shortcut_if_fail_tips) if (Build.VERSION.SDK_INT < 26) { /* Android8.0 */ - Toast.makeText(ctx, "如失败 请手动授予权限", Toast.LENGTH_SHORT).show() val addShortcutIntent = Intent("com.android.launcher.action.INSTALL_SHORTCUT") // 不允许重复创建 addShortcutIntent.putExtra("duplicate", false) // 经测试不是根据快捷方式的名字判断重复的 @@ -116,13 +114,11 @@ object MyTools { addShortcutIntent.putExtra( Intent.EXTRA_SHORTCUT_ICON_RESOURCE, Intent.ShortcutIconResource.fromContext( - ctx, R.drawable.ic_switch + ctx, iconResId ) ) - // 设置关联程序 - val launcherIntent = Intent(Intent.ACTION_MAIN) - launcherIntent.setClass(ctx, ScSwitchActivity::class.java) + launcherIntent.action = Intent.ACTION_MAIN launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER) addShortcutIntent .putExtra(Intent.EXTRA_SHORTCUT_INTENT, launcherIntent) @@ -132,22 +128,24 @@ object MyTools { } else { val shortcutManager: ShortcutManager = ctx.getSystemService(ShortcutManager::class.java) if (shortcutManager.isRequestPinShortcutSupported) { - val intent = Intent( - ctx, ScSwitchActivity::class.java - ) - intent.action = Intent.ACTION_VIEW - val pinShortcutInfo = ShortcutInfo.Builder(ctx, "tts_server") + launcherIntent.action = Intent.ACTION_VIEW + val pinShortcutInfo = ShortcutInfo.Builder(ctx, id) .setIcon( - Icon.createWithResource(ctx, R.drawable.ic_switch) + Icon.createWithResource(ctx, iconResId) ) - .setIntent(intent) - .setShortLabel("开关") + .setIntent(launcherIntent) + .setShortLabel(name) .build() val pinnedShortcutCallbackIntent = shortcutManager .createShortcutResultIntent(pinShortcutInfo) //Get notified when a shortcut is pinned successfully// + val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + } val successCallback = PendingIntent.getBroadcast( - ctx, 0, pinnedShortcutCallbackIntent, 0 + ctx, 0, pinnedShortcutCallbackIntent, pendingIntentFlags ) shortcutManager.requestPinShortcut( pinShortcutInfo, successCallback.intentSender diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ParcelUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ParcelUtils.kt new file mode 100644 index 000000000..4e1660f53 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ParcelUtils.kt @@ -0,0 +1,15 @@ +package com.github.jing332.tts_server_android.utils + +import android.os.Parcel +import android.os.Parcelable + +@Suppress("UNCHECKED_CAST") +fun Parcelable?.clone(): T? { + val p = Parcel.obtain() + p.writeValue(this) + p.setDataPosition(0) + val c: Class = this!!::class.java + val newObject = p.readValue(c.classLoader) as T? + p.recycle() + return newObject +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/SharedPrefsUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/SharedPrefsUtils.kt deleted file mode 100644 index 624c3e2bd..000000000 --- a/app/src/main/java/com/github/jing332/tts_server_android/utils/SharedPrefsUtils.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.jing332.tts_server_android.utils - -import android.content.Context - -class SharedPrefsUtils { - companion object { - @JvmStatic - fun getWakeLock(ctx: Context): Boolean { - val pref = ctx.getSharedPreferences("config", Context.MODE_PRIVATE) - return pref.getBoolean("wakeLock", false) - } - - @JvmStatic - fun setWakeLock(ctx: Context, isWakeLock: Boolean) { - val editor = ctx.getSharedPreferences("config", Context.MODE_PRIVATE).edit() - editor.putBoolean("wakeLock", isWakeLock) - editor.apply() - } - - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/SizeUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/SizeUtils.kt new file mode 100644 index 000000000..6085a98cc --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/SizeUtils.kt @@ -0,0 +1,51 @@ +package com.github.jing332.tts_server_android.utils + +import android.content.res.Resources + +object SizeUtils { + + /** + * Value of dp to value of px. + * + * @param dpValue The value of dp. + * @return value of px + */ + fun dp2px(dpValue: Float): Int { + val scale: Float = Resources.getSystem().displayMetrics.density + return (dpValue * scale + 0.5f).toInt() + } + + /** + * Value of px to value of dp. + * + * @param pxValue The value of px. + * @return value of dp + */ + fun px2dp(pxValue: Float): Int { + val scale: Float = Resources.getSystem().displayMetrics.density + return (pxValue / scale + 0.5f).toInt() + } + + /** + * Value of sp to value of px. + * + * @param spValue The value of sp. + * @return value of px + */ + @Suppress("DEPRECATION") + fun sp2px(spValue: Float): Int { + val fontScale: Float = Resources.getSystem().displayMetrics.scaledDensity + return (spValue * fontScale + 0.5f).toInt() + } + + /** + * Value of px to value of sp. + * + * @param pxValue The value of px. + * @return value of sp + */ + fun px2sp(pxValue: Float): Int { + val fontScale: Float = Resources.getSystem().getDisplayMetrics().scaledDensity + return (pxValue / fontScale + 0.5f).toInt() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/SoftKeyboardUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/SoftKeyboardUtils.kt new file mode 100644 index 000000000..f2d2b7ca1 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/SoftKeyboardUtils.kt @@ -0,0 +1,39 @@ +package com.github.jing332.tts_server_android.utils + +import android.app.Activity +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager + +// https://juejin.cn/post/6844903471687172104 +object SoftKeyboardUtils { + /** + * 隐藏软键盘(只适用于Activity,不适用于Fragment) + */ + fun hideSoftKeyboard(activity: Activity) { + val view: View? = activity.currentFocus + if (view != null) { + val inputMethodManager: InputMethodManager = + activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow( + view.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + } + + /** + * 隐藏软键盘(可用于Activity,Fragment) + */ + fun hideSoftKeyboard(context: Context, viewList: List?) { + if (viewList == null) return + val inputMethodManager: InputMethodManager = + context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager + for (v in viewList) { + inputMethodManager.hideSoftInputFromWindow( + v?.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/StringUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/StringUtils.kt new file mode 100644 index 000000000..e83c872a9 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/StringUtils.kt @@ -0,0 +1,125 @@ +package com.github.jing332.tts_server_android.utils + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.regex.Pattern +import kotlin.math.ln +import kotlin.math.pow + +object StringUtils { + private val silentPattern by lazy { Pattern.compile("[\\s\\p{C}\\p{P}\\p{Z}\\p{S}]") } + private val splitSentencesRegex by lazy { Pattern.compile("[。??!!;;]") } + + fun Long.sizeToReadable(): String { + val bytes = this + val unit = 1024 + if (bytes < unit) return "$bytes B" + val exp = (ln(bytes.toDouble()) / ln(unit.toDouble())).toInt() + val pre = "KMGTPE"[exp - 1] + "i" + return String.format("%.1f %sB", bytes / unit.toDouble().pow(exp.toDouble()), pre) + } + + fun formattedDate(): String = + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + + /** + * 是否为不发音的字符串 + */ + fun isSilent(s: String): Boolean { + return silentPattern.matcher(s).replaceAll("").isEmpty() + } + + /** + * 分割长句并保留分隔符 + */ + fun splitSentences(s: String): List { + val m = splitSentencesRegex.matcher(s) + val list = splitSentencesRegex.split(s) + //保留分隔符 + if (list.isNotEmpty()) { + var count = 0 + while (count < list.size) { + if (m.find()) { + list[count] += m.group() + } + count++ + } + } + + return list.filter { it.replace("”", "").isNotBlank() } + } + + /** + * 限制字符串长度 + */ + fun String.limitLength(maxLength: Int = 20, suffix: String = ""): String { + return if (length >= maxLength) + substring(0, maxLength) + suffix + else this + } + +} + +/** + * json字符串头尾加[ ] + */ +fun String.toJsonListString(): String { + var s = this.trim().removeSuffix(",") + if (!startsWith("[")) s = "[$s" + if (!endsWith("]")) s += "]" + return s +} + + +/** + * 字符串中汉字数量 + */ +fun String.lengthOfChinese(): Int { + var count = 0 + val c: CharArray = toCharArray() + for (i in c.indices) { + val len = Integer.toBinaryString(c[i].code) + if (len.length > 8) count++ + } + return count +} + +/** + * 转为html粗体标签 + */ +fun String.toHtmlBold(): String { + return "$this" +} + +fun String.toHtmlItalic(): String { + return "$this" +} + +fun String.toHtmlSmall(): String { + return "$this" +} + +fun String.appendHtmlBr(count: Int = 1): String { + val strBuilder = StringBuilder(this) + for (i in (1..count)) { + strBuilder.append("
") + } + return strBuilder.toString() +} + +fun String.toNumberInt(): Int { + return this.replace(Regex("[^0-9]"), "").toIntOrNull() ?: 0 +} + +fun String.bytesToReadable(bytes: Long): String { + val kb = 1024 + val mb = kb * 1024 + val gb = mb * 1024 + + return when { + bytes < mb -> "${"%.2f".format(bytes.toDouble() / kb)} KB" + bytes < gb -> "${"%.2f".format(bytes.toDouble() / mb)} MB" + else -> "$bytes B" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ThrottleUtil.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ThrottleUtil.kt new file mode 100644 index 000000000..a21162fe7 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ThrottleUtil.kt @@ -0,0 +1,20 @@ +package com.github.jing332.tts_server_android.utils + +import kotlinx.coroutines.* + +@OptIn(DelicateCoroutinesApi::class) +class ThrottleUtil(private val scope: CoroutineScope = GlobalScope, val time: Long = 100L) { + var job: Job? = null + + fun runAction( + dispatcher: CoroutineDispatcher = Dispatchers.Main, + action: suspend () -> Unit, + ) { + job?.cancel() + job = null + job = scope.launch(dispatcher) { + delay(time) + action.invoke() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ThrowableUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ThrowableUtils.kt new file mode 100644 index 000000000..b3dc13eed --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ThrowableUtils.kt @@ -0,0 +1,17 @@ +package com.github.jing332.tts_server_android.utils + +import cn.hutool.core.exceptions.ExceptionUtil.getThrowableList + + +object ThrowableUtils { + fun getRootCause(throwable: Throwable?): Throwable? { + val list = getThrowableList(throwable) + return if (list.size < 2) null else list[list.size - 1] as Throwable + } +} + +val Throwable.rootCause: Throwable? + get() = ThrowableUtils.getRootCause(this) + +val Throwable.readableString: String + get() = "${rootCause}\n⬇ More:\n${stackTraceToString()}" diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ToastUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ToastUtils.kt new file mode 100644 index 000000000..6a6a06c33 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ToastUtils.kt @@ -0,0 +1,49 @@ +@file:Suppress("unused") +/* https://github.com/gedoor/legado/blob/master/app/src/main/java/io/legado/app/utils/ToastUtils.kt */ +package com.github.jing332.tts_server_android.utils + +import android.content.Context +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment + +fun Context.toast(@StringRes message: Int, vararg args: Any) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, getString(message, *args), Toast.LENGTH_SHORT).show() + } + } +} + +fun Context.toast(message: CharSequence?) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + } +} + +fun Context.longToast(@StringRes message: Int, vararg args: Any) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, getString(message, *args), Toast.LENGTH_LONG).show() + } + } +} + +fun Context.longToast(message: CharSequence?) { + runOnUI { + kotlin.runCatching { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + } +} + + +fun Fragment.toast(@StringRes message: Int) = requireActivity().toast(message) + +fun Fragment.toast(message: CharSequence) = requireActivity().toast(message) + +fun Fragment.longToast(@StringRes message: Int) = requireContext().longToast(message) + +fun Fragment.longToast(message: CharSequence) = requireContext().longToast(message) \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/VarDelegate.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/VarDelegate.kt new file mode 100644 index 000000000..9db70c1d4 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/VarDelegate.kt @@ -0,0 +1,10 @@ +package com.github.jing332.tts_server_android.utils + +import kotlin.reflect.KProperty + +class VarDelegate(var value: Any?) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): Any? = value + operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Any?) { + value = newValue + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/jing332/tts_server_android/utils/ZipUtils.kt b/app/src/main/java/com/github/jing332/tts_server_android/utils/ZipUtils.kt new file mode 100644 index 000000000..b39b4e8e6 --- /dev/null +++ b/app/src/main/java/com/github/jing332/tts_server_android/utils/ZipUtils.kt @@ -0,0 +1,146 @@ +package com.github.jing332.tts_server_android.utils + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.Deflater +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.coroutines.coroutineContext + + +object ZipUtils { + const val TAG = "ZipUtils" + + suspend fun zipFolder(sourceFolder: File, zipFile: File) { + zipFile.delete() + zipFile.parentFile?.mkdirs() + zipFile.createNewFile() + + val fos = FileOutputStream(zipFile) + val zos = ZipOutputStream(BufferedOutputStream(fos)) + + zos.setLevel(Deflater.DEFAULT_COMPRESSION) + zipFolder(sourceFolder, zos, "") + zos.closeEntry() + zos.close() + } + + @Throws(IOException::class) + private suspend fun zipFolder(folder: File, zos: ZipOutputStream, parentPath: String) { + val files = folder.listFiles() + val buffer = ByteArray(4096) + var bytesRead: Int + + for (file in files!!) { + if (!coroutineContext.isActive) break + + if (file.isDirectory) { + zipFolder(file, zos, parentPath + file.name + "/") + } else { + val fis = FileInputStream(file) + val bis = BufferedInputStream(fis) + val entryPath = parentPath + file.name + val entry = ZipEntry(entryPath) + zos.putNextEntry(entry) + while (bis.read(buffer).also { bytesRead = it } != -1) { + if (!coroutineContext.isActive) break + zos.write(buffer, 0, bytesRead) + } + bis.close() + fis.close() + } + } + } + + suspend fun unzipFile(zis: ZipInputStream, destFolder: File) { + val buffer = ByteArray(4096) + var bytesRead: Int + var entry: ZipEntry? = zis.nextEntry + while (coroutineContext.isActive && entry != null) { + val entryName = entry.name + val entryPath = destFolder.absolutePath + File.separator + entryName + + if (entry.isDirectory) { + val dir = File(entryPath) + dir.mkdirs() + } else { + val file = File(entryPath) + file.parentFile?.mkdirs() + file.delete() + file.createNewFile() + val fos = FileOutputStream(entryPath) + val bos = BufferedOutputStream(fos) + while (zis.read(buffer).also { bytesRead = it } != -1) { + bos.write(buffer, 0, bytesRead) + } + bos.close() + } + zis.closeEntry() + + entry = zis.nextEntry + } + zis.close() + } + + + /** + * @param onProgress readCompressedSize 已经解压的压缩大小 + */ + suspend fun unzipFile( + zis: ZipInputStream, + destFolder: File, + bufferSize: Int = 4096, + + onProgress: (readCompressedSize: Long, entry: ZipEntry?) -> Unit + ) { + val buffer = ByteArray(bufferSize) + var bytesRead = 0 + var totalBytesRead = 0L + + withContext(Dispatchers.IO) { + var entry: ZipEntry? = zis.nextEntry + while (coroutineContext.isActive && entry != null) { + val entryName = entry.name + val entryPath = destFolder.absolutePath + File.separator + entryName + + onProgress(totalBytesRead, entry) + Log.d(TAG, "unzipFile: $entryName") + if (entry.isDirectory) { + File(entryPath).mkdirs() + } else { + val file = File(entryPath) + file.parentFile?.mkdirs() + file.delete() + file.createNewFile() + FileOutputStream(file).use { fos -> + BufferedOutputStream(fos).use { bos -> + while (coroutineContext.isActive && zis.read(buffer) + .also { bytesRead = it } != -1 + ) { + bos.write(buffer, 0, bytesRead) + } + } + } + } + zis.closeEntry() + + totalBytesRead += entry.compressedSize + val next = zis.nextEntry + if (next == null) + onProgress(totalBytesRead, null) + + entry = next + } + zis.close() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt new file mode 100644 index 000000000..5b8b8fd86 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefresh.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.Drag +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable +import androidx.compose.ui.unit.Velocity + +/** + * A nested scroll modifier that provides scroll events to [state]. + * + * Note that this modifier must be added above a scrolling container, such as a lazy column, in + * order to receive scroll events. For example: + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param state The [PullRefreshState] associated with this pull-to-refresh component. + * The state will be updated by this modifier. + * @param enabled If not enabled, all scroll delta and fling velocity will be ignored. + */ +// TODO(b/244423199): Move pullRefresh into its own material library similar to material-ripple. +fun Modifier.pullRefresh( + state: PullRefreshState, + enabled: Boolean = true, +) = inspectable(inspectorInfo = debugInspectorInfo { + name = "pullRefresh" + properties["state"] = state + properties["enabled"] = enabled +}) { + Modifier.pullRefresh(state::onPull, state::onRelease, enabled) +} + +/** + * A nested scroll modifier that provides [onPull] and [onRelease] callbacks to aid building custom + * pull refresh components. + * + * Note that this modifier must be added above a scrolling container, such as a lazy column, in + * order to receive scroll events. For example: + * + * @sample androidx.compose.material.samples.CustomPullRefreshSample + * + * @param onPull Callback for dispatching vertical scroll delta, takes float pullDelta as argument. + * Positive delta (pulling down) is dispatched only if the child does not consume it (i.e. pulling + * down despite being at the top of a scrollable component), whereas negative delta (swiping up) is + * dispatched first (in case it is needed to push the indicator back up), and then the unconsumed + * delta is passed on to the child. The callback returns how much delta was consumed. + * @param onRelease Callback for when drag is released, takes float flingVelocity as argument. + * The callback returns how much velocity was consumed - in most cases this should only consume + * velocity if pull refresh has been dragged already and the velocity is positive (the fling is + * downwards), as an upwards fling should typically still scroll a scrollable component beneath the + * pullRefresh. This is invoked before any remaining velocity is passed to the child. + * @param enabled If not enabled, all scroll delta and fling velocity will be ignored and neither + * [onPull] nor [onRelease] will be invoked. + */ +fun Modifier.pullRefresh( + onPull: (pullDelta: Float) -> Float, + onRelease: suspend (flingVelocity: Float) -> Float, + enabled: Boolean = true, +) = inspectable(inspectorInfo = debugInspectorInfo { + name = "pullRefresh" + properties["onPull"] = onPull + properties["onRelease"] = onRelease + properties["enabled"] = enabled +}) { + Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled)) +} + +private class PullRefreshNestedScrollConnection( + private val onPull: (pullDelta: Float) -> Float, + private val onRelease: suspend (flingVelocity: Float) -> Float, + private val enabled: Boolean, +) : NestedScrollConnection { + + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled -> Offset.Zero + source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up + else -> Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = when { + !enabled -> Offset.Zero + source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down + else -> Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return Velocity(0f, onRelease(available.y)) + } +} diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt new file mode 100644 index 000000000..12ac2b682 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicator.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * The default indicator for Compose pull-to-refresh, based on Android's SwipeRefreshLayout. + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param refreshing A boolean representing whether a refresh is occurring. + * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. + * @param modifier Modifiers for the indicator. + * @param backgroundColor The color of the indicator's background. + * @param contentColor The color of the indicator's arc and arrow. + * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + */ +@Composable +// TODO(b/244423199): Consider whether the state parameter should be replaced with lambdas to +// enable people to use this indicator with custom pull-to-refresh components. +fun PullRefreshIndicator( + refreshing: Boolean, + state: PullRefreshState, + modifier: Modifier = Modifier, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + contentColor: Color = contentColorFor(backgroundColor), + scale: Boolean = false, +) { + val showElevation by remember(refreshing, state) { + derivedStateOf { refreshing || state.position > 0.5f } + } + + Surface( + modifier = modifier + .size(IndicatorSize) + .pullRefreshIndicatorTransform(state, scale), + shape = SpinnerShape, + color = backgroundColor, + shadowElevation = if (showElevation) Elevation else 0.dp, + ) { + Crossfade( + targetState = refreshing, + animationSpec = tween(durationMillis = CrossfadeDurationMs) + ) { refreshing -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val spinnerSize = (ArcRadius + StrokeWidth).times(2) + + if (refreshing) { + CircularProgressIndicator( + color = contentColor, + strokeWidth = StrokeWidth, + modifier = Modifier.size(spinnerSize), + ) + } else { + CircularArrowIndicator(state, contentColor, Modifier.size(spinnerSize)) + } + } + } + } +} + +/** + * Modifier.size MUST be specified. + */ +@Composable +private fun CircularArrowIndicator( + state: PullRefreshState, + color: Color, + modifier: Modifier, +) { + val path = remember { Path().apply { fillType = PathFillType.EvenOdd } } + + val targetAlpha by remember(state) { + derivedStateOf { + if (state.progress >= 1f) MaxAlpha else MinAlpha + } + } + + val alphaState = animateFloatAsState(targetValue = targetAlpha, animationSpec = AlphaTween) + + // Empty semantics for tests + Canvas(modifier.semantics {}) { + val values = ArrowValues(state.progress) + val alpha = alphaState.value + + rotate(degrees = values.rotation) { + val arcRadius = ArcRadius.toPx() + StrokeWidth.toPx() / 2f + val arcBounds = Rect( + size.center.x - arcRadius, + size.center.y - arcRadius, + size.center.x + arcRadius, + size.center.y + arcRadius + ) + drawArc( + color = color, + alpha = alpha, + startAngle = values.startAngle, + sweepAngle = values.endAngle - values.startAngle, + useCenter = false, + topLeft = arcBounds.topLeft, + size = arcBounds.size, + style = Stroke( + width = StrokeWidth.toPx(), + cap = StrokeCap.Square + ) + ) + drawArrow(path, arcBounds, color, alpha, values) + } + } +} + +@Immutable +private class ArrowValues( + val rotation: Float, + val startAngle: Float, + val endAngle: Float, + val scale: Float, +) + +private fun ArrowValues(progress: Float): ArrowValues { + // Discard first 40% of progress. Scale remaining progress to full range between 0 and 100%. + val adjustedPercent = max(min(1f, progress) - 0.4f, 0f) * 5 / 3 + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + + // Calculations based on SwipeRefreshLayout specification. + val endTrim = adjustedPercent * MaxProgressArc + val rotation = (-0.25f + 0.4f * adjustedPercent + tensionPercent) * 0.5f + val startAngle = rotation * 360 + val endAngle = (rotation + endTrim) * 360 + val scale = min(1f, adjustedPercent) + + return ArrowValues(rotation, startAngle, endAngle, scale) +} + +private fun DrawScope.drawArrow( + arrow: Path, + bounds: Rect, + color: Color, + alpha: Float, + values: ArrowValues, +) { + arrow.reset() + arrow.moveTo(0f, 0f) // Move to left corner + arrow.lineTo(x = ArrowWidth.toPx() * values.scale, y = 0f) // Line to right corner + + // Line to tip of arrow + arrow.lineTo( + x = ArrowWidth.toPx() * values.scale / 2, + y = ArrowHeight.toPx() * values.scale + ) + + val radius = min(bounds.width, bounds.height) / 2f + val inset = ArrowWidth.toPx() * values.scale / 2f + arrow.translate( + Offset( + x = radius + bounds.center.x - inset, + y = bounds.center.y + StrokeWidth.toPx() / 2f + ) + ) + arrow.close() + rotate(degrees = values.endAngle) { + drawPath(path = arrow, color = color, alpha = alpha) + } +} + +private const val CrossfadeDurationMs = 100 +private const val MaxProgressArc = 0.8f + +private val IndicatorSize = 40.dp +private val SpinnerShape = CircleShape +private val ArcRadius = 7.5.dp +private val StrokeWidth = 2.5.dp +private val ArrowWidth = 10.dp +private val ArrowHeight = 5.dp +private val Elevation = 6.dp + +// Values taken from SwipeRefreshLayout +private const val MinAlpha = 0.3f +private const val MaxAlpha = 1f +private val AlphaTween = tween(300, easing = LinearEasing) diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt new file mode 100644 index 000000000..afbaa0e6b --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshIndicatorTransform.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable + +/** + * A modifier for translating the position and scaling the size of a pull-to-refresh indicator + * based on the given [PullRefreshState]. + * + * @sample androidx.compose.material.samples.PullRefreshIndicatorTransformSample + * + * @param state The [PullRefreshState] which determines the position of the indicator. + * @param scale A boolean controlling whether the indicator's size scales with pull progress or not. + */ +// TODO: Consider whether the state parameter should be replaced with lambdas. +fun Modifier.pullRefreshIndicatorTransform( + state: PullRefreshState, + scale: Boolean = false, +) = inspectable(inspectorInfo = debugInspectorInfo { + name = "pullRefreshIndicatorTransform" + properties["state"] = state + properties["scale"] = scale +}) { + Modifier + // Essentially we only want to clip the at the top, so the indicator will not appear when + // the position is 0. It is preferable to clip the indicator as opposed to the layout that + // contains the indicator, as this would also end up clipping shadows drawn by items in a + // list for example - so we leave the clipping to the scrolling container. We use MAX_VALUE + // for the other dimensions to allow for more room for elevation / arbitrary indicators - we + // only ever really want to clip at the top edge. + .drawWithContent { + clipRect( + top = 0f, + left = -Float.MAX_VALUE, + right = Float.MAX_VALUE, + bottom = Float.MAX_VALUE + ) { + this@drawWithContent.drawContent() + } + } + .graphicsLayer { + translationY = state.position - size.height + + if (scale && !state.refreshing) { + val scaleFraction = LinearOutSlowInEasing + .transform(state.position / state.threshold) + .coerceIn(0f, 1f) + scaleX = scaleFraction + scaleY = scaleFraction + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt new file mode 100644 index 000000000..aeab7bbe0 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/material3/pullrefresh/PullRefreshState.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.material3.pullrefresh + +import androidx.compose.animation.core.animate +import androidx.compose.foundation.MutatorMutex +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.pow + +/** + * Creates a [PullRefreshState] that is remembered across compositions. + * + * Changes to [refreshing] will result in [PullRefreshState] being updated. + * + * @sample androidx.compose.material.samples.PullRefreshSample + * + * @param refreshing A boolean representing whether a refresh is currently occurring. + * @param onRefresh The function to be called to trigger a refresh. + * @param refreshThreshold The threshold below which, if a release + * occurs, [onRefresh] will be called. + * @param refreshingOffset The offset at which the indicator will be drawn while refreshing. This + * offset corresponds to the position of the bottom of the indicator. + */ +@Composable +fun rememberPullRefreshState( + refreshing: Boolean, + onRefresh: () -> Unit, + refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, + refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, +): PullRefreshState { + require(refreshThreshold > 0.dp) { "The refresh trigger must be greater than zero!" } + + val scope = rememberCoroutineScope() + val onRefreshState = rememberUpdatedState(onRefresh) + val thresholdPx: Float + val refreshingOffsetPx: Float + + with(LocalDensity.current) { + thresholdPx = refreshThreshold.toPx() + refreshingOffsetPx = refreshingOffset.toPx() + } + + val state = remember(scope) { + PullRefreshState(scope, onRefreshState, refreshingOffsetPx, thresholdPx) + } + + SideEffect { + state.setRefreshing(refreshing) + state.setThreshold(thresholdPx) + state.setRefreshingOffset(refreshingOffsetPx) + } + + return state +} + +/** + * A state object that can be used in conjunction with [pullRefresh] to add pull-to-refresh + * behaviour to a scroll component. Based on Android's SwipeRefreshLayout. + * + * Provides [progress], a float representing how far the user has pulled as a percentage of the + * refreshThreshold. Values of one or less indicate that the user has not yet pulled past the + * threshold. Values greater than one indicate how far past the threshold the user has pulled. + * + * Can be used in conjunction with [pullRefreshIndicatorTransform] to implement Android-like + * pull-to-refresh behaviour with a custom indicator. + * + * Should be created using [rememberPullRefreshState]. + */ +class PullRefreshState internal constructor( + private val animationScope: CoroutineScope, + private val onRefreshState: State<() -> Unit>, + refreshingOffset: Float, + threshold: Float, +) { + /** + * A float representing how far the user has pulled as a percentage of the refreshThreshold. + * + * If the component has not been pulled at all, progress is zero. If the pull has reached + * halfway to the threshold, progress is 0.5f. A value greater than 1 indicates that pull has + * gone beyond the refreshThreshold - e.g. a value of 2f indicates that the user has pulled to + * two times the refreshThreshold. + */ + val progress get() = adjustedDistancePulled / threshold + + internal val refreshing get() = _refreshing + internal val position get() = _position + internal val threshold get() = _threshold + + private val adjustedDistancePulled by derivedStateOf { distancePulled * DragMultiplier } + + private var _refreshing by mutableStateOf(false) + private var _position by mutableStateOf(0f) + private var distancePulled by mutableStateOf(0f) + private var _threshold by mutableStateOf(threshold) + private var _refreshingOffset by mutableStateOf(refreshingOffset) + + internal fun onPull(pullDelta: Float): Float { + if (_refreshing) return 0f // Already refreshing, do nothing. + + val newOffset = (distancePulled + pullDelta).coerceAtLeast(0f) + val dragConsumed = newOffset - distancePulled + distancePulled = newOffset + _position = calculateIndicatorPosition() + return dragConsumed + } + + internal fun onRelease(velocity: Float): Float { + if (refreshing) return 0f // Already refreshing, do nothing + + if (adjustedDistancePulled > threshold) { + onRefreshState.value() + } + animateIndicatorTo(0f) + val consumed = when { + // We are flinging without having dragged the pull refresh (for example a fling inside + // a list) - don't consume + distancePulled == 0f -> 0f + // If the velocity is negative, the fling is upwards, and we don't want to prevent the + // the list from scrolling + velocity < 0f -> 0f + // We are showing the indicator, and the fling is downwards - consume everything + else -> velocity + } + distancePulled = 0f + return consumed + } + + internal fun setRefreshing(refreshing: Boolean) { + if (_refreshing != refreshing) { + _refreshing = refreshing + distancePulled = 0f + animateIndicatorTo(if (refreshing) _refreshingOffset else 0f) + } + } + + internal fun setThreshold(threshold: Float) { + _threshold = threshold + } + + internal fun setRefreshingOffset(refreshingOffset: Float) { + if (_refreshingOffset != refreshingOffset) { + _refreshingOffset = refreshingOffset + if (refreshing) animateIndicatorTo(refreshingOffset) + } + } + + // Make sure to cancel any existing animations when we launch a new one. We use this instead of + // Animatable as calling snapTo() on every drag delta has a one frame delay, and some extra + // overhead of running through the animation pipeline instead of directly mutating the state. + private val mutatorMutex = MutatorMutex() + + private fun animateIndicatorTo(offset: Float) = animationScope.launch { + mutatorMutex.mutate { + animate(initialValue = _position, targetValue = offset) { value, _ -> + _position = value + } + } + } + + private fun calculateIndicatorPosition(): Float = when { + // If drag hasn't gone past the threshold, the position is the adjustedDistancePulled. + adjustedDistancePulled <= threshold -> adjustedDistancePulled + else -> { + // How far beyond the threshold pull has gone, as a percentage of the threshold. + val overshootPercent = abs(progress) - 1.0f + // Limit the overshoot to 200%. Linear between 0 and 200. + val linearTension = overshootPercent.coerceIn(0f, 2f) + // Non-linear tension. Increases with linearTension, but at a decreasing rate. + val tensionPercent = linearTension - linearTension.pow(2) / 4 + // The additional offset beyond the threshold. + val extraOffset = threshold * tensionPercent + threshold + extraOffset + } + } +} + +/** + * Default parameter values for [rememberPullRefreshState]. + */ +object PullRefreshDefaults { + /** + * If the indicator is below this threshold offset when it is released, a refresh + * will be triggered. + */ + val RefreshThreshold = 80.dp + + /** + * The offset at which the indicator should be rendered whilst a refresh is occurring. + */ + val RefreshingOffset = 56.dp +} + +/** + * The distance pulled is multiplied by this value to give us the adjusted distance pulled, which + * is used in calculating the indicator position (when the adjusted distance pulled is less than + * the refresh threshold, it is the indicator position, otherwise the indicator position is + * derived from the progress). + */ +private const val DragMultiplier = 0.5f diff --git a/app/src/main/res/color/bg_text_btn_color_selector.xml b/app/src/main/res/color/bg_text_btn_color_selector.xml new file mode 100644 index 000000000..690650b50 --- /dev/null +++ b/app/src/main/res/color/bg_text_btn_color_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/color/tab_text_color.xml b/app/src/main/res/color/tab_text_color.xml new file mode 100644 index 000000000..1babc4616 --- /dev/null +++ b/app/src/main/res/color/tab_text_color.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_error_24.xml b/app/src/main/res/drawable/baseline_error_24.xml new file mode 100644 index 000000000..af1c8f102 --- /dev/null +++ b/app/src/main/res/drawable/baseline_error_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/baseline_tag_24.xml b/app/src/main/res/drawable/baseline_tag_24.xml new file mode 100644 index 000000000..7083374e2 --- /dev/null +++ b/app/src/main/res/drawable/baseline_tag_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/bg_spinner_item_selector.xml b/app/src/main/res/drawable/bg_spinner_item_selector.xml new file mode 100644 index 000000000..df76a603d --- /dev/null +++ b/app/src/main/res/drawable/bg_spinner_item_selector.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_app_launcher_foreground.xml b/app/src/main/res/drawable/ic_app_launcher_foreground.xml new file mode 100644 index 000000000..bd1df8caa --- /dev/null +++ b/app/src/main/res/drawable/ic_app_launcher_foreground.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_app_notification.xml b/app/src/main/res/drawable/ic_app_notification.xml index 57eeddd8f..4ddc505f5 100644 --- a/app/src/main/res/drawable/ic_app_notification.xml +++ b/app/src/main/res/drawable/ic_app_notification.xml @@ -1,22 +1,10 @@ - - - - - + xmlns:tools="http://schemas.android.com/tools" + android:width="24dp" android:height="24dp" android:viewportWidth="1024" android:viewportHeight="1024"> + + + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 000000000..a4343f59d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml b/app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml new file mode 100644 index 000000000..a575c392d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_compare_arrows_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_file_open_24.xml b/app/src/main/res/drawable/ic_baseline_file_open_24.xml new file mode 100644 index 000000000..0729ba8ef --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_file_open_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_input_24.xml b/app/src/main/res/drawable/ic_baseline_input_24.xml new file mode 100644 index 000000000..9706f6807 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_input_24.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml b/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml new file mode 100644 index 000000000..240e289b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_output_24.xml b/app/src/main/res/drawable/ic_baseline_output_24.xml new file mode 100644 index 000000000..c99b56f0f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_output_24.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_remove_24.xml b/app/src/main/res/drawable/ic_baseline_remove_24.xml new file mode 100644 index 000000000..1f34aea96 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_remove_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_save_24.xml b/app/src/main/res/drawable/ic_baseline_save_24.xml new file mode 100644 index 000000000..711d2f322 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_save_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_select_all_24.xml b/app/src/main/res/drawable/ic_baseline_select_all_24.xml new file mode 100644 index 000000000..da3458ae2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_select_all_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_config.xml b/app/src/main/res/drawable/ic_config.xml new file mode 100644 index 000000000..241922c9f --- /dev/null +++ b/app/src/main/res/drawable/ic_config.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/drawable/ic_microsoft.xml b/app/src/main/res/drawable/ic_microsoft.xml new file mode 100644 index 000000000..a03d29340 --- /dev/null +++ b/app/src/main/res/drawable/ic_microsoft.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_microsoft_edge.xml b/app/src/main/res/drawable/ic_microsoft_edge.xml new file mode 100644 index 000000000..a326e9d04 --- /dev/null +++ b/app/src/main/res/drawable/ic_microsoft_edge.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_shortcut_plugin.xml b/app/src/main/res/drawable/ic_shortcut_plugin.xml new file mode 100644 index 000000000..22f6a9cd7 --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_plugin.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_shortcut_replace.xml b/app/src/main/res/drawable/ic_shortcut_replace.xml new file mode 100644 index 000000000..6f872c040 --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_replace.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_shortcut_speech_rule.xml b/app/src/main/res/drawable/ic_shortcut_speech_rule.xml new file mode 100644 index 000000000..1de8be3c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_shortcut_speech_rule.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_switch.xml b/app/src/main/res/drawable/ic_switch.xml index 6c0424c5c..27723308b 100644 --- a/app/src/main/res/drawable/ic_switch.xml +++ b/app/src/main/res/drawable/ic_switch.xml @@ -1,57 +1,49 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tts.xml b/app/src/main/res/drawable/ic_tts.xml new file mode 100644 index 000000000..bfe145ad0 --- /dev/null +++ b/app/src/main/res/drawable/ic_tts.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_web.xml b/app/src/main/res/drawable/ic_web.xml new file mode 100644 index 000000000..c9e966c2f --- /dev/null +++ b/app/src/main/res/drawable/ic_web.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 248c116c8..000000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - -