diff --git a/.github/workflows/beta_ci.yml b/.github/workflows/beta_ci.yml new file mode 100644 index 00000000..e839aca1 --- /dev/null +++ b/.github/workflows/beta_ci.yml @@ -0,0 +1,208 @@ +name: Pilipala Beta + +on: + workflow_dispatch: + push: + branches: + - "main" + paths-ignore: + - "**.md" + - "**.txt" + - ".github/**" + - ".idea/**" + - "!.github/workflows/**" + +jobs: + update_version: + name: Read and update version + runs-on: ubuntu-latest + + outputs: + # 定义输出变量 version,以便在其他job中引用 + new_version: ${{ steps.version.outputs.new_version }} + last_commit: ${{ steps.get-last-commit.outputs.last_commit }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + - name: 获取first parent commit次数 + id: get-first-parent-commit-count + run: | + version=$(yq e .version pubspec.yaml | cut -d "+" -f 1) + recent_release_tag=$(git tag -l | grep $version | egrep -v "[-|+]" || true) + if [[ "x$recent_release_tag" == "x" ]]; then + echo "当前版本tag不存在,请手动生成tag." + exit 1 + fi + git log --oneline --first-parent $recent_release_tag..HEAD + first_parent_commit_count=$(git rev-list --first-parent --count $recent_release_tag..HEAD) + echo "count=$first_parent_commit_count" >> $GITHUB_OUTPUT + + - name: 获取最后一次提交 + id: get-last-commit + run: | + last_commit=$(git log -1 --pretty="%h %s" --first-parent) + echo "last_commit=$last_commit" >> $GITHUB_OUTPUT + + - name: 更新版本号 + id: version + run: | + # 读取版本号 + VERSION=$(yq e .version pubspec.yaml | cut -d "+" -f 1) + + # 获取GitHub Actions的run_number + #RUN_NUMBER=${{ github.run_number }} + + # 构建新版本号 + NEW_VERSION=$VERSION-beta.${{ steps.get-first-parent-commit-count.outputs.count }} + + # 输出新版本号 + echo "New version: $NEW_VERSION" + + # 设置新版本号为输出变量 + echo "new_version=$NEW_VERSION" >>$GITHUB_OUTPUT + + android: + name: Build CI (Android) + needs: update_version + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: 构建Java环境 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: "17" + token: ${{secrets.GIT_TOKEN}} + + - name: 检查缓存 + uses: actions/cache@v2 + id: cache-flutter + with: + path: /root/flutter-sdk + key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }} + + - name: 安装Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.16.5 + channel: any + + - name: 下载项目依赖 + run: flutter pub get + + - name: 解码生成 jks + run: echo $KEYSTORE_BASE64 | base64 -di > android/app/vvex.jks + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + + - name: 更新版本号 + id: version + run: | + # 更新pubspec.yaml文件中的版本号 + sed -i "s/version: .*+/version: ${{ needs.update_version.outputs.new_version }}+/g" pubspec.yaml + + - name: flutter build apk + run: flutter build apk --release --split-per-abi + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD}} + + - name: 重命名应用 + run: | + for file in build/app/outputs/flutter-apk/app-*.apk; do + if [[ $file =~ app-(.?*)release.apk ]]; then + new_file_name="build/app/outputs/flutter-apk/Pili-${BASH_REMATCH[1]}v${{ needs.update_version.outputs.new_version }}.apk" + mv "$file" "$new_file_name" + fi + done + + - name: 上传 + uses: actions/upload-artifact@v3 + with: + name: Pilipala-Beta + path: | + build/app/outputs/flutter-apk/Pili-*.apk + + iOS: + name: Build CI (iOS) + needs: update_version + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: 安装Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: subosito/flutter-action@v2.10.0 + with: + cache: true + flutter-version: 3.16.5 + + - name: 更新版本号 + id: version + run: | + # 更新pubspec.yaml文件中的版本号 + sed -i "" "s/version: .*+/version: ${{ needs.update_version.outputs.new_version }}+/g" pubspec.yaml + + - name: flutter build ipa + run: | + flutter build ios --release --no-codesign + ln -sf ./build/ios/iphoneos Payload + zip -r9 app.ipa Payload/runner.app + + - name: 重命名应用 + run: | + DATE=${{ steps.date.outputs.date }} + for file in app.ipa; do + new_file_name="build/Pili-v${{ needs.update_version.outputs.new_version }}.ipa" + mv "$file" "$new_file_name" + done + + - name: 上传 + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Pilipala-Beta + path: | + build/Pili-*.ipa + + upload: + runs-on: ubuntu-latest + + needs: + - update_version + - android + - iOS + steps: + - uses: actions/download-artifact@v3 + with: + name: Pilipala-Beta + path: ./Pilipala-Beta + + - name: 发送到Telegram频道 + uses: xireiki/channel-post@v1.0.7 + with: + bot_token: ${{ secrets.BOT_TOKEN }} + chat_id: ${{ secrets.CHAT_ID }} + large_file: true + api_id: ${{ secrets.TELEGRAM_API_ID }} + api_hash: ${{ secrets.TELEGRAM_API_HASH }} + method: sendFile + path: Pilipala-Beta/* + parse_mode: Markdown + context: "*Beta版本: v${{ needs.update_version.outputs.new_version }}*\n更新内容: [${{ needs.update_version.outputs.last_commit }}](${{ github.event.head_commit.url }})" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 0e0986c8..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: build_apk - -# action事件触发 -on: - push: - # push tag时触发 - tags: - - 'v*.*.*' - -# 可以有多个jobs -jobs: - build_apk: - # 运行环境 ubuntu-latest window-latest mac-latest - runs-on: ubuntu-latest - - # 每个jobs中可以有多个steps - steps: - - name: 代码迁出 - uses: actions/checkout@v3 - - - name: 构建Java环境 - uses: actions/setup-java@v3 - with: - distribution: "zulu" - java-version: "17" - token: ${{secrets.GIT_TOKEN}} - - - name: 检查缓存 - uses: actions/cache@v2 - id: cache-flutter - with: - path: /root/flutter-sdk # Flutter SDK 的路径 - key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }} - - - name: 安装Flutter - if: steps.cache-flutter.outputs.cache-hit != 'true' - uses: subosito/flutter-action@v2 - with: - flutter-version: 3.16.4 - channel: any - - - name: 下载项目依赖 - run: flutter pub get - - - name: 解码生成 jks - run: echo $KEYSTORE_BASE64 | base64 -di > android/app/vvex.jks - env: - KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} - - - name: flutter build apk - # 对应 android/app/build.gradle signingConfigs中的配置项 - run: flutter build apk --release --split-per-abi - env: - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - KEY_PASSWORD: ${{ secrets.KEY_PASSWORD}} - - - name: 获取版本号 - id: version - run: echo "version=${GITHUB_REF#refs/tags/v}" >>$GITHUB_OUTPUT - - # - name: 获取当前日期 - # id: date - # run: echo "date=$(date +'%m%d')" >>$GITHUB_OUTPUT - - - name: 重命名应用 Pili-arm64-v8a-*.*.*.0101.apk - run: | - # DATE=${{ steps.date.outputs.date }} - for file in build/app/outputs/flutter-apk/app-*-release.apk; do - if [[ $file =~ app-(.*)-release.apk ]]; then - new_file_name="build/app/outputs/flutter-apk/Pili-${BASH_REMATCH[1]}-${{ steps.version.outputs.version }}.apk" - mv "$file" "$new_file_name" - fi - done - - - name: 构建和发布release - uses: ncipollo/release-action@v1 - with: - # release title - name: v${{ steps.version.outputs.version }} - artifacts: "build/app/outputs/flutter-apk/Pili-*.apk" - bodyFile: "change_log/${{steps.version.outputs.version}}.md" - token: ${{ secrets.GIT_TOKEN }} - allowUpdates: true diff --git a/.github/workflows/release_ci.yml b/.github/workflows/release_ci.yml new file mode 100644 index 00000000..78230645 --- /dev/null +++ b/.github/workflows/release_ci.yml @@ -0,0 +1,157 @@ +name: Pilipala Release + +# action事件触发 +on: + push: + # push tag时触发 + tags: + - "v*.*.*" + +# 可以有多个jobs +jobs: + android: + # 运行环境 ubuntu-latest window-latest mac-latest + runs-on: ubuntu-latest + + # 每个jobs中可以有多个steps + steps: + - name: 代码迁出 + uses: actions/checkout@v3 + + - name: 构建Java环境 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: "17" + token: ${{secrets.GIT_TOKEN}} + + - name: 检查缓存 + uses: actions/cache@v2 + id: cache-flutter + with: + path: /root/flutter-sdk # Flutter SDK 的路径 + key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.lock') }} + + - name: 安装Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.16.5 + channel: any + + - name: 下载项目依赖 + run: flutter pub get + + - name: 解码生成 jks + run: echo $KEYSTORE_BASE64 | base64 -di > android/app/vvex.jks + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + + - name: flutter build apk + run: flutter build apk --release --split-per-abi + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD}} + + - name: flutter build apk + run: flutter build apk --release + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD}} + + - name: 获取版本号 + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >>$GITHUB_OUTPUT + + # - name: 获取当前日期 + # id: date + # run: echo "date=$(date +'%m%d')" >>$GITHUB_OUTPUT + + - name: 重命名应用 + run: | + # DATE=${{ steps.date.outputs.date }} + for file in build/app/outputs/flutter-apk/app-*.apk; do + if [[ $file =~ app-(.?*)release.apk ]]; then + new_file_name="build/app/outputs/flutter-apk/Pili-${BASH_REMATCH[1]}${{ steps.version.outputs.version }}.apk" + mv "$file" "$new_file_name" + fi + done + + - name: 上传 + uses: actions/upload-artifact@v3 + with: + name: Pilipala-Release + path: | + build/app/outputs/flutter-apk/Pili-*.apk + + iOS: + runs-on: macos-latest + + steps: + - name: 代码迁出 + uses: actions/checkout@v4 + + - name: 安装Flutter + if: steps.cache-flutter.outputs.cache-hit != 'true' + uses: subosito/flutter-action@v2.10.0 + with: + cache: true + flutter-version: 3.16.5 + + - name: flutter build ipa + run: | + flutter build ios --release --no-codesign + ln -sf ./build/ios/iphoneos Payload + zip -r9 app.ipa Payload/runner.app + + - name: 获取版本号 + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >>$GITHUB_OUTPUT + + - name: 重命名应用 + run: | + DATE=${{ steps.date.outputs.date }} + for file in app.ipa; do + new_file_name="build/Pili-${{ steps.version.outputs.version }}.ipa" + mv "$file" "$new_file_name" + done + + - name: 上传 + uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Pilipala-Release + path: | + build/Pili-*.ipa + + upload: + runs-on: ubuntu-latest + + needs: + - android + - iOS + steps: + - uses: actions/download-artifact@v3 + with: + name: Pilipala-Release + path: ./Pilipala-Release + + - name: Install dependencies + run: sudo apt-get install tree -y + + - name: Get version + id: version + run: echo "version=${GITHUB_REF#refs/tags/v}" >>$GITHUB_OUTPUT + + - name: Upload Release + uses: ncipollo/release-action@v1 + with: + name: v${{ steps.version.outputs.version }} + token: ${{ secrets.GIT_TOKEN }} + omitBodyDuringUpdate: true + omitNameDuringUpdate: true + omitPrereleaseDuringUpdate: true + allowUpdates: true + artifacts: Pilipala-Release/* diff --git a/.gitignore b/.gitignore index 24476c5d..55e2eaf3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,29 @@ migrate_working_dir/ # is commented out by default. #.vscode/ +# Flutter repo-specific +/bin/cache/ +/bin/internal/bootstrap.bat +/bin/internal/bootstrap.sh +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/devicelab/ABresults*.json +/dev/docs/doc/ +/dev/docs/api_docs.zip +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version +analysis_benchmark.json + +# packages file containing multi-root paths +.packages.generated + # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id @@ -31,14 +54,83 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ - -# Symbolication related -app.*.symbols +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds # Obfuscation related app.*.map.json -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# Android related +**/android/**/gradle-wrapper.jar +.gradle/ +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/Flutter/ephemeral/ +**/Pods/ +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/ephemeral +**/xcuserdata/ + +# Windows +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake + +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock +!.vscode/settings.json \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 46f6d986..237bd07f 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@

使用Flutter开发的BiliBili第三方客户端

-home -home -home +home +home +home
-home +home
@@ -26,13 +26,15 @@ Xcode 13.4 不支持**auto_orientation**,请注释相关代码 ```bash -[✓] Flutter (Channel stable, 3.10.6, on macOS 12.1 21C52 darwin-arm64, locale +[✓] Flutter (Channel stable, 3.16.4, on macOS 14.1.2 23B92 darwin-arm64, locale zh-Hans-CN) -[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.2) -[✓] Xcode - develop for iOS and macOS (Xcode 13.4) +[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0) +[✓] Xcode - develop for iOS and macOS (Xcode 15.1) [✓] Chrome - develop for the web -[✓] Android Studio (version 2022.2) -[✓] VS Code (version 1.77.3) +[✓] Android Studio (version 2022.3) +[✓] VS Code (version 1.85.1) +[✓] Connected device (3 available) +[✓] Network resources ``` @@ -42,6 +44,7 @@ Xcode 13.4 不支持**auto_orientation**,请注释相关代码 ## 技术交流 Telegram: https://t.me/+lm_oOVmF0RJiODk1 +QQ频道: https://pd.qq.com/s/365esodk3
@@ -87,7 +90,7 @@ Telegram: https://t.me/+lm_oOVmF0RJiODk1 - [x] 画质选择(高清画质未解锁) - [x] 音质选择(视视频而定) - [x] 解码格式选择(视视频而定) - - [ ] 弹幕 + - [x] 弹幕 - [ ] 字幕 - [x] 记忆播放 - [x] 视频比例:高度/宽度适应、填充、包含等 diff --git a/android/app/build.gradle b/android/app/build.gradle index 1198b6fc..3dc4f82a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -58,11 +58,10 @@ android { applicationId "com.guozhigq.pilipala" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - // minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName - minSdkVersion 19 + minSdkVersion 21 multiDexEnabled true } @@ -95,3 +94,14 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } + +ext.abiCodes = ["x86_64": 1, "armeabi-v7a": 2, "arm64-v8a": 3] +import com.android.build.OutputFile +android.applicationVariants.all { variant -> + variant.outputs.each { output -> + def abiVersionCode = project.ext.abiCodes.get(output.getFilter(OutputFile.ABI)) + if (abiVersionCode != null) { + output.versionCodeOverride = variant.versionCode * 10 + abiVersionCode + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2e3896e1..c52d8447 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -67,9 +67,9 @@ - + @@ -223,6 +223,10 @@ android:pathPattern="/mobile/video/.*" /> + + diff --git a/assets/images/live/default_bg.webp b/assets/images/live/default_bg.webp new file mode 100644 index 00000000..a58259de Binary files /dev/null and b/assets/images/live/default_bg.webp differ diff --git a/assets/images/video/danmu_close.svg b/assets/images/video/danmu_close.svg new file mode 100644 index 00000000..9f48027b --- /dev/null +++ b/assets/images/video/danmu_close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/video/danmu_open.svg b/assets/images/video/danmu_open.svg new file mode 100644 index 00000000..24e8d7a9 --- /dev/null +++ b/assets/images/video/danmu_open.svg @@ -0,0 +1 @@ +Layer 1 \ No newline at end of file diff --git a/assets/sreenshot/174shots_so.png b/assets/screenshots/174shots_so.png similarity index 100% rename from assets/sreenshot/174shots_so.png rename to assets/screenshots/174shots_so.png diff --git a/assets/sreenshot/510shots_so.png b/assets/screenshots/510shots_so.png similarity index 100% rename from assets/sreenshot/510shots_so.png rename to assets/screenshots/510shots_so.png diff --git a/assets/sreenshot/850shots_so.png b/assets/screenshots/850shots_so.png similarity index 100% rename from assets/sreenshot/850shots_so.png rename to assets/screenshots/850shots_so.png diff --git a/assets/sreenshot/bangumi.png b/assets/screenshots/bangumi.png similarity index 100% rename from assets/sreenshot/bangumi.png rename to assets/screenshots/bangumi.png diff --git a/assets/sreenshot/bangumi_detail.png b/assets/screenshots/bangumi_detail.png similarity index 100% rename from assets/sreenshot/bangumi_detail.png rename to assets/screenshots/bangumi_detail.png diff --git a/assets/sreenshot/dynamic.png b/assets/screenshots/dynamic.png similarity index 100% rename from assets/sreenshot/dynamic.png rename to assets/screenshots/dynamic.png diff --git a/assets/sreenshot/home.png b/assets/screenshots/home.png similarity index 100% rename from assets/sreenshot/home.png rename to assets/screenshots/home.png diff --git a/assets/sreenshot/main_screen.png b/assets/screenshots/main_screen.png similarity index 100% rename from assets/sreenshot/main_screen.png rename to assets/screenshots/main_screen.png diff --git a/assets/sreenshot/media.png b/assets/screenshots/media.png similarity index 100% rename from assets/sreenshot/media.png rename to assets/screenshots/media.png diff --git a/assets/sreenshot/member.png b/assets/screenshots/member.png similarity index 100% rename from assets/sreenshot/member.png rename to assets/screenshots/member.png diff --git a/assets/sreenshot/search.png b/assets/screenshots/search.png similarity index 100% rename from assets/sreenshot/search.png rename to assets/screenshots/search.png diff --git a/assets/sreenshot/set.png b/assets/screenshots/set.png similarity index 100% rename from assets/sreenshot/set.png rename to assets/screenshots/set.png diff --git a/change_log/1.0.15.0101.md b/change_log/1.0.15.0101.md new file mode 100644 index 00000000..184a6b3c --- /dev/null +++ b/change_log/1.0.15.0101.md @@ -0,0 +1,22 @@ +## 1.0.15 + +元旦快乐~ 🎉 + +### 功能 ++ 转发动态评论展示 ++ 推荐、最热、收藏视频增肌日期显示 + +### 修复 ++ 全屏播放相关问题 ++ 评论区@用户展示问题 ++ 登录状态闪退问题 ++ pip意外触发问题 ++ 动态页tab切换样式问题 + +### 优化 ++ 首页默认使用web端推荐 ++ 取消iOS路由切换效果 ++ 视频分享中添加Up主 + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/change_log/1.0.16.0102.md b/change_log/1.0.16.0102.md new file mode 100644 index 00000000..b0a85a0f --- /dev/null +++ b/change_log/1.0.16.0102.md @@ -0,0 +1,15 @@ +## 1.0.16 + + +### 功能 ++ toast 背景支持透明度调节 + +### 修复 ++ web端推荐未展示【已关注】 ++ up主动态页异常 ++ 未打开自动播放时,视频详情页异常 ++ 视频暂停状态取消自动ip + + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/change_log/1.0.17.0125.md b/change_log/1.0.17.0125.md new file mode 100644 index 00000000..dc8bcb62 --- /dev/null +++ b/change_log/1.0.17.0125.md @@ -0,0 +1,39 @@ +## 1.0.17 + + +### 功能 ++ 视频全屏时隐藏进度条 ++ 动态内容增加投稿跳转 ++ 未开启自动播放时点击封面播放 ++ 弹幕发送标识 ++ 定时关闭 ++ 推荐视频卡片拉黑up功能 ++ 首页tabbar编辑排序 + +### 修复 ++ 连续跳转搜索页未刷新 ++ 搜索结果为空时页面异常 ++ 评论区链接解析 ++ 视频全屏状态栏背景色 ++ 私信对话气泡位置 ++ 设置up关注分组样式 ++ 每次推荐请求数据相同 ++ iOS代理网络异常 ++ 双击切换播放状态无声 ++ 设置自定义倍速白屏 ++ 免登录查看1080p + +### 优化 ++ 首页web端推荐观看数展示 ++ 首页web端推荐接口更新 ++ 首页样式 ++ 搜索页跳转 ++ 弹幕资源优化 ++ 图片渲染占用内存优化(部分) ++ 两次返回退出应用 ++ schame 补充 + + + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/change_log/1.0.18.0130.md b/change_log/1.0.18.0130.md new file mode 100644 index 00000000..2f0b80ca --- /dev/null +++ b/change_log/1.0.18.0130.md @@ -0,0 +1,16 @@ +## 1.0.18 + + +### 功能 + + +### 修复 + + +### 优化 + + + + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/change_log/1.0.19.0131.md b/change_log/1.0.19.0131.md new file mode 100644 index 00000000..1fd3071b --- /dev/null +++ b/change_log/1.0.19.0131.md @@ -0,0 +1,15 @@ +## 1.0.19 + + +### 修复 ++ 视频404、评论加载错误 ++ bvav转换 + +### 优化 ++ 视频详情页内存占用 + + + + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/change_log/1.0.20.0303.md b/change_log/1.0.20.0303.md new file mode 100644 index 00000000..1d8c4e00 --- /dev/null +++ b/change_log/1.0.20.0303.md @@ -0,0 +1,31 @@ +## 1.0.20 + + +### 功能 ++ 评论区增加表情 ++ 首页渐变背景开关 ++ 媒体库显示「我的订阅」 ++ 评论区链接解析 ++ 默认启动页设置 + +### 修复 ++ 评论区内容重复 ++ pip相关问题 ++ 播放多p视频评论不刷新 ++ 视频评论翻页重复 + +### 优化 ++ url scheme优化 ++ 图片预览放大 ++ 图片加载速度 ++ 视频评论区复制 ++ 全屏显示视频标题 ++ 网络异常处理 + + + + + + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/change_log/1.0.21.0306.md b/change_log/1.0.21.0306.md new file mode 100644 index 00000000..3a582dbb --- /dev/null +++ b/change_log/1.0.21.0306.md @@ -0,0 +1,9 @@ +## 1.0.21 + +### 修复 ++ 推荐视频全屏问题 ++ 番剧全屏播放时灰屏问题 ++ 评论回调导致页面卡死问题 + +更多更新日志可在Github上查看 +问题反馈、功能建议请查看「关于」页面。 diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 00000000..1a6e2446 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,9 @@ +PiliPala is a third-party Bilibili client developed in Flutter. + +Top Features: + +* List of recommended videos +* List of hottest videos +* Popular live streams +* List of bangumis +* Block videos from blacklisted users diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 00000000..1f72277e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 00000000..db737743 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 00000000..ae00cf9f Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 00000000..bf16b34f Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 00000000..fbdfb88c Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 00000000..429e00fb --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +A third-party Bilibili client developed in Flutter diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 00000000..2b0d34f3 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +PiliPala diff --git a/fastlane/metadata/android/zh-CN/changelogs/2001.txt b/fastlane/metadata/android/zh-CN/changelogs/2001.txt new file mode 100644 index 00000000..a5e2c0a4 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/2001.txt @@ -0,0 +1,21 @@ +修复 + +* 全屏弹幕消失 +* iOS 全屏/退出全屏视频暂停 +* 个人主页关注状态 +* 视频合集向下滑动UI问题 +* 媒体库滑动底栏不隐藏 +* 个人主页动态加载问题 * 2 +* 未登录状态访问个人主页异常 +* 视频搜索标题特殊字符转义 +* iOS 闪退 +* 消息页面夜间模式异常 +* 消息页面含有撤回消息时异常 +* 弹幕速度 + +优化 + +* 全屏播放方案优化 +* 弹幕加载逻辑优化 +* 点赞、投币逻辑优化 +* 进度条及播放时间渲染优化 diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 100644 index 00000000..361386e6 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1,9 @@ +PiliPala 是使用 Flutter 开发的 BiliBili 第三方客户端。 + +主要功能: + +* 推荐视频列表 (app 端) +* 最热视频列表 +* 热门直播 +* 番剧列表 +* 屏蔽黑名单内用户视频 diff --git a/fastlane/metadata/android/zh-CN/images/featureGraphic.png b/fastlane/metadata/android/zh-CN/images/featureGraphic.png new file mode 100644 index 00000000..1f72277e Binary files /dev/null and b/fastlane/metadata/android/zh-CN/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/zh-CN/images/icon.png b/fastlane/metadata/android/zh-CN/images/icon.png new file mode 100644 index 00000000..db737743 Binary files /dev/null and b/fastlane/metadata/android/zh-CN/images/icon.png differ diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png new file mode 100644 index 00000000..ae00cf9f Binary files /dev/null and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png new file mode 100644 index 00000000..bf16b34f Binary files /dev/null and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png new file mode 100644 index 00000000..fbdfb88c Binary files /dev/null and b/fastlane/metadata/android/zh-CN/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 100644 index 00000000..683129cc --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +使用 Flutter 开发的 BiliBili 第三方客户端 diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt new file mode 100644 index 00000000..2b0d34f3 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/title.txt @@ -0,0 +1 @@ +PiliPala diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5f855b9d..2c1a635b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,12 +9,18 @@ PODS: - Flutter - connectivity_plus (0.0.1): - Flutter + - FlutterMacOS - ReachabilitySwift - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_mailer (0.0.1): + - Flutter - flutter_volume_controller (0.0.1): - Flutter + - fluttertoast (0.0.2): + - Flutter + - Toast - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) @@ -33,7 +39,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - permission_handler_apple (9.1.1): + - permission_handler_apple (9.3.0): - Flutter - ReachabilitySwift (5.0.0) - saver_gallery (0.0.1): @@ -49,6 +55,7 @@ PODS: - Flutter - system_proxy (0.0.1): - Flutter + - Toast (4.1.0) - url_launcher_ios (0.0.1): - Flutter - volume_controller (0.0.1): @@ -65,10 +72,12 @@ DEPENDENCIES: - audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) - auto_orientation (from `.symlinks/plugins/auto_orientation/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) + - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - gt3_flutter_plugin (from `.symlinks/plugins/gt3_flutter_plugin/ios`) - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) - media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`) @@ -93,6 +102,7 @@ SPEC REPOS: - FMDB - GT3Captcha-iOS - ReachabilitySwift + - Toast EXTERNAL SOURCES: appscheme: @@ -104,13 +114,17 @@ EXTERNAL SOURCES: auto_orientation: :path: ".symlinks/plugins/auto_orientation/ios" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" + :path: ".symlinks/plugins/connectivity_plus/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_mailer: + :path: ".symlinks/plugins/flutter_mailer/ios" flutter_volume_controller: :path: ".symlinks/plugins/flutter_volume_controller/ios" + fluttertoast: + :path: ".symlinks/plugins/fluttertoast/ios" gt3_flutter_plugin: :path: ".symlinks/plugins/gt3_flutter_plugin/ios" media_kit_libs_ios_video: @@ -153,10 +167,12 @@ SPEC CHECKSUMS: audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 auto_orientation: 102ed811a5938d52c86520ddd7ecd3a126b5d39d - connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a + connectivity_plus: e2dad488011aeb593e219360e804c43cc1af5770 device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_volume_controller: e4d5832f08008180f76e30faf671ffd5a425e529 + fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a gt3_flutter_plugin: bfa1f26e9a09dc00401514be5ed437f964cabf23 GT3Captcha-iOS: 5e3b1077834d8a9d6f4d64a447a30af3e14affe6 @@ -165,7 +181,7 @@ SPEC CHECKSUMS: media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 saver_gallery: 2b4e584106fde2407ab51560f3851564963e6b78 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 @@ -173,11 +189,12 @@ SPEC CHECKSUMS: sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a status_bar_control: 7c84146799e6a076315cc1550f78ef53aae3e446 system_proxy: bec1a5c5af67dd3e3ebf43979400a8756c04cc44 + Toast: ec33c32b8688982cecc6348adeae667c1b9938da url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9 wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 webview_cookie_manager: eaf920722b493bd0f7611b5484771ca53fed03f7 - webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a + webview_flutter_wkwebview: 4f3e50f7273d31e5500066ed267e3ae4309c5ae4 PODFILE CHECKSUM: 637cd290bed23275b5f5ffcc7eb1e73d0a5fb2be diff --git a/lib/common/widgets/content_container.dart b/lib/common/widgets/content_container.dart index 076a02e9..0abd4bf2 100644 --- a/lib/common/widgets/content_container.dart +++ b/lib/common/widgets/content_container.dart @@ -20,7 +20,7 @@ class ContentContainer extends StatelessWidget { builder: (BuildContext context, BoxConstraints constraints) { return SingleChildScrollView( clipBehavior: childClipBehavior ?? Clip.hardEdge, - physics: isScrollable ? null : NeverScrollableScrollPhysics(), + physics: isScrollable ? null : const NeverScrollableScrollPhysics(), child: ConstrainedBox( constraints: constraints.copyWith( minHeight: constraints.maxHeight, @@ -34,7 +34,7 @@ class ContentContainer extends StatelessWidget { child: contentWidget!, ) else - Spacer(), + const Spacer(), if (bottomWidget != null) bottomWidget!, ], ), diff --git a/lib/common/widgets/custom_toast.dart b/lib/common/widgets/custom_toast.dart index 9cd3461d..f732fd85 100644 --- a/lib/common/widgets/custom_toast.dart +++ b/lib/common/widgets/custom_toast.dart @@ -1,17 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/utils/storage.dart'; + +Box setting = GStrorage.setting; class CustomToast extends StatelessWidget { + const CustomToast({super.key, required this.msg}); + final String msg; - const CustomToast({Key? key, required this.msg}) : super(key: key); @override Widget build(BuildContext context) { + final double toastOpacity = + setting.get(SettingBoxKey.defaultToastOp, defaultValue: 1.0) as double; return Container( margin: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 30), padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, + color: Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(toastOpacity), borderRadius: BorderRadius.circular(20), ), child: Text( diff --git a/lib/common/widgets/html_render.dart b/lib/common/widgets/html_render.dart index 2e97ceed..bf58d78c 100644 --- a/lib/common/widgets/html_render.dart +++ b/lib/common/widgets/html_render.dart @@ -1,45 +1,46 @@ -import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:get/get.dart'; +import 'network_img_layer.dart'; // ignore: must_be_immutable class HtmlRender extends StatelessWidget { - String? htmlContent; - final int? imgCount; - final List? imgList; - - HtmlRender({ + const HtmlRender({ this.htmlContent, this.imgCount, this.imgList, super.key, }); + final String? htmlContent; + final int? imgCount; + final List? imgList; + @override Widget build(BuildContext context) { return Html( data: htmlContent, - onLinkTap: (url, buildContext, attributes) => {}, + onLinkTap: (String? url, Map buildContext, attributes) {}, extensions: [ TagExtension( - tagsToExtend: {"img"}, - builder: (extensionContext) { + tagsToExtend: {'img'}, + builder: (ExtensionContext extensionContext) { try { - Map attributes = extensionContext.attributes; - List key = attributes.keys.toList(); - String? imgUrl = key.contains('src') - ? attributes['src'] - : attributes['data-src']; - if (imgUrl!.startsWith('//')) { + final Map attributes = + extensionContext.attributes; + final List key = attributes.keys.toList(); + String imgUrl = key.contains('src') + ? attributes['src'] as String + : attributes['data-src'] as String; + if (imgUrl.startsWith('//')) { imgUrl = 'https:$imgUrl'; } if (imgUrl.startsWith('http://')) { imgUrl = imgUrl.replaceAll('http://', 'https://'); } imgUrl = imgUrl.contains('@') ? imgUrl.split('@').first : imgUrl; - bool isEmote = imgUrl.contains('/emote/'); - bool isMall = imgUrl.contains('/mall/'); + final bool isEmote = imgUrl.contains('/emote/'); + final bool isMall = imgUrl.contains('/mall/'); if (isMall) { return const SizedBox(); } @@ -58,38 +59,37 @@ class HtmlRender extends StatelessWidget { src: imgUrl, ); } catch (err) { - print(err); return const SizedBox(); } }, ), ], style: { - "html": Style( + 'html': Style( fontSize: FontSize.medium, lineHeight: LineHeight.percent(140), ), - "body": Style(margin: Margins.zero, padding: HtmlPaddings.zero), - "a": Style( + 'body': Style(margin: Margins.zero, padding: HtmlPaddings.zero), + 'a': Style( color: Theme.of(context).colorScheme.primary, textDecoration: TextDecoration.none, ), - "p": Style( + 'p': Style( margin: Margins.only(bottom: 10), ), - "span": Style( + 'span': Style( fontSize: FontSize.medium, height: Height(1.65), ), - "div": Style(height: Height.auto()), - "li > p": Style( + 'div': Style(height: Height.auto()), + 'li > p': Style( display: Display.inline, ), - "li": Style( + 'li': Style( padding: HtmlPaddings.only(bottom: 4), textAlign: TextAlign.justify, ), - "img": Style(margin: Margins.only(top: 4, bottom: 4)), + 'img': Style(margin: Margins.only(top: 4, bottom: 4)), }, ); } diff --git a/lib/common/widgets/http_error.dart b/lib/common/widgets/http_error.dart index b02182c6..cbc6659b 100644 --- a/lib/common/widgets/http_error.dart +++ b/lib/common/widgets/http_error.dart @@ -22,20 +22,27 @@ class HttpError extends StatelessWidget { "assets/images/error.svg", height: 200, ), - const SizedBox(height: 20), + const SizedBox(height: 30), Text( errMsg ?? '请求异常', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleSmall, ), - const SizedBox(height: 30), - OutlinedButton.icon( + const SizedBox(height: 20), + FilledButton.tonal( onPressed: () { fn!(); }, - icon: const Icon(Icons.arrow_forward_outlined, size: 20), - label: Text(btnText ?? '点击重试'), - ) + style: ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((states) { + return Theme.of(context).colorScheme.primary.withAlpha(20); + }), + ), + child: Text( + btnText ?? '点击重试', + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + ), ], ), ), diff --git a/lib/common/widgets/live_card.dart b/lib/common/widgets/live_card.dart index 01d0bf32..4034756d 100644 --- a/lib/common/widgets/live_card.dart +++ b/lib/common/widgets/live_card.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/utils/utils.dart'; +import '../../utils/utils.dart'; +import '../constants.dart'; +import 'network_img_layer.dart'; class LiveCard extends StatelessWidget { // ignore: prefer_typing_uninitialized_variables - final liveItem; + final dynamic liveItem; const LiveCard({ Key? key, @@ -14,7 +14,7 @@ class LiveCard extends StatelessWidget { @override Widget build(BuildContext context) { - String heroTag = Utils.makeHeroTag(liveItem.roomid); + final String heroTag = Utils.makeHeroTag(liveItem.roomid); return Card( elevation: 0, @@ -23,7 +23,6 @@ class LiveCard extends StatelessWidget { borderRadius: BorderRadius.circular(0), side: BorderSide( color: Theme.of(context).dividerColor.withOpacity(0.08), - width: 1, ), ), margin: EdgeInsets.zero, @@ -33,15 +32,16 @@ class LiveCard extends StatelessWidget { children: [ AspectRatio( aspectRatio: StyleString.aspectRatio, - child: LayoutBuilder(builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; + child: LayoutBuilder(builder: + (BuildContext context, BoxConstraints boxConstraints) { + final double maxWidth = boxConstraints.maxWidth; + final double maxHeight = boxConstraints.maxHeight; return Stack( children: [ Hero( tag: heroTag, child: NetworkImgLayer( - src: liveItem.cover, + src: liveItem.cover as String, type: 'emote', width: maxWidth, height: maxHeight, @@ -58,7 +58,7 @@ class LiveCard extends StatelessWidget { // view: liveItem.stat.view, // danmaku: liveItem.stat.danmaku, // duration: liveItem.duration, - online: liveItem.online, + online: liveItem.online as int, ), ), ), @@ -90,7 +90,7 @@ class LiveContent extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - liveItem.title, + liveItem.title as String, textAlign: TextAlign.start, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, @@ -99,7 +99,7 @@ class LiveContent extends StatelessWidget { SizedBox( width: double.infinity, child: Text( - liveItem.uname, + liveItem.uname as String, maxLines: 1, style: TextStyle( fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, @@ -114,9 +114,9 @@ class LiveContent extends StatelessWidget { } class LiveStat extends StatelessWidget { - final int? online; + const LiveStat({super.key, required this.online}); - const LiveStat({Key? key, required this.online}) : super(key: key); + final int? online; @override Widget build(BuildContext context) { @@ -136,7 +136,7 @@ class LiveStat extends StatelessWidget { ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ // Row( // children: [ // StatView( diff --git a/lib/common/widgets/network_img_layer.dart b/lib/common/widgets/network_img_layer.dart index c44bd0e7..06c35974 100644 --- a/lib/common/widgets/network_img_layer.dart +++ b/lib/common/widgets/network_img_layer.dart @@ -1,78 +1,104 @@ -import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/utils/storage.dart'; +import 'package:pilipala/utils/extension.dart'; +import 'package:pilipala/utils/global_data.dart'; +import '../../utils/storage.dart'; +import '../constants.dart'; -Box setting = GStrorage.setting; +Box setting = GStrorage.setting; class NetworkImgLayer extends StatelessWidget { - final String? src; - final double? width; - final double? height; - final double? cacheW; - final double? cacheH; - final String? type; - final Duration? fadeOutDuration; - final Duration? fadeInDuration; - final int? quality; - const NetworkImgLayer({ - Key? key, + super.key, this.src, required this.width, required this.height, - this.cacheW, - this.cacheH, this.type, this.fadeOutDuration, this.fadeInDuration, // 图片质量 默认1% this.quality, - }) : super(key: key); + this.origAspectRatio, + }); + + final String? src; + final double width; + final double height; + final String? type; + final Duration? fadeOutDuration; + final Duration? fadeInDuration; + final int? quality; + final double? origAspectRatio; @override Widget build(BuildContext context) { - double pr = MediaQuery.of(context).devicePixelRatio; - int picQuality = setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10); + final int defaultImgQuality = GlobalData().imgQuality; + final String imageUrl = + '${src!.startsWith('//') ? 'https:${src!}' : src!}@${quality ?? defaultImgQuality}q.webp'; + print(imageUrl); + int? memCacheWidth, memCacheHeight; + double aspectRatio = (width / height).toDouble(); - // double pr = 2; - return src != '' + void setMemCacheSizes() { + if (aspectRatio > 1) { + memCacheHeight = height.cacheSize(context); + } else if (aspectRatio < 1) { + memCacheWidth = width.cacheSize(context); + } else { + if (origAspectRatio != null && origAspectRatio! > 1) { + memCacheWidth = width.cacheSize(context); + } else if (origAspectRatio != null && origAspectRatio! < 1) { + memCacheHeight = height.cacheSize(context); + } else { + memCacheWidth = width.cacheSize(context); + memCacheHeight = height.cacheSize(context); + } + } + } + + setMemCacheSizes(); + + if (memCacheWidth == null && memCacheHeight == null) { + memCacheWidth = width.toInt(); + } + + return src != '' && src != null ? ClipRRect( - clipBehavior: Clip.hardEdge, - borderRadius: BorderRadius.circular(type == 'avatar' - ? 50 - : type == 'emote' - ? 0 - : StyleString.imgRadius.x), + clipBehavior: Clip.antiAlias, + borderRadius: BorderRadius.circular( + type == 'avatar' + ? 50 + : type == 'emote' + ? 0 + : StyleString.imgRadius.x, + ), child: CachedNetworkImage( - imageUrl: - '${src!.startsWith('//') ? 'https:${src!}' : src!}@${quality ?? picQuality}q.webp', - width: width ?? double.infinity, - height: height ?? double.infinity, - alignment: Alignment.center, - maxWidthDiskCache: ((cacheW ?? width!) * pr).toInt(), - // maxHeightDiskCache: (cacheH ?? height!).toInt(), - memCacheWidth: ((cacheW ?? width!) * pr).toInt(), - // memCacheHeight: (cacheH ?? height!).toInt(), + imageUrl: imageUrl, + width: width, + height: height, + memCacheWidth: memCacheWidth, + memCacheHeight: memCacheHeight, fit: BoxFit.cover, fadeOutDuration: - fadeOutDuration ?? const Duration(milliseconds: 200), + fadeOutDuration ?? const Duration(milliseconds: 120), fadeInDuration: - fadeInDuration ?? const Duration(milliseconds: 200), - // filterQuality: FilterQuality.high, - errorWidget: (context, url, error) => placeholder(context), - placeholder: (context, url) => placeholder(context), + fadeInDuration ?? const Duration(milliseconds: 120), + filterQuality: FilterQuality.low, + errorWidget: (BuildContext context, String url, Object error) => + placeholder(context), + placeholder: (BuildContext context, String url) => + placeholder(context), ), ) : placeholder(context); } - Widget placeholder(context) { + Widget placeholder(BuildContext context) { return Container( - width: width ?? double.infinity, - height: height ?? double.infinity, - clipBehavior: Clip.hardEdge, + width: width, + height: height, + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Theme.of(context).colorScheme.onInverseSurface.withOpacity(0.4), borderRadius: BorderRadius.circular(type == 'avatar' @@ -81,14 +107,19 @@ class NetworkImgLayer extends StatelessWidget { ? 0 : StyleString.imgRadius.x), ), - child: Center( - child: Image.asset( - type == 'avatar' - ? 'assets/images/noface.jpeg' - : 'assets/images/loading.png', - width: 300, - height: 300, - )), + child: type == 'bg' + ? const SizedBox() + : Center( + child: Image.asset( + type == 'avatar' + ? 'assets/images/noface.jpeg' + : 'assets/images/loading.png', + width: width, + height: height, + cacheWidth: width.cacheSize(context), + cacheHeight: height.cacheSize(context), + ), + ), ); } } diff --git a/lib/common/widgets/overlay_pop.dart b/lib/common/widgets/overlay_pop.dart index 53d4c9a1..fe9b9377 100644 --- a/lib/common/widgets/overlay_pop.dart +++ b/lib/common/widgets/overlay_pop.dart @@ -1,16 +1,17 @@ import 'package:flutter/material.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/utils/download.dart'; +import '../../utils/download.dart'; +import '../constants.dart'; +import 'network_img_layer.dart'; class OverlayPop extends StatelessWidget { + const OverlayPop({super.key, this.videoItem, this.closeFn}); + final dynamic videoItem; final Function? closeFn; - const OverlayPop({super.key, this.videoItem, this.closeFn}); @override Widget build(BuildContext context) { - double imgWidth = MediaQuery.of(context).size.width - 8 * 2; + final double imgWidth = MediaQuery.sizeOf(context).width - 8 * 2; return Container( margin: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( @@ -19,7 +20,6 @@ class OverlayPop extends StatelessWidget { ), child: Column( mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Stack( @@ -27,7 +27,7 @@ class OverlayPop extends StatelessWidget { NetworkImgLayer( width: imgWidth, height: imgWidth / StyleString.aspectRatio, - src: videoItem.pic!, + src: videoItem.pic! as String, quality: 100, ), Positioned( @@ -61,7 +61,7 @@ class OverlayPop extends StatelessWidget { children: [ Expanded( child: Text( - videoItem.title!, + videoItem.title! as String, ), ), const SizedBox(width: 4), @@ -69,7 +69,10 @@ class OverlayPop extends StatelessWidget { tooltip: '保存封面图', onPressed: () async { await DownloadUtils.downloadImg( - videoItem.pic ?? videoItem.cover); + videoItem.pic != null + ? videoItem.pic as String + : videoItem.cover as String, + ); // closeFn!(); }, icon: const Icon(Icons.download, size: 20), diff --git a/lib/common/widgets/pull_to_refresh_header.dart b/lib/common/widgets/pull_to_refresh_header.dart index 3333a0a6..46db5138 100644 --- a/lib/common/widgets/pull_to_refresh_header.dart +++ b/lib/common/widgets/pull_to_refresh_header.dart @@ -17,8 +17,8 @@ class PullToRefreshHeader extends StatelessWidget { this.info, this.lastRefreshTime, { this.color, - Key? key, - }) : super(key: key); + super.key, + }); final PullToRefreshScrollNotificationInfo? info; final DateTime? lastRefreshTime; @@ -28,7 +28,7 @@ class PullToRefreshHeader extends StatelessWidget { Widget build(BuildContext context) { final PullToRefreshScrollNotificationInfo? infos = info; if (infos == null) { - return Container(); + return const SizedBox(); } String text = ''; if (infos.mode == PullToRefreshIndicatorMode.armed) { @@ -65,7 +65,6 @@ class PullToRefreshHeader extends StatelessWidget { top: top, child: Row( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Container( diff --git a/lib/common/widgets/stat/danmu.dart b/lib/common/widgets/stat/danmu.dart index 44f662a9..c1c439db 100644 --- a/lib/common/widgets/stat/danmu.dart +++ b/lib/common/widgets/stat/danmu.dart @@ -3,7 +3,7 @@ import 'package:pilipala/utils/utils.dart'; class StatDanMu extends StatelessWidget { final String? theme; - final int? danmu; + final dynamic danmu; final String? size; const StatDanMu({Key? key, this.theme, this.danmu, this.size}) diff --git a/lib/common/widgets/stat/view.dart b/lib/common/widgets/stat/view.dart index 8b97b605..2665e2d4 100644 --- a/lib/common/widgets/stat/view.dart +++ b/lib/common/widgets/stat/view.dart @@ -3,7 +3,7 @@ import 'package:pilipala/utils/utils.dart'; class StatView extends StatelessWidget { final String? theme; - final int? view; + final dynamic view; final String? size; const StatView({Key? key, this.theme, this.view, this.size}) diff --git a/lib/common/widgets/video_card_h.dart b/lib/common/widgets/video_card_h.dart index 551f4063..99059a9e 100644 --- a/lib/common/widgets/video_card_h.dart +++ b/lib/common/widgets/video_card_h.dart @@ -1,17 +1,29 @@ +import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/common/widgets/badge.dart'; -import 'package:pilipala/common/widgets/stat/danmu.dart'; -import 'package:pilipala/common/widgets/stat/view.dart'; -import 'package:pilipala/http/search.dart'; -import 'package:pilipala/http/user.dart'; -import 'package:pilipala/utils/utils.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; +import '../../http/search.dart'; +import '../../http/user.dart'; +import '../../http/video.dart'; +import '../../utils/utils.dart'; +import '../constants.dart'; +import 'badge.dart'; +import 'network_img_layer.dart'; +import 'stat/danmu.dart'; +import 'stat/view.dart'; // 视频卡片 - 水平布局 class VideoCardH extends StatelessWidget { + const VideoCardH({ + super.key, + required this.videoItem, + this.longPress, + this.longPressEnd, + this.source = 'normal', + this.showOwner = true, + this.showView = true, + this.showDanmaku = true, + this.showPubdate = false, + }); // ignore: prefer_typing_uninitialized_variables final videoItem; final Function()? longPress; @@ -22,23 +34,15 @@ class VideoCardH extends StatelessWidget { final bool showDanmaku; final bool showPubdate; - const VideoCardH({ - Key? key, - required this.videoItem, - this.longPress, - this.longPressEnd, - this.source = 'normal', - this.showOwner = true, - this.showView = true, - this.showDanmaku = true, - this.showPubdate = false, - }) : super(key: key); - @override Widget build(BuildContext context) { - int aid = videoItem.aid; - String bvid = videoItem.bvid; - String heroTag = Utils.makeHeroTag(aid); + final int aid = videoItem.aid; + final String bvid = videoItem.bvid; + String type = 'video'; + try { + type = videoItem.type; + } catch (_) {} + final String heroTag = Utils.makeHeroTag(aid); return GestureDetector( onLongPress: () { if (longPress != null) { @@ -53,7 +57,11 @@ class VideoCardH extends StatelessWidget { child: InkWell( onTap: () async { try { - int cid = + if (type == 'ketang') { + SmartDialog.showToast('课堂视频暂不支持播放'); + return; + } + final int cid = videoItem.cid ?? await SearchHttp.ab2c(aid: aid, bvid: bvid); Get.toNamed('/video?bvid=$bvid&cid=$cid', arguments: {'videoItem': videoItem, 'heroTag': heroTag}); @@ -65,11 +73,11 @@ class VideoCardH extends StatelessWidget { padding: const EdgeInsets.fromLTRB( StyleString.safeSpace, 5, StyleString.safeSpace, 5), child: LayoutBuilder( - builder: (context, boxConstraints) { - double width = (boxConstraints.maxWidth - + builder: (BuildContext context, BoxConstraints boxConstraints) { + final double width = (boxConstraints.maxWidth - StyleString.cardSpace * 6 / - MediaQuery.of(context).textScaleFactor) / + MediaQuery.textScalerOf(context).scale(1.0)) / 2; return Container( constraints: const BoxConstraints(minHeight: 88), @@ -77,31 +85,38 @@ class VideoCardH extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ AspectRatio( aspectRatio: StyleString.aspectRatio, child: LayoutBuilder( - builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; + builder: (BuildContext context, + BoxConstraints boxConstraints) { + final double maxWidth = boxConstraints.maxWidth; + final double maxHeight = boxConstraints.maxHeight; return Stack( children: [ Hero( tag: heroTag, child: NetworkImgLayer( - src: videoItem.pic, + src: videoItem.pic as String, width: maxWidth, height: maxHeight, ), ), - PBadge( - text: Utils.timeFormat(videoItem.duration!), - top: null, - right: 6.0, - bottom: 6.0, - left: null, - type: 'gray', - ), + if (videoItem.duration != 0) + PBadge( + text: Utils.timeFormat(videoItem.duration!), + right: 6.0, + bottom: 6.0, + type: 'gray', + ), + if (type != 'video') + PBadge( + text: type, + left: 6.0, + bottom: 6.0, + type: 'primary', + ), // if (videoItem.rcmdReason != null && // videoItem.rcmdReason.content != '') // pBadge(videoItem.rcmdReason.content, context, @@ -159,7 +174,7 @@ class VideoContent extends StatelessWidget { children: [ if (videoItem.title is String) ...[ Text( - videoItem.title, + videoItem.title as String, textAlign: TextAlign.start, style: const TextStyle( fontWeight: FontWeight.w500, @@ -172,9 +187,9 @@ class VideoContent extends StatelessWidget { maxLines: 2, text: TextSpan( children: [ - for (var i in videoItem.title) ...[ + for (final i in videoItem.title) ...[ TextSpan( - text: i['text'], + text: i['text'] as String, style: TextStyle( fontWeight: FontWeight.w500, letterSpacing: 0.3, @@ -216,7 +231,7 @@ class VideoContent extends StatelessWidget { Row( children: [ Text( - videoItem.owner.name, + videoItem.owner.name as String, style: TextStyle( fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, @@ -230,14 +245,14 @@ class VideoContent extends StatelessWidget { if (showView) ...[ StatView( theme: 'gray', - view: videoItem.stat.view, + view: videoItem.stat.view as int, ), const SizedBox(width: 8), ], if (showDanmaku) StatDanMu( theme: 'gray', - danmu: videoItem.stat.danmaku, + danmu: videoItem.stat.danmaku as int, ), const Spacer(), @@ -267,7 +282,6 @@ class VideoContent extends StatelessWidget { height: 24, child: PopupMenuButton( padding: EdgeInsets.zero, - tooltip: '稍后再看', icon: Icon( Icons.more_vert_outlined, color: Theme.of(context).colorScheme.outline, @@ -281,11 +295,11 @@ class VideoContent extends StatelessWidget { PopupMenuItem( onTap: () async { var res = await UserHttp.toViewLater( - bvid: videoItem.bvid); + bvid: videoItem.bvid as String); SmartDialog.showToast(res['msg']); }, value: 'pause', - height: 35, + height: 40, child: const Row( children: [ Icon(Icons.watch_later_outlined, size: 16), @@ -294,6 +308,60 @@ class VideoContent extends StatelessWidget { ], ), ), + const PopupMenuDivider(), + PopupMenuItem( + onTap: () async { + SmartDialog.show( + useSystem: true, + animationType: + SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: Text( + '确定拉黑:${videoItem.owner.name}(${videoItem.owner.mid})?' + '\n\n注:被拉黑的Up可以在隐私设置-黑名单管理中解除'), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: Text( + '点错了', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline), + ), + ), + TextButton( + onPressed: () async { + var res = await VideoHttp.relationMod( + mid: videoItem.owner.mid, + act: 5, + reSrc: 11, + ); + SmartDialog.dismiss(); + SmartDialog.showToast(res['code'] == 0 + ? '成功' + : res['msg']); + }, + child: const Text('确认'), + ) + ], + ); + }, + ); + }, + value: 'pause', + height: 40, + child: Row( + children: [ + const Icon(Icons.block, size: 16), + const SizedBox(width: 6), + Text('拉黑:${videoItem.owner.name}', + style: const TextStyle(fontSize: 13)) + ], + ), + ), ], ), ), diff --git a/lib/common/widgets/video_card_v.dart b/lib/common/widgets/video_card_v.dart index fa15a75c..0d96f7b7 100644 --- a/lib/common/widgets/video_card_v.dart +++ b/lib/common/widgets/video_card_v.dart @@ -1,17 +1,19 @@ +import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/common/widgets/badge.dart'; -import 'package:pilipala/common/widgets/stat/danmu.dart'; -import 'package:pilipala/common/widgets/stat/view.dart'; -import 'package:pilipala/http/dynamics.dart'; -import 'package:pilipala/http/search.dart'; -import 'package:pilipala/http/user.dart'; -import 'package:pilipala/models/common/search_type.dart'; -import 'package:pilipala/utils/id_utils.dart'; -import 'package:pilipala/utils/utils.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; +import '../../models/model_rec_video_item.dart'; +import 'stat/danmu.dart'; +import 'stat/view.dart'; +import '../../http/dynamics.dart'; +import '../../http/search.dart'; +import '../../http/user.dart'; +import '../../http/video.dart'; +import '../../models/common/search_type.dart'; +import '../../utils/id_utils.dart'; +import '../../utils/utils.dart'; +import '../constants.dart'; +import 'badge.dart'; +import 'network_img_layer.dart'; // 视频卡片 - 垂直布局 class VideoCardV extends StatelessWidget { @@ -159,12 +161,12 @@ class VideoCardV extends StatelessWidget { height: maxHeight, ), ), - if (videoItem.duration != null) + if (videoItem.duration > 0) if (crossAxisCount == 1) ...[ PBadge( bottom: 10, right: 10, - text: videoItem.duration, + text: Utils.timeFormat(videoItem.duration), ) ] else ...[ PBadge( @@ -172,7 +174,7 @@ class VideoCardV extends StatelessWidget { right: 7, size: 'small', type: 'gray', - text: videoItem.duration, + text: Utils.timeFormat(videoItem.duration), ) ], ], @@ -217,15 +219,10 @@ class VideoContent extends StatelessWidget { ), if (videoItem.goto == 'av' && crossAxisCount == 1) ...[ const SizedBox(width: 10), - WatchLater( + VideoPopupMenu( size: 32, iconSize: 18, - callFn: () async { - int aid = videoItem.param; - var res = - await UserHttp.toViewLater(bvid: IdUtils.av2bv(aid)); - SmartDialog.showToast(res['msg']); - }, + videoItem: videoItem, ), ], ], @@ -234,6 +231,7 @@ class VideoContent extends StatelessWidget { const SizedBox(height: 2), VideoStat( videoItem: videoItem, + crossAxisCount: crossAxisCount, ), ], if (crossAxisCount == 1) const SizedBox(height: 4), @@ -266,6 +264,14 @@ class VideoContent extends StatelessWidget { fs: 9, ) ], + if (videoItem.isFollowed == 1) ...[ + const PBadge( + text: '已关注', + stack: 'normal', + size: 'small', + type: 'color', + ) + ], Expanded( flex: crossAxisCount == 1 ? 0 : 1, child: Text( @@ -289,19 +295,15 @@ class VideoContent extends StatelessWidget { ), VideoStat( videoItem: videoItem, + crossAxisCount: crossAxisCount, ), const Spacer(), ], if (videoItem.goto == 'av' && crossAxisCount != 1) ...[ - WatchLater( + VideoPopupMenu( size: 24, iconSize: 14, - callFn: () async { - int aid = videoItem.param; - var res = - await UserHttp.toViewLater(bvid: IdUtils.av2bv(aid)); - SmartDialog.showToast(res['msg']); - }, + videoItem: videoItem, ), ] else ...[ const SizedBox(height: 24) @@ -317,42 +319,55 @@ class VideoContent extends StatelessWidget { class VideoStat extends StatelessWidget { final dynamic videoItem; + final int crossAxisCount; const VideoStat({ Key? key, required this.videoItem, + required this.crossAxisCount, }) : super(key: key); @override Widget build(BuildContext context) { - return RichText( - maxLines: 1, - text: TextSpan( - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, - color: Theme.of(context).colorScheme.outline, + return Row( + children: [ + StatView( + theme: 'gray', + view: videoItem.stat.view, ), - children: [ - if (videoItem.stat.view != '-') - TextSpan(text: '${videoItem.stat.view}观看'), - if (videoItem.stat.danmu != '-') - TextSpan(text: ' • ${videoItem.stat.danmu}弹幕'), - ], - ), + const SizedBox(width: 8), + StatDanMu( + theme: 'gray', + danmu: videoItem.stat.danmu, + ), + if (videoItem is RecVideoItemModel) ...[ + crossAxisCount > 1 ? const Spacer() : const SizedBox(width: 8), + RichText( + maxLines: 1, + text: TextSpan( + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + text: Utils.formatTimestampToRelativeTime(videoItem.pubdate)), + ), + const SizedBox(width: 4), + ] + ], ); } } -class WatchLater extends StatelessWidget { +class VideoPopupMenu extends StatelessWidget { final double? size; final double? iconSize; - final Function? callFn; + final dynamic videoItem; - const WatchLater({ + const VideoPopupMenu({ Key? key, required this.size, required this.iconSize, - this.callFn, + required this.videoItem, }) : super(key: key); @override @@ -362,7 +377,6 @@ class WatchLater extends StatelessWidget { height: size, child: PopupMenuButton( padding: EdgeInsets.zero, - tooltip: '稍后再看', icon: Icon( Icons.more_vert_outlined, color: Theme.of(context).colorScheme.outline, @@ -373,9 +387,13 @@ class WatchLater extends StatelessWidget { onSelected: (String type) {}, itemBuilder: (BuildContext context) => >[ PopupMenuItem( - onTap: () => callFn!(), + onTap: () async { + var res = + await UserHttp.toViewLater(bvid: videoItem.bvid as String); + SmartDialog.showToast(res['msg']); + }, value: 'pause', - height: 35, + height: 40, child: const Row( children: [ Icon(Icons.watch_later_outlined, size: 16), @@ -384,6 +402,55 @@ class WatchLater extends StatelessWidget { ], ), ), + const PopupMenuDivider(), + PopupMenuItem( + onTap: () async { + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: Text( + '确定拉黑:${videoItem.owner.name}(${videoItem.owner.mid})?' + '\n\n注:被拉黑的Up可以在隐私设置-黑名单管理中解除'), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: Text( + '点错了', + style: TextStyle( + color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () async { + var res = await VideoHttp.relationMod( + mid: videoItem.owner.mid, + act: 5, + reSrc: 11, + ); + SmartDialog.dismiss(); + SmartDialog.showToast(res['msg'] ?? '成功'); + }, + child: const Text('确认'), + ) + ], + ); + }, + ); + }, + value: 'pause', + height: 40, + child: Row( + children: [ + const Icon(Icons.block, size: 16), + const SizedBox(width: 6), + Text('拉黑:${videoItem.owner.name}', + style: const TextStyle(fontSize: 13)) + ], + ), + ), ], ), ); diff --git a/lib/http/api.dart b/lib/http/api.dart index 75a121ac..fa4cc1e8 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -1,15 +1,17 @@ +import 'constants.dart'; + class Api { // 推荐视频 static const String recommendListApp = - 'https://app.bilibili.com/x/v2/feed/index'; - static const String recommendList = '/x/web-interface/index/top/feed/rcmd'; + '${HttpString.appBaseUrl}/x/v2/feed/index'; + static const String recommendListWeb = '/x/web-interface/index/top/feed/rcmd'; // 热门视频 static const String hotList = '/x/web-interface/popular'; // 视频流 // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/video/videostream_url.md - static const String videoUrl = '/x/player/playurl'; + static const String videoUrl = '/x/player/wbi/playurl'; // 视频详情 // 竖屏 https://api.bilibili.com/x/web-interface/view?aid=527403921 @@ -152,7 +154,7 @@ class Api { // 动态点赞 static const String likeDynamic = - 'https://api.vc.bilibili.com/dynamic_like/v1/dynamic_like/thumb'; + '${HttpString.tUrl}/dynamic_like/v1/dynamic_like/thumb'; // 获取稍后再看 static const String seeYouLater = '/x/v2/history/toview'; @@ -183,7 +185,7 @@ class Api { static const String searchDefault = '/x/web-interface/wbi/search/default'; // 搜索关键词 - static const String serachSuggest = + static const String searchSuggest = 'https://s.search.bilibili.com/main/suggest'; // 分类搜索 @@ -212,6 +214,9 @@ class Api { // https://api.bilibili.com/x/relation/tags static const String followingsClass = '/x/relation/tags'; + // 搜索follow + static const followSearch = '/x/relation/followings/search'; + // 粉丝 // vmid 用户id pn 页码 ps 每页个数,最大50 order: desc // order_type 排序规则 最近访问传空,最常访问传 attention @@ -220,13 +225,17 @@ class Api { // 直播 // ?page=1&page_size=30&platform=web static const String liveList = - 'https://api.live.bilibili.com/xlive/web-interface/v1/second/getUserRecommend'; + '${HttpString.liveBaseUrl}/xlive/web-interface/v1/second/getUserRecommend'; // 直播间详情 // cid roomId // qn 80:流畅,150:高清,400:蓝光,10000:原画,20000:4K, 30000:杜比 static const String liveRoomInfo = - 'https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo'; + '${HttpString.liveBaseUrl}/xlive/web-room/v2/index/getRoomPlayInfo'; + + // 直播间详情 H5 + static const String liveRoomInfoH5 = + '${HttpString.liveBaseUrl}/xlive/web-room/v1/index/getH5InfoByRoom'; // 用户信息 需要Wbi签名 // https://api.bilibili.com/x/space/wbi/acc/info?mid=503427686&token=&platform=web&web_location=1550101&w_rid=d709892496ce93e3d94d6d37c95bde91&wts=1689301482 @@ -338,13 +347,13 @@ class Api { /// wts=1697305010 static const String sessionList = - 'https://api.vc.bilibili.com/session_svr/v1/session_svr/get_sessions'; + '${HttpString.tUrl}/session_svr/v1/session_svr/get_sessions'; /// 私聊用户信息 /// uids /// build=0&mobi_app=web static const String sessionAccountList = - 'https://api.vc.bilibili.com/account/v1/user/cards'; + '${HttpString.tUrl}/account/v1/user/cards'; /// https://api.vc.bilibili.com/svr_sync/v1/svr_sync/fetch_session_msgs? /// talker_id=400787461& @@ -358,7 +367,7 @@ class Api { /// wts=1697350697 static const String sessionMsg = - 'https://api.vc.bilibili.com/svr_sync/v1/svr_sync/fetch_session_msgs'; + '${HttpString.tUrl}/svr_sync/v1/svr_sync/fetch_session_msgs'; /// 标记已读 POST /// talker_id: @@ -369,7 +378,7 @@ class Api { /// csrf_token: /// csrf: static const String updateAck = - 'https://api.vc.bilibili.com/session_svr/v1/session_svr/update_ack'; + '${HttpString.tUrl}/session_svr/v1/session_svr/update_ack'; // 获取某个动态详情 // timezone_offset=-480 @@ -388,11 +397,11 @@ class Api { // captcha验证码 static const String getCaptcha = - 'https://passport.bilibili.com/x/passport-login/captcha?source=main_web'; + '${HttpString.passBaseUrl}/x/passport-login/captcha?source=main_web'; // web端短信验证码 static const String smsCode = - 'https://passport.bilibili.com/x/passport-login/web/sms/send'; + '${HttpString.passBaseUrl}/x/passport-login/web/sms/send'; // web端验证码登录 @@ -400,7 +409,7 @@ class Api { // app端短信验证码 static const String appSmsCode = - 'https://passport.bilibili.com/x/passport-login/sms/send'; + '${HttpString.passBaseUrl}/x/passport-login/sms/send'; // app端验证码登录 @@ -414,17 +423,16 @@ class Api { /// key /// rhash static const String loginInByPwdApi = - 'https://passport.bilibili.com/x/passport-login/oauth2/login'; + '${HttpString.passBaseUrl}/x/passport-login/oauth2/login'; /// 密码加密密钥 /// disable_rcmd /// local_id - static const getWebKey = - 'https://passport.bilibili.com/x/passport-login/web/key'; + static const getWebKey = '${HttpString.passBaseUrl}/x/passport-login/web/key'; /// cookie转access_key static const cookieToKey = - 'https://passport.bilibili.com/x/passport-tv-login/h5/qrcode/confirm'; + '${HttpString.passBaseUrl}/x/passport-tv-login/h5/qrcode/confirm'; /// 申请二维码(TV端) static const getTVCode = @@ -432,7 +440,7 @@ class Api { ///扫码登录(TV端) static const qrcodePoll = - 'https://passport.bilibili.com/x/passport-tv-login/qrcode/poll'; + '${HttpString.passBaseUrl}/x/passport-tv-login/qrcode/poll'; /// 置顶视频 static const getTopVideoApi = '/x/space/top/arc'; @@ -466,4 +474,38 @@ class Api { /// page_size static const getSeasonDetailApi = '/x/polymer/web-space/seasons_archives_list'; + + /// 获取未读动态数 + static const getUnreadDynamic = '/x/web-interface/dynamic/entrance'; + + /// 用户动态主页 + static const dynamicSpmPrefix = 'https://space.bilibili.com/1/dynamic'; + + /// 激活buvid3 + static const activateBuvidApi = '/x/internal/gaia-gateway/ExClimbWuzhi'; + + /// 获取字幕配置 + static const getSubtitleConfig = '/x/player/v2'; + + /// 我的订阅 + static const userSubFolder = '/x/v3/fav/folder/collected/list'; + + /// 我的订阅详情 + static const userSubFolderDetail = '/x/space/fav/season/list'; + + /// 表情 + static const emojiList = '/x/emote/user/panel/web'; + + /// 已读标记 + static const String ackSessionMsg = + '${HttpString.tUrl}/session_svr/v1/session_svr/update_ack'; + + /// 发送私信 + static const String sendMsg = '${HttpString.tUrl}/web_im/v1/web_im/send_msg'; + + /// 排行榜 + static const String getRankApi = "/x/web-interface/ranking/v2"; + + /// 取消订阅 + static const String cancelSub = '/x/v3/fav/season/unfav'; } diff --git a/lib/http/bangumi.dart b/lib/http/bangumi.dart index bd20366c..91508682 100644 --- a/lib/http/bangumi.dart +++ b/lib/http/bangumi.dart @@ -1,5 +1,5 @@ -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/bangumi/list.dart'; +import '../models/bangumi/list.dart'; +import 'index.dart'; class BangumiHttp { static Future bangumiList({int? page}) async { diff --git a/lib/http/black.dart b/lib/http/black.dart index 81a7c0c9..0c6a63ab 100644 --- a/lib/http/black.dart +++ b/lib/http/black.dart @@ -1,5 +1,5 @@ -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/user/black.dart'; +import '../models/user/black.dart'; +import 'index.dart'; class BlackHttp { static Future blackList({required int pn, int? ps}) async { diff --git a/lib/http/common.dart b/lib/http/common.dart new file mode 100644 index 00000000..d711a7e7 --- /dev/null +++ b/lib/http/common.dart @@ -0,0 +1,17 @@ +import 'index.dart'; + +class CommonHttp { + static Future unReadDynamic() async { + var res = await Request().get(Api.getUnreadDynamic, + data: {'alltype_offset': 0, 'video_offset': '', 'article_offset': 0}); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']['dyn_basic_infos']}; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } +} diff --git a/lib/http/constants.dart b/lib/http/constants.dart index cf10a606..3d749ee8 100644 --- a/lib/http/constants.dart +++ b/lib/http/constants.dart @@ -1,7 +1,10 @@ class HttpString { static const String baseUrl = 'https://www.bilibili.com'; - static const String baseApiUrl = 'https://api.bilibili.com'; + static const String apiBaseUrl = 'https://api.bilibili.com'; static const String tUrl = 'https://api.vc.bilibili.com'; + static const String appBaseUrl = 'https://app.bilibili.com'; + static const String liveBaseUrl = 'https://api.live.bilibili.com'; + static const String passBaseUrl = 'https://passport.bilibili.com'; static const List validateStatusCodes = [ 302, 304, diff --git a/lib/http/danmaku.dart b/lib/http/danmaku.dart index e34320e7..0b108755 100644 --- a/lib/http/danmaku.dart +++ b/lib/http/danmaku.dart @@ -1,9 +1,6 @@ import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/danmaku/dm.pb.dart'; - -import 'constants.dart'; +import '../models/danmaku/dm.pb.dart'; +import 'index.dart'; class DanmakaHttp { // 获取视频弹幕 @@ -24,21 +21,23 @@ class DanmakaHttp { ); return DmSegMobileReply.fromBuffer(response.data); } + static Future shootDanmaku({ - int type = 1,//弹幕类选择(1:视频弹幕 2:漫画弹幕) - required int oid,// 视频cid - required String msg,//弹幕文本(长度小于 100 字符) - int mode = 1,// 弹幕类型(1:滚动弹幕 4:底端弹幕 5:顶端弹幕 6:逆向弹幕(不能使用) 7:高级弹幕 8:代码弹幕(不能使用) 9:BAS弹幕(pool必须为2)) + int type = 1, //弹幕类选择(1:视频弹幕 2:漫画弹幕) + required int oid, // 视频cid + required String msg, //弹幕文本(长度小于 100 字符) + int mode = + 1, // 弹幕类型(1:滚动弹幕 4:底端弹幕 5:顶端弹幕 6:逆向弹幕(不能使用) 7:高级弹幕 8:代码弹幕(不能使用) 9:BAS弹幕(pool必须为2)) // String? aid,// 稿件avid // String? bvid,// bvid与aid必须有一个 required String bvid, - int? progress,// 弹幕出现在视频内的时间(单位为毫秒,默认为0) - int? color,// 弹幕颜色(默认白色,16777215) - int? fontsize,// 弹幕字号(默认25) - int? pool,// 弹幕池选择(0:普通池 1:字幕池 2:特殊池(代码/BAS弹幕)默认普通池,0) + int? progress, // 弹幕出现在视频内的时间(单位为毫秒,默认为0) + int? color, // 弹幕颜色(默认白色,16777215) + int? fontsize, // 弹幕字号(默认25) + int? pool, // 弹幕池选择(0:普通池 1:字幕池 2:特殊池(代码/BAS弹幕)默认普通池,0) //int? rnd,// 当前时间戳*1000000(若无此项,则发送弹幕冷却时间限制为90s;若有此项,则发送弹幕冷却时间限制为5s) - int? colorful,//60001:专属渐变彩色(需要会员) - int? checkbox_type,//是否带 UP 身份标识(0:普通;4:带有标识) + int? colorful, //60001:专属渐变彩色(需要会员) + int? checkbox_type, //是否带 UP 身份标识(0:普通;4:带有标识) // String? csrf,//CSRF Token(位于 Cookie) Cookie 方式必要 // String? access_key,// APP 登录 Token APP 方式必要 }) async { diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 7a22ab13..d62de12f 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -1,6 +1,6 @@ -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/dynamics/result.dart'; -import 'package:pilipala/models/dynamics/up.dart'; +import '../models/dynamics/result.dart'; +import '../models/dynamics/up.dart'; +import 'index.dart'; class DynamicsHttp { static Future followDynamic({ diff --git a/lib/http/fan.dart b/lib/http/fan.dart index 932cc79f..a69f58c8 100644 --- a/lib/http/fan.dart +++ b/lib/http/fan.dart @@ -1,5 +1,5 @@ -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/fans/result.dart'; +import '../models/fans/result.dart'; +import 'index.dart'; class FanHttp { static Future fans({int? vmid, int? pn, int? ps, String? orderType}) async { diff --git a/lib/http/follow.dart b/lib/http/follow.dart index f50762c4..316aa95a 100644 --- a/lib/http/follow.dart +++ b/lib/http/follow.dart @@ -1,5 +1,5 @@ -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/follow/result.dart'; +import '../models/follow/result.dart'; +import 'index.dart'; class FollowHttp { static Future followings( diff --git a/lib/http/html.dart b/lib/http/html.dart index 41570d0a..100887e5 100644 --- a/lib/http/html.dart +++ b/lib/http/html.dart @@ -1,6 +1,6 @@ import 'package:html/dom.dart'; import 'package:html/parser.dart'; -import 'package:pilipala/http/index.dart'; +import 'index.dart'; class HtmlHttp { // article @@ -15,7 +15,7 @@ class HtmlHttp { Match match = regex.firstMatch(response.data)!; String matchedString = match.group(0)!; response = await Request().get( - 'https:$matchedString' + '/', + 'https:$matchedString/', extra: {'ua': 'pc'}, ); } @@ -40,9 +40,13 @@ class HtmlHttp { // String opusContent = opusDetail.querySelector('.opus-module-content')!.innerHtml; - String test = opusDetail - .querySelector('.horizontal-scroll-album__pic__img')! - .innerHtml; + String? test; + try { + test = opusDetail + .querySelector('.horizontal-scroll-album__pic__img')! + .innerHtml; + } catch (_) {} + String commentId = opusDetail .querySelector('.bili-comment-container')! .className @@ -54,7 +58,7 @@ class HtmlHttp { 'avatar': avatar, 'uname': uname, 'updateTime': updateTime, - 'content': test + opusContent, + 'content': (test ?? '') + opusContent, 'commentId': int.parse(commentId) }; } catch (err) { diff --git a/lib/http/init.dart b/lib/http/init.dart index 1e55be38..a0b36369 100644 --- a/lib/http/init.dart +++ b/lib/http/init.dart @@ -1,17 +1,21 @@ // ignore_for_file: avoid_print +import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'dart:io'; -import 'dart:async'; -import 'package:dio/dio.dart'; +import 'dart:math' show Random; import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; import 'package:dio/io.dart'; -import 'package:dio_http2_adapter/dio_http2_adapter.dart'; -import 'package:hive/hive.dart'; -import 'package:pilipala/utils/storage.dart'; -import 'package:pilipala/utils/utils.dart'; -import 'package:pilipala/http/constants.dart'; -import 'package:pilipala/http/interceptor.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +// import 'package:dio_http2_adapter/dio_http2_adapter.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/utils/id_utils.dart'; +import '../utils/storage.dart'; +import '../utils/utils.dart'; +import 'api.dart'; +import 'constants.dart'; +import 'interceptor.dart'; class Request { static final Request _instance = Request._internal(); @@ -20,25 +24,26 @@ class Request { factory Request() => _instance; Box setting = GStrorage.setting; static Box localCache = GStrorage.localCache; - late dynamic enableSystemProxy; + late bool enableSystemProxy; late String systemProxyHost; late String systemProxyPort; + static final RegExp spmPrefixExp = RegExp(r''); /// 设置cookie static setCookie() async { Box userInfoCache = GStrorage.userInfo; - var cookiePath = await Utils.getCookiePath(); - var cookieJar = PersistCookieJar( + final String cookiePath = await Utils.getCookiePath(); + final PersistCookieJar cookieJar = PersistCookieJar( ignoreExpires: true, storage: FileStorage(cookiePath), ); cookieManager = CookieManager(cookieJar); dio.interceptors.add(cookieManager); - var cookie = await cookieManager.cookieJar + final List cookie = await cookieManager.cookieJar .loadForRequest(Uri.parse(HttpString.baseUrl)); - var userInfo = userInfoCache.get('userInfoCache'); + final userInfo = userInfoCache.get('userInfoCache'); if (userInfo != null && userInfo.mid != null) { - var cookie2 = await cookieManager.cookieJar + final List cookie2 = await cookieManager.cookieJar .loadForRequest(Uri.parse(HttpString.tUrl)); if (cookie2.isEmpty) { try { @@ -50,22 +55,22 @@ class Request { } setOptionsHeaders(userInfo, userInfo != null && userInfo.mid != null); - if (cookie.isEmpty) { - try { - await Request().get(HttpString.baseUrl); - } catch (e) { - log("setCookie, ${e.toString()}"); - } + try { + await buvidActivate(); + } catch (e) { + log("setCookie, ${e.toString()}"); } - var cookieString = - cookie.map((cookie) => '${cookie.name}=${cookie.value}').join('; '); + + final String cookieString = cookie + .map((Cookie cookie) => '${cookie.name}=${cookie.value}') + .join('; '); dio.options.headers['cookie'] = cookieString; } // 从cookie中获取 csrf token static Future getCsrf() async { - var cookies = await cookieManager.cookieJar - .loadForRequest(Uri.parse(HttpString.baseApiUrl)); + List cookies = await cookieManager.cookieJar + .loadForRequest(Uri.parse(HttpString.apiBaseUrl)); String token = ''; if (cookies.where((e) => e.name == 'bili_jct').isNotEmpty) { token = cookies.firstWhere((e) => e.name == 'bili_jct').value; @@ -73,17 +78,45 @@ class Request { return token; } - static setOptionsHeaders(userInfo, status) { + static setOptionsHeaders(userInfo, bool status) { if (status) { dio.options.headers['x-bili-mid'] = userInfo.mid.toString(); + dio.options.headers['x-bili-aurora-eid'] = + IdUtils.genAuroraEid(userInfo.mid); } dio.options.headers['env'] = 'prod'; dio.options.headers['app-key'] = 'android64'; - dio.options.headers['x-bili-aurora-eid'] = 'UlMFQVcABlAH'; dio.options.headers['x-bili-aurora-zone'] = 'sh001'; dio.options.headers['referer'] = 'https://www.bilibili.com/'; } + static Future buvidActivate() async { + var html = await Request().get(Api.dynamicSpmPrefix); + String spmPrefix = spmPrefixExp.firstMatch(html.data)!.group(1)!; + Random rand = Random(); + String rand_png_end = base64.encode( + List.generate(32, (_) => rand.nextInt(256)) + + List.filled(4, 0) + + [73, 69, 78, 68] + + List.generate(4, (_) => rand.nextInt(256)) + ); + + String jsonData = json.encode({ + '3064': 1, + '39c8': '${spmPrefix}.fp.risk', + '3c43': { + 'adca': 'Linux', + 'bfe9': rand_png_end.substring(rand_png_end.length - 50), + }, + }); + + await Request().post( + Api.activateBuvidApi, + data: {'payload': jsonData}, + options: Options(contentType: 'application/json') + ); + } + /* * config it and create */ @@ -91,7 +124,7 @@ class Request { //BaseOptions、Options、RequestOptions 都可以配置参数,优先级别依次递增,且可以根据优先级别覆盖参数 BaseOptions options = BaseOptions( //请求基地址,可以包含子路径 - baseUrl: HttpString.baseApiUrl, + baseUrl: HttpString.apiBaseUrl, //连接服务器超时时间,单位是毫秒. connectTimeout: const Duration(milliseconds: 12000), //响应流上前后两次接受到数据的间隔,单位为毫秒。 @@ -100,30 +133,31 @@ class Request { headers: {}, ); - enableSystemProxy = - setting.get(SettingBoxKey.enableSystemProxy, defaultValue: false); + enableSystemProxy = setting.get(SettingBoxKey.enableSystemProxy, + defaultValue: false) as bool; systemProxyHost = localCache.get(LocalCacheKey.systemProxyHost, defaultValue: ''); systemProxyPort = localCache.get(LocalCacheKey.systemProxyPort, defaultValue: ''); - dio = Dio(options) + dio = Dio(options); - /// fix 第三方登录 302重定向 跟iOS代理问题冲突 - ..httpClientAdapter = Http2Adapter( - ConnectionManager( - idleTimeout: const Duration(milliseconds: 10000), - onClientCreate: (_, config) => config.onBadCertificate = (_) => true, - ), - ); + /// fix 第三方登录 302重定向 跟iOS代理问题冲突 + // ..httpClientAdapter = Http2Adapter( + // ConnectionManager( + // idleTimeout: const Duration(milliseconds: 10000), + // onClientCreate: (_, ClientSetting config) => + // config.onBadCertificate = (_) => true, + // ), + // ); /// 设置代理 if (enableSystemProxy) { dio.httpClientAdapter = IOHttpClientAdapter( createHttpClient: () { - final client = HttpClient(); + final HttpClient client = HttpClient(); // Config the client. - client.findProxy = (uri) { + client.findProxy = (Uri uri) { // return 'PROXY host:port'; return 'PROXY $systemProxyHost:$systemProxyPort'; }; @@ -145,7 +179,7 @@ class Request { )); dio.transformer = BackgroundTransformer(); - dio.options.validateStatus = (status) { + dio.options.validateStatus = (int? status) { return status! >= 200 && status < 300 || HttpString.validateStatusCodes.contains(status); }; @@ -156,7 +190,7 @@ class Request { */ get(url, {data, options, cancelToken, extra}) async { Response response; - Options options = Options(); + final Options options = Options(); ResponseType resType = ResponseType.json; if (extra != null) { resType = extra!['resType'] ?? ResponseType.json; @@ -175,8 +209,14 @@ class Request { ); return response; } on DioException catch (e) { - print('get error: $e'); - return Future.error(await ApiInterceptor.dioError(e)); + Response errResponse = Response( + data: { + 'message': await ApiInterceptor.dioError(e) + }, // 将自定义 Map 数据赋值给 Response 的 data 属性 + statusCode: 200, + requestOptions: RequestOptions(), + ); + return errResponse; } } @@ -197,8 +237,14 @@ class Request { // print('post success: ${response.data}'); return response; } on DioException catch (e) { - print('post error: $e'); - return Future.error(await ApiInterceptor.dioError(e)); + Response errResponse = Response( + data: { + 'message': await ApiInterceptor.dioError(e) + }, // 将自定义 Map 数据赋值给 Response 的 data 属性 + statusCode: 200, + requestOptions: RequestOptions(), + ); + return errResponse; } } diff --git a/lib/http/interceptor.dart b/lib/http/interceptor.dart index 7b398caa..a5359283 100644 --- a/lib/http/interceptor.dart +++ b/lib/http/interceptor.dart @@ -1,11 +1,10 @@ // ignore_for_file: avoid_print -import 'package:dio/dio.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/utils/storage.dart'; -// import 'package:get/get.dart' hide Response; +import '../utils/storage.dart'; class ApiInterceptor extends Interceptor { @override @@ -21,16 +20,16 @@ class ApiInterceptor extends Interceptor { void onResponse(Response response, ResponseInterceptorHandler handler) { try { if (response.statusCode == 302) { - List locations = response.headers['location']!; + final List locations = response.headers['location']!; if (locations.isNotEmpty) { if (locations.first.startsWith('https://www.mcbbs.net')) { - final uri = Uri.parse(locations.first); - final accessKey = uri.queryParameters['access_key']; - final mid = uri.queryParameters['mid']; + final Uri uri = Uri.parse(locations.first); + final String? accessKey = uri.queryParameters['access_key']; + final String? mid = uri.queryParameters['mid']; try { Box localCache = GStrorage.localCache; - localCache.put( - LocalCacheKey.accessKey, {'mid': mid, 'value': accessKey}); + localCache.put(LocalCacheKey.accessKey, + {'mid': mid, 'value': accessKey}); } catch (_) {} } } @@ -46,54 +45,57 @@ class ApiInterceptor extends Interceptor { void onError(DioException err, ErrorInterceptorHandler handler) async { // 处理网络请求错误 // handler.next(err); - SmartDialog.showToast( - await dioError(err), - displayType: SmartToastType.onlyRefresh, - ); + String url = err.requestOptions.uri.toString(); + if (!url.contains('heartBeat')) { + SmartDialog.showToast( + await dioError(err), + displayType: SmartToastType.onlyRefresh, + ); + } super.onError(err, handler); } - static Future dioError(DioException error) async { + static Future dioError(DioException error) async { switch (error.type) { case DioExceptionType.badCertificate: return '证书有误!'; case DioExceptionType.badResponse: return '服务器异常,请稍后重试!'; case DioExceptionType.cancel: - return "请求已被取消,请重新请求"; + return '请求已被取消,请重新请求'; case DioExceptionType.connectionError: return '连接错误,请检查网络设置'; case DioExceptionType.connectionTimeout: - return "网络连接超时,请检查网络设置"; + return '网络连接超时,请检查网络设置'; case DioExceptionType.receiveTimeout: - return "响应超时,请稍后重试!"; + return '响应超时,请稍后重试!'; case DioExceptionType.sendTimeout: - return "发送请求超时,请检查网络设置"; + return '发送请求超时,请检查网络设置'; case DioExceptionType.unknown: - var res = await checkConect(); - return res + " \n 网络异常,请稍后重试!"; - default: - return "Dio异常"; + final String res = await checkConnect(); + return '$res,网络异常!'; } } - static Future checkConect() async { - final connectivityResult = await (Connectivity().checkConnectivity()); - if (connectivityResult == ConnectivityResult.mobile) { - return 'connected with mobile network'; - } else if (connectivityResult == ConnectivityResult.wifi) { - return 'connected with wifi network'; - } else if (connectivityResult == ConnectivityResult.ethernet) { - // I am connected to a ethernet network. - } else if (connectivityResult == ConnectivityResult.vpn) { - // I am connected to a vpn network. - // Note for iOS and macOS: - // There is no separate network interface type for [vpn]. - // It returns [other] on any device (also simulator) - } else if (connectivityResult == ConnectivityResult.other) { - // I am connected to a network which is not in the above mentioned networks. - } else if (connectivityResult == ConnectivityResult.none) { - return 'not connected to any network'; + static Future checkConnect() async { + final List connectivityResult = + await Connectivity().checkConnectivity(); + if (connectivityResult.contains(ConnectivityResult.mobile)) { + return '正在使用移动流量'; + } else if (connectivityResult.contains(ConnectivityResult.wifi)) { + return '正在使用wifi'; + } else if (connectivityResult.contains(ConnectivityResult.ethernet)) { + return '正在使用局域网'; + } else if (connectivityResult.contains(ConnectivityResult.vpn)) { + return '正在使用代理网络'; + } else if (connectivityResult.contains(ConnectivityResult.bluetooth)) { + return '正在使用蓝牙网络'; + } else if (connectivityResult.contains(ConnectivityResult.other)) { + return '正在使用其他网络'; + } else if (connectivityResult.contains(ConnectivityResult.none)) { + return '未连接到任何网络'; + } else { + return ''; } } } diff --git a/lib/http/live.dart b/lib/http/live.dart index 2ae9aad7..e624120e 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -1,7 +1,8 @@ -import 'package:pilipala/http/api.dart'; -import 'package:pilipala/http/init.dart'; -import 'package:pilipala/models/live/item.dart'; -import 'package:pilipala/models/live/room_info.dart'; +import '../models/live/item.dart'; +import '../models/live/room_info.dart'; +import '../models/live/room_info_h5.dart'; +import 'api.dart'; +import 'init.dart'; class LiveHttp { static Future liveList( @@ -46,4 +47,22 @@ class LiveHttp { }; } } + + static Future liveRoomInfoH5({roomId, qn}) async { + var res = await Request().get(Api.liveRoomInfoH5, data: { + 'room_id': roomId, + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': RoomInfoH5Model.fromJson(res.data['data']) + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/login.dart b/lib/http/login.dart index 8d2a254e..ff3fee23 100644 --- a/lib/http/login.dart +++ b/lib/http/login.dart @@ -1,13 +1,12 @@ import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; - import 'package:dio/dio.dart'; import 'package:encrypt/encrypt.dart'; -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/login/index.dart'; -import 'package:pilipala/utils/login.dart'; import 'package:uuid/uuid.dart'; +import '../models/login/index.dart'; +import '../utils/login.dart'; +import 'index.dart'; class LoginHttp { static Future queryCaptcha() async { diff --git a/lib/http/member.dart b/lib/http/member.dart index 20826451..1af0f9a4 100644 --- a/lib/http/member.dart +++ b/lib/http/member.dart @@ -1,17 +1,17 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/dynamics/result.dart'; -import 'package:pilipala/models/follow/result.dart'; -import 'package:pilipala/models/member/archive.dart'; -import 'package:pilipala/models/member/coin.dart'; -import 'package:pilipala/models/member/info.dart'; -import 'package:pilipala/models/member/seasons.dart'; -import 'package:pilipala/models/member/tags.dart'; -import 'package:pilipala/utils/storage.dart'; -import 'package:pilipala/utils/utils.dart'; -import 'package:pilipala/utils/wbi_sign.dart'; +import '../common/constants.dart'; +import '../models/dynamics/result.dart'; +import '../models/follow/result.dart'; +import '../models/member/archive.dart'; +import '../models/member/coin.dart'; +import '../models/member/info.dart'; +import '../models/member/seasons.dart'; +import '../models/member/tags.dart'; +import '../utils/storage.dart'; +import '../utils/utils.dart'; +import '../utils/wbi_sign.dart'; +import 'index.dart'; class MemberHttp { static Future memberInfo({ @@ -79,6 +79,8 @@ class MemberHttp { String order = 'pubdate', bool orderAvoided = true, }) async { + String dmImgStr = Utils.base64EncodeRandomString(16, 64); + String dmCoverImgStr = Utils.base64EncodeRandomString(32, 128); Map params = await WbiSign().makSign({ 'mid': mid, 'ps': ps, @@ -88,7 +90,11 @@ class MemberHttp { 'order': order, 'platform': 'web', 'web_location': 1550101, - 'order_avoided': orderAvoided + 'order_avoided': orderAvoided, + 'dm_img_list': '[]', + 'dm_img_str': dmImgStr.substring(0, dmImgStr.length - 2), + 'dm_cover_img_str': dmCoverImgStr.substring(0, dmCoverImgStr.length - 2), + 'dm_img_inter': '{"ds":[],"wh":[0,0,0],"of":[0,0,0]}', }); var res = await Request().get( Api.memberArchive, @@ -101,10 +107,13 @@ class MemberHttp { 'data': MemberArchiveDataModel.fromJson(res.data['data']) }; } else { + Map errMap = { + -352: '风控校验失败,请检查登录状态', + }; return { 'status': false, 'data': [], - 'msg': res.data['message'], + 'msg': errMap[res.data['code']] ?? res.data['message'], }; } } @@ -123,10 +132,13 @@ class MemberHttp { 'data': DynamicsDataModel.fromJson(res.data['data']), }; } else { + Map errMap = { + -352: '风控校验失败,请检查登录状态', + }; return { 'status': false, 'data': [], - 'msg': res.data['message'], + 'msg': errMap[res.data['code']] ?? res.data['message'], }; } } @@ -461,4 +473,41 @@ class MemberHttp { }; } } + + // 搜索follow + static Future getfollowSearch({ + required int mid, + required int ps, + required int pn, + required String name, + }) async { + Map data = { + 'vmid': mid, + 'pn': pn, + 'ps': ps, + 'order': 'desc', + 'order_type': 'attention', + 'gaia_source': 'main_web', + 'name': name, + 'web_location': 333.999, + }; + Map params = await WbiSign().makSign(data); + var res = await Request().get(Api.followSearch, data: { + ...data, + 'w_rid': params['w_rid'], + 'wts': params['wts'], + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': FollowDataModel.fromJson(res.data['data']) + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/msg.dart b/lib/http/msg.dart index 7055d260..d1d31958 100644 --- a/lib/http/msg.dart +++ b/lib/http/msg.dart @@ -1,8 +1,9 @@ -import 'package:pilipala/http/api.dart'; -import 'package:pilipala/http/init.dart'; -import 'package:pilipala/models/msg/account.dart'; -import 'package:pilipala/models/msg/session.dart'; -import 'package:pilipala/utils/wbi_sign.dart'; +import 'dart:math'; +import '../models/msg/account.dart'; +import '../models/msg/session.dart'; +import '../utils/wbi_sign.dart'; +import 'api.dart'; +import 'init.dart'; class MsgHttp { // 会话列表 @@ -22,14 +23,22 @@ class MsgHttp { Map signParams = await WbiSign().makSign(params); var res = await Request().get(Api.sessionList, data: signParams); if (res.data['code'] == 0) { - return { - 'status': true, - 'data': SessionDataModel.fromJson(res.data['data']), - }; + try { + return { + 'status': true, + 'data': SessionDataModel.fromJson(res.data['data']), + }; + } catch (err) { + return { + 'status': false, + 'data': [], + 'msg': err.toString(), + }; + } } else { return { 'status': false, - 'date': [], + 'data': [], 'msg': res.data['message'], }; } @@ -42,12 +51,16 @@ class MsgHttp { 'mobi_app': 'web', }); if (res.data['code'] == 0) { - return { - 'status': true, - 'data': res.data['data'] - .map((e) => AccountListModel.fromJson(e)) - .toList(), - }; + try { + return { + 'status': true, + 'data': res.data['data'] + .map((e) => AccountListModel.fromJson(e)) + .toList(), + }; + } catch (err) { + print('err🔟: $err'); + } } else { return { 'status': false, @@ -86,4 +99,125 @@ class MsgHttp { }; } } + + // 消息标记已读 + static Future ackSessionMsg({ + int? talkerId, + int? ackSeqno, + }) async { + String csrf = await Request.getCsrf(); + Map params = await WbiSign().makSign({ + 'talker_id': talkerId, + 'session_type': 1, + 'ack_seqno': ackSeqno, + 'build': 0, + 'mobi_app': 'web', + 'csrf_token': csrf, + 'csrf': csrf + }); + var res = await Request().get(Api.ackSessionMsg, data: params); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'], + }; + } else { + return { + 'status': false, + 'date': [], + 'msg': "message: ${res.data['message']}," + " msg: ${res.data['msg']}," + " code: ${res.data['code']}", + }; + } + } + + // 发送私信 + static Future sendMsg({ + int? senderUid, + int? receiverId, + int? receiverType, + int? msgType, + dynamic content, + }) async { + String csrf = await Request.getCsrf(); + Map params = await WbiSign().makSign({ + 'msg[sender_uid]': senderUid, + 'msg[receiver_id]': receiverId, + 'msg[receiver_type]': receiverType ?? 1, + 'msg[msg_type]': msgType ?? 1, + 'msg[msg_status]': 0, + 'msg[dev_id]': getDevId(), + 'msg[timestamp]': DateTime.now().millisecondsSinceEpoch ~/ 1000, + 'msg[new_face_version]': 0, + 'msg[content]': content, + 'from_firework': 0, + 'build': 0, + 'mobi_app': 'web', + 'csrf_token': csrf, + 'csrf': csrf, + }); + var res = + await Request().post(Api.sendMsg, queryParameters: { + ...params, + 'csrf_token': csrf, + 'csrf': csrf, + }, data: { + 'w_sender_uid': params['msg[sender_uid]'], + 'w_receiver_id': params['msg[receiver_id]'], + 'w_dev_id': params['msg[dev_id]'], + 'w_rid': params['w_rid'], + 'wts': params['wts'], + 'csrf_token': csrf, + 'csrf': csrf, + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'], + }; + } else { + return { + 'status': false, + 'date': [], + 'msg': "message: ${res.data['message']}," + " msg: ${res.data['msg']}," + " code: ${res.data['code']}", + }; + } + } + + static String getDevId() { + final List b = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F' + ]; + final List s = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".split(''); + for (int i = 0; i < s.length; i++) { + if ('-' == s[i] || '4' == s[i]) { + continue; + } + final int randomInt = Random().nextInt(16); + if ('x' == s[i]) { + s[i] = b[randomInt]; + } else { + s[i] = b[3 & randomInt | 8]; + } + } + return s.join(); + } } diff --git a/lib/http/reply.dart b/lib/http/reply.dart index 790a017f..f080ed51 100644 --- a/lib/http/reply.dart +++ b/lib/http/reply.dart @@ -1,6 +1,7 @@ -import 'package:pilipala/http/api.dart'; -import 'package:pilipala/http/init.dart'; -import 'package:pilipala/models/video/reply/data.dart'; +import '../models/video/reply/data.dart'; +import '../models/video/reply/emote.dart'; +import 'api.dart'; +import 'init.dart'; class ReplyHttp { static Future replyList({ @@ -100,4 +101,23 @@ class ReplyHttp { }; } } + + static Future getEmoteList({String? business}) async { + var res = await Request().get(Api.emojiList, data: { + 'business': business ?? 'reply', + 'web_location': '333.1245', + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': EmoteModelData.fromJson(res.data['data']), + }; + } else { + return { + 'status': false, + 'date': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/http/search.dart b/lib/http/search.dart index b94ace2c..18481ea8 100644 --- a/lib/http/search.dart +++ b/lib/http/search.dart @@ -1,13 +1,12 @@ import 'dart:convert'; - import 'package:hive/hive.dart'; -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/bangumi/info.dart'; -import 'package:pilipala/models/common/search_type.dart'; -import 'package:pilipala/models/search/hot.dart'; -import 'package:pilipala/models/search/result.dart'; -import 'package:pilipala/models/search/suggest.dart'; -import 'package:pilipala/utils/storage.dart'; +import '../models/bangumi/info.dart'; +import '../models/common/search_type.dart'; +import '../models/search/hot.dart'; +import '../models/search/result.dart'; +import '../models/search/suggest.dart'; +import '../utils/storage.dart'; +import 'index.dart'; class SearchHttp { static Box setting = GStrorage.setting; @@ -37,7 +36,7 @@ class SearchHttp { // 获取搜索建议 static Future searchSuggest({required term}) async { - var res = await Request().get(Api.serachSuggest, + var res = await Request().get(Api.searchSuggest, data: {'term': term, 'main_ver': 'v1', 'highlight': term}); if (res.data is String) { Map resultMap = json.decode(res.data); @@ -129,25 +128,28 @@ class SearchHttp { } } - static Future ab2c({int? aid, String? bvid}) async { + static Future ab2c({int? aid, String? bvid}) async { Map data = {}; if (aid != null) { data['aid'] = aid; } else if (bvid != null) { data['bvid'] = bvid; } - var res = await Request().get(Api.ab2c, data: {...data}); + final dynamic res = + await Request().get(Api.ab2c, data: {...data}); return res.data['data'].first['cid']; } - static Future bangumiInfo({int? seasonId, int? epId}) async { - Map data = {}; + static Future> bangumiInfo( + {int? seasonId, int? epId}) async { + final Map data = {}; if (seasonId != null) { data['season_id'] = seasonId; } else if (epId != null) { data['ep_id'] = epId; } - var res = await Request().get(Api.bangumiInfo, data: {...data}); + final dynamic res = + await Request().get(Api.bangumiInfo, data: {...data}); if (res.data['code'] == 0) { return { 'status': true, diff --git a/lib/http/user.dart b/lib/http/user.dart index 45da72e4..bae61720 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -1,14 +1,15 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/http/api.dart'; -import 'package:pilipala/http/init.dart'; -import 'package:pilipala/models/model_hot_video_item.dart'; -import 'package:pilipala/models/user/fav_detail.dart'; -import 'package:pilipala/models/user/fav_folder.dart'; -import 'package:pilipala/models/user/history.dart'; -import 'package:pilipala/models/user/info.dart'; -import 'package:pilipala/models/user/stat.dart'; -import 'package:pilipala/utils/wbi_sign.dart'; +import '../common/constants.dart'; +import '../models/model_hot_video_item.dart'; +import '../models/user/fav_detail.dart'; +import '../models/user/fav_folder.dart'; +import '../models/user/history.dart'; +import '../models/user/info.dart'; +import '../models/user/stat.dart'; +import '../models/user/sub_detail.dart'; +import '../models/user/sub_folder.dart'; +import 'api.dart'; +import 'init.dart'; class UserHttp { static Future userStat({required int mid}) async { @@ -306,4 +307,63 @@ class UserHttp { return {'status': false, 'msg': res.data['message']}; } } + + // 我的订阅 + static Future userSubFolder({ + required int mid, + required int pn, + required int ps, + }) async { + var res = await Request().get(Api.userSubFolder, data: { + 'up_mid': mid, + 'ps': ps, + 'pn': pn, + 'platform': 'web', + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': SubFolderModelData.fromJson(res.data['data']) + }; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + static Future userSubFolderDetail({ + required int seasonId, + required int pn, + required int ps, + }) async { + var res = await Request().get(Api.userSubFolderDetail, data: { + 'season_id': seasonId, + 'ps': ps, + 'pn': pn, + }); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': SubDetailModelData.fromJson(res.data['data']) + }; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + // 取消订阅 + static Future cancelSub({required int seasonId}) async { + var res = await Request().post( + Api.cancelSub, + queryParameters: { + 'platform': 'web', + 'season_id': seasonId, + 'csrf': await Request.getCsrf(), + }, + ); + if (res.data['code'] == 0) { + return {'status': true}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } } diff --git a/lib/http/video.dart b/lib/http/video.dart index 9429a04b..d43656b2 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -1,19 +1,21 @@ import 'dart:developer'; - import 'package:hive/hive.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/http/api.dart'; -import 'package:pilipala/http/init.dart'; -import 'package:pilipala/models/common/reply_type.dart'; -import 'package:pilipala/models/home/rcmd/result.dart'; -import 'package:pilipala/models/model_hot_video_item.dart'; -import 'package:pilipala/models/model_rec_video_item.dart'; -import 'package:pilipala/models/user/fav_folder.dart'; -import 'package:pilipala/models/video/ai.dart'; -import 'package:pilipala/models/video/play/url.dart'; -import 'package:pilipala/models/video_detail_res.dart'; -import 'package:pilipala/utils/storage.dart'; -import 'package:pilipala/utils/wbi_sign.dart'; +import '../common/constants.dart'; +import '../models/common/reply_type.dart'; +import '../models/home/rcmd/result.dart'; +import '../models/model_hot_video_item.dart'; +import '../models/model_rec_video_item.dart'; +import '../models/user/fav_folder.dart'; +import '../models/video/ai.dart'; +import '../models/video/play/url.dart'; +import '../models/video/subTitile/result.dart'; +import '../models/video_detail_res.dart'; +import '../utils/recommend_filter.dart'; +import '../utils/storage.dart'; +import '../utils/subtitle.dart'; +import '../utils/wbi_sign.dart'; +import 'api.dart'; +import 'init.dart'; /// res.data['code'] == 0 请求正常返回结果 /// res.data['data'] 为结果 @@ -30,30 +32,44 @@ class VideoHttp { static Future rcmdVideoList({required int ps, required int freshIdx}) async { try { var res = await Request().get( - Api.recommendList, + Api.recommendListWeb, data: { 'version': 1, - 'feed_version': 'V3', + 'feed_version': 'V8', + 'homepage_ver': 1, 'ps': ps, 'fresh_idx': freshIdx, - 'fresh_type': 999999 + 'brush': freshIdx, + 'fresh_type': 4 }, ); if (res.data['code'] == 0) { List list = []; + List blackMidsList = + setting.get(SettingBoxKey.blackMidsList, defaultValue: [-1]); for (var i in res.data['data']['item']) { - list.add(RecVideoItemModel.fromJson(i)); + //过滤掉live与ad,以及拉黑用户 + if (i['goto'] == 'av' && + (i['owner'] != null && + !blackMidsList.contains(i['owner']['mid']))) { + RecVideoItemModel videoItem = RecVideoItemModel.fromJson(i); + if (!RecommendFilter.filter(videoItem)) { + list.add(videoItem); + } + } } return {'status': true, 'data': list}; } else { - return {'status': false, 'data': [], 'msg': ''}; + return {'status': false, 'data': [], 'msg': res.data['message']}; } } catch (err) { return {'status': false, 'data': [], 'msg': err.toString()}; } } - static Future rcmdVideoListApp({int? ps, required int freshIdx}) async { + // 添加额外的loginState变量模拟未登录状态 + static Future rcmdVideoListApp( + {bool loginStatus = true, required int freshIdx}) async { try { var res = await Request().get( Api.recommendListApp, @@ -66,9 +82,11 @@ class VideoHttp { 'device_name': 'vivo', 'pull': freshIdx == 0 ? 'true' : 'false', 'appkey': Constants.appKey, - 'access_key': localCache - .get(LocalCacheKey.accessKey, defaultValue: {})['value'] ?? - '' + 'access_key': loginStatus + ? (localCache.get(LocalCacheKey.accessKey, + defaultValue: {})['value'] ?? + '') + : '' }, ); if (res.data['code'] == 0) { @@ -81,12 +99,15 @@ class VideoHttp { (!enableRcmdDynamic ? i['card_goto'] != 'picture' : true) && (i['args'] != null && !blackMidsList.contains(i['args']['up_mid']))) { - list.add(RecVideoItemAppModel.fromJson(i)); + RecVideoItemAppModel videoItem = RecVideoItemAppModel.fromJson(i); + if (!RecommendFilter.filter(videoItem)) { + list.add(videoItem); + } } } return {'status': true, 'data': list}; } else { - return {'status': false, 'data': [], 'msg': ''}; + return {'status': false, 'data': [], 'msg': res.data['message']}; } } catch (err) { return {'status': false, 'data': [], 'msg': err.toString()}; @@ -111,7 +132,7 @@ class VideoHttp { } return {'status': true, 'data': list}; } else { - return {'status': false, 'data': []}; + return {'status': false, 'data': [], 'msg': res.data['message']}; } } catch (err) { return {'status': false, 'data': [], 'msg': err}; @@ -122,27 +143,34 @@ class VideoHttp { static Future videoUrl( {int? avid, String? bvid, required int cid, int? qn}) async { Map data = { - // 'avid': avid, - 'bvid': bvid, 'cid': cid, - // 'qn': qn ?? 80, + 'qn': qn ?? 80, // 获取所有格式的视频 'fnval': 4048, - // 'fnver': '', - 'fourk': 1, - // 'session': '', - // 'otype': '', - // 'type': '', - // 'platform': '', - // 'high_quality': '' }; + if (avid != null) { + data['avid'] = avid; + } + if (bvid != null) { + data['bvid'] = bvid; + } + // 免登录查看1080p if (userInfoCache.get('userInfoCache') == null && setting.get(SettingBoxKey.p1080, defaultValue: true)) { data['try_look'] = 1; } + + Map params = await WbiSign().makSign({ + ...data, + 'fourk': 1, + 'voice_balance': 1, + 'gaia_source': 'pre-load', + 'web_location': 1550101, + }); + try { - var res = await Request().get(Api.videoUrl, data: data); + var res = await Request().get(Api.videoUrl, data: params); if (res.data['code'] == 0) { return { 'status': true, @@ -190,7 +218,10 @@ class VideoHttp { if (res.data['code'] == 0) { List list = []; for (var i in res.data['data']) { - list.add(HotVideoItemModel.fromJson(i)); + HotVideoItemModel videoItem = HotVideoItemModel.fromJson(i); + if (!RecommendFilter.filter(videoItem, relatedVideos: true)) { + list.add(videoItem); + } } return {'status': true, 'data': list}; } else { @@ -211,10 +242,11 @@ class VideoHttp { // 获取投币状态 static Future hasCoinVideo({required String bvid}) async { var res = await Request().get(Api.hasCoinVideo, data: {'bvid': bvid}); + print('res: $res'); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { - return {'status': true, 'data': []}; + return {'status': false, 'data': []}; } } @@ -292,7 +324,7 @@ class VideoHttp { if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { - return {'status': false, 'data': []}; + return {'status': false, 'data': [], 'msg': res.data['message']}; } } @@ -348,7 +380,7 @@ class VideoHttp { if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { - return {'status': true, 'data': []}; + return {'status': false, 'data': []}; } } @@ -364,7 +396,7 @@ class VideoHttp { if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; } else { - return {'status': true, 'data': []}; + return {'status': false, 'data': []}; } } @@ -420,6 +452,8 @@ class VideoHttp { }); if (res.data['code'] == 0) { return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'data': null, 'msg': res.data['message']}; } } @@ -434,11 +468,63 @@ class VideoHttp { 'up_mid': upMid, }); var res = await Request().get(Api.aiConclusion, data: params); - if (res.data['code'] == 0) { + if (res.data['code'] == 0 && res.data['data']['code'] == 0) { return { 'status': true, 'data': AiConclusionModel.fromJson(res.data['data']), }; + } else { + return {'status': false, 'data': []}; } } + + static Future getSubtitle({int? cid, String? bvid}) async { + var res = await Request().get(Api.getSubtitleConfig, data: { + 'cid': cid, + 'bvid': bvid, + }); + try { + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': SubTitlteModel.fromJson(res.data['data']), + }; + } else { + return {'status': false, 'data': [], 'msg': res.data['msg']}; + } + } catch (err) { + print(err); + } + } + + // 视频排行 + static Future getRankVideoList(int rid) async { + try { + var rankApi = "${Api.getRankApi}?rid=$rid&type=all"; + var res = await Request().get(rankApi); + if (res.data['code'] == 0) { + List list = []; + List blackMidsList = + setting.get(SettingBoxKey.blackMidsList, defaultValue: [-1]); + for (var i in res.data['data']['list']) { + if (!blackMidsList.contains(i['owner']['mid'])) { + list.add(HotVideoItemModel.fromJson(i)); + } + } + return {'status': true, 'data': list}; + } else { + return {'status': false, 'data': [], 'msg': res.data['message']}; + } + } catch (err) { + return {'status': false, 'data': [], 'msg': err}; + } + } + + // 获取字幕内容 + static Future> getSubtitleContent(url) async { + var res = await Request().get('https:$url'); + final String content = SubTitleUtils.convertToWebVTT(res.data['body']); + final List body = res.data['body']; + return {'content': content, 'body': body}; + } } diff --git a/lib/main.dart b/lib/main.dart index 20a5b569..7fdaeeb0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,11 +16,15 @@ import 'package:pilipala/pages/search/index.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/router/app_pages.dart'; import 'package:pilipala/pages/main/view.dart'; +import 'package:pilipala/services/disable_battery_opt.dart'; import 'package:pilipala/services/service_locator.dart'; import 'package:pilipala/utils/app_scheme.dart'; import 'package:pilipala/utils/data.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:media_kit/media_kit.dart'; // Provides [Player], [Media], [Playlist] etc. +import 'package:pilipala/utils/recommend_filter.dart'; +import 'package:catcher_2/catcher_2.dart'; +import './services/loggeer.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -30,7 +34,36 @@ void main() async { .then((_) async { await GStrorage.init(); await setupServiceLocator(); - runApp(const MyApp()); + clearLogs(); + Request(); + await Request.setCookie(); + RecommendFilter(); + + // 异常捕获 logo记录 + final Catcher2Options debugConfig = Catcher2Options( + SilentReportMode(), + [ + FileHandler(await getLogsPath()), + ConsoleHandler( + enableDeviceParameters: false, + enableApplicationParameters: false, + ) + ], + ); + + final Catcher2Options releaseConfig = Catcher2Options( + SilentReportMode(), + [FileHandler(await getLogsPath())], + ); + + Catcher2( + debugConfig: debugConfig, + releaseConfig: releaseConfig, + runAppFunction: () { + runApp(const MyApp()); + }, + ); + // 小白条、导航栏沉浸 SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( @@ -38,10 +71,9 @@ void main() async { systemNavigationBarDividerColor: Colors.transparent, statusBarColor: Colors.transparent, )); - await Request.setCookie(); Data.init(); - GStrorage.lazyInit(); PiliSchame.init(); + DisableBatteryOpt(); }); } @@ -112,6 +144,13 @@ class MyApp extends StatelessWidget { ? darkColorScheme : lightColorScheme, useMaterial3: true, + snackBarTheme: SnackBarThemeData( + actionTextColor: lightColorScheme.primary, + backgroundColor: lightColorScheme.secondaryContainer, + closeIconColor: lightColorScheme.secondary, + contentTextStyle: TextStyle(color: lightColorScheme.secondary), + elevation: 20, + ), pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: ZoomPageTransitionsBuilder( @@ -126,6 +165,13 @@ class MyApp extends StatelessWidget { ? lightColorScheme : darkColorScheme, useMaterial3: true, + snackBarTheme: SnackBarThemeData( + actionTextColor: darkColorScheme.primary, + backgroundColor: darkColorScheme.secondaryContainer, + closeIconColor: darkColorScheme.secondary, + contentTextStyle: TextStyle(color: darkColorScheme.secondary), + elevation: 20, + ), ), localizationsDelegates: const [ GlobalCupertinoLocalizations.delegate, @@ -141,9 +187,8 @@ class MyApp extends StatelessWidget { return FlutterSmartDialog( toastBuilder: (String msg) => CustomToast(msg: msg), child: MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaleFactor: - MediaQuery.of(context).textScaleFactor * textScale), + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), child: child!, ), ); diff --git a/lib/models/common/dynamic_badge_mode.dart b/lib/models/common/dynamic_badge_mode.dart new file mode 100644 index 00000000..2609c5e2 --- /dev/null +++ b/lib/models/common/dynamic_badge_mode.dart @@ -0,0 +1,9 @@ +enum DynamicBadgeMode { hidden, point, number } + +extension DynamicBadgeModeDesc on DynamicBadgeMode { + String get description => ['隐藏', '红点', '数字'][index]; +} + +extension DynamicBadgeModeCode on DynamicBadgeMode { + int get code => [0, 1, 2][index]; +} diff --git a/lib/models/common/dynamics_type.dart b/lib/models/common/dynamics_type.dart index 337f6aec..f4e20a4b 100644 --- a/lib/models/common/dynamics_type.dart +++ b/lib/models/common/dynamics_type.dart @@ -7,5 +7,5 @@ enum DynamicsType { extension BusinessTypeExtension on DynamicsType { String get values => ['all', 'video', 'pgc', 'article'][index]; - String get labels => ['全部', '视频', '追番', '专栏'][index]; + String get labels => ['全部', '投稿', '番剧', '专栏'][index]; } diff --git a/lib/models/common/gesture_mode.dart b/lib/models/common/gesture_mode.dart new file mode 100644 index 00000000..1149ae12 --- /dev/null +++ b/lib/models/common/gesture_mode.dart @@ -0,0 +1,12 @@ +enum FullScreenGestureMode { + /// 从上滑到下 + fromToptoBottom, + + /// 从下滑到上 + fromBottomtoTop, +} + +extension FullScreenGestureModeExtension on FullScreenGestureMode { + String get values => ['fromToptoBottom', 'fromBottomtoTop'][index]; + String get labels => ['从上往下滑进入全屏', '从下往上滑进入全屏'][index]; +} diff --git a/lib/models/common/index.dart b/lib/models/common/index.dart new file mode 100644 index 00000000..89a05076 --- /dev/null +++ b/lib/models/common/index.dart @@ -0,0 +1,4 @@ +library commonn_model; + +export './business_type.dart'; +export './gesture_mode.dart'; diff --git a/lib/models/common/nav_bar_config.dart b/lib/models/common/nav_bar_config.dart new file mode 100644 index 00000000..9ebe8e6f --- /dev/null +++ b/lib/models/common/nav_bar_config.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +List defaultNavigationBars = [ + { + 'id': 0, + 'icon': const Icon( + Icons.home_outlined, + size: 21, + ), + 'selectIcon': const Icon( + Icons.home, + size: 21, + ), + 'label': "首页", + 'count': 0, + }, + { + 'id': 1, + 'icon': const Icon( + Icons.trending_up, + size: 21, + ), + 'selectIcon': const Icon( + Icons.trending_up_outlined, + size: 21, + ), + 'label': "排行榜", + 'count': 0, + }, + { + 'id': 2, + 'icon': const Icon( + Icons.motion_photos_on_outlined, + size: 21, + ), + 'selectIcon': const Icon( + Icons.motion_photos_on, + size: 21, + ), + 'label': "动态", + 'count': 0, + }, + { + 'id': 3, + 'icon': const Icon( + Icons.video_collection_outlined, + size: 20, + ), + 'selectIcon': const Icon( + Icons.video_collection, + size: 21, + ), + 'label': "媒体库", + 'count': 0, + } +]; diff --git a/lib/models/common/rank_type.dart b/lib/models/common/rank_type.dart new file mode 100644 index 00000000..2ce6d3b5 --- /dev/null +++ b/lib/models/common/rank_type.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/pages/rank/zone/index.dart'; + +enum RandType { + all, + creation, + animation, + music, + dance, + game, + knowledge, + technology, + sport, + car, + life, + food, + animal, + madness, + fashion, + entertainment, + film, + origin, + rookie +} + +extension RankTypeDesc on RandType { + String get description => [ + '全站', + '国创相关', + '动画', + '音乐', + '舞蹈', + '游戏', + '知识', + '科技', + '运动', + '汽车', + '生活', + '美食', + '动物圈', + '鬼畜', + '时尚', + '娱乐', + '影视' + ][index]; + + String get id => [ + 'all', + 'creation', + 'animation', + 'music', + 'dance', + 'game', + 'knowledge', + 'technology', + 'sport', + 'car', + 'life', + 'food', + 'animal', + 'madness', + 'fashion', + 'entertainment', + 'film' + ][index]; +} + +List tabsConfig = [ + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '全站', + 'type': RandType.all, + 'ctr': Get.put, + 'page': const ZonePage(rid: 0), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '国创相关', + 'type': RandType.creation, + 'ctr': Get.put, + 'page': const ZonePage(rid: 168), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '动画', + 'type': RandType.animation, + 'ctr': Get.put, + 'page': const ZonePage(rid: 1), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '音乐', + 'type': RandType.music, + 'ctr': Get.put, + 'page': const ZonePage(rid: 3), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '舞蹈', + 'type': RandType.dance, + 'ctr': Get.put, + 'page': const ZonePage(rid: 129), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '游戏', + 'type': RandType.game, + 'ctr': Get.put, + 'page': const ZonePage(rid: 4), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '知识', + 'type': RandType.knowledge, + 'ctr': Get.put, + 'page': const ZonePage(rid: 36), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '科技', + 'type': RandType.technology, + 'ctr': Get.put, + 'page': const ZonePage(rid: 188), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '运动', + 'type': RandType.sport, + 'ctr': Get.put, + 'page': const ZonePage(rid: 234), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '汽车', + 'type': RandType.car, + 'ctr': Get.put, + 'page': const ZonePage(rid: 223), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '生活', + 'type': RandType.life, + 'ctr': Get.put, + 'page': const ZonePage(rid: 160), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '美食', + 'type': RandType.food, + 'ctr': Get.put, + 'page': const ZonePage(rid: 211), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '动物圈', + 'type': RandType.animal, + 'ctr': Get.put, + 'page': const ZonePage(rid: 217), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '鬼畜', + 'type': RandType.madness, + 'ctr': Get.put, + 'page': const ZonePage(rid: 119), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '时尚', + 'type': RandType.fashion, + 'ctr': Get.put, + 'page': const ZonePage(rid: 155), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '娱乐', + 'type': RandType.entertainment, + 'ctr': Get.put, + 'page': const ZonePage(rid: 5), + }, + { + 'icon': const Icon( + Icons.live_tv_outlined, + size: 15, + ), + 'label': '影视', + 'type': RandType.film, + 'ctr': Get.put, + 'page': const ZonePage(rid: 181), + } +]; diff --git a/lib/models/common/rcmd_type.dart b/lib/models/common/rcmd_type.dart new file mode 100644 index 00000000..2dfdad1c --- /dev/null +++ b/lib/models/common/rcmd_type.dart @@ -0,0 +1,7 @@ +// 首页推荐类型 +enum RcmdType { web, app, notLogin } + +extension RcmdTypeExtension on RcmdType { + String get values => ['web', 'app', 'notLogin'][index]; + String get labels => ['web端', 'app端', '游客模式'][index]; +} diff --git a/lib/models/common/reply_sort_type.dart b/lib/models/common/reply_sort_type.dart index 7c203c13..89da82b3 100644 --- a/lib/models/common/reply_sort_type.dart +++ b/lib/models/common/reply_sort_type.dart @@ -1,6 +1,6 @@ -enum ReplySortType { time, like, reply } +enum ReplySortType { time, like } extension ReplySortTypeExtension on ReplySortType { - String get titles => ['最新评论', '最热评论', '回复最多'][index]; - String get labels => ['最新', '最热', '最多回复'][index]; + String get titles => ['最新评论', '最热评论'][index]; + String get labels => ['最新', '最热'][index]; } diff --git a/lib/models/common/subtitle_type.dart b/lib/models/common/subtitle_type.dart new file mode 100644 index 00000000..11716351 --- /dev/null +++ b/lib/models/common/subtitle_type.dart @@ -0,0 +1,47 @@ +enum SubtitleType { + // 中文(中国) + zhCN, + // 中文(自动翻译) + aizh, + // 英语(自动生成) + aien, +} + +extension SubtitleTypeExtension on SubtitleType { + String get description { + switch (this) { + case SubtitleType.zhCN: + return '中文(中国)'; + case SubtitleType.aizh: + return '中文(自动翻译)'; + case SubtitleType.aien: + return '英语(自动生成)'; + } + } +} + +extension SubtitleIdExtension on SubtitleType { + String get id { + switch (this) { + case SubtitleType.zhCN: + return 'zh-CN'; + case SubtitleType.aizh: + return 'ai-zh'; + case SubtitleType.aien: + return 'ai-en'; + } + } +} + +extension SubtitleCodeExtension on SubtitleType { + int get code { + switch (this) { + case SubtitleType.zhCN: + return 1; + case SubtitleType.aizh: + return 2; + case SubtitleType.aien: + return 3; + } + } +} diff --git a/lib/models/common/tab_type.dart b/lib/models/common/tab_type.dart index 90d19029..e530d7e5 100644 --- a/lib/models/common/tab_type.dart +++ b/lib/models/common/tab_type.dart @@ -9,6 +9,7 @@ enum TabType { live, rcmd, hot, bangumi } extension TabTypeDesc on TabType { String get description => ['直播', '推荐', '热门', '番剧'][index]; + String get id => ['live', 'rcmd', 'hot', 'bangumi'][index]; } List tabsConfig = [ diff --git a/lib/models/dynamics/result.dart b/lib/models/dynamics/result.dart index d8aff7b5..2f7c2d40 100644 --- a/lib/models/dynamics/result.dart +++ b/lib/models/dynamics/result.dart @@ -78,12 +78,14 @@ class ItemModulesModel { this.moduleDynamic, // this.moduleInter, this.moduleStat, + this.moduleTag, }); ModuleAuthorModel? moduleAuthor; ModuleDynamicModel? moduleDynamic; // ModuleInterModel? moduleInter; ModuleStatModel? moduleStat; + Map? moduleTag; ItemModulesModel.fromJson(Map json) { moduleAuthor = json['module_author'] != null @@ -96,6 +98,7 @@ class ItemModulesModel { moduleStat = json['module_stat'] != null ? ModuleStatModel.fromJson(json['module_stat']) : null; + moduleTag = json['module_tag']; } } diff --git a/lib/models/dynamics/up.dart b/lib/models/dynamics/up.dart index cfd1fa7d..9bb82f70 100644 --- a/lib/models/dynamics/up.dart +++ b/lib/models/dynamics/up.dart @@ -2,18 +2,28 @@ class FollowUpModel { FollowUpModel({ this.liveUsers, this.upList, + this.liveList, + this.myInfo, }); LiveUsers? liveUsers; List? upList; + List? liveList; + MyInfo? myInfo; FollowUpModel.fromJson(Map json) { liveUsers = json['live_users'] != null ? LiveUsers.fromJson(json['live_users']) : null; + liveList = json['live_users'] != null + ? json['live_users']['items'] + .map((e) => LiveUserItem.fromJson(e)) + .toList() + : []; upList = json['up_list'] != null ? json['up_list'].map((e) => UpItem.fromJson(e)).toList() : []; + myInfo = json['my_info'] != null ? MyInfo.fromJson(json['my_info']) : null; } } @@ -93,3 +103,21 @@ class UpItem { uname = json['uname']; } } + +class MyInfo { + MyInfo({ + this.face, + this.mid, + this.name, + }); + + String? face; + int? mid; + String? name; + + MyInfo.fromJson(Map json) { + face = json['face']; + mid = json['mid']; + name = json['name']; + } +} diff --git a/lib/models/github/latest.dart b/lib/models/github/latest.dart index 8730a4ba..c4b88b63 100644 --- a/lib/models/github/latest.dart +++ b/lib/models/github/latest.dart @@ -17,8 +17,9 @@ class LatestDataModel { url = json['url']; tagName = json['tag_name']; createdAt = json['created_at']; - assets = - json['assets'].map((e) => AssetItem.fromJson(e)).toList(); + assets = json['assets'] != null + ? json['assets'].map((e) => AssetItem.fromJson(e)).toList() + : []; body = json['body']; } } diff --git a/lib/models/home/rcmd/result.dart b/lib/models/home/rcmd/result.dart index a2a8006d..78747d1a 100644 --- a/lib/models/home/rcmd/result.dart +++ b/lib/models/home/rcmd/result.dart @@ -1,8 +1,5 @@ -import 'package:hive/hive.dart'; +import 'package:pilipala/utils/id_utils.dart'; -part 'result.g.dart'; - -@HiveType(typeId: 0) class RecVideoItemAppModel { RecVideoItemAppModel({ this.id, @@ -27,47 +24,27 @@ class RecVideoItemAppModel { this.adInfo, }); - @HiveField(0) int? id; - @HiveField(1) int? aid; - @HiveField(2) String? bvid; - @HiveField(3) int? cid; - @HiveField(4) String? pic; - @HiveField(5) RcmdStat? stat; - @HiveField(6) - String? duration; - @HiveField(7) + int? duration; String? title; - @HiveField(8) int? isFollowed; - @HiveField(9) RcmdOwner? owner; - @HiveField(10) RcmdReason? rcmdReason; - @HiveField(11) String? goto; - @HiveField(12) int? param; - @HiveField(13) String? uri; - @HiveField(14) String? talkBack; // 番剧 - @HiveField(15) String? bangumiView; - @HiveField(16) String? bangumiFollow; - @HiveField(17) String? bangumiBadge; - @HiveField(18) String? cardType; - @HiveField(19) Map? adInfo; RecVideoItemAppModel.fromJson(Map json) { @@ -75,17 +52,32 @@ class RecVideoItemAppModel { ? json['player_args']['aid'] : int.parse(json['param'] ?? '-1'); aid = json['player_args'] != null ? json['player_args']['aid'] : -1; - bvid = null; + bvid = json['player_args'] != null + ? IdUtils.av2bv(json['player_args']['aid']) + : ''; cid = json['player_args'] != null ? json['player_args']['cid'] : -1; pic = json['cover']; stat = RcmdStat.fromJson(json); - duration = json['cover_right_text']; + // 改用player_args中的duration作为原始数据(秒数) + duration = + json['player_args'] != null ? json['player_args']['duration'] : -1; + //duration = json['cover_right_text']; title = json['title']; - isFollowed = 0; owner = RcmdOwner.fromJson(json); rcmdReason = json['rcmd_reason_style'] != null ? RcmdReason.fromJson(json['rcmd_reason_style']) : null; + // 由于app端api并不会直接返回与owner的关注状态 + // 所以借用推荐原因是否为“已关注”、“新关注”等判别关注状态,从而与web端接口等效 + isFollowed = rcmdReason != null && + rcmdReason!.content != null && + rcmdReason!.content!.contains('关注') + ? 1 + : 0; + // 如果是,就无需再显示推荐原因,交由view统一处理即可 + if (isFollowed == 1) { + rcmdReason = null; + } goto = json['goto']; param = int.parse(json['param']); uri = json['uri']; @@ -102,18 +94,14 @@ class RecVideoItemAppModel { } } -@HiveType(typeId: 1) class RcmdStat { RcmdStat({ this.view, this.like, this.danmu, }); - @HiveField(0) String? view; - @HiveField(1) String? like; - @HiveField(2) String? danmu; RcmdStat.fromJson(Map json) { @@ -122,13 +110,10 @@ class RcmdStat { } } -@HiveType(typeId: 2) class RcmdOwner { RcmdOwner({this.name, this.mid}); - @HiveField(0) String? name; - @HiveField(1) int? mid; RcmdOwner.fromJson(Map json) { @@ -141,13 +126,11 @@ class RcmdOwner { } } -@HiveType(typeId: 8) class RcmdReason { RcmdReason({ this.content, }); - @HiveField(0) String? content; RcmdReason.fromJson(Map json) { diff --git a/lib/models/home/rcmd/result.g.dart b/lib/models/home/rcmd/result.g.dart deleted file mode 100644 index 43bf4bcf..00000000 --- a/lib/models/home/rcmd/result.g.dart +++ /dev/null @@ -1,209 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'result.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class RecVideoItemAppModelAdapter extends TypeAdapter { - @override - final int typeId = 0; - - @override - RecVideoItemAppModel read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return RecVideoItemAppModel( - id: fields[0] as int?, - aid: fields[1] as int?, - bvid: fields[2] as String?, - cid: fields[3] as int?, - pic: fields[4] as String?, - stat: fields[5] as RcmdStat?, - duration: fields[6] as String?, - title: fields[7] as String?, - isFollowed: fields[8] as int?, - owner: fields[9] as RcmdOwner?, - rcmdReason: fields[10] as RcmdReason?, - goto: fields[11] as String?, - param: fields[12] as int?, - uri: fields[13] as String?, - talkBack: fields[14] as String?, - bangumiView: fields[15] as String?, - bangumiFollow: fields[16] as String?, - bangumiBadge: fields[17] as String?, - cardType: fields[18] as String?, - adInfo: (fields[19] as Map?)?.cast(), - ); - } - - @override - void write(BinaryWriter writer, RecVideoItemAppModel obj) { - writer - ..writeByte(20) - ..writeByte(0) - ..write(obj.id) - ..writeByte(1) - ..write(obj.aid) - ..writeByte(2) - ..write(obj.bvid) - ..writeByte(3) - ..write(obj.cid) - ..writeByte(4) - ..write(obj.pic) - ..writeByte(5) - ..write(obj.stat) - ..writeByte(6) - ..write(obj.duration) - ..writeByte(7) - ..write(obj.title) - ..writeByte(8) - ..write(obj.isFollowed) - ..writeByte(9) - ..write(obj.owner) - ..writeByte(10) - ..write(obj.rcmdReason) - ..writeByte(11) - ..write(obj.goto) - ..writeByte(12) - ..write(obj.param) - ..writeByte(13) - ..write(obj.uri) - ..writeByte(14) - ..write(obj.talkBack) - ..writeByte(15) - ..write(obj.bangumiView) - ..writeByte(16) - ..write(obj.bangumiFollow) - ..writeByte(17) - ..write(obj.bangumiBadge) - ..writeByte(18) - ..write(obj.cardType) - ..writeByte(19) - ..write(obj.adInfo); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is RecVideoItemAppModelAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class RcmdStatAdapter extends TypeAdapter { - @override - final int typeId = 1; - - @override - RcmdStat read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return RcmdStat( - view: fields[0] as String?, - like: fields[1] as String?, - danmu: fields[2] as String?, - ); - } - - @override - void write(BinaryWriter writer, RcmdStat obj) { - writer - ..writeByte(3) - ..writeByte(0) - ..write(obj.view) - ..writeByte(1) - ..write(obj.like) - ..writeByte(2) - ..write(obj.danmu); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is RcmdStatAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class RcmdOwnerAdapter extends TypeAdapter { - @override - final int typeId = 2; - - @override - RcmdOwner read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return RcmdOwner( - name: fields[0] as String?, - mid: fields[1] as int?, - ); - } - - @override - void write(BinaryWriter writer, RcmdOwner obj) { - writer - ..writeByte(2) - ..writeByte(0) - ..write(obj.name) - ..writeByte(1) - ..write(obj.mid); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is RcmdOwnerAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class RcmdReasonAdapter extends TypeAdapter { - @override - final int typeId = 8; - - @override - RcmdReason read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return RcmdReason( - content: fields[0] as String?, - ); - } - - @override - void write(BinaryWriter writer, RcmdReason obj) { - writer - ..writeByte(1) - ..writeByte(0) - ..write(obj.content); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is RcmdReasonAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} diff --git a/lib/models/live/quality.dart b/lib/models/live/quality.dart new file mode 100644 index 00000000..677d615b --- /dev/null +++ b/lib/models/live/quality.dart @@ -0,0 +1,43 @@ +enum LiveQuality { + dolby, + super4K, + origin, + bluRay, + superHD, + smooth, + flunt, +} + +extension LiveQualityCode on LiveQuality { + static final List _codeList = [ + 30000, + 20000, + 10000, + 400, + 250, + 150, + 80, + ]; + int get code => _codeList[index]; + + static LiveQuality? fromCode(int code) { + final index = _codeList.indexOf(code); + if (index != -1) { + return LiveQuality.values[index]; + } + return null; + } +} + +extension VideoQualityDesc on LiveQuality { + static final List _descList = [ + '杜比', + '4K', + '原画', + '蓝光', + '超清', + '高清', + '流畅', + ]; + get description => _descList[index]; +} diff --git a/lib/models/live/room_info_h5.dart b/lib/models/live/room_info_h5.dart new file mode 100644 index 00000000..a0c19621 --- /dev/null +++ b/lib/models/live/room_info_h5.dart @@ -0,0 +1,130 @@ +class RoomInfoH5Model { + RoomInfoH5Model({ + this.roomInfo, + this.anchorInfo, + this.isRoomFeed, + this.watchedShow, + this.likeInfoV3, + this.blockInfo, + }); + + RoomInfo? roomInfo; + AnchorInfo? anchorInfo; + int? isRoomFeed; + Map? watchedShow; + LikeInfoV3? likeInfoV3; + Map? blockInfo; + + RoomInfoH5Model.fromJson(Map json) { + roomInfo = RoomInfo.fromJson(json['room_info']); + anchorInfo = AnchorInfo.fromJson(json['anchor_info']); + isRoomFeed = json['is_room_feed']; + watchedShow = json['watched_show']; + likeInfoV3 = LikeInfoV3.fromJson(json['like_info_v3']); + blockInfo = json['block_info']; + } +} + +class RoomInfo { + RoomInfo({ + this.uid, + this.roomId, + this.title, + this.cover, + this.description, + this.liveStatus, + this.liveStartTime, + this.areaId, + this.areaName, + this.parentAreaId, + this.parentAreaName, + this.online, + this.background, + this.appBackground, + this.liveId, + }); + + int? uid; + int? roomId; + String? title; + String? cover; + String? description; + int? liveStatus; + int? liveStartTime; + int? areaId; + String? areaName; + int? parentAreaId; + String? parentAreaName; + int? online; + String? background; + String? appBackground; + String? liveId; + + RoomInfo.fromJson(Map json) { + uid = json['uid']; + roomId = json['room_id']; + title = json['title']; + cover = json['cover']; + description = json['description']; + liveStatus = json['liveS_satus']; + liveStartTime = json['live_start_time']; + areaId = json['area_id']; + areaName = json['area_name']; + parentAreaId = json['parent_area_id']; + parentAreaName = json['parent_area_name']; + online = json['online']; + background = json['background']; + appBackground = json['app_background']; + liveId = json['live_id']; + } +} + +class AnchorInfo { + AnchorInfo({ + this.baseInfo, + this.relationInfo, + }); + + BaseInfo? baseInfo; + RelationInfo? relationInfo; + + AnchorInfo.fromJson(Map json) { + baseInfo = BaseInfo.fromJson(json['base_info']); + relationInfo = RelationInfo.fromJson(json['relation_info']); + } +} + +class BaseInfo { + BaseInfo({ + this.uname, + this.face, + }); + + String? uname; + String? face; + + BaseInfo.fromJson(Map json) { + uname = json['uname']; + face = json['face']; + } +} + +class RelationInfo { + RelationInfo({this.attention}); + + int? attention; + + RelationInfo.fromJson(Map json) { + attention = json['attention']; + } +} + +class LikeInfoV3 { + LikeInfoV3({this.totalLikes}); + + int? totalLikes; + + LikeInfoV3.fromJson(Map json) { + totalLikes = json['total_likes']; + } +} diff --git a/lib/models/member/archive.dart b/lib/models/member/archive.dart index 5d2ea77e..d735ab7c 100644 --- a/lib/models/member/archive.dart +++ b/lib/models/member/archive.dart @@ -142,7 +142,7 @@ class Stat { Stat.fromJson(Map json) { view = json["play"]; - danmaku = json['comment']; + danmaku = json['video_review']; } } diff --git a/lib/models/member/info.dart b/lib/models/member/info.dart index 789131ee..83f94c54 100644 --- a/lib/models/member/info.dart +++ b/lib/models/member/info.dart @@ -47,18 +47,23 @@ class Vip { this.status, this.dueDate, this.label, + this.nicknameColor, }); int? type; int? status; int? dueDate; Map? label; + int? nicknameColor; Vip.fromJson(Map json) { type = json['type']; status = json['status']; dueDate = json['due_date']; label = json['label']; + nicknameColor = json['nickname_color'] == '' + ? null + : int.parse("0xFF${json['nickname_color'].replaceAll('#', '')}"); } } diff --git a/lib/models/model_rec_video_item.dart b/lib/models/model_rec_video_item.dart index f8c1731b..1503f192 100644 --- a/lib/models/model_rec_video_item.dart +++ b/lib/models/model_rec_video_item.dart @@ -36,7 +36,7 @@ class RecVideoItemModel { @HiveField(6) String? title = ''; @HiveField(7) - String? duration = ''; + int? duration = -1; @HiveField(8) int? pubdate = -1; @HiveField(9) @@ -56,7 +56,7 @@ class RecVideoItemModel { uri = json["uri"]; pic = json["pic"]; title = json["title"]; - duration = json["duration"].toString(); + duration = json["duration"]; pubdate = json["pubdate"]; owner = Owner.fromJson(json["owner"]); stat = Stat.fromJson(json["stat"]); @@ -82,6 +82,7 @@ class Stat { int? danmu; Stat.fromJson(Map json) { + // 无需在model中转换以保留原始数据,在view层处理即可 view = json["view"]; like = json["like"]; danmu = json['danmaku']; diff --git a/lib/models/model_rec_video_item.g.dart b/lib/models/model_rec_video_item.g.dart index 99f096c2..dc614354 100644 --- a/lib/models/model_rec_video_item.g.dart +++ b/lib/models/model_rec_video_item.g.dart @@ -24,7 +24,7 @@ class RecVideoItemModelAdapter extends TypeAdapter { uri: fields[4] as String?, pic: fields[5] as String?, title: fields[6] as String?, - duration: fields[7] as String?, + duration: fields[7] as int?, pubdate: fields[8] as int?, owner: fields[9] as Owner?, stat: fields[10] as Stat?, diff --git a/lib/models/msg/session.dart b/lib/models/msg/session.dart index 1fa05cb0..b6c1b6a6 100644 --- a/lib/models/msg/session.dart +++ b/lib/models/msg/session.dart @@ -8,7 +8,7 @@ class SessionDataModel { this.hasMore, }); - List? sessionList; + List? sessionList; int? hasMore; SessionDataModel.fromJson(Map json) { @@ -121,35 +121,37 @@ class LastMsg { this.msgKey, this.msgStatus, this.notifyCode, - this.newFaceVersion, + // this.newFaceVersion, }); int? senderIid; int? receiverType; int? receiverId; int? msgType; - Map? content; + dynamic content; int? msgSeqno; int? timestamp; String? atUids; int? msgKey; int? msgStatus; String? notifyCode; - int? newFaceVersion; + // int? newFaceVersion; LastMsg.fromJson(Map json) { senderIid = json['sender_uid']; receiverType = json['receiver_type']; receiverId = json['receiver_id']; msgType = json['msg_type']; - content = jsonDecode(json['content']); + content = json['content'] != null && json['content'] != '' + ? jsonDecode(json['content']) + : ''; msgSeqno = json['msg_seqno']; timestamp = json['timestamp']; atUids = json['at_uids']; msgKey = json['msg_key']; msgStatus = json['msg_status']; notifyCode = json['notify_code']; - newFaceVersion = json['new_face_version']; + // newFaceVersion = json['new_face_version']; } } @@ -166,7 +168,7 @@ class SessionMsgDataModel { int? hasMore; int? minSeqno; int? maxSeqno; - List? eInfos; + List? eInfos; SessionMsgDataModel.fromJson(Map json) { messages = json['messages'] @@ -214,7 +216,9 @@ class MessageItem { receiverId = json['receiver_id']; // 1 文本 2 图片 18 系统提示 10 系统通知 5 撤回的消息 msgType = json['msg_type']; - content = jsonDecode(json['content']); + content = json['content'] != null && json['content'] != '' + ? jsonDecode(json['content']) + : ''; msgSeqno = json['msg_seqno']; timestamp = json['timestamp']; atUids = json['at_uids']; diff --git a/lib/models/search/result.dart b/lib/models/search/result.dart index 3d381ed9..418fb99d 100644 --- a/lib/models/search/result.dart +++ b/lib/models/search/result.dart @@ -85,7 +85,9 @@ class SearchVideoItemModel { // title = json['title'].replaceAll(RegExp(r'<.*?>'), ''); title = Em.regTitle(json['title']); description = json['description']; - pic = 'https:${json['pic']}'; + pic = json['pic'] != null && json['pic'].startsWith('//') + ? 'https:${json['pic']}' + : json['pic'] ?? ''; videoReview = json['video_review']; pubdate = json['pubdate']; senddate = json['senddate']; @@ -435,7 +437,8 @@ class SearchArticleItemModel { pubTime = json['pub_time']; like = json['like']; title = Em.regTitle(json['title']); - subTitle = json['title'].replaceAll(RegExp(r'<[^>]*>'), ''); + subTitle = + Em.decodeHtmlEntities(json['title'].replaceAll(RegExp(r'<[^>]*>'), '')); rankOffset = json['rank_offset']; mid = json['mid']; imageUrls = json['image_urls']; diff --git a/lib/models/user/fav_folder.dart b/lib/models/user/fav_folder.dart index 6d3f9975..c45e2de9 100644 --- a/lib/models/user/fav_folder.dart +++ b/lib/models/user/fav_folder.dart @@ -15,7 +15,7 @@ class FavFolderData { ? json['list'] .map((e) => FavFolderItemData.fromJson(e)) .toList() - : [FavFolderItemData()]; + : []; hasMore = json['has_more']; } } diff --git a/lib/models/user/sub_detail.dart b/lib/models/user/sub_detail.dart new file mode 100644 index 00000000..a1e52e55 --- /dev/null +++ b/lib/models/user/sub_detail.dart @@ -0,0 +1,123 @@ +class SubDetailModelData { + DetailInfo? info; + List? medias; + + SubDetailModelData({this.info, this.medias}); + + SubDetailModelData.fromJson(Map json) { + info = DetailInfo.fromJson(json['info']); + if (json['medias'] != null) { + medias = []; + json['medias'].forEach((v) { + medias!.add(SubDetailMediaItem.fromJson(v)); + }); + } + } +} + +class SubDetailMediaItem { + int? id; + String? title; + String? cover; + String? pic; + int? duration; + int? pubtime; + String? bvid; + Map? upper; + Map? cntInfo; + int? enableVt; + String? vtDisplay; + + SubDetailMediaItem({ + this.id, + this.title, + this.cover, + this.pic, + this.duration, + this.pubtime, + this.bvid, + this.upper, + this.cntInfo, + this.enableVt, + this.vtDisplay, + }); + + SubDetailMediaItem.fromJson(Map json) { + id = json['id']; + title = json['title']; + cover = json['cover']; + pic = json['cover']; + duration = json['duration']; + pubtime = json['pubtime']; + bvid = json['bvid']; + upper = json['upper']; + cntInfo = json['cnt_info']; + enableVt = json['enable_vt']; + vtDisplay = json['vt_display']; + } + + Map toJson() { + final data = {}; + data['id'] = id; + data['title'] = title; + data['cover'] = cover; + data['duration'] = duration; + data['pubtime'] = pubtime; + data['bvid'] = bvid; + data['upper'] = upper; + data['cnt_info'] = cntInfo; + data['enable_vt'] = enableVt; + data['vt_display'] = vtDisplay; + return data; + } +} + +class DetailInfo { + int? id; + int? seasonType; + String? title; + String? cover; + Map? upper; + Map? cntInfo; + int? mediaCount; + String? intro; + int? enableVt; + + DetailInfo({ + this.id, + this.seasonType, + this.title, + this.cover, + this.upper, + this.cntInfo, + this.mediaCount, + this.intro, + this.enableVt, + }); + + DetailInfo.fromJson(Map json) { + id = json['id']; + seasonType = json['season_type']; + title = json['title']; + cover = json['cover']; + upper = json['upper']; + cntInfo = json['cnt_info']; + mediaCount = json['media_count']; + intro = json['intro']; + enableVt = json['enable_vt']; + } + + Map toJson() { + final data = {}; + data['id'] = id; + data['season_type'] = seasonType; + data['title'] = title; + data['cover'] = cover; + data['upper'] = upper; + data['cnt_info'] = cntInfo; + data['media_count'] = mediaCount; + data['intro'] = intro; + data['enable_vt'] = enableVt; + return data; + } +} diff --git a/lib/models/user/sub_folder.dart b/lib/models/user/sub_folder.dart new file mode 100644 index 00000000..d496a1cf --- /dev/null +++ b/lib/models/user/sub_folder.dart @@ -0,0 +1,111 @@ +class SubFolderModelData { + final int? count; + final List? list; + + SubFolderModelData({ + this.count, + this.list, + }); + + factory SubFolderModelData.fromJson(Map json) { + return SubFolderModelData( + count: json['count'], + list: json['list'] != null + ? (json['list'] as List) + .map((i) => SubFolderItemData.fromJson(i)) + .toList() + : null, + ); + } +} + +class SubFolderItemData { + final int? id; + final int? fid; + final int? mid; + final int? attr; + final String? title; + final String? cover; + final Upper? upper; + final int? coverType; + final String? intro; + final int? ctime; + final int? mtime; + final int? state; + final int? favState; + final int? mediaCount; + final int? viewCount; + final int? vt; + final int? playSwitch; + final int? type; + final String? link; + final String? bvid; + + SubFolderItemData({ + this.id, + this.fid, + this.mid, + this.attr, + this.title, + this.cover, + this.upper, + this.coverType, + this.intro, + this.ctime, + this.mtime, + this.state, + this.favState, + this.mediaCount, + this.viewCount, + this.vt, + this.playSwitch, + this.type, + this.link, + this.bvid, + }); + + factory SubFolderItemData.fromJson(Map json) { + return SubFolderItemData( + id: json['id'], + fid: json['fid'], + mid: json['mid'], + attr: json['attr'], + title: json['title'], + cover: json['cover'], + upper: json['upper'] != null ? Upper.fromJson(json['upper']) : null, + coverType: json['cover_type'], + intro: json['intro'], + ctime: json['ctime'], + mtime: json['mtime'], + state: json['state'], + favState: json['fav_state'], + mediaCount: json['media_count'], + viewCount: json['view_count'], + vt: json['vt'], + playSwitch: json['play_switch'], + type: json['type'], + link: json['link'], + bvid: json['bvid'], + ); + } +} + +class Upper { + final int? mid; + final String? name; + final String? face; + + Upper({ + this.mid, + this.name, + this.face, + }); + + factory Upper.fromJson(Map json) { + return Upper( + mid: json['mid'], + name: json['name'], + face: json['face'], + ); + } +} diff --git a/lib/models/video/play/url.dart b/lib/models/video/play/url.dart index 4c43cb00..792cd50d 100644 --- a/lib/models/video/play/url.dart +++ b/lib/models/video/play/url.dart @@ -34,6 +34,7 @@ class PlayUrlModel { String? seekParam; String? seekType; Dash? dash; + List? durl; List? supportFormats; // String? highFormat; int? lastPlayTime; @@ -52,7 +53,8 @@ class PlayUrlModel { videoCodecid = json['video_codecid']; seekParam = json['seek_param']; seekType = json['seek_type']; - dash = Dash.fromJson(json['dash']); + dash = json['dash'] != null ? Dash.fromJson(json['dash']) : null; + durl = json['durl']?.map((e) => Durl.fromJson(e)).toList(); supportFormats = json['support_formats'] != null ? json['support_formats'] .map((e) => FormatItem.fromJson(e)) @@ -250,3 +252,30 @@ class Flac { audio = json['audio'] != null ? AudioItem.fromJson(json['audio']) : null; } } + +class Durl { + Durl({ + this.order, + this.length, + this.size, + this.ahead, + this.vhead, + this.url, + }); + + int? order; + int? length; + int? size; + String? ahead; + String? vhead; + String? url; + + Durl.fromJson(Map json) { + order = json['order']; + length = json['length']; + size = json['size']; + ahead = json['ahead']; + vhead = json['vhead']; + url = json['url']; + } +} diff --git a/lib/models/video/reply/content.dart b/lib/models/video/reply/content.dart index ad1759ac..d62a4bca 100644 --- a/lib/models/video/reply/content.dart +++ b/lib/models/video/reply/content.dart @@ -2,24 +2,26 @@ class ReplyContent { ReplyContent({ this.message, this.atNameToMid, // @的用户的mid null - this.memebers, // 被@的用户List 如果有的话 [] + this.members, // 被@的用户List 如果有的话 [] this.emote, // 表情包 如果有的话 null this.jumpUrl, // {} this.pictures, // {} this.vote, this.richText, this.isText, + this.topicsMeta, }); String? message; Map? atNameToMid; - List? memebers; + List? members; Map? emote; Map? jumpUrl; List? pictures; Map? vote; Map? richText; bool? isText; + Map? topicsMeta; ReplyContent.fromJson(Map json) { message = json['message'] @@ -27,7 +29,11 @@ class ReplyContent { .replaceAll('"', '"') .replaceAll(''', "'"); atNameToMid = json['at_name_to_mid'] ?? {}; - memebers = json['memebers'] ?? []; + members = json['members'] != null + ? json['members'] + .map((e) => MemberItemModel.fromJson(e)) + .toList() + : []; emote = json['emote'] ?? {}; jumpUrl = json['jump_url'] ?? {}; pictures = json['pictures'] ?? []; @@ -35,5 +41,21 @@ class ReplyContent { richText = json['rich_text'] ?? {}; // 不包含@ 笔记 图片的时候,文字可折叠 isText = atNameToMid!.isEmpty && vote!.isEmpty && pictures!.isEmpty; + topicsMeta = json['topics_meta'] ?? {}; + } +} + +class MemberItemModel { + MemberItemModel({ + required this.mid, + required this.uname, + }); + + late String mid; + late String uname; + + MemberItemModel.fromJson(Map json) { + mid = json['mid']; + uname = json['uname']; } } diff --git a/lib/models/video/reply/emote.dart b/lib/models/video/reply/emote.dart new file mode 100644 index 00000000..b4071826 --- /dev/null +++ b/lib/models/video/reply/emote.dart @@ -0,0 +1,120 @@ +class EmoteModelData { + final List? packages; + + EmoteModelData({ + required this.packages, + }); + + factory EmoteModelData.fromJson(Map jsonRes) { + final List? packages = + jsonRes['packages'] is List ? [] : null; + if (packages != null) { + for (final dynamic item in jsonRes['packages']!) { + if (item != null) { + try { + packages.add(PackageItem.fromJson(item)); + } catch (_) {} + } + } + } + return EmoteModelData( + packages: packages, + ); + } +} + +class PackageItem { + final int? id; + final String? text; + final String? url; + final int? mtime; + final int? type; + final int? attr; + final Meta? meta; + final List? emote; + + PackageItem({ + required this.id, + required this.text, + required this.url, + required this.mtime, + required this.type, + required this.attr, + required this.meta, + required this.emote, + }); + + factory PackageItem.fromJson(Map jsonRes) { + final List? emote = jsonRes['emote'] is List ? [] : null; + if (emote != null) { + for (final dynamic item in jsonRes['emote']!) { + if (item != null) { + try { + emote.add(Emote.fromJson(item)); + } catch (_) {} + } + } + } + return PackageItem( + id: jsonRes['id'], + text: jsonRes['text'], + url: jsonRes['url'], + mtime: jsonRes['mtime'], + type: jsonRes['type'], + attr: jsonRes['attr'], + meta: Meta.fromJson(jsonRes['meta']), + emote: emote, + ); + } +} + +class Meta { + final int? size; + final List? suggest; + + Meta({ + required this.size, + required this.suggest, + }); + + factory Meta.fromJson(Map jsonRes) => Meta( + size: jsonRes['size'], + suggest: jsonRes['suggest'] is List ? [] : null, + ); +} + +class Emote { + final int? id; + final int? packageId; + final String? text; + final String? url; + final int? mtime; + final int? type; + final int? attr; + final Meta? meta; + final dynamic activity; + + Emote({ + required this.id, + required this.packageId, + required this.text, + required this.url, + required this.mtime, + required this.type, + required this.attr, + required this.meta, + required this.activity, + }); + + factory Emote.fromJson(Map jsonRes) => Emote( + id: jsonRes['id'], + packageId: jsonRes['package_id'], + text: jsonRes['text'], + url: jsonRes['url'], + mtime: jsonRes['mtime'], + type: jsonRes['type'], + attr: jsonRes['attr'], + meta: Meta.fromJson(jsonRes['meta']), + activity: jsonRes['activity'], + ); +} diff --git a/lib/models/video/subTitile/content.dart b/lib/models/video/subTitile/content.dart new file mode 100644 index 00000000..b18098a4 --- /dev/null +++ b/lib/models/video/subTitile/content.dart @@ -0,0 +1,20 @@ +class SubTitileContentModel { + double? from; + double? to; + int? location; + String? content; + + SubTitileContentModel({ + this.from, + this.to, + this.location, + this.content, + }); + + SubTitileContentModel.fromJson(Map json) { + from = json['from']; + to = json['to']; + location = json['location']; + content = json['content']; + } +} diff --git a/lib/models/video/subTitile/result.dart b/lib/models/video/subTitile/result.dart new file mode 100644 index 00000000..d3e32e55 --- /dev/null +++ b/lib/models/video/subTitile/result.dart @@ -0,0 +1,89 @@ +import 'package:get/get.dart'; +import '../../common/subtitle_type.dart'; + +class SubTitlteModel { + SubTitlteModel({ + this.aid, + this.bvid, + this.cid, + this.loginMid, + this.loginMidHash, + this.isOwner, + this.name, + this.subtitles, + }); + + int? aid; + String? bvid; + int? cid; + int? loginMid; + String? loginMidHash; + bool? isOwner; + String? name; + List? subtitles; + + factory SubTitlteModel.fromJson(Map json) => SubTitlteModel( + aid: json["aid"], + bvid: json["bvid"], + cid: json["cid"], + loginMid: json["login_mid"], + loginMidHash: json["login_mid_hash"], + isOwner: json["is_owner"], + name: json["name"], + subtitles: json["subtitle"] != null + ? json["subtitle"]["subtitles"] + .map((x) => SubTitlteItemModel.fromJson(x)) + .toList() + : [], + ); +} + +class SubTitlteItemModel { + SubTitlteItemModel({ + this.id, + this.lan, + this.lanDoc, + this.isLock, + this.subtitleUrl, + this.type, + this.aiType, + this.aiStatus, + this.title, + this.code, + this.content, + this.body, + }); + + int? id; + String? lan; + String? lanDoc; + bool? isLock; + String? subtitleUrl; + int? type; + int? aiType; + int? aiStatus; + String? title; + int? code; + String? content; + List? body; + + factory SubTitlteItemModel.fromJson(Map json) => + SubTitlteItemModel( + id: json["id"], + lan: json["lan"].replaceAll('-', ''), + lanDoc: json["lan_doc"], + isLock: json["is_lock"], + subtitleUrl: json["subtitle_url"], + type: json["type"], + aiType: json["ai_type"], + aiStatus: json["ai_status"], + title: json["lan_doc"], + code: SubtitleType.values + .firstWhereOrNull( + (element) => element.id.toString() == json["lan"]) + ?.index ?? + -1, + content: '', + body: [], + ); +} diff --git a/lib/pages/about/index.dart b/lib/pages/about/index.dart index 17eabee5..b381691a 100644 --- a/lib/pages/about/index.dart +++ b/lib/pages/about/index.dart @@ -7,6 +7,7 @@ import 'package:pilipala/http/index.dart'; import 'package:pilipala/models/github/latest.dart'; import 'package:pilipala/utils/utils.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../utils/cache_manage.dart'; class AboutPage extends StatefulWidget { const AboutPage({super.key}); @@ -17,10 +18,23 @@ class AboutPage extends StatefulWidget { class _AboutPageState extends State { final AboutController _aboutController = Get.put(AboutController()); + String cacheSize = ''; + + @override + void initState() { + super.initState(); + // 读取缓存占用 + getCacheSize(); + } + + Future getCacheSize() async { + final res = await CacheManage().loadApplicationCache(); + setState(() => cacheSize = res); + } @override Widget build(BuildContext context) { - Color outline = Theme.of(context).colorScheme.outline; + final Color outline = Theme.of(context).colorScheme.outline; TextStyle subTitleStyle = TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.outline); return Scaffold( @@ -29,7 +43,6 @@ class _AboutPageState extends State { ), body: SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Image.asset( 'assets/images/logo/logo_android_2.png', @@ -40,29 +53,54 @@ class _AboutPageState extends State { style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), - Text( - '使用Flutter开发的哔哩哔哩第三方客户端', - style: TextStyle(color: Theme.of(context).colorScheme.outline), - ), - const SizedBox(height: 20), Obx( - () => ListTile( - title: const Text("当前版本"), - trailing: Text(_aboutController.currentVersion.value, - style: subTitleStyle), - ), - ), - Obx( - () => ListTile( - onTap: () => _aboutController.onUpdate(), - title: const Text('最新版本'), - trailing: Text( - _aboutController.isLoading.value - ? '正在获取' - : _aboutController.isUpdate.value - ? '有新版本 ❤️${_aboutController.remoteVersion.value}' - : '当前已是最新版', - style: subTitleStyle, + () => Badge( + isLabelVisible: _aboutController.isLoading.value + ? false + : _aboutController.isUpdate.value, + label: const Text('New'), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 30), + child: FilledButton.tonal( + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () => _aboutController.githubRelease(), + title: const Text('Github下载'), + ), + ListTile( + onTap: () => _aboutController.panDownload(), + title: const Text('网盘下载'), + ), + ListTile( + onTap: () => _aboutController.webSiteUrl(), + title: const Text('官网下载'), + ), + ListTile( + onTap: () => _aboutController.qimiao(), + title: const Text('奇妙应用'), + ), + SizedBox( + height: + MediaQuery.of(context).padding.bottom + + 20) + ], + ); + }, + ); + }, + child: Text( + 'V${_aboutController.currentVersion.value}', + style: subTitleStyle.copyWith( + color: Theme.of(context).primaryColor, + ), + ), + ), ), ), ), @@ -74,19 +112,22 @@ class _AboutPageState extends State { // size: 16, // ), // ), - Divider( - thickness: 1, - height: 30, - color: Theme.of(context).colorScheme.outlineVariant, - ), ListTile( onTap: () => _aboutController.githubUrl(), - title: const Text('Github'), + title: const Text('开源地址'), trailing: Text( 'github.com/guozhigq/pilipala', style: subTitleStyle, ), ), + ListTile( + onTap: () => _aboutController.webSiteUrl(), + title: const Text('访问官网'), + trailing: Text( + 'https://pilipalanet.mysxl.cn', + style: subTitleStyle, + ), + ), ListTile( onTap: () => _aboutController.panDownload(), title: const Text('网盘下载'), @@ -108,24 +149,64 @@ class _AboutPageState extends State { ), ), ListTile( - onTap: () => _aboutController.qqChanel(), - title: const Text('QQ群'), + onTap: () { + showModalBottomSheet( + context: context, + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () => _aboutController.qqChanel(), + title: const Text('QQ群'), + trailing: Text( + '616150809', + style: subTitleStyle, + ), + ), + ListTile( + onTap: () => _aboutController.tgChanel(), + title: const Text('TG频道'), + trailing: Text( + 'https://t.me/+lm_oOVmF0RJiODk1', + style: subTitleStyle, + ), + ), + SizedBox( + height: MediaQuery.of(context).padding.bottom + 20) + ], + ); + }, + ); + }, + title: const Text('交流社区'), trailing: Icon( Icons.arrow_forward_ios, size: 16, color: outline, ), ), - ListTile( - onTap: () => _aboutController.tgChanel(), - title: const Text('TG频道'), - trailing: Icon(Icons.arrow_forward_ios, size: 16, color: outline), - ), ListTile( onTap: () => _aboutController.aPay(), title: const Text('赞助'), trailing: Icon(Icons.arrow_forward_ios, size: 16, color: outline), ), + ListTile( + onTap: () => _aboutController.logs(), + title: const Text('错误日志'), + trailing: Icon(Icons.arrow_forward_ios, size: 16, color: outline), + ), + ListTile( + onTap: () async { + var cleanStatus = await CacheManage().clearCacheAll(); + if (cleanStatus) { + getCacheSize(); + } + }, + title: const Text('清除缓存'), + subtitle: Text('图片及网络缓存 $cacheSize', style: subTitleStyle), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 20) ], ), ), @@ -172,12 +253,16 @@ class AboutController extends GetxController { // 获取远程版本 Future getRemoteApp() async { var result = await Request().get(Api.latestApp, extra: {'ua': 'pc'}); + isLoading.value = false; + if (result.data == null || result.data.isEmpty) { + SmartDialog.showToast('获取远程版本失败,请检查网络'); + return; + } data = LatestDataModel.fromJson(result.data); remoteAppInfo = data; remoteVersion.value = data.tagName!; isUpdate.value = Utils.needUpdate(currentVersion.value, remoteVersion.value); - isLoading.value = false; } // 跳转下载/本地更新 @@ -193,11 +278,26 @@ class AboutController extends GetxController { ); } + githubRelease() { + launchUrl( + Uri.parse('https://github.com/guozhigq/pilipala/releases'), + mode: LaunchMode.externalApplication, + ); + } + // 从网盘下载 panDownload() { - launchUrl( - Uri.parse('https://www.123pan.com/s/9sVqVv-flu0A.html'), - mode: LaunchMode.externalApplication, + Clipboard.setData( + const ClipboardData(text: 'pili'), + ); + SmartDialog.showToast( + '已复制提取码:pili', + displayTime: const Duration(milliseconds: 500), + ).then( + (value) => launchUrl( + Uri.parse('https://www.123pan.com/s/9sVqVv-flu0A.html'), + mode: LaunchMode.externalApplication, + ), ); } @@ -213,7 +313,7 @@ class AboutController extends GetxController { // qq频道 qqChanel() { Clipboard.setData( - const ClipboardData(text: '489981949'), + const ClipboardData(text: '616150809'), ); SmartDialog.showToast('已复制QQ群号'); } @@ -245,4 +345,24 @@ class AboutController extends GetxController { print(e); } } + + // 官网 + webSiteUrl() { + launchUrl( + Uri.parse('https://pilipalanet.mysxl.cn'), + mode: LaunchMode.externalApplication, + ); + } + + qimiao() { + launchUrl( + Uri.parse('https://www.magicalapk.com/home'), + mode: LaunchMode.externalApplication, + ); + } + + // 日志 + logs() { + Get.toNamed('/logs'); + } } diff --git a/lib/pages/bangumi/controller.dart b/lib/pages/bangumi/controller.dart index 09afc43a..e5748d6c 100644 --- a/lib/pages/bangumi/controller.dart +++ b/lib/pages/bangumi/controller.dart @@ -7,8 +7,8 @@ import 'package:pilipala/utils/storage.dart'; class BangumiController extends GetxController { final ScrollController scrollController = ScrollController(); - RxList bangumiList = [BangumiListItemModel()].obs; - RxList bangumiFollowList = [BangumiListItemModel()].obs; + RxList bangumiList = [].obs; + RxList bangumiFollowList = [].obs; int _currentPage = 1; bool isLoadingMore = true; Box userInfoCache = GStrorage.userInfo; diff --git a/lib/pages/bangumi/introduction/controller.dart b/lib/pages/bangumi/introduction/controller.dart index f37a3310..12f0c053 100644 --- a/lib/pages/bangumi/introduction/controller.dart +++ b/lib/pages/bangumi/introduction/controller.dart @@ -25,13 +25,6 @@ class BangumiIntroController extends GetxController { ? int.tryParse(Get.parameters['epId']!) : null; - // 是否预渲染 骨架屏 - bool preRender = false; - - // 视频详情 上个页面传入 - Map? videoItem = {}; - BangumiInfoModel? bangumiItem; - // 请求状态 RxBool isLoading = false.obs; @@ -63,27 +56,6 @@ class BangumiIntroController extends GetxController { @override void onInit() { super.onInit(); - if (Get.arguments.isNotEmpty) { - if (Get.arguments.containsKey('bangumiItem')) { - preRender = true; - bangumiItem = Get.arguments['bangumiItem']; - // bangumiItem!['pic'] = args.pic; - // if (args.title is String) { - // videoItem!['title'] = args.title; - // } else { - // String str = ''; - // for (Map map in args.title) { - // str += map['text']; - // } - // videoItem!['title'] = str; - // } - // if (args.stat != null) { - // videoItem!['stat'] = args.stat; - // } - // videoItem!['pubdate'] = args.pubdate; - // videoItem!['owner'] = args.owner; - } - } userInfo = userInfoCache.get('userInfoCache'); userLogin = userInfo != null; } @@ -183,20 +155,21 @@ class BangumiIntroController extends GetxController { actions: [ TextButton(onPressed: () => Get.back(), child: const Text('取消')), TextButton( - onPressed: () async { - var res = await VideoHttp.coinVideo( - bvid: bvid, multiply: _tempThemeValue); - if (res['status']) { - SmartDialog.showToast('投币成功 👏'); - hasCoin.value = true; - bangumiDetail.value.stat!['coins'] = - bangumiDetail.value.stat!['coins'] + _tempThemeValue; - } else { - SmartDialog.showToast(res['msg']); - } - Get.back(); - }, - child: const Text('确定')) + onPressed: () async { + var res = await VideoHttp.coinVideo( + bvid: bvid, multiply: _tempThemeValue); + if (res['status']) { + SmartDialog.showToast('投币成功 👏'); + hasCoin.value = true; + bangumiDetail.value.stat!['coins'] = + bangumiDetail.value.stat!['coins'] + _tempThemeValue; + } else { + SmartDialog.showToast(res['msg']); + } + Get.back(); + }, + child: const Text('确定'), + ) ], ); }); @@ -218,14 +191,12 @@ class BangumiIntroController extends GetxController { addIds: addMediaIdsNew.join(','), delIds: delMediaIdsNew.join(',')); if (result['status']) { - if (result['data']['prompt']) { - addMediaIdsNew = []; - delMediaIdsNew = []; - Get.back(); - // 重新获取收藏状态 - queryHasFavVideo(); - SmartDialog.showToast('✅ 操作成功'); - } + addMediaIdsNew = []; + delMediaIdsNew = []; + // 重新获取收藏状态 + queryHasFavVideo(); + SmartDialog.showToast('✅ 操作成功'); + Get.back(); } } diff --git a/lib/pages/bangumi/introduction/view.dart b/lib/pages/bangumi/introduction/view.dart index 63c66515..6255ffda 100644 --- a/lib/pages/bangumi/introduction/view.dart +++ b/lib/pages/bangumi/introduction/view.dart @@ -12,11 +12,10 @@ import 'package:pilipala/models/bangumi/info.dart'; import 'package:pilipala/pages/bangumi/widgets/bangumi_panel.dart'; import 'package:pilipala/pages/video/detail/index.dart'; import 'package:pilipala/pages/video/detail/introduction/widgets/action_item.dart'; -import 'package:pilipala/pages/video/detail/introduction/widgets/action_row_item.dart'; import 'package:pilipala/pages/video/detail/introduction/widgets/fav_panel.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; - +import '../../../common/widgets/http_error.dart'; import 'controller.dart'; import 'widgets/intro_detail.dart'; @@ -51,12 +50,8 @@ class _BangumiIntroPanelState extends State cid = widget.cid!; bangumiIntroController = Get.put(BangumiIntroController(), tag: heroTag); videoDetailCtr = Get.find(tag: heroTag); - bangumiIntroController.bangumiDetail.listen((value) { - bangumiDetail = value; - }); _futureBuilderFuture = bangumiIntroController.queryBangumiIntro(); - videoDetailCtr.cid.listen((p0) { - print('🐶🐶$p0'); + videoDetailCtr.cid.listen((int p0) { cid = p0; setState(() {}); }); @@ -67,29 +62,34 @@ class _BangumiIntroPanelState extends State super.build(context); return FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SliverToBoxAdapter(child: SizedBox()); + } if (snapshot.data['status']) { // 请求成功 - - return BangumiInfo( - loadingStatus: false, - bangumiDetail: bangumiDetail, - cid: cid, + return Obx( + () => BangumiInfo( + bangumiDetail: bangumiIntroController.bangumiDetail.value, + cid: cid, + ), ); } else { // 请求错误 - // return HttpError( - // errMsg: snapshot.data['msg'], - // fn: () => Get.back(), - // ); - return SizedBox(); + return HttpError( + errMsg: snapshot.data['msg'], + fn: () => Get.back(), + ); } } else { - return BangumiInfo( - loadingStatus: true, - bangumiDetail: bangumiDetail, - cid: cid, + return const SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Center( + child: CircularProgressIndicator(), + ), + ), ); } }, @@ -98,16 +98,14 @@ class _BangumiIntroPanelState extends State } class BangumiInfo extends StatefulWidget { - final bool loadingStatus; - final BangumiInfoModel? bangumiDetail; - final int? cid; - const BangumiInfo({ - Key? key, - this.loadingStatus = false, + super.key, this.bangumiDetail, this.cid, - }) : super(key: key); + }); + + final BangumiInfoModel? bangumiDetail; + final int? cid; @override State createState() => _BangumiInfoState(); @@ -118,29 +116,28 @@ class _BangumiInfoState extends State { late final BangumiIntroController bangumiIntroController; late final VideoDetailController videoDetailCtr; Box localCache = GStrorage.localCache; - late final BangumiInfoModel? bangumiItem; late double sheetHeight; int? cid; bool isProcessing = false; void Function()? handleState(Future Function() action) { - return isProcessing ? null : () async { - setState(() => isProcessing = true); - await action(); - setState(() => isProcessing = false); - }; + return isProcessing + ? null + : () async { + setState(() => isProcessing = true); + await action(); + setState(() => isProcessing = false); + }; } + @override void initState() { super.initState(); bangumiIntroController = Get.put(BangumiIntroController(), tag: heroTag); videoDetailCtr = Get.find(tag: heroTag); - bangumiItem = bangumiIntroController.bangumiItem; sheetHeight = localCache.get('sheetHeight'); cid = widget.cid!; - print('cid: $cid'); videoDetailCtr.cid.listen((p0) { cid = p0; - print('cid: $cid'); setState(() {}); }); } @@ -155,7 +152,7 @@ class _BangumiInfoState extends State { context: context, useRootNavigator: true, isScrollControlled: true, - builder: (context) { + builder: (BuildContext context) { return FavPanel(ctr: bangumiIntroController); }, ); @@ -175,218 +172,166 @@ class _BangumiInfoState extends State { @override Widget build(BuildContext context) { - ThemeData t = Theme.of(context); + final ThemeData t = Theme.of(context); return SliverPadding( padding: const EdgeInsets.only( left: StyleString.safeSpace, right: StyleString.safeSpace, top: 20), sliver: SliverToBoxAdapter( - child: !widget.loadingStatus || bangumiItem != null - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - NetworkImgLayer( - width: 105, - height: 160, - src: !widget.loadingStatus - ? widget.bangumiDetail!.cover! - : bangumiItem!.cover!, - ), - if (bangumiItem != null && - bangumiItem!.rating != null) - PBadge( - text: - '评分 ${!widget.loadingStatus ? widget.bangumiDetail!.rating!['score']! : bangumiItem!.rating!['score']!}', - top: null, - right: 6, - bottom: 6, - left: null, + NetworkImgLayer( + width: 105, + height: 160, + src: widget.bangumiDetail!.cover!, + ), + PBadge( + text: '评分 ${widget.bangumiDetail!.rating!['score']!}', + top: null, + right: 6, + bottom: 6, + left: null, + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + onTap: () => showIntroDetail(), + child: SizedBox( + height: 158, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.bangumiDetail!.title!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ], - ), - const SizedBox(width: 10), - Expanded( - child: InkWell( - onTap: () => showIntroDetail(), - child: SizedBox( - height: 158, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: Text( - !widget.loadingStatus - ? widget.bangumiDetail!.title! - : bangumiItem!.title!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 20), - SizedBox( - width: 34, - height: 34, - child: IconButton( - style: ButtonStyle( - padding: MaterialStateProperty.all( - EdgeInsets.zero), - backgroundColor: - MaterialStateProperty.resolveWith( - (states) { - return t - .colorScheme.primaryContainer - .withOpacity(0.7); - }), - ), - onPressed: () => - bangumiIntroController.bangumiAdd(), - icon: Icon( - Icons.favorite_border_rounded, - color: t.colorScheme.primary, - size: 22, - ), - ), - ), - ], + const SizedBox(width: 20), + SizedBox( + width: 34, + height: 34, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all( + EdgeInsets.zero), + backgroundColor: + MaterialStateProperty.resolveWith( + (Set states) { + return t.colorScheme.primaryContainer + .withOpacity(0.7); + }), ), - Row( - children: [ - StatView( - theme: 'gray', - view: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['views'] - : bangumiItem!.stat!['views'], - size: 'medium', - ), - const SizedBox(width: 6), - StatDanMu( - theme: 'gray', - danmu: !widget.loadingStatus - ? widget - .bangumiDetail!.stat!['danmakus'] - : bangumiItem!.stat!['danmakus'], - size: 'medium', - ), - ], + onPressed: () => + bangumiIntroController.bangumiAdd(), + icon: Icon( + Icons.favorite_border_rounded, + color: t.colorScheme.primary, + size: 22, ), - const SizedBox(height: 6), - Row( - children: [ - Text( - !widget.loadingStatus - ? (widget.bangumiDetail!.areas! - .isNotEmpty - ? widget.bangumiDetail!.areas! - .first['name'] - : '') - : (bangumiItem!.areas!.isNotEmpty - ? bangumiItem! - .areas!.first['name'] - : ''), - style: TextStyle( - fontSize: 12, - color: t.colorScheme.outline, - ), - ), - const SizedBox(width: 6), - Text( - !widget.loadingStatus - ? widget.bangumiDetail! - .publish!['pub_time_show'] - : bangumiItem! - .publish!['pub_time_show'], - style: TextStyle( - fontSize: 12, - color: t.colorScheme.outline, - ), - ), - ], - ), - // const SizedBox(height: 4), - Text( - !widget.loadingStatus - ? widget.bangumiDetail!.newEp!['desc'] - : bangumiItem!.newEp!['desc'], - style: TextStyle( - fontSize: 12, - color: t.colorScheme.outline, - ), - ), - // const SizedBox(height: 10), - const Spacer(), - Text( - '简介:${!widget.loadingStatus ? widget.bangumiDetail!.evaluate! : bangumiItem!.evaluate!}', - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - color: t.colorScheme.outline, - ), - ), - ], + ), ), + ], + ), + Row( + children: [ + StatView( + theme: 'gray', + view: widget.bangumiDetail!.stat!['views'], + size: 'medium', + ), + const SizedBox(width: 6), + StatDanMu( + theme: 'gray', + danmu: widget.bangumiDetail!.stat!['danmakus'], + size: 'medium', + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + (widget.bangumiDetail!.areas!.isNotEmpty + ? widget.bangumiDetail!.areas!.first['name'] + : ''), + style: TextStyle( + fontSize: 12, + color: t.colorScheme.outline, + ), + ), + const SizedBox(width: 6), + Text( + widget.bangumiDetail!.publish!['pub_time_show'], + style: TextStyle( + fontSize: 12, + color: t.colorScheme.outline, + ), + ), + ], + ), + Text( + widget.bangumiDetail!.newEp!['desc'], + style: TextStyle( + fontSize: 12, + color: t.colorScheme.outline, ), ), - ), - ], + const Spacer(), + Text( + '简介:${widget.bangumiDetail!.evaluate!}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: t.colorScheme.outline, + ), + ), + ], + ), ), - const SizedBox(height: 6), - // 点赞收藏转发 布局样式1 - // SingleChildScrollView( - // padding: const EdgeInsets.only(top: 7, bottom: 7), - // scrollDirection: Axis.horizontal, - // child: actionRow( - // context, - // bangumiIntroController, - // videoDetailCtr, - // ), - // ), - // 点赞收藏转发 布局样式2 - actionGrid(context, bangumiIntroController), - // 番剧分p - if ((!widget.loadingStatus && - widget.bangumiDetail!.episodes!.isNotEmpty) || - bangumiItem != null && - bangumiItem!.episodes!.isNotEmpty) ...[ - BangumiPanel( - pages: bangumiItem != null - ? bangumiItem!.episodes! - : widget.bangumiDetail!.episodes!, - cid: cid ?? - (bangumiItem != null - ? bangumiItem!.episodes!.first.cid - : widget.bangumiDetail!.episodes!.first.cid), - sheetHeight: sheetHeight, - changeFuc: (bvid, cid, aid) => bangumiIntroController - .changeSeasonOrbangu(bvid, cid, aid), - ) - ], - ], - ) - : const SizedBox( - height: 100, - child: Center( - child: CircularProgressIndicator(), ), ), - ), + ], + ), + const SizedBox(height: 6), + + /// 点赞收藏转发 + actionGrid(context, bangumiIntroController), + // 番剧分p + if (widget.bangumiDetail!.episodes!.isNotEmpty) ...[ + BangumiPanel( + pages: widget.bangumiDetail!.episodes!, + cid: cid ?? widget.bangumiDetail!.episodes!.first.cid, + sheetHeight: sheetHeight, + changeFuc: (bvid, cid, aid) => + bangumiIntroController.changeSeasonOrbangu(bvid, cid, aid), + bangumiDetail: bangumiIntroController.bangumiDetail.value, + ) + ], + ], + )), ); } Widget actionGrid(BuildContext context, bangumiIntroController) { - return LayoutBuilder(builder: (context, constraints) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { return Material( child: Padding( padding: const EdgeInsets.only(top: 16, bottom: 8), @@ -394,61 +339,50 @@ class _BangumiInfoState extends State { height: constraints.maxWidth / 5 * 0.8, child: GridView.count( primary: false, - padding: const EdgeInsets.all(0), + padding: EdgeInsets.zero, crossAxisCount: 5, childAspectRatio: 1.25, children: [ Obx( () => ActionItem( - icon: const Icon(FontAwesomeIcons.thumbsUp), - selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), - onTap: handleState(bangumiIntroController.actionLikeVideo), - selectStatus: bangumiIntroController.hasLike.value, - loadingStatus: false, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['likes']!.toString() - : bangumiItem!.stat!['likes']!.toString()), + icon: const Icon(FontAwesomeIcons.thumbsUp), + selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), + onTap: handleState(bangumiIntroController.actionLikeVideo), + selectStatus: bangumiIntroController.hasLike.value, + text: widget.bangumiDetail!.stat!['likes']!.toString(), + ), ), Obx( () => ActionItem( - icon: const Icon(FontAwesomeIcons.b), - selectIcon: const Icon(FontAwesomeIcons.b), - onTap: handleState(bangumiIntroController.actionCoinVideo), - selectStatus: bangumiIntroController.hasCoin.value, - loadingStatus: false, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['coins']!.toString() - : bangumiItem!.stat!['coins']!.toString()), + icon: const Icon(FontAwesomeIcons.b), + selectIcon: const Icon(FontAwesomeIcons.b), + onTap: handleState(bangumiIntroController.actionCoinVideo), + selectStatus: bangumiIntroController.hasCoin.value, + text: widget.bangumiDetail!.stat!['coins']!.toString(), + ), ), Obx( () => ActionItem( - icon: const Icon(FontAwesomeIcons.star), - selectIcon: const Icon(FontAwesomeIcons.solidStar), - onTap: () => showFavBottomSheet(), - selectStatus: bangumiIntroController.hasFav.value, - loadingStatus: false, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['favorite']!.toString() - : bangumiItem!.stat!['favorite']!.toString()), + icon: const Icon(FontAwesomeIcons.star), + selectIcon: const Icon(FontAwesomeIcons.solidStar), + onTap: () => showFavBottomSheet(), + selectStatus: bangumiIntroController.hasFav.value, + text: widget.bangumiDetail!.stat!['favorite']!.toString(), + ), ), ActionItem( icon: const Icon(FontAwesomeIcons.comment), selectIcon: const Icon(FontAwesomeIcons.reply), onTap: () => videoDetailCtr.tabCtr.animateTo(1), selectStatus: false, - loadingStatus: false, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['reply']!.toString() - : bangumiItem!.stat!['reply']!.toString(), + text: widget.bangumiDetail!.stat!['reply']!.toString(), ), ActionItem( - icon: const Icon(FontAwesomeIcons.shareFromSquare), - onTap: () => bangumiIntroController.actionShareVideo(), - selectStatus: false, - loadingStatus: false, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['share']!.toString() - : bangumiItem!.stat!['share']!.toString()), + icon: const Icon(FontAwesomeIcons.shareFromSquare), + onTap: () => bangumiIntroController.actionShareVideo(), + selectStatus: false, + text: widget.bangumiDetail!.stat!['share']!.toString(), + ), ], ), ), @@ -456,63 +390,4 @@ class _BangumiInfoState extends State { ); }); } - - Widget actionRow(BuildContext context, videoIntroController, videoDetailCtr) { - return Row(children: [ - Obx( - () => ActionRowItem( - icon: const Icon(FontAwesomeIcons.thumbsUp), - onTap: handleState(videoIntroController.actionLikeVideo), - selectStatus: videoIntroController.hasLike.value, - loadingStatus: widget.loadingStatus, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['likes']!.toString() - : '-', - ), - ), - const SizedBox(width: 8), - Obx( - () => ActionRowItem( - icon: const Icon(FontAwesomeIcons.b), - onTap: handleState(videoIntroController.actionCoinVideo), - selectStatus: videoIntroController.hasCoin.value, - loadingStatus: widget.loadingStatus, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['coins']!.toString() - : '-', - ), - ), - const SizedBox(width: 8), - Obx( - () => ActionRowItem( - icon: const Icon(FontAwesomeIcons.heart), - onTap: () => showFavBottomSheet(), - selectStatus: videoIntroController.hasFav.value, - loadingStatus: widget.loadingStatus, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['favorite']!.toString() - : '-', - ), - ), - const SizedBox(width: 8), - ActionRowItem( - icon: const Icon(FontAwesomeIcons.comment), - onTap: () { - videoDetailCtr.tabCtr.animateTo(1); - }, - selectStatus: false, - loadingStatus: widget.loadingStatus, - text: !widget.loadingStatus - ? widget.bangumiDetail!.stat!['reply']!.toString() - : '-', - ), - const SizedBox(width: 8), - ActionRowItem( - icon: const Icon(FontAwesomeIcons.share), - onTap: () => videoIntroController.actionShareVideo(), - selectStatus: false, - loadingStatus: widget.loadingStatus, - text: '转发'), - ]); - } } diff --git a/lib/pages/bangumi/view.dart b/lib/pages/bangumi/view.dart index a2e8ae0f..f59f94a2 100644 --- a/lib/pages/bangumi/view.dart +++ b/lib/pages/bangumi/view.dart @@ -4,11 +4,11 @@ import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; +import 'package:nil/nil.dart'; import 'package:pilipala/common/constants.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/main/index.dart'; -import 'package:pilipala/pages/rcmd/view.dart'; import 'controller.dart'; import 'widgets/bangumu_card_v.dart'; @@ -74,7 +74,7 @@ class _BangumiPageState extends State super.build(context); return RefreshIndicator( onRefresh: () async { - await _bangumidController.queryBangumiListFeed(type: 'init'); + await _bangumidController.queryBangumiListFeed(); return _bangumidController.queryBangumiFollow(); }, child: CustomScrollView( @@ -112,10 +112,11 @@ class _BangumiPageState extends State ), ), SizedBox( - height: 258, + height: 268, child: FutureBuilder( future: _futureBuilderFutureFollow, - builder: (context, snapshot) { + builder: + (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.data == null) { @@ -156,10 +157,10 @@ class _BangumiPageState extends State ), ); } else { - return const SizedBox(); + return nil; } } else { - return const SizedBox(); + return nil; } }, ), @@ -188,7 +189,7 @@ class _BangumiPageState extends State StyleString.safeSpace, 0, StyleString.safeSpace, 0), sliver: FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { Map data = snapshot.data as Map; if (data['status']) { @@ -197,7 +198,10 @@ class _BangumiPageState extends State } else { return HttpError( errMsg: data['msg'], - fn: () => {}, + fn: () { + _futureBuilderFuture = + _bangumidController.queryBangumiListFeed(); + }, ); } } else { @@ -206,7 +210,6 @@ class _BangumiPageState extends State }, ), ), - LoadingMore() ], ), ); @@ -222,13 +225,13 @@ class _BangumiPageState extends State // 列数 crossAxisCount: 3, mainAxisExtent: Get.size.width / 3 / 0.65 + - 32 * MediaQuery.of(context).textScaleFactor, + MediaQuery.textScalerOf(context).scale(32.0), ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return bangumiList!.isNotEmpty ? BangumiCardV(bangumiItem: bangumiList[index]) - : const SizedBox(); + : nil; }, childCount: bangumiList!.isNotEmpty ? bangumiList!.length : 10, ), diff --git a/lib/pages/bangumi/widgets/bangumi_panel.dart b/lib/pages/bangumi/widgets/bangumi_panel.dart index 6948172f..05fd814c 100644 --- a/lib/pages/bangumi/widgets/bangumi_panel.dart +++ b/lib/pages/bangumi/widgets/bangumi_panel.dart @@ -8,19 +8,21 @@ import 'package:pilipala/utils/storage.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class BangumiPanel extends StatefulWidget { - final List pages; - final int? cid; - final double? sheetHeight; - final Function? changeFuc; - const BangumiPanel({ super.key, required this.pages, this.cid, this.sheetHeight, this.changeFuc, + this.bangumiDetail, }); + final List pages; + final int? cid; + final double? sheetHeight; + final Function? changeFuc; + final BangumiInfoModel? bangumiDetail; + @override State createState() => _BangumiPanelState(); } @@ -50,10 +52,10 @@ class _BangumiPanelState extends State { } videoDetailCtr = Get.find(tag: heroTag); - videoDetailCtr.cid.listen((p0) { + videoDetailCtr.cid.listen((int p0) { cid = p0; setState(() {}); - currentIndex = widget.pages.indexWhere((e) => e.cid == cid); + currentIndex = widget.pages.indexWhere((EpisodeItem e) => e.cid == cid); scrollToIndex(); }); } @@ -65,6 +67,47 @@ class _BangumiPanelState extends State { super.dispose(); } + Widget buildPageListItem( + EpisodeItem page, + int index, + bool isCurrentIndex, + ) { + Color primary = Theme.of(context).colorScheme.primary; + return ListTile( + onTap: () { + Get.back(); + setState(() { + changeFucCall(page, index); + }); + }, + dense: false, + leading: isCurrentIndex + ? Image.asset( + 'assets/images/live.gif', + color: primary, + height: 12, + ) + : null, + title: Text( + '第${page.title}话 ${page.longTitle!}', + style: TextStyle( + fontSize: 14, + color: isCurrentIndex + ? primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + trailing: page.badge != null + ? Text( + page.badge!, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ) + : const SizedBox(), + ); + } + void showBangumiPanel() { showBottomSheet( context: context, @@ -105,37 +148,22 @@ class _BangumiPanelState extends State { Expanded( child: Material( child: ScrollablePositionedList.builder( - itemCount: widget.pages.length, - itemBuilder: (context, index) => ListTile( - onTap: () { - setState(() { - changeFucCall(widget.pages[index], index); - }); - }, - dense: false, - leading: index == currentIndex - ? Image.asset( - 'assets/images/live.gif', - color: Theme.of(context).colorScheme.primary, - height: 12, + itemCount: widget.pages.length + 1, + itemBuilder: (BuildContext context, int index) { + bool isLastItem = index == widget.pages.length; + bool isCurrentIndex = currentIndex == index; + return isLastItem + ? SizedBox( + height: + MediaQuery.of(context).padding.bottom + + 20, ) - : null, - title: Text( - '第${index + 1}话 ${widget.pages[index].longTitle!}', - style: TextStyle( - fontSize: 14, - color: index == currentIndex - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface, - ), - ), - trailing: widget.pages[index].badge != null - ? Image.asset( - 'assets/images/big-vip.png', - height: 20, - ) - : const SizedBox(), - ), + : buildPageListItem( + widget.pages[index], + index, + isCurrentIndex, + ); + }, itemScrollController: itemScrollController, ), ), @@ -150,7 +178,7 @@ class _BangumiPanelState extends State { } void changeFucCall(item, i) async { - if (item.badge != null && vipStatus != 1) { + if (item.badge != null && item.badge == '会员' && vipStatus != 1) { SmartDialog.showToast('需要大会员'); return; } @@ -177,11 +205,11 @@ class _BangumiPanelState extends State { return Column( children: [ Padding( - padding: const EdgeInsets.only(top: 10, bottom: 6), + padding: const EdgeInsets.only(top: 10, bottom: 10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('合集 '), + const Text('选集 '), Expanded( child: Text( ' 正在播放:${widget.pages[currentIndex].longTitle}', @@ -201,7 +229,7 @@ class _BangumiPanelState extends State { ), onPressed: () => showBangumiPanel(), child: Text( - '全${widget.pages.length}话', + '${widget.bangumiDetail!.newEp!['desc']}', style: const TextStyle(fontSize: 13), ), ), @@ -212,78 +240,79 @@ class _BangumiPanelState extends State { SizedBox( height: 60, child: ListView.builder( - controller: listViewScrollCtr, - scrollDirection: Axis.horizontal, - itemCount: widget.pages.length, - itemExtent: 150, - itemBuilder: ((context, i) { - return Container( - width: 150, - margin: const EdgeInsets.only(right: 10), - child: Material( - color: Theme.of(context).colorScheme.onInverseSurface, - borderRadius: BorderRadius.circular(6), - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () => changeFucCall(widget.pages[i], i), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (i == currentIndex) ...[ - Image.asset( - 'assets/images/live.png', + controller: listViewScrollCtr, + scrollDirection: Axis.horizontal, + itemCount: widget.pages.length, + itemExtent: 150, + itemBuilder: (BuildContext context, int i) { + return Container( + width: 150, + margin: const EdgeInsets.only(right: 10), + child: Material( + color: Theme.of(context).colorScheme.onInverseSurface, + borderRadius: BorderRadius.circular(6), + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () => changeFucCall(widget.pages[i], i), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (i == currentIndex) ...[ + Image.asset( + 'assets/images/live.png', + color: Theme.of(context).colorScheme.primary, + height: 12, + ), + const SizedBox(width: 6) + ], + Text( + '第${i + 1}话', + style: TextStyle( + fontSize: 13, + color: i == currentIndex + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface), + ), + const SizedBox(width: 2), + if (widget.pages[i].badge != null) ...[ + const Spacer(), + Text( + widget.pages[i].badge!, + style: TextStyle( + fontSize: 12, color: Theme.of(context).colorScheme.primary, - height: 12, ), - const SizedBox(width: 6) - ], - Text( - '第${i + 1}话', - style: TextStyle( - fontSize: 13, - color: i == currentIndex - ? Theme.of(context) - .colorScheme - .primary - : Theme.of(context) - .colorScheme - .onSurface), ), - const SizedBox(width: 2), - if (widget.pages[i].badge != null) ...[ - Image.asset( - 'assets/images/big-vip.png', - height: 16, - ), - ], - ], - ), - const SizedBox(height: 3), - Text( - widget.pages[i].longTitle!, - maxLines: 1, - style: TextStyle( - fontSize: 13, - color: i == currentIndex - ? Theme.of(context).colorScheme.primary - : Theme.of(context) - .colorScheme - .onSurface), - overflow: TextOverflow.ellipsis, - ) - ], - ), + ] + ], + ), + const SizedBox(height: 3), + Text( + widget.pages[i].longTitle!, + maxLines: 1, + style: TextStyle( + fontSize: 13, + color: i == currentIndex + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface), + overflow: TextOverflow.ellipsis, + ) + ], ), ), ), - ); - })), + ), + ); + }, + ), ) ], ); diff --git a/lib/pages/bangumi/widgets/bangumu_card_v.dart b/lib/pages/bangumi/widgets/bangumu_card_v.dart index 937d9d40..c1233ddf 100644 --- a/lib/pages/bangumi/widgets/bangumu_card_v.dart +++ b/lib/pages/bangumi/widgets/bangumu_card_v.dart @@ -11,17 +11,16 @@ import 'package:pilipala/common/widgets/network_img_layer.dart'; // 视频卡片 - 垂直布局 class BangumiCardV extends StatelessWidget { - // ignore: prefer_typing_uninitialized_variables - final bangumiItem; - final Function()? longPress; - final Function()? longPressEnd; - const BangumiCardV({ - Key? key, + super.key, required this.bangumiItem, this.longPress, this.longPressEnd, - }) : super(key: key); + }); + + final bangumiItem; + final Function()? longPress; + final Function()? longPressEnd; @override Widget build(BuildContext context) { @@ -43,9 +42,9 @@ class BangumiCardV extends StatelessWidget { // }, child: InkWell( onTap: () async { - int seasonId = bangumiItem.seasonId; + final int seasonId = bangumiItem.seasonId; SmartDialog.showLoading(msg: '获取中...'); - var res = await SearchHttp.bangumiInfo(seasonId: seasonId); + final res = await SearchHttp.bangumiInfo(seasonId: seasonId); SmartDialog.dismiss().then((value) { if (res['status']) { if (res['data'].episodes.isEmpty) { @@ -81,8 +80,8 @@ class BangumiCardV extends StatelessWidget { child: AspectRatio( aspectRatio: 0.65, child: LayoutBuilder(builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; + final double maxWidth = boxConstraints.maxWidth; + final double maxHeight = boxConstraints.maxHeight; return Stack( children: [ Hero( @@ -124,9 +123,9 @@ class BangumiCardV extends StatelessWidget { } class BangumiContent extends StatelessWidget { + const BangumiContent({super.key, required this.bangumiItem}); // ignore: prefer_typing_uninitialized_variables final bangumiItem; - const BangumiContent({Key? key, required this.bangumiItem}) : super(key: key); @override Widget build(BuildContext context) { return Expanded( diff --git a/lib/pages/blacklist/index.dart b/lib/pages/blacklist/index.dart index 09cbaee8..402790f5 100644 --- a/lib/pages/blacklist/index.dart +++ b/lib/pages/blacklist/index.dart @@ -70,7 +70,7 @@ class _BlackListPageState extends State { onRefresh: () async => await _blackListController.queryBlacklist(), child: FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { var data = snapshot.data; if (data['status']) { @@ -139,7 +139,7 @@ class BlackListController extends GetxController { int currentPage = 1; int pageSize = 50; RxInt total = 0.obs; - RxList blackList = [BlackListItem()].obs; + RxList blackList = [].obs; Future queryBlacklist({type = 'init'}) async { if (type == 'init') { diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart index c7d627a8..11e097e1 100644 --- a/lib/pages/danmaku/controller.dart +++ b/lib/pages/danmaku/controller.dart @@ -1,26 +1,23 @@ import 'package:pilipala/http/danmaku.dart'; import 'package:pilipala/models/danmaku/dm.pb.dart'; -import 'package:pilipala/plugin/pl_player/index.dart'; class PlDanmakuController { PlDanmakuController(this.cid); final int cid; - Map> dmSegMap = {}; + Map> dmSegMap = {}; // 已请求的段落标记 List requestedSeg = []; bool get initiated => requestedSeg.isNotEmpty; - static int SEGMENT_LENGTH = 60 * 6 * 1000; + static int segmentLength = 60 * 6 * 1000; void initiate(int videoDuration, int progress) { if (requestedSeg.isEmpty) { - int segCount = (videoDuration / SEGMENT_LENGTH).ceil(); + int segCount = (videoDuration / segmentLength).ceil(); requestedSeg = List.generate(segCount, (index) => false); } - queryDanmaku( - calcSegment(progress) - ); + queryDanmaku(calcSegment(progress)); } void dispose() { @@ -29,17 +26,17 @@ class PlDanmakuController { } int calcSegment(int progress) { - return progress ~/ SEGMENT_LENGTH; + return progress ~/ segmentLength; } void queryDanmaku(int segmentIndex) async { assert(requestedSeg[segmentIndex] == false); requestedSeg[segmentIndex] = true; - DmSegMobileReply result = - await DanmakaHttp.queryDanmaku(cid: cid, segmentIndex: segmentIndex + 1); + final DmSegMobileReply result = await DanmakaHttp.queryDanmaku( + cid: cid, segmentIndex: segmentIndex + 1); if (result.elems.isNotEmpty) { for (var element in result.elems) { - int pos = element.progress ~/ 100;//每0.1秒存储一次 + int pos = element.progress ~/ 100; //每0.1秒存储一次 if (dmSegMap[pos] == null) { dmSegMap[pos] = []; } diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 027b9dfa..109f0206 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -1,4 +1,3 @@ -import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; @@ -36,6 +35,7 @@ class _PlDanmakuState extends State { late double opacityVal; late double fontSizeVal; late double danmakuDurationVal; + late double strokeWidth; int latestAddedPosition = -1; @override @@ -43,15 +43,13 @@ class _PlDanmakuState extends State { super.initState(); enableShowDanmaku = setting.get(SettingBoxKey.enableShowDanmaku, defaultValue: false); - _plDanmakuController = - PlDanmakuController(widget.cid); + _plDanmakuController = PlDanmakuController(widget.cid); if (mounted) { playerController = widget.playerController; if (enableShowDanmaku || playerController.isOpenDanmu.value) { _plDanmakuController.initiate( playerController.duration.value.inMilliseconds, - playerController.position.value.inMilliseconds - ); + playerController.position.value.inMilliseconds); } playerController ..addStatusLister(playerListener) @@ -61,14 +59,14 @@ class _PlDanmakuState extends State { if (p0 && !_plDanmakuController.initiated) { _plDanmakuController.initiate( playerController.duration.value.inMilliseconds, - playerController.position.value.inMilliseconds - ); + playerController.position.value.inMilliseconds); } }); blockTypes = playerController.blockTypes; showArea = playerController.showArea; opacityVal = playerController.opacityVal; fontSizeVal = playerController.fontSizeVal; + strokeWidth = playerController.strokeWidth; danmakuDurationVal = playerController.danmakuDurationVal; } @@ -87,7 +85,7 @@ class _PlDanmakuState extends State { return; } int currentPosition = position.inMilliseconds; - currentPosition -= currentPosition % 100;//取整百的毫秒数 + currentPosition -= currentPosition % 100; //取整百的毫秒数 if (currentPosition == latestAddedPosition) { return; @@ -98,17 +96,18 @@ class _PlDanmakuState extends State { _plDanmakuController.getCurrentDanmaku(currentPosition); if (currentDanmakuList != null) { - Color? defaultColor = playerController.blockTypes.contains(6) ? - DmUtils.decimalToColor(16777215) : null; + Color? defaultColor = playerController.blockTypes.contains(6) + ? DmUtils.decimalToColor(16777215) + : null; - _controller!.addItems( - currentDanmakuList.map((e) => DanmakuItem( - e.content, - color: defaultColor ?? DmUtils.decimalToColor(e.color), - time: e.progress, - type: DmUtils.getPosition(e.mode), - )).toList() - ); + _controller!.addItems(currentDanmakuList + .map((e) => DanmakuItem( + e.content, + color: defaultColor ?? DmUtils.decimalToColor(e.color), + time: e.progress, + type: DmUtils.getPosition(e.mode), + )) + .toList()); } } @@ -128,7 +127,7 @@ class _PlDanmakuState extends State { duration: const Duration(milliseconds: 100), child: DanmakuView( createdController: (DanmakuController e) async { - widget.playerController.danmakuController = _controller = e; + playerController.danmakuController = _controller = e; }, option: DanmakuOption( fontSize: 15 * fontSizeVal, @@ -137,7 +136,9 @@ class _PlDanmakuState extends State { hideTop: blockTypes.contains(5), hideScroll: blockTypes.contains(2), hideBottom: blockTypes.contains(4), - duration: danmakuDurationVal / widget.playerController.playbackSpeed, + duration: + danmakuDurationVal / playerController.playbackSpeed, + strokeWidth: strokeWidth, // initDuration / // (danmakuSpeedVal * widget.playerController.playbackSpeed), ), diff --git a/lib/pages/dynamics/controller.dart b/lib/pages/dynamics/controller.dart index 26ba2b22..b7676663 100644 --- a/lib/pages/dynamics/controller.dart +++ b/lib/pages/dynamics/controller.dart @@ -20,7 +20,7 @@ import 'package:pilipala/utils/utils.dart'; class DynamicsController extends GetxController { int page = 1; String? offset = ''; - RxList dynamicsList = [DynamicItemModel()].obs; + RxList dynamicsList = [].obs; Rx dynamicsType = DynamicsType.values[0].obs; RxString dynamicsTypeLabel = '全部'.obs; final ScrollController scrollController = ScrollController(); @@ -105,7 +105,7 @@ class DynamicsController extends GetxController { onSelectType(value) async { dynamicsType.value = filterTypeList[value]['value']; - dynamicsList.value = [DynamicItemModel()]; + dynamicsList.value = []; page = 1; initialValue.value = value; await queryFollowDynamic(); @@ -249,8 +249,8 @@ class DynamicsController extends GetxController { return {'status': false, 'msg': '账号未登录'}; } if (type == 'init') { - upData.value.upList = []; - upData.value.liveUsers = LiveUsers(); + upData.value.upList = []; + upData.value.liveList = []; } var res = await DynamicsHttp.followUp(); if (res['status']) { @@ -258,20 +258,23 @@ class DynamicsController extends GetxController { if (upData.value.upList!.isEmpty) { mid.value = -1; } + upData.value.upList!.insertAll(0, [ + UpItem(face: '', uname: '全部动态', mid: -1), + UpItem(face: userInfo.face, uname: '我', mid: userInfo.mid), + ]); } return res; } onSelectUp(mid) async { dynamicsType.value = DynamicsType.values[0]; - dynamicsList.value = [DynamicItemModel()]; + dynamicsList.value = []; page = 1; queryFollowDynamic(); } onRefresh() async { page = 1; - print('onRefresh'); await queryFollowUp(); await queryFollowDynamic(); } @@ -293,7 +296,7 @@ class DynamicsController extends GetxController { dynamicsType.value = DynamicsType.values[0]; initialValue.value = 0; SmartDialog.showToast('还原默认加载'); - dynamicsList.value = [DynamicItemModel()]; + dynamicsList.value = []; queryFollowDynamic(); } } diff --git a/lib/pages/dynamics/deatil/controller.dart b/lib/pages/dynamics/detail/controller.dart similarity index 88% rename from lib/pages/dynamics/deatil/controller.dart rename to lib/pages/dynamics/detail/controller.dart index 62f0245d..8e117383 100644 --- a/lib/pages/dynamics/deatil/controller.dart +++ b/lib/pages/dynamics/detail/controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:pilipala/http/html.dart'; import 'package:pilipala/http/reply.dart'; import 'package:pilipala/models/common/reply_sort_type.dart'; import 'package:pilipala/models/video/reply/item.dart'; @@ -16,7 +17,7 @@ class DynamicDetailController extends GetxController { int currentPage = 0; bool isLoadingMore = false; RxString noMore = ''.obs; - RxList replyList = [ReplyItemModel()].obs; + RxList replyList = [].obs; RxInt acount = 0.obs; final ScrollController scrollController = ScrollController(); @@ -36,6 +37,10 @@ class DynamicDetailController extends GetxController { } int deaultReplySortIndex = setting.get(SettingBoxKey.replySortType, defaultValue: 0); + if (deaultReplySortIndex == 2) { + setting.put(SettingBoxKey.replySortType, 0); + deaultReplySortIndex = 0; + } _sortType = ReplySortType.values[deaultReplySortIndex]; sortTypeTitle.value = _sortType.titles; sortTypeLabel.value = _sortType.labels; @@ -91,9 +96,6 @@ class DynamicDetailController extends GetxController { _sortType = ReplySortType.like; break; case ReplySortType.like: - _sortType = ReplySortType.reply; - break; - case ReplySortType.reply: _sortType = ReplySortType.time; break; default: @@ -103,4 +105,10 @@ class DynamicDetailController extends GetxController { replyList.clear(); queryReplyList(reqType: 'init'); } + + // 根据jumpUrl获取动态html + reqHtmlByOpusId(int id) async { + var res = await HtmlHttp.reqHtml(id, 'opus'); + oid = res['commentId']; + } } diff --git a/lib/pages/dynamics/deatil/index.dart b/lib/pages/dynamics/detail/index.dart similarity index 100% rename from lib/pages/dynamics/deatil/index.dart rename to lib/pages/dynamics/detail/index.dart diff --git a/lib/pages/dynamics/deatil/view.dart b/lib/pages/dynamics/detail/view.dart similarity index 88% rename from lib/pages/dynamics/deatil/view.dart rename to lib/pages/dynamics/detail/view.dart index 116e0d27..840cd33f 100644 --- a/lib/pages/dynamics/deatil/view.dart +++ b/lib/pages/dynamics/detail/view.dart @@ -7,11 +7,12 @@ import 'package:get/get.dart'; import 'package:pilipala/common/skeleton/video_reply.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/models/common/reply_type.dart'; -import 'package:pilipala/pages/dynamics/deatil/index.dart'; +import 'package:pilipala/models/dynamics/result.dart'; +import 'package:pilipala/pages/dynamics/detail/index.dart'; import 'package:pilipala/pages/dynamics/widgets/author_panel.dart'; import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; -import 'package:pilipala/pages/video/detail/replyNew/index.dart'; -import 'package:pilipala/pages/video/detail/replyReply/index.dart'; +import 'package:pilipala/pages/video/detail/reply_new/index.dart'; +import 'package:pilipala/pages/video/detail/reply_reply/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; @@ -35,39 +36,17 @@ class _DynamicDetailPageState extends State bool _visibleTitle = false; String? action; // 回复类型 - late int type; + late int replyType; bool _isFabVisible = true; + int oid = 0; + int? opusId; + bool isOpusId = false; @override void initState() { super.initState(); - int oid = 0; // floor 1原创 2转发 - if (Get.arguments['floor'] == 1) { - oid = int.parse(Get.arguments['item'].basic!['comment_id_str']); - print(oid); - } else { - try { - String type = Get.arguments['item'].modules.moduleDynamic.major.type; - - /// TODO - if (type == 'MAJOR_TYPE_OPUS') { - } else { - oid = Get.arguments['item'].modules.moduleDynamic.major.draw.id; - } - } catch (_) {} - } - int commentType = 11; - try { - commentType = Get.arguments['item'].basic!['comment_type']; - } catch (_) {} - type = (commentType == 0) ? 11 : commentType; - - action = - Get.arguments.containsKey('action') ? Get.arguments['action'] : null; - _dynamicDetailController = - Get.put(DynamicDetailController(oid, type), tag: oid.toString()); - _futureBuilderFuture = _dynamicDetailController.queryReplyList(); + init(); titleStreamC = StreamController(); if (action == 'comment') { _visibleTitle = true; @@ -83,6 +62,49 @@ class _DynamicDetailPageState extends State scrollListener(); } + // 页面初始化 + void init() async { + Map args = Get.arguments; + // 楼层 + int floor = args['floor']; + // 从action栏点击进入 + action = args.containsKey('action') ? args['action'] : null; + // 评论类型 + int commentType = args['item'].basic!['comment_type'] ?? 11; + replyType = (commentType == 0) ? 11 : commentType; + + if (floor == 1) { + oid = int.parse(args['item'].basic!['comment_id_str']); + } else { + try { + ModuleDynamicModel moduleDynamic = args['item'].modules.moduleDynamic; + String majorType = moduleDynamic.major!.type!; + + if (majorType == 'MAJOR_TYPE_OPUS') { + // 转发的动态 + String jumpUrl = moduleDynamic.major!.opus!.jumpUrl!; + opusId = int.parse(jumpUrl.split('/').last); + if (opusId != null) { + isOpusId = true; + _dynamicDetailController = Get.put( + DynamicDetailController(oid, replyType), + tag: opusId.toString()); + await _dynamicDetailController.reqHtmlByOpusId(opusId!); + setState(() {}); + } + } else { + oid = moduleDynamic.major!.draw!.id!; + } + } catch (_) {} + } + if (!isOpusId) { + _dynamicDetailController = + Get.put(DynamicDetailController(oid, replyType), tag: oid.toString()); + } + _futureBuilderFuture = _dynamicDetailController.queryReplyList(); + } + + // 查看二级评论 void replyReply(replyItem) { int oid = replyItem.oid; int rpid = replyItem.rpid!; @@ -100,13 +122,14 @@ class _DynamicDetailPageState extends State oid: oid, rpid: rpid, source: 'dynamic', - replyType: ReplyType.values[type], + replyType: ReplyType.values[replyType], firstFloor: replyItem, ), ), ); } + // 滑动事件监听 void scrollListener() { scrollController = _dynamicDetailController.scrollController; scrollController.addListener( @@ -307,7 +330,8 @@ class _DynamicDetailPageState extends State replyLevel: '1', replyReply: (replyItem) => replyReply(replyItem), - replyType: ReplyType.values[type], + replyType: + ReplyType.values[replyType], addReply: (replyItem) { _dynamicDetailController .replyList[index].replies! @@ -365,7 +389,7 @@ class _DynamicDetailPageState extends State IdUtils.bv2av(Get.parameters['bvid']!), root: 0, parent: 0, - replyType: ReplyType.values[type], + replyType: ReplyType.values[replyType], ); }, ).then( diff --git a/lib/pages/dynamics/view.dart b/lib/pages/dynamics/view.dart index 575c8767..fe594a43 100644 --- a/lib/pages/dynamics/view.dart +++ b/lib/pages/dynamics/view.dart @@ -14,6 +14,7 @@ import 'package:pilipala/pages/main/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; +import '../mine/controller.dart'; import 'controller.dart'; import 'widgets/dynamic_panel.dart'; import 'widgets/up_panel.dart'; @@ -28,6 +29,7 @@ class DynamicsPage extends StatefulWidget { class _DynamicsPageState extends State with AutomaticKeepAliveClientMixin { final DynamicsController _dynamicsController = Get.put(DynamicsController()); + final MineController mineController = Get.put(MineController()); late Future _futureBuilderFuture; late Future _futureBuilderFutureUp; Box userInfoCache = GStrorage.userInfo; @@ -192,22 +194,6 @@ class _DynamicsPageState extends State ) ], ), - // Obx( - // () => Visibility( - // visible: _dynamicsController.userLogin.value, - // child: Positioned( - // right: 4, - // top: 0, - // bottom: 0, - // child: IconButton( - // padding: EdgeInsets.zero, - // onPressed: () => - // {feedBack(), _dynamicsController.resetSearch()}, - // icon: const Icon(Icons.history, size: 21), - // ), - // ), - // ), - // ), ], ), ), @@ -229,7 +215,8 @@ class _DynamicsPageState extends State return Obx(() => UpPanel(_dynamicsController.upData.value)); } else { return const SliverToBoxAdapter( - child: SizedBox(height: 80)); + child: SizedBox(height: 80), + ); } } else { return const SliverToBoxAdapter( @@ -240,15 +227,6 @@ class _DynamicsPageState extends State } }, ), - SliverToBoxAdapter( - child: Container( - height: 6, - color: Theme.of(context) - .colorScheme - .onInverseSurface - .withOpacity(0.5), - ), - ), FutureBuilder( future: _futureBuilderFuture, builder: (context, snapshot) { @@ -280,6 +258,14 @@ class _DynamicsPageState extends State } }, ); + } else if (data['msg'] == "账号未登录") { + return HttpError( + errMsg: data['msg'], + btnText: "去登录", + fn: () { + mineController.onLogin(); + }, + ); } else { return HttpError( errMsg: data['msg'], diff --git a/lib/pages/dynamics/widgets/article_panel.dart b/lib/pages/dynamics/widgets/article_panel.dart index e68d966d..19707435 100644 --- a/lib/pages/dynamics/widgets/article_panel.dart +++ b/lib/pages/dynamics/widgets/article_panel.dart @@ -34,25 +34,25 @@ Widget articlePanel(item, context, {floor = 1}) { ), const SizedBox(height: 8), ], - Text( - item.modules.moduleDynamic.major.opus.title, - style: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 2), - if (item.modules.moduleDynamic.major.opus.summary.text != - 'undefined') ...[ - Text( - item.modules.moduleDynamic.major.opus.summary.richTextNodes.first - .text, - maxLines: 4, - style: const TextStyle(height: 1.55), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - ], + // Text( + // item.modules.moduleDynamic.major.opus.title, + // style: Theme.of(context) + // .textTheme + // .titleMedium! + // .copyWith(fontWeight: FontWeight.bold), + // ), + // const SizedBox(height: 2), + // if (item.modules.moduleDynamic.major.opus.summary.text != + // 'undefined') ...[ + // Text( + // item.modules.moduleDynamic.major.opus.summary.richTextNodes.first + // .text, + // maxLines: 4, + // style: const TextStyle(height: 1.55), + // overflow: TextOverflow.ellipsis, + // ), + // const SizedBox(height: 2), + // ], picWidget(item, context) ], ), diff --git a/lib/pages/dynamics/widgets/author_panel.dart b/lib/pages/dynamics/widgets/author_panel.dart index b6ea5eb9..0d3baecd 100644 --- a/lib/pages/dynamics/widgets/author_panel.dart +++ b/lib/pages/dynamics/widgets/author_panel.dart @@ -44,15 +44,19 @@ class AuthorPanel extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - item.modules.moduleAuthor.name, - style: TextStyle( - color: item.modules.moduleAuthor!.vip != null && - item.modules.moduleAuthor!.vip['status'] > 0 - ? const Color.fromARGB(255, 251, 100, 163) - : Theme.of(context).colorScheme.onBackground, - fontSize: Theme.of(context).textTheme.titleSmall!.fontSize, - ), + Row( + children: [ + Text( + item.modules.moduleAuthor.name, + style: TextStyle( + color: item.modules.moduleAuthor!.vip != null && + item.modules.moduleAuthor!.vip['status'] > 0 + ? const Color.fromARGB(255, 251, 100, 163) + : Theme.of(context).colorScheme.onBackground, + fontSize: Theme.of(context).textTheme.titleSmall!.fontSize, + ), + ), + ], ), DefaultTextStyle.merge( style: TextStyle( diff --git a/lib/pages/dynamics/widgets/content_panel.dart b/lib/pages/dynamics/widgets/content_panel.dart index 680d21a2..e1beaeb2 100644 --- a/lib/pages/dynamics/widgets/content_panel.dart +++ b/lib/pages/dynamics/widgets/content_panel.dart @@ -1,5 +1,7 @@ // 内容 import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/dynamics/result.dart'; import 'package:pilipala/pages/preview/index.dart'; @@ -43,11 +45,20 @@ class _ContentState extends State { if (len == 1) { OpusPicsModel pictureItem = pics.first; picList.add(pictureItem.url!); - spanChilds.add(const TextSpan(text: '\n')); + + /// 图片上方的空白间隔 + // spanChilds.add(const TextSpan(text: '\n')); spanChilds.add( WidgetSpan( child: LayoutBuilder( builder: (context, BoxConstraints box) { + double maxWidth = box.maxWidth.truncateToDouble(); + double maxHeight = box.maxWidth * 0.6; // 设置最大高度 + double height = maxWidth * + 0.5 * + (pictureItem.height != null && pictureItem.width != null + ? pictureItem.height! / pictureItem.width! + : 1); return GestureDetector( onTap: () { showDialog( @@ -58,18 +69,29 @@ class _ContentState extends State { }, ); }, - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: NetworkImgLayer( - src: pictureItem.url, + child: Container( + padding: const EdgeInsets.only(top: 4), + constraints: BoxConstraints(maxHeight: maxHeight), width: box.maxWidth / 2, - height: box.maxWidth * - 0.5 * - (pictureItem.height != null && pictureItem.width != null - ? pictureItem.height! / pictureItem.width! - : 1), - ), - ), + height: height, + child: Stack( + children: [ + Positioned.fill( + child: NetworkImgLayer( + src: pictureItem.url, + width: maxWidth / 2, + height: height, + ), + ), + height > Get.size.height * 0.9 + ? const PBadge( + text: '长图', + right: 8, + bottom: 8, + ) + : const SizedBox(), + ], + )), ); }, ), @@ -83,6 +105,7 @@ class _ContentState extends State { list.add( LayoutBuilder( builder: (context, BoxConstraints box) { + double maxWidth = box.maxWidth.truncateToDouble(); return GestureDetector( onTap: () { showDialog( @@ -95,8 +118,10 @@ class _ContentState extends State { }, child: NetworkImgLayer( src: pics[i].url, - width: box.maxWidth, - height: box.maxWidth, + width: maxWidth, + height: maxWidth, + origAspectRatio: + pics[i].width!.toInt() / pics[i].height!.toInt(), ), ); }, @@ -107,7 +132,7 @@ class _ContentState extends State { WidgetSpan( child: LayoutBuilder( builder: (context, BoxConstraints box) { - double maxWidth = box.maxWidth; + double maxWidth = box.maxWidth.truncateToDouble(); double crossCount = len < 3 ? 2 : 3; double height = maxWidth / crossCount * diff --git a/lib/pages/dynamics/widgets/pic_panel.dart b/lib/pages/dynamics/widgets/pic_panel.dart index 25b22c21..4e94e6fd 100644 --- a/lib/pages/dynamics/widgets/pic_panel.dart +++ b/lib/pages/dynamics/widgets/pic_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:pilipala/common/constants.dart'; import 'package:pilipala/common/widgets/badge.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; @@ -87,7 +88,7 @@ Widget picWidget(item, context) { childAspectRatio: aspectRatio, children: list, ), - if (len == 1 && origAspectRatio < 0.4) + if (len == 1 && height > Get.size.height * 0.9) const PBadge( text: '长图', top: null, diff --git a/lib/pages/dynamics/widgets/rich_node_panel.dart b/lib/pages/dynamics/widgets/rich_node_panel.dart index 8b7dcd69..5ffee5f1 100644 --- a/lib/pages/dynamics/widgets/rich_node_panel.dart +++ b/lib/pages/dynamics/widgets/rich_node_panel.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/http/search.dart'; // 富文本 InlineSpan richNode(item, context) { @@ -17,6 +19,17 @@ InlineSpan richNode(item, context) { // 动态页面 richTextNodes 层级可能与主页动态层级不同 richTextNodes = item.modules.moduleDynamic.major.opus.summary.richTextNodes; + if (item.modules.moduleDynamic.major.opus.title != null) { + spanChilds.add( + TextSpan( + text: item.modules.moduleDynamic.major.opus.title + '\n', + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold), + ), + ); + } } if (richTextNodes == null || richTextNodes.isEmpty) { return spacer; @@ -191,6 +204,39 @@ InlineSpan richNode(item, context) { ), ); } + // 投稿 + if (i.type == 'RICH_TEXT_NODE_TYPE_BV') { + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.play_circle_outline_outlined, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + spanChilds.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: () async { + try { + int cid = await SearchHttp.ab2c(bvid: i.rid); + Get.toNamed('/video?bvid=${i.rid}&cid=$cid', + arguments: {'pic': null, 'heroTag': i.rid}); + } catch (err) { + SmartDialog.showToast(err.toString()); + } + }, + child: Text( + '${i.text} ', + style: authorStyle, + ), + ), + ), + ); + } } // if (contentType == 'major' && // item.modules.moduleDynamic.major.opus.pics.isNotEmpty) { diff --git a/lib/pages/dynamics/widgets/up_panel.dart b/lib/pages/dynamics/widgets/up_panel.dart index 8a2c5dac..fd0ae642 100644 --- a/lib/pages/dynamics/widgets/up_panel.dart +++ b/lib/pages/dynamics/widgets/up_panel.dart @@ -1,16 +1,14 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:hive/hive.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/dynamics/up.dart'; import 'package:pilipala/models/live/item.dart'; import 'package:pilipala/pages/dynamics/controller.dart'; import 'package:pilipala/utils/feed_back.dart'; -import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; class UpPanel extends StatefulWidget { - final FollowUpModel? upData; + final FollowUpModel upData; const UpPanel(this.upData, {Key? key}) : super(key: key); @override @@ -24,39 +22,22 @@ class _UpPanelState extends State { List upList = []; List liveList = []; static const itemPadding = EdgeInsets.symmetric(horizontal: 5, vertical: 0); - Box userInfoCache = GStrorage.userInfo; - var userInfo; + late MyInfo userInfo; - @override - void initState() { - super.initState(); - upList = widget.upData!.upList!; - if (widget.upData!.liveUsers != null) { - liveList = widget.upData!.liveUsers!.items!; - } - upList.insert( - 0, - UpItem( - face: 'https://files.catbox.moe/8uc48f.png', uname: '全部动态', mid: -1), - ); - userInfo = userInfoCache.get('userInfoCache'); - upList.insert( - 1, - UpItem( - face: userInfo.face, - uname: '我', - mid: userInfo.mid, - ), - ); + void listFormat() { + userInfo = widget.upData.myInfo!; + upList = widget.upData.upList!; + liveList = widget.upData.liveList!; } @override Widget build(BuildContext context) { + listFormat(); return SliverPersistentHeader( floating: true, pinned: false, delegate: _SliverHeaderDelegate( - height: 124, + height: liveList.isNotEmpty || upList.isNotEmpty ? 126 : 0, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, @@ -91,7 +72,7 @@ class _UpPanelState extends State { color: Theme.of(context).colorScheme.background, child: Row( children: [ - Expanded( + Flexible( child: ListView( scrollDirection: Axis.horizontal, controller: scrollController, @@ -121,6 +102,13 @@ class _UpPanelState extends State { ], ), ), + Container( + height: 6, + color: Theme.of(context) + .colorScheme + .onInverseSurface + .withOpacity(0.5), + ), ], )), ); @@ -139,7 +127,7 @@ class _UpPanelState extends State { int liveLen = liveList.length; int upLen = upList.length; double itemWidth = contentWidth + itemPadding.horizontal; - double screenWidth = MediaQuery.of(context).size.width; + double screenWidth = MediaQuery.sizeOf(context).width; double moveDistance = 0.0; if (itemWidth * (upList.length + liveList.length) <= screenWidth) { } else if ((upLen - i - 0.5) * itemWidth > screenWidth / 2) { @@ -171,6 +159,9 @@ class _UpPanelState extends State { }, onLongPress: () { feedBack(); + if (data.mid == -1) { + return; + } String heroTag = Utils.makeHeroTag(data.mid); Get.toNamed('/member?mid=${data.mid}', arguments: {'face': data.face, 'heroTag': heroTag}); @@ -198,12 +189,19 @@ class _UpPanelState extends State { backgroundColor: data.type == 'live' ? Theme.of(context).colorScheme.secondaryContainer : Theme.of(context).colorScheme.primary, - child: NetworkImgLayer( - width: 49, - height: 49, - src: data.face, - type: 'avatar', - ), + child: data.face != '' + ? NetworkImgLayer( + width: 50, + height: 50, + src: data.face, + type: 'avatar', + ) + : const CircleAvatar( + radius: 25, + backgroundImage: AssetImage( + 'assets/images/noface.jpeg', + ), + ), ), Padding( padding: const EdgeInsets.only(top: 4), @@ -271,13 +269,11 @@ class UpPanelSkeleton extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - width: 49, - height: 49, + width: 50, + height: 50, decoration: BoxDecoration( color: Theme.of(context).colorScheme.onInverseSurface, - borderRadius: const BorderRadius.all( - Radius.circular(24), - ), + borderRadius: BorderRadius.circular(50), ), ), Container( diff --git a/lib/pages/emote/controller.dart b/lib/pages/emote/controller.dart new file mode 100644 index 00000000..c1a4c504 --- /dev/null +++ b/lib/pages/emote/controller.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../http/reply.dart'; +import '../../models/video/reply/emote.dart'; + +class EmotePanelController extends GetxController + with GetTickerProviderStateMixin { + late List emotePackage; + late TabController tabController; + + Future getEmote() async { + var res = await ReplyHttp.getEmoteList(business: 'reply'); + if (res['status']) { + emotePackage = res['data'].packages; + tabController = TabController(length: emotePackage.length, vsync: this); + } + return res; + } +} diff --git a/lib/pages/emote/index.dart b/lib/pages/emote/index.dart new file mode 100644 index 00000000..32ce53e3 --- /dev/null +++ b/lib/pages/emote/index.dart @@ -0,0 +1,4 @@ +library emote; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/emote/view.dart b/lib/pages/emote/view.dart new file mode 100644 index 00000000..d30767c3 --- /dev/null +++ b/lib/pages/emote/view.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../models/video/reply/emote.dart'; +import 'controller.dart'; + +class EmotePanel extends StatefulWidget { + final Function onChoose; + const EmotePanel({super.key, required this.onChoose}); + + @override + State createState() => _EmotePanelState(); +} + +class _EmotePanelState extends State + with AutomaticKeepAliveClientMixin { + final EmotePanelController _emotePanelController = + Get.put(EmotePanelController()); + late Future _futureBuilderFuture; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + _futureBuilderFuture = _emotePanelController.getEmote(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + List emotePackage = + _emotePanelController.emotePackage; + + return Column( + children: [ + Expanded( + child: TabBarView( + controller: _emotePanelController.tabController, + children: emotePackage.map( + (e) { + int size = e.emote!.first.meta!.size!; + int type = e.type!; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 0), + child: GridView.builder( + gridDelegate: + SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: size == 1 ? 40 : 60, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: e.emote!.length, + itemBuilder: (context, index) { + return Material( + color: Colors.transparent, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + child: InkWell( + onTap: () { + widget.onChoose(e, e.emote![index]); + }, + child: Padding( + padding: const EdgeInsets.all(3), + child: type == 4 + ? Text( + e.emote![index].text!, + overflow: TextOverflow.clip, + maxLines: 1, + ) + : Image.network( + e.emote![index].url!, + width: size * 38, + height: size * 38, + ), + ), + ), + ); + }, + ), + ); + }, + ).toList(), + )), + Divider( + height: 1, + color: Theme.of(context).dividerColor.withOpacity(0.1), + ), + TabBar( + controller: _emotePanelController.tabController, + dividerColor: Colors.transparent, + isScrollable: true, + tabs: _emotePanelController.emotePackage + .map((e) => Tab(text: e.text)) + .toList(), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 20), + ], + ); + } else { + return Center(child: Text(data['msg'])); + } + } else { + return const Center(child: Text('加载中...')); + } + }); + } +} diff --git a/lib/pages/fan/controller.dart b/lib/pages/fan/controller.dart index 8675ada7..c1c2a427 100644 --- a/lib/pages/fan/controller.dart +++ b/lib/pages/fan/controller.dart @@ -10,7 +10,7 @@ class FansController extends GetxController { int pn = 1; int ps = 20; int total = 0; - RxList fansList = [FansItemModel()].obs; + RxList fansList = [].obs; late int mid; late String name; var userInfo; diff --git a/lib/pages/fav/controller.dart b/lib/pages/fav/controller.dart index c3f76186..a5f94525 100644 --- a/lib/pages/fav/controller.dart +++ b/lib/pages/fav/controller.dart @@ -24,7 +24,7 @@ class FavController extends GetxController { if (!hasMore.value) { return; } - var res = await await UserHttp.userfavFolder( + var res = await UserHttp.userfavFolder( pn: currentPage, ps: pageSize, mid: userInfo!.mid!, diff --git a/lib/pages/favDetail/widget/fav_video_card.dart b/lib/pages/favDetail/widget/fav_video_card.dart deleted file mode 100644 index 471f19bc..00000000 --- a/lib/pages/favDetail/widget/fav_video_card.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:get/get.dart'; -import 'package:flutter/material.dart'; -import 'package:pilipala/common/constants.dart'; -import 'package:pilipala/common/widgets/stat/danmu.dart'; -import 'package:pilipala/common/widgets/stat/view.dart'; -import 'package:pilipala/http/search.dart'; -import 'package:pilipala/http/video.dart'; -import 'package:pilipala/models/common/search_type.dart'; -import 'package:pilipala/utils/id_utils.dart'; -import 'package:pilipala/utils/utils.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; - -// 收藏视频卡片 - 水平布局 -class FavVideoCardH extends StatelessWidget { - final dynamic videoItem; - final Function? callFn; - - const FavVideoCardH({Key? key, required this.videoItem, this.callFn}) - : super(key: key); - - @override - Widget build(BuildContext context) { - int id = videoItem.id; - String bvid = videoItem.bvid ?? IdUtils.av2bv(id); - String heroTag = Utils.makeHeroTag(id); - return InkWell( - onTap: () async { - // int? seasonId; - String? epId; - if (videoItem.ogv != null && videoItem.ogv['type_name'] == '番剧') { - videoItem.cid = await SearchHttp.ab2c(bvid: bvid); - // seasonId = videoItem.ogv['season_id']; - epId = videoItem.epId; - } else if (videoItem.page == 0 || videoItem.page > 1) { - var result = await VideoHttp.videoIntro(bvid: bvid); - if (result['status']) { - epId = result['data'].epId; - } - } - - Map parameters = { - 'bvid': bvid, - 'cid': videoItem.cid.toString(), - 'epId': epId ?? '', - }; - // if (seasonId != null) { - // parameters['seasonId'] = seasonId.toString(); - // } - Get.toNamed('/video', parameters: parameters, arguments: { - 'videoItem': videoItem, - 'heroTag': heroTag, - 'videoType': - epId != null ? SearchType.media_bangumi : SearchType.video, - }); - }, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - StyleString.safeSpace, 5, StyleString.safeSpace, 5), - child: LayoutBuilder( - builder: (context, boxConstraints) { - double width = - (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; - return SizedBox( - height: width / StyleString.aspectRatio, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: StyleString.aspectRatio, - child: LayoutBuilder( - builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; - return Stack( - children: [ - Hero( - tag: heroTag, - child: NetworkImgLayer( - src: videoItem.pic, - width: maxWidth, - height: maxHeight, - ), - ), - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 1, horizontal: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: Colors.black54.withOpacity(0.4)), - child: Text( - Utils.timeFormat(videoItem.duration!), - style: const TextStyle( - fontSize: 11, color: Colors.white), - ), - ), - ) - ], - ); - }, - ), - ), - VideoContent(videoItem: videoItem, callFn: callFn) - ], - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class VideoContent extends StatelessWidget { - final dynamic videoItem; - final Function? callFn; - const VideoContent({super.key, required this.videoItem, this.callFn}); - - @override - Widget build(BuildContext context) { - return Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - videoItem.title, - textAlign: TextAlign.start, - style: const TextStyle( - fontWeight: FontWeight.w500, - letterSpacing: 0.3, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const Spacer(), - Text( - videoItem.owner.name, - style: TextStyle( - fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, - color: Theme.of(context).colorScheme.outline, - ), - ), - Row( - children: [ - StatView( - theme: 'gray', - view: videoItem.cntInfo['play'], - ), - const SizedBox(width: 8), - StatDanMu(theme: 'gray', danmu: videoItem.cntInfo['danmaku']), - const Spacer(), - SizedBox( - width: 26, - height: 26, - child: IconButton( - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - ), - onPressed: () { - showDialog( - context: Get.context!, - builder: (context) { - return AlertDialog( - title: const Text('提示'), - content: const Text('要取消收藏吗?'), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text( - '取消', - style: TextStyle( - color: Theme.of(context) - .colorScheme - .outline), - )), - TextButton( - onPressed: () async { - await callFn!(); - Get.back(); - }, - child: const Text('确定取消'), - ) - ], - ); - }, - ); - }, - icon: Icon( - Icons.clear_outlined, - color: Theme.of(context).colorScheme.outline, - size: 18, - ), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/favDetail/controller.dart b/lib/pages/fav_detail/controller.dart similarity index 78% rename from lib/pages/favDetail/controller.dart rename to lib/pages/fav_detail/controller.dart index c2c63dd5..55d5b884 100644 --- a/lib/pages/favDetail/controller.dart +++ b/lib/pages/fav_detail/controller.dart @@ -16,7 +16,7 @@ class FavDetailController extends GetxController { RxMap favInfo = {}.obs; RxList favList = [].obs; RxString loadingText = '加载中...'.obs; - int mediaCount = 0; + RxInt mediaCount = 0.obs; @override void onInit() { @@ -29,12 +29,12 @@ class FavDetailController extends GetxController { } Future queryUserFavFolderDetail({type = 'init'}) async { - if (type == 'onLoad' && favList.length >= mediaCount) { + if (type == 'onLoad' && favList.length >= mediaCount.value) { loadingText.value = '没有更多了'; return; } isLoadingMore = true; - var res = await await UserHttp.userFavFolderDetail( + var res = await UserHttp.userFavFolderDetail( pn: currentPage, ps: 20, mediaId: mediaId!, @@ -43,11 +43,11 @@ class FavDetailController extends GetxController { favInfo.value = res['data'].info; if (currentPage == 1 && type == 'init') { favList.value = res['data'].medias; - mediaCount = res['data'].info['media_count']; + mediaCount.value = res['data'].info['media_count']; } else if (type == 'onLoad') { favList.addAll(res['data'].medias); } - if (favList.length >= mediaCount) { + if (favList.length >= mediaCount.value) { loadingText.value = '没有更多了'; } } @@ -60,16 +60,14 @@ class FavDetailController extends GetxController { var result = await VideoHttp.favVideo( aid: id, addIds: '', delIds: mediaId.toString()); if (result['status']) { - if (result['data']['prompt']) { - List dataList = favList; - for (var i in dataList) { - if (i.id == id) { - dataList.remove(i); - break; - } + List dataList = favList; + for (var i in dataList) { + if (i.id == id) { + dataList.remove(i); + break; } - SmartDialog.showToast('取消收藏'); } + SmartDialog.showToast('取消收藏'); } } diff --git a/lib/pages/favDetail/index.dart b/lib/pages/fav_detail/index.dart similarity index 100% rename from lib/pages/favDetail/index.dart rename to lib/pages/fav_detail/index.dart diff --git a/lib/pages/favDetail/view.dart b/lib/pages/fav_detail/view.dart similarity index 95% rename from lib/pages/favDetail/view.dart rename to lib/pages/fav_detail/view.dart index fedc85fd..d94f5149 100644 --- a/lib/pages/favDetail/view.dart +++ b/lib/pages/fav_detail/view.dart @@ -7,7 +7,7 @@ import 'package:pilipala/common/skeleton/video_card_h.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/common/widgets/no_data.dart'; -import 'package:pilipala/pages/favDetail/index.dart'; +import 'package:pilipala/pages/fav_detail/index.dart'; import 'widget/fav_video_card.dart'; @@ -24,10 +24,12 @@ class _FavDetailPageState extends State { Get.put(FavDetailController()); late StreamController titleStreamC; // a Future? _futureBuilderFuture; + late String mediaId; @override void initState() { super.initState(); + mediaId = Get.parameters['mediaId']!; _futureBuilderFuture = _favDetailController.queryUserFavFolderDetail(); titleStreamC = StreamController(); _controller.addListener( @@ -82,7 +84,7 @@ class _FavDetailPageState extends State { style: Theme.of(context).textTheme.titleMedium, ), Text( - '共${_favDetailController.item!.mediaCount!}条视频', + '共${_favDetailController.mediaCount}条视频', style: Theme.of(context).textTheme.labelMedium, ) ], @@ -94,8 +96,8 @@ class _FavDetailPageState extends State { ), actions: [ IconButton( - onPressed: () => Get.toNamed( - '/favSearch?searchType=0&mediaId=${Get.parameters['mediaId']!}'), + onPressed: () => + Get.toNamed('/favSearch?searchType=0&mediaId=$mediaId'), icon: const Icon(Icons.search_outlined), ), // IconButton( @@ -173,7 +175,7 @@ class _FavDetailPageState extends State { padding: const EdgeInsets.only(top: 15, bottom: 8, left: 14), child: Obx( () => Text( - '共${_favDetailController.favList.length}条视频', + '共${_favDetailController.mediaCount}条视频', style: TextStyle( fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, diff --git a/lib/pages/fav_detail/widget/fav_video_card.dart b/lib/pages/fav_detail/widget/fav_video_card.dart new file mode 100644 index 00000000..1c4008ff --- /dev/null +++ b/lib/pages/fav_detail/widget/fav_video_card.dart @@ -0,0 +1,257 @@ +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/widgets/stat/danmu.dart'; +import 'package:pilipala/common/widgets/stat/view.dart'; +import 'package:pilipala/http/search.dart'; +import 'package:pilipala/http/video.dart'; +import 'package:pilipala/models/common/search_type.dart'; +import 'package:pilipala/utils/id_utils.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import '../../../common/widgets/badge.dart'; + +// 收藏视频卡片 - 水平布局 +class FavVideoCardH extends StatelessWidget { + final dynamic videoItem; + final Function? callFn; + final int? searchType; + + const FavVideoCardH({ + Key? key, + required this.videoItem, + this.callFn, + this.searchType, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + int id = videoItem.id; + String bvid = videoItem.bvid ?? IdUtils.av2bv(id); + String heroTag = Utils.makeHeroTag(id); + return InkWell( + onTap: () async { + // int? seasonId; + String? epId; + if (videoItem.ogv != null && + (videoItem.ogv['type_name'] == '番剧' || + videoItem.ogv['type_name'] == '国创')) { + videoItem.cid = await SearchHttp.ab2c(bvid: bvid); + // seasonId = videoItem.ogv['season_id']; + epId = videoItem.epId; + } else if (videoItem.page == 0 || videoItem.page > 1) { + var result = await VideoHttp.videoIntro(bvid: bvid); + if (result['status']) { + epId = result['data'].epId; + } + } + + Map parameters = { + 'bvid': bvid, + 'cid': videoItem.cid.toString(), + 'epId': epId ?? '', + }; + // if (seasonId != null) { + // parameters['seasonId'] = seasonId.toString(); + // } + Get.toNamed('/video', parameters: parameters, arguments: { + 'videoItem': videoItem, + 'heroTag': heroTag, + 'videoType': + epId != null ? SearchType.media_bangumi : SearchType.video, + }); + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + StyleString.safeSpace, 5, StyleString.safeSpace, 5), + child: LayoutBuilder( + builder: (context, boxConstraints) { + double width = + (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; + return SizedBox( + height: width / StyleString.aspectRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Stack( + children: [ + Hero( + tag: heroTag, + child: NetworkImgLayer( + src: videoItem.pic, + width: maxWidth, + height: maxHeight, + ), + ), + PBadge( + text: Utils.timeFormat(videoItem.duration!), + right: 6.0, + bottom: 6.0, + type: 'gray', + ), + if (videoItem.ogv != null) ...[ + PBadge( + text: videoItem.ogv['type_name'], + top: 6.0, + right: 6.0, + bottom: null, + left: null, + ), + ], + ], + ); + }, + ), + ), + VideoContent( + videoItem: videoItem, + callFn: callFn, + searchType: searchType, + ) + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class VideoContent extends StatelessWidget { + final dynamic videoItem; + final Function? callFn; + final int? searchType; + const VideoContent({ + super.key, + required this.videoItem, + this.callFn, + this.searchType, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + videoItem.title, + textAlign: TextAlign.start, + style: const TextStyle( + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (videoItem.ogv != null) ...[ + Text( + videoItem.intro, + style: TextStyle( + fontSize: + Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + const Spacer(), + Text( + Utils.dateFormat(videoItem.favTime), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline), + ), + if (videoItem.owner.name != '') ...[ + Text( + videoItem.owner.name, + style: TextStyle( + fontSize: + Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + StatView( + theme: 'gray', + view: videoItem.cntInfo['play'], + ), + const SizedBox(width: 8), + StatDanMu( + theme: 'gray', danmu: videoItem.cntInfo['danmaku']), + const Spacer(), + ], + ), + ), + ], + ), + searchType != 1 + ? Positioned( + right: 0, + bottom: -4, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + showDialog( + context: Get.context!, + builder: (context) { + return AlertDialog( + title: const Text('提示'), + content: const Text('要取消收藏吗?'), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + '取消', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .outline), + )), + TextButton( + onPressed: () async { + await callFn!(); + Get.back(); + }, + child: const Text('确定取消'), + ) + ], + ); + }, + ); + }, + icon: Icon( + Icons.clear_outlined, + color: Theme.of(context).colorScheme.outline, + size: 18, + ), + ), + ) + : const SizedBox(), + ], + ), + ), + ); + } +} diff --git a/lib/pages/fav_search/controller.dart b/lib/pages/fav_search/controller.dart index 642fea6b..371a3a07 100644 --- a/lib/pages/fav_search/controller.dart +++ b/lib/pages/fav_search/controller.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/http/user.dart'; import 'package:pilipala/models/user/fav_detail.dart'; +import '../../http/video.dart'; + class FavSearchController extends GetxController { final ScrollController scrollController = ScrollController(); Rx controller = TextEditingController().obs; @@ -72,4 +75,19 @@ class FavSearchController extends GetxController { if (!hasMore) return; searchFav(type: 'onLoad'); } + + onCancelFav(int id) async { + var result = await VideoHttp.favVideo( + aid: id, addIds: '', delIds: mediaId.toString()); + if (result['status']) { + List dataList = favList; + for (var i in dataList) { + if (i.id == id) { + dataList.remove(i); + break; + } + } + SmartDialog.showToast('取消收藏'); + } + } } diff --git a/lib/pages/fav_search/view.dart b/lib/pages/fav_search/view.dart index 83c2440b..9b2ab15d 100644 --- a/lib/pages/fav_search/view.dart +++ b/lib/pages/fav_search/view.dart @@ -3,14 +3,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/skeleton/video_card_h.dart'; import 'package:pilipala/common/widgets/no_data.dart'; -import 'package:pilipala/pages/favDetail/widget/fav_video_card.dart'; +import 'package:pilipala/pages/fav_detail/widget/fav_video_card.dart'; import 'controller.dart'; class FavSearchPage extends StatefulWidget { - final int? sourceType; - final int? mediaId; - const FavSearchPage({super.key, this.sourceType, this.mediaId}); + const FavSearchPage({super.key}); @override State createState() => _FavSearchPageState(); @@ -19,11 +17,12 @@ class FavSearchPage extends StatefulWidget { class _FavSearchPageState extends State { final FavSearchController _favSearchCtr = Get.put(FavSearchController()); late ScrollController scrollController; + late int searchType; @override void initState() { super.initState(); - + searchType = int.parse(Get.parameters['searchType']!); scrollController = _favSearchCtr.scrollController; scrollController.addListener( () { @@ -100,7 +99,11 @@ class _FavSearchPageState extends State { } else { return FavVideoCardH( videoItem: _favSearchCtr.favList[index], - callFn: () => null, + searchType: searchType, + callFn: () => searchType != 1 + ? _favSearchCtr + .onCancelFav(_favSearchCtr.favList[index].id!) + : {}, ); } }, diff --git a/lib/pages/follow/view.dart b/lib/pages/follow/view.dart index a4f1011b..9633e7f0 100644 --- a/lib/pages/follow/view.dart +++ b/lib/pages/follow/view.dart @@ -37,6 +37,29 @@ class _FollowPageState extends State { : '${_followController.name}的关注', style: Theme.of(context).textTheme.titleMedium, ), + actions: [ + IconButton( + onPressed: () => Get.toNamed('/followSearch?mid=$mid'), + icon: const Icon(Icons.search_outlined), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (BuildContext context) => [ + PopupMenuItem( + onTap: () => Get.toNamed('/blackListPage'), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.block, size: 19), + SizedBox(width: 10), + Text('黑名单管理'), + ], + ), + ) + ], + ), + const SizedBox(width: 6), + ], ), body: Obx( () => !_followController.isOwner.value @@ -52,6 +75,7 @@ class _FollowPageState extends State { TabBar( controller: _followController.tabController, isScrollable: true, + tabAlignment: TabAlignment.start, tabs: [ for (var i in data['data']) ...[ Tab(text: i.name), @@ -86,3 +110,22 @@ class _FollowPageState extends State { ); } } + +class _FakeAPI { + static const List _kOptions = [ + 'aardvark', + 'bobcat', + 'chameleon', + ]; + // Searches the options, but injects a fake "network" delay. + static Future> search(String query) async { + await Future.delayed( + const Duration(seconds: 1)); // Fake 1 second delay. + if (query == '') { + return const Iterable.empty(); + } + return _kOptions.where((String option) { + return option.contains(query.toLowerCase()); + }); + } +} diff --git a/lib/pages/follow/widgets/follow_item.dart b/lib/pages/follow/widgets/follow_item.dart index ac9cc01b..d21a89bc 100644 --- a/lib/pages/follow/widgets/follow_item.dart +++ b/lib/pages/follow/widgets/follow_item.dart @@ -42,7 +42,7 @@ class FollowItem extends StatelessWidget { overflow: TextOverflow.ellipsis, ), dense: true, - trailing: ctr!.isOwner.value + trailing: ctr != null && ctr!.isOwner.value ? SizedBox( height: 34, child: TextButton( diff --git a/lib/pages/follow_search/controller.dart b/lib/pages/follow_search/controller.dart new file mode 100644 index 00000000..9fd1590d --- /dev/null +++ b/lib/pages/follow_search/controller.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/member.dart'; + +import '../../models/follow/result.dart'; + +class FollowSearchController extends GetxController { + Rx controller = TextEditingController().obs; + final FocusNode searchFocusNode = FocusNode(); + RxString searchKeyWord = ''.obs; + String hintText = '搜索'; + RxString loadingStatus = 'init'.obs; + late int mid = 1; + RxString uname = ''.obs; + int ps = 20; + int pn = 1; + RxList followList = [].obs; + RxInt total = 0.obs; + + @override + void onInit() { + super.onInit(); + mid = int.parse(Get.parameters['mid']!); + } + + // 清空搜索 + void onClear() { + if (searchKeyWord.value.isNotEmpty && controller.value.text != '') { + controller.value.clear(); + searchKeyWord.value = ''; + } else { + Get.back(); + } + } + + void onChange(value) { + searchKeyWord.value = value; + } + + // 提交搜索内容 + void submit() { + loadingStatus.value = 'loading'; + searchFollow(); + } + + Future searchFollow({type = 'init'}) async { + if (controller.value.text == '') { + return {'status': true, 'data': [].obs}; + } + if (type == 'init') { + ps = 1; + } + var res = await MemberHttp.getfollowSearch( + mid: mid, + ps: ps, + pn: pn, + name: controller.value.text, + ); + if (res['status']) { + if (type == 'init') { + followList.value = res['data'].list; + } else { + followList.addAll(res['data'].list); + } + total.value = res['data'].total; + } + return res; + } + + void onLoad() { + searchFollow(type: 'onLoad'); + } +} diff --git a/lib/pages/follow_search/index.dart b/lib/pages/follow_search/index.dart new file mode 100644 index 00000000..805d8c47 --- /dev/null +++ b/lib/pages/follow_search/index.dart @@ -0,0 +1,4 @@ +library follow_search; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/follow_search/view.dart b/lib/pages/follow_search/view.dart new file mode 100644 index 00000000..6be42676 --- /dev/null +++ b/lib/pages/follow_search/view.dart @@ -0,0 +1,121 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/pages/follow_search/index.dart'; + +import '../follow/widgets/follow_item.dart'; + +class FollowSearchPage extends StatefulWidget { + const FollowSearchPage({super.key}); + + @override + State createState() => _FollowSearchPageState(); +} + +class _FollowSearchPageState extends State { + final FollowSearchController _followSearchController = + Get.put(FollowSearchController()); + late Future? _futureBuilder; + final ScrollController scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _futureBuilder = _followSearchController.searchFollow(); + scrollController.addListener( + () { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + EasyThrottle.throttle( + 'my-throttler', const Duration(milliseconds: 500), () { + _followSearchController.onLoad(); + }); + } + }, + ); + } + + void reRequest() { + setState(() { + _futureBuilder = _followSearchController.searchFollow(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + actions: [ + IconButton( + onPressed: reRequest, + icon: const Icon(CupertinoIcons.search, size: 22), + ), + const SizedBox(width: 6), + ], + title: TextField( + autofocus: true, + focusNode: _followSearchController.searchFocusNode, + controller: _followSearchController.controller.value, + textInputAction: TextInputAction.search, + onChanged: (value) => _followSearchController.onChange(value), + decoration: InputDecoration( + hintText: _followSearchController.hintText, + border: InputBorder.none, + suffixIcon: IconButton( + icon: Icon( + Icons.clear, + size: 22, + color: Theme.of(context).colorScheme.outline, + ), + onPressed: () => _followSearchController.onClear(), + ), + ), + onSubmitted: (String value) => reRequest(), + ), + ), + body: FutureBuilder( + future: _futureBuilder, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + var data = snapshot.data; + if (data == null) { + return CustomScrollView( + slivers: [ + HttpError(errMsg: snapshot.data['msg'], fn: reRequest) + ], + ); + } + if (data['status']) { + RxList followList = _followSearchController.followList; + return Obx( + () => followList.isNotEmpty + ? ListView.builder( + controller: scrollController, + itemCount: followList.length, + itemBuilder: ((context, index) { + return FollowItem( + item: followList[index], + ); + }), + ) + : CustomScrollView( + slivers: [HttpError(errMsg: '未搜索到结果', fn: reRequest)], + ), + ); + } else { + return CustomScrollView( + slivers: [ + HttpError(errMsg: snapshot.data['msg'], fn: reRequest) + ], + ); + } + } else { + return const SizedBox(); + } + }), + ); + } +} diff --git a/lib/pages/history/controller.dart b/lib/pages/history/controller.dart index e7822cd9..a1f18113 100644 --- a/lib/pages/history/controller.dart +++ b/lib/pages/history/controller.dart @@ -88,8 +88,10 @@ class HistoryController extends GetxController { // 观看历史暂停状态 Future historyStatus() async { var res = await UserHttp.historyStatus(); - pauseStatus.value = res.data['data']; - localCache.put(LocalCacheKey.historyPause, res.data['data']); + if (res.data['code'] == 0) { + pauseStatus.value = res.data['data']; + localCache.put(LocalCacheKey.historyPause, res.data['data']); + } } // 清空观看历史 diff --git a/lib/pages/history/view.dart b/lib/pages/history/view.dart index d8fc60f0..92e1eee7 100644 --- a/lib/pages/history/view.dart +++ b/lib/pages/history/view.dart @@ -70,10 +70,6 @@ class _HistoryPageState extends State { child1: AppBar( titleSpacing: 0, centerTitle: false, - leading: IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.arrow_back_outlined), - ), title: Text( '观看记录', style: Theme.of(context).textTheme.titleMedium, diff --git a/lib/pages/history/widgets/item.dart b/lib/pages/history/widgets/item.dart index a83e118b..39c6931d 100644 --- a/lib/pages/history/widgets/item.dart +++ b/lib/pages/history/widgets/item.dart @@ -185,7 +185,7 @@ class HistoryItem extends StatelessWidget { ? '已看完' : '${Utils.timeFormat(videoItem.progress!)}/${Utils.timeFormat(videoItem.duration!)}', right: 6.0, - bottom: 6.0, + bottom: 8.0, type: 'gray', ), // 右上角 @@ -258,6 +258,27 @@ class HistoryItem extends StatelessWidget { ), ), ), + videoItem.progress != 0 + ? Positioned( + left: 3, + right: 3, + bottom: 0, + child: ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular( + StyleString.imgRadius.x), + bottomRight: Radius.circular( + StyleString.imgRadius.x), + ), + child: LinearProgressIndicator( + value: videoItem.progress == -1 + ? 100 + : videoItem.progress / + videoItem.duration, + ), + ), + ) + : const SizedBox() ], ), VideoContent(videoItem: videoItem, ctr: ctr) diff --git a/lib/pages/home/controller.dart b/lib/pages/home/controller.dart index 5b722527..fb85be0b 100644 --- a/lib/pages/home/controller.dart +++ b/lib/pages/home/controller.dart @@ -5,15 +5,17 @@ import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/models/common/tab_type.dart'; import 'package:pilipala/utils/storage.dart'; +import '../../http/index.dart'; class HomeController extends GetxController with GetTickerProviderStateMixin { bool flag = false; - late List tabs; - int initialIndex = 1; + late RxList tabs = [].obs; + RxInt initialIndex = 1.obs; late TabController tabController; late List tabsCtrList; late List tabsPageList; Box userInfoCache = GStrorage.userInfo; + Box settingStorage = GStrorage.setting; RxBool userLogin = false.obs; RxString userFace = ''.obs; var userInfo; @@ -21,6 +23,10 @@ class HomeController extends GetxController with GetTickerProviderStateMixin { late final StreamController searchBarStream = StreamController.broadcast(); late bool hideSearchBar; + late List defaultTabs; + late List tabbarSort; + RxString defaultSearch = ''.obs; + late bool enableGradientBg; @override void onInit() { @@ -28,19 +34,15 @@ class HomeController extends GetxController with GetTickerProviderStateMixin { userInfo = userInfoCache.get('userInfoCache'); userLogin.value = userInfo != null; userFace.value = userInfo != null ? userInfo.face : ''; - - // 进行tabs配置 - tabs = tabsConfig; - tabsCtrList = tabsConfig.map((e) => e['ctr']).toList(); - tabsPageList = tabsConfig.map((e) => e['page']).toList(); - - tabController = TabController( - initialIndex: initialIndex, - length: tabs.length, - vsync: this, - ); hideSearchBar = setting.get(SettingBoxKey.hideSearchBar, defaultValue: true); + if (setting.get(SettingBoxKey.enableSearchWord, defaultValue: true)) { + searchDefault(); + } + enableGradientBg = + setting.get(SettingBoxKey.enableGradientBg, defaultValue: true); + // 进行tabs配置 + setTabConfig(); } void onRefresh() { @@ -62,4 +64,54 @@ class HomeController extends GetxController with GetTickerProviderStateMixin { if (val) return; userFace.value = userInfo != null ? userInfo.face : ''; } + + void setTabConfig() async { + defaultTabs = [...tabsConfig]; + tabbarSort = settingStorage.get(SettingBoxKey.tabbarSort, + defaultValue: ['live', 'rcmd', 'hot', 'bangumi']); + defaultTabs.retainWhere( + (item) => tabbarSort.contains((item['type'] as TabType).id)); + defaultTabs.sort((a, b) => tabbarSort + .indexOf((a['type'] as TabType).id) + .compareTo(tabbarSort.indexOf((b['type'] as TabType).id))); + + tabs.value = defaultTabs; + + if (tabbarSort.contains(TabType.rcmd.id)) { + initialIndex.value = tabbarSort.indexOf(TabType.rcmd.id); + } else { + initialIndex.value = 0; + } + tabsCtrList = tabs.map((e) => e['ctr']).toList(); + tabsPageList = tabs.map((e) => e['page']).toList(); + + tabController = TabController( + initialIndex: initialIndex.value, + length: tabs.length, + vsync: this, + ); + // 监听 tabController 切换 + if (enableGradientBg) { + tabController.animation!.addListener(() { + if (tabController.indexIsChanging) { + if (initialIndex.value != tabController.index) { + initialIndex.value = tabController.index; + } + } else { + final int temp = tabController.animation!.value.round(); + if (initialIndex.value != temp) { + initialIndex.value = temp; + tabController.index = initialIndex.value; + } + } + }); + } + } + + void searchDefault() async { + var res = await Request().get(Api.searchDefault); + if (res.data['code'] == 0) { + defaultSearch.value = res.data['data']['name']; + } + } } diff --git a/lib/pages/home/view.dart b/lib/pages/home/view.dart index bcde246e..b0cef90b 100644 --- a/lib/pages/home/view.dart +++ b/lib/pages/home/view.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/pages/mine/index.dart'; -import 'package:pilipala/pages/search/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import './controller.dart'; @@ -30,7 +30,7 @@ class _HomePageState extends State stream = _homeController.searchBarStream.stream; } - showUserBottonSheet() { + showUserBottomSheet() { feedBack(); showModalBottomSheet( context: context, @@ -46,50 +46,104 @@ class _HomePageState extends State @override Widget build(BuildContext context) { super.build(context); + Brightness currentBrightness = MediaQuery.of(context).platformBrightness; + // 设置状态栏图标的亮度 + if (_homeController.enableGradientBg) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarIconBrightness: currentBrightness == Brightness.light + ? Brightness.dark + : Brightness.light, + )); + } return Scaffold( extendBody: true, extendBodyBehindAppBar: true, - appBar: AppBar(toolbarHeight: 0, elevation: 0), - body: Column( + appBar: _homeController.enableGradientBg + ? null + : AppBar(toolbarHeight: 0, elevation: 0), + body: Stack( children: [ - CustomAppBar( - stream: _homeController.hideSearchBar - ? stream - : StreamController.broadcast().stream, - ctr: _homeController, - callback: showUserBottonSheet, - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - height: 42, - child: Align( - alignment: Alignment.center, - child: TabBar( - controller: _homeController.tabController, - tabs: [ - for (var i in _homeController.tabs) Tab(text: i['label']) - ], - isScrollable: true, - dividerColor: Colors.transparent, - enableFeedback: true, - splashBorderRadius: BorderRadius.circular(10), - tabAlignment: TabAlignment.center, - onTap: (value) { - feedBack(); - if (_homeController.initialIndex == value) { - _homeController.tabsCtrList[value]().animateToTop(); - } - _homeController.initialIndex = value; - }, + // gradient background + if (_homeController.enableGradientBg) ...[ + Align( + alignment: Alignment.topLeft, + child: Opacity( + opacity: 0.6, + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context) + .colorScheme + .primary + .withOpacity(0.9), + Theme.of(context) + .colorScheme + .primary + .withOpacity(0.5), + Theme.of(context).colorScheme.surface + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: const [0, 0.0034, 0.34]), + ), + ), ), ), - ), - Expanded( - child: TabBarView( - controller: _homeController.tabController, - children: _homeController.tabsPageList, - ), + ], + Column( + children: [ + CustomAppBar( + stream: _homeController.hideSearchBar + ? stream + : StreamController.broadcast().stream, + ctr: _homeController, + callback: showUserBottomSheet, + ), + if (_homeController.tabs.length > 1) ...[ + if (_homeController.enableGradientBg) ...[ + const CustomTabs(), + ] else ...[ + const SizedBox(height: 4), + SizedBox( + width: double.infinity, + height: 42, + child: Align( + alignment: Alignment.center, + child: TabBar( + controller: _homeController.tabController, + tabs: [ + for (var i in _homeController.tabs) + Tab(text: i['label']) + ], + isScrollable: true, + dividerColor: Colors.transparent, + enableFeedback: true, + splashBorderRadius: BorderRadius.circular(10), + tabAlignment: TabAlignment.center, + onTap: (value) { + feedBack(); + if (_homeController.initialIndex.value == value) { + _homeController.tabsCtrList[value]().animateToTop(); + } + _homeController.initialIndex.value = value; + }, + ), + ), + ), + ], + ] else ...[ + const SizedBox(height: 6), + ], + Expanded( + child: TabBarView( + controller: _homeController.tabController, + children: _homeController.tabsPageList, + ), + ), + ], ), ], ), @@ -119,87 +173,23 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { return StreamBuilder( stream: stream, initialData: true, - builder: (context, AsyncSnapshot snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { + final RxBool isUserLoggedIn = ctr!.userLogin; + final double top = MediaQuery.of(context).padding.top; return AnimatedOpacity( opacity: snapshot.data ? 1 : 0, duration: const Duration(milliseconds: 300), child: AnimatedContainer( curve: Curves.easeInOutCubicEmphasized, duration: const Duration(milliseconds: 500), - height: snapshot.data - ? MediaQuery.of(context).padding.top + 52 - : MediaQuery.of(context).padding.top - 10, - child: Container( - padding: EdgeInsets.only( - left: 20, - right: 20, - bottom: 0, - top: MediaQuery.of(context).padding.top + 4, - ), - child: Row( - children: [ - const Expanded(child: SearchPage()), - if (ctr!.userLogin.value) ...[ - const SizedBox(width: 6), - IconButton( - onPressed: () => Get.toNamed('/whisper'), - icon: const Icon(Icons.notifications_none)) - ], - const SizedBox(width: 6), - Obx( - () => ctr!.userLogin.value - ? Stack( - children: [ - Obx( - () => NetworkImgLayer( - type: 'avatar', - width: 34, - height: 34, - src: ctr!.userFace.value, - ), - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => callback!(), - splashColor: Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(0.3), - borderRadius: const BorderRadius.all( - Radius.circular(50), - ), - ), - ), - ) - ], - ) - : SizedBox( - width: 38, - height: 38, - child: IconButton( - style: ButtonStyle( - padding: - MaterialStateProperty.all(EdgeInsets.zero), - backgroundColor: - MaterialStateProperty.resolveWith((states) { - return Theme.of(context) - .colorScheme - .onInverseSurface; - }), - ), - onPressed: () => callback!(), - icon: Icon( - Icons.person_rounded, - size: 22, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ), - ], - ), + height: snapshot.data ? top + 52 : top, + padding: EdgeInsets.fromLTRB(14, top + 6, 14, 0), + child: UserInfoWidget( + top: top, + ctr: ctr, + userLogin: isUserLoggedIn, + userFace: ctr?.userFace.value, + callback: () => callback!(), ), ), ); @@ -207,3 +197,239 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { ); } } + +class UserInfoWidget extends StatelessWidget { + const UserInfoWidget({ + Key? key, + required this.top, + required this.userLogin, + required this.userFace, + required this.callback, + required this.ctr, + }) : super(key: key); + + final double top; + final RxBool userLogin; + final String? userFace; + final VoidCallback? callback; + final HomeController? ctr; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SearchBar(ctr: ctr), + if (userLogin.value) ...[ + const SizedBox(width: 4), + ClipRect( + child: IconButton( + onPressed: () => Get.toNamed('/whisper'), + icon: const Icon(Icons.notifications_none), + ), + ) + ], + const SizedBox(width: 8), + Obx( + () => userLogin.value + ? Stack( + children: [ + NetworkImgLayer( + type: 'avatar', + width: 34, + height: 34, + src: userFace, + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => callback?.call(), + splashColor: Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(0.3), + borderRadius: const BorderRadius.all( + Radius.circular(50), + ), + ), + ), + ) + ], + ) + : DefaultUser(callback: () => callback!()), + ), + ], + ); + } +} + +class DefaultUser extends StatelessWidget { + const DefaultUser({super.key, this.callback}); + final Function? callback; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 38, + height: 38, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + backgroundColor: MaterialStateProperty.resolveWith((states) { + return Theme.of(context).colorScheme.onInverseSurface; + }), + ), + onPressed: () => callback?.call(), + icon: Icon( + Icons.person_rounded, + size: 22, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } +} + +class CustomTabs extends StatefulWidget { + const CustomTabs({super.key}); + + @override + State createState() => _CustomTabsState(); +} + +class _CustomTabsState extends State { + final HomeController _homeController = Get.put(HomeController()); + + void onTap(int index) { + feedBack(); + if (_homeController.initialIndex.value == index) { + _homeController.tabsCtrList[index]().animateToTop(); + } + _homeController.initialIndex.value = index; + _homeController.tabController.index = index; + } + + @override + Widget build(BuildContext context) { + return Container( + height: 44, + margin: const EdgeInsets.only(top: 4), + child: Obx( + () => ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 14.0), + scrollDirection: Axis.horizontal, + itemCount: _homeController.tabs.length, + separatorBuilder: (BuildContext context, int index) { + return const SizedBox(width: 10); + }, + itemBuilder: (BuildContext context, int index) { + String label = _homeController.tabs[index]['label']; + return Obx( + () => CustomChip( + onTap: () => onTap(index), + label: label, + selected: index == _homeController.initialIndex.value, + ), + ); + }, + ), + ), + ); + } +} + +class CustomChip extends StatelessWidget { + final Function onTap; + final String label; + final bool selected; + const CustomChip({ + super.key, + required this.onTap, + required this.label, + required this.selected, + }); + + @override + Widget build(BuildContext context) { + final ColorScheme colorTheme = Theme.of(context).colorScheme; + final Color secondaryContainer = colorTheme.secondaryContainer; + final TextStyle chipTextStyle = selected + ? const TextStyle(fontWeight: FontWeight.bold, fontSize: 13) + : const TextStyle(fontSize: 13); + final ColorScheme colorScheme = Theme.of(context).colorScheme; + const VisualDensity visualDensity = + VisualDensity(horizontal: -4.0, vertical: -2.0); + return InputChip( + side: BorderSide( + color: selected + ? colorScheme.onSecondaryContainer.withOpacity(0.2) + : Colors.transparent, + ), + backgroundColor: secondaryContainer, + selectedColor: secondaryContainer, + color: MaterialStateProperty.resolveWith( + (Set states) => secondaryContainer.withAlpha(200)), + padding: const EdgeInsets.fromLTRB(7, 1, 7, 1), + label: Text(label, style: chipTextStyle), + onPressed: () => onTap(), + selected: selected, + showCheckmark: false, + visualDensity: visualDensity, + ); + } +} + +class SearchBar extends StatelessWidget { + const SearchBar({ + Key? key, + required this.ctr, + }) : super(key: key); + + final HomeController? ctr; + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + return Expanded( + child: Container( + width: 250, + height: 44, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(25), + ), + child: Material( + color: colorScheme.onSecondaryContainer.withOpacity(0.05), + child: InkWell( + splashColor: colorScheme.primaryContainer.withOpacity(0.3), + onTap: () => Get.toNamed( + '/search', + parameters: {'hintText': ctr!.defaultSearch.value}, + ), + child: Row( + children: [ + const SizedBox(width: 14), + Icon( + Icons.search_outlined, + color: colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 10), + Obx( + () => Expanded( + child: Text( + ctr!.defaultSearch.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: colorScheme.outline), + ), + ), + ), + const SizedBox(width: 15), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/hot/controller.dart b/lib/pages/hot/controller.dart index 65706c32..85072475 100644 --- a/lib/pages/hot/controller.dart +++ b/lib/pages/hot/controller.dart @@ -7,7 +7,7 @@ class HotController extends GetxController { final ScrollController scrollController = ScrollController(); final int _count = 20; int _currentPage = 1; - RxList videoList = [HotVideoItemModel()].obs; + RxList videoList = [].obs; bool isLoadingMore = false; bool flag = false; OverlayEntry? popupDialog; diff --git a/lib/pages/hot/view.dart b/lib/pages/hot/view.dart index 4b098063..7a0a57ea 100644 --- a/lib/pages/hot/view.dart +++ b/lib/pages/hot/view.dart @@ -70,67 +70,70 @@ class _HotPageState extends State with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); - return Scaffold( - body: RefreshIndicator( - onRefresh: () async { - return await _hotController.onRefresh(); - }, - child: CustomScrollView( - controller: _hotController.scrollController, - slivers: [ - SliverPadding( - // 单列布局 EdgeInsets.zero - padding: - const EdgeInsets.fromLTRB(0, StyleString.safeSpace - 5, 0, 0), - sliver: FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data as Map; - if (data['status']) { - return Obx( - () => SliverList( - delegate: - SliverChildBuilderDelegate((context, index) { - return VideoCardH( - videoItem: _hotController.videoList[index], - longPress: () { - _hotController.popupDialog = _createPopupDialog( - _hotController.videoList[index]); - Overlay.of(context) - .insert(_hotController.popupDialog!); - }, - longPressEnd: () { - _hotController.popupDialog?.remove(); - }, - ); - }, childCount: _hotController.videoList.length), - ), - ); - } else { - return HttpError( - errMsg: data['msg'], - fn: () => setState(() {}), - ); - } + return RefreshIndicator( + onRefresh: () async { + return await _hotController.onRefresh(); + }, + child: CustomScrollView( + controller: _hotController.scrollController, + slivers: [ + SliverPadding( + // 单列布局 EdgeInsets.zero + padding: + const EdgeInsets.fromLTRB(0, StyleString.safeSpace - 5, 0, 0), + sliver: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + return Obx( + () => SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return VideoCardH( + videoItem: _hotController.videoList[index], + showPubdate: true, + longPress: () { + _hotController.popupDialog = _createPopupDialog( + _hotController.videoList[index]); + Overlay.of(context) + .insert(_hotController.popupDialog!); + }, + longPressEnd: () { + _hotController.popupDialog?.remove(); + }, + ); + }, childCount: _hotController.videoList.length), + ), + ); } else { - // 骨架屏 - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - return const VideoCardHSkeleton(); - }, childCount: 10), + return HttpError( + errMsg: data['msg'], + fn: () { + setState(() { + _futureBuilderFuture = + _hotController.queryHotFeed('init'); + }); + }, ); } - }, - ), + } else { + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoCardHSkeleton(); + }, childCount: 10), + ); + } + }, ), - SliverToBoxAdapter( - child: SizedBox( - height: MediaQuery.of(context).padding.bottom + 10, - ), - ) - ], - ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).padding.bottom + 10, + ), + ) + ], ), ); } diff --git a/lib/pages/html/controller.dart b/lib/pages/html/controller.dart index f3187828..1175ce29 100644 --- a/lib/pages/html/controller.dart +++ b/lib/pages/html/controller.dart @@ -96,9 +96,6 @@ class HtmlRenderController extends GetxController { _sortType = ReplySortType.like; break; case ReplySortType.like: - _sortType = ReplySortType.reply; - break; - case ReplySortType.reply: _sortType = ReplySortType.time; break; default: diff --git a/lib/pages/html/view.dart b/lib/pages/html/view.dart index 478626af..9f0c865c 100644 --- a/lib/pages/html/view.dart +++ b/lib/pages/html/view.dart @@ -10,8 +10,8 @@ import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/pages/video/detail/reply/widgets/reply_item.dart'; -import 'package:pilipala/pages/video/detail/replyNew/index.dart'; -import 'package:pilipala/pages/video/detail/replyReply/index.dart'; +import 'package:pilipala/pages/video/detail/reply_new/index.dart'; +import 'package:pilipala/pages/video/detail/reply_reply/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'controller.dart'; diff --git a/lib/pages/live/controller.dart b/lib/pages/live/controller.dart index 6a26f0d2..74fb6e9a 100644 --- a/lib/pages/live/controller.dart +++ b/lib/pages/live/controller.dart @@ -10,8 +10,7 @@ class LiveController extends GetxController { int count = 12; int _currentPage = 1; RxInt crossAxisCount = 2.obs; - RxList liveList = [LiveItemModel()].obs; - bool isLoadingMore = false; + RxList liveList = [].obs; bool flag = false; OverlayEntry? popupDialog; Box setting = GStrorage.setting; @@ -39,7 +38,6 @@ class LiveController extends GetxController { } _currentPage += 1; } - isLoadingMore = false; return res; } diff --git a/lib/pages/live/view.dart b/lib/pages/live/view.dart index 302d226d..f3f91c9e 100644 --- a/lib/pages/live/view.dart +++ b/lib/pages/live/view.dart @@ -11,7 +11,6 @@ import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/common/widgets/overlay_pop.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/main/index.dart'; -import 'package:pilipala/pages/rcmd/index.dart'; import 'controller.dart'; import 'widgets/live_item.dart'; @@ -45,8 +44,8 @@ class _LivePageState extends State () { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { - EasyThrottle.throttle('liveList', const Duration(seconds: 1), () { - _liveController.isLoadingMore = true; + EasyThrottle.throttle('liveList', const Duration(milliseconds: 200), + () { _liveController.onLoad(); }); } @@ -108,24 +107,20 @@ class _LivePageState extends State } else { return HttpError( errMsg: data['msg'], - fn: () => {}, + fn: () { + setState(() { + _futureBuilderFuture = + _liveController.queryLiveList('init'); + }); + }, ); } } else { - // 缓存数据 - if (_liveController.liveList.length > 1) { - return contentGrid( - _liveController, _liveController.liveList); - } - // 骨架屏 - else { - return contentGrid(_liveController, []); - } + return contentGrid(_liveController, []); } }, ), ), - LoadingMore(ctr: _liveController) ], ), ), @@ -162,8 +157,9 @@ class _LivePageState extends State crossAxisCount: crossAxisCount, mainAxisExtent: Get.size.width / crossAxisCount / StyleString.aspectRatio + - (crossAxisCount == 1 ? 48 : 68) * - MediaQuery.of(context).textScaleFactor, + MediaQuery.textScalerOf(context).scale( + (crossAxisCount == 1 ? 48 : 68), + ), ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { diff --git a/lib/pages/live/widgets/live_item.dart b/lib/pages/live/widgets/live_item.dart index 8fa797fb..9218d4fb 100644 --- a/lib/pages/live/widgets/live_item.dart +++ b/lib/pages/live/widgets/live_item.dart @@ -184,18 +184,32 @@ class VideoStat extends StatelessWidget { tileMode: TileMode.mirror, ), ), - child: RichText( - maxLines: 1, - textAlign: TextAlign.justify, - softWrap: false, - text: TextSpan( - style: const TextStyle(fontSize: 11, color: Colors.white), - children: [ - TextSpan(text: liveItem!.areaName!), - TextSpan(text: liveItem!.watchedShow!['text_small']), - ], - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + liveItem!.areaName!, + style: const TextStyle(fontSize: 11, color: Colors.white), + ), + Text( + liveItem!.watchedShow!['text_small'], + style: const TextStyle(fontSize: 11, color: Colors.white), + ), + ], ), + + // child: RichText( + // maxLines: 1, + // textAlign: TextAlign.justify, + // softWrap: false, + // text: TextSpan( + // style: const TextStyle(fontSize: 11, color: Colors.white), + // children: [ + // TextSpan(text: liveItem!.areaName!), + // TextSpan(text: liveItem!.watchedShow!['text_small']), + // ], + // ), + // ), ); } } diff --git a/lib/pages/liveRoom/controller.dart b/lib/pages/liveRoom/controller.dart deleted file mode 100644 index 2f489fec..00000000 --- a/lib/pages/liveRoom/controller.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:get/get.dart'; -import 'package:pilipala/http/constants.dart'; -import 'package:pilipala/http/live.dart'; -import 'package:pilipala/models/live/room_info.dart'; -import 'package:pilipala/plugin/pl_player/index.dart'; - -class LiveRoomController extends GetxController { - String cover = ''; - late int roomId; - dynamic liveItem; - late String heroTag; - double volume = 0.0; - // 静音状态 - RxBool volumeOff = false.obs; - PlPlayerController plPlayerController = - PlPlayerController.getInstance(videoType: 'live'); - - // MeeduPlayerController meeduPlayerController = MeeduPlayerController( - // colorTheme: Theme.of(Get.context!).colorScheme.primary, - // pipEnabled: true, - // controlsStyle: ControlsStyle.live, - // enabledButtons: const EnabledButtons(pip: true), - // ); - - @override - void onInit() { - super.onInit(); - roomId = int.parse(Get.parameters['roomid']!); - if (Get.arguments != null) { - liveItem = Get.arguments['liveItem']; - heroTag = Get.arguments['heroTag'] ?? ''; - if (liveItem != null && liveItem.pic != null && liveItem.pic != '') { - cover = liveItem.pic; - } - if (liveItem != null && liveItem.cover != null && liveItem.cover != '') { - cover = liveItem.cover; - } - } - queryLiveInfo(); - } - - playerInit(source) { - plPlayerController.setDataSource( - DataSource( - videoSource: source, - audioSource: null, - type: DataSourceType.network, - httpHeaders: { - 'user-agent': - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15', - 'referer': HttpString.baseUrl - }, - ), - // 硬解 - enableHA: true, - autoplay: true, - ); - } - - Future queryLiveInfo() async { - var res = await LiveHttp.liveRoomInfo(roomId: roomId, qn: 10000); - if (res['status']) { - List codec = - res['data'].playurlInfo.playurl.stream.first.format.first.codec; - CodecItem item = codec.first; - String videoUrl = (item.urlInfo?.first.host)! + - item.baseUrl! + - item.urlInfo!.first.extra!; - playerInit(videoUrl); - } - } - - void setVolumn(value) { - if (value == 0) { - // 设置音量 - volumeOff.value = false; - } else { - // 取消音量 - volume = value; - volumeOff.value = true; - } - } -} diff --git a/lib/pages/liveRoom/view.dart b/lib/pages/liveRoom/view.dart deleted file mode 100644 index 125460b9..00000000 --- a/lib/pages/liveRoom/view.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'dart:io'; - -import 'package:floating/floating.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/plugin/pl_player/index.dart'; - -import 'controller.dart'; -import 'widgets/bottom_control.dart'; - -class LiveRoomPage extends StatefulWidget { - const LiveRoomPage({super.key}); - - @override - State createState() => _LiveRoomPageState(); -} - -class _LiveRoomPageState extends State { - final LiveRoomController _liveRoomController = Get.put(LiveRoomController()); - PlPlayerController? plPlayerController; - - bool isShowCover = true; - bool isPlay = true; - Floating? floating; - - @override - void initState() { - super.initState(); - plPlayerController = _liveRoomController.plPlayerController; - plPlayerController!.onPlayerStatusChanged.listen( - (PlayerStatus status) { - if (status == PlayerStatus.playing) { - isShowCover = false; - setState(() {}); - } - }, - ); - if (Platform.isAndroid) { - floating = Floating(); - } - } - - @override - void dispose() { - plPlayerController!.dispose(); - if (floating != null) { - floating!.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - Widget childWhenDisabled = Scaffold( - primary: true, - appBar: AppBar( - centerTitle: false, - titleSpacing: 0, - title: _liveRoomController.liveItem != null - ? Row( - children: [ - NetworkImgLayer( - width: 34, - height: 34, - type: 'avatar', - src: _liveRoomController.liveItem.face, - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _liveRoomController.liveItem.uname, - style: const TextStyle(fontSize: 14), - ), - const SizedBox(height: 1), - if (_liveRoomController.liveItem.watchedShow != null) - Text( - _liveRoomController - .liveItem.watchedShow['text_large'] ?? - '', - style: const TextStyle(fontSize: 12)), - ], - ), - ], - ) - : const SizedBox(), - // actions: [ - // SizedBox( - // height: 34, - // child: ElevatedButton(onPressed: () {}, child: const Text('关注')), - // ), - // const SizedBox(width: 12), - // ], - ), - body: Column( - children: [ - Stack( - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: plPlayerController!.videoPlayerController != null - ? PLVideoPlayer( - controller: plPlayerController!, - bottomControl: BottomControl( - controller: plPlayerController, - liveRoomCtr: _liveRoomController, - floating: floating, - ), - ) - : const SizedBox(), - ), - // if (_liveRoomController.liveItem != null && - // _liveRoomController.liveItem.cover != null) - // Visibility( - // visible: isShowCover, - // child: Positioned( - // top: 0, - // left: 0, - // right: 0, - // child: NetworkImgLayer( - // type: 'emote', - // src: _liveRoomController.liveItem.cover, - // width: Get.size.width, - // height: videoHeight, - // ), - // ), - // ), - ], - ), - ], - ), - ); - Widget childWhenEnabled = AspectRatio( - aspectRatio: 16 / 9, - child: plPlayerController!.videoPlayerController != null - ? PLVideoPlayer( - controller: plPlayerController!, - bottomControl: BottomControl( - controller: plPlayerController, - liveRoomCtr: _liveRoomController, - ), - ) - : const SizedBox(), - ); - if (Platform.isAndroid) { - return PiPSwitcher( - childWhenDisabled: childWhenDisabled, - childWhenEnabled: childWhenEnabled, - ); - } else { - return childWhenDisabled; - } - } -} diff --git a/lib/pages/live_room/controller.dart b/lib/pages/live_room/controller.dart new file mode 100644 index 00000000..5c2a9800 --- /dev/null +++ b/lib/pages/live_room/controller.dart @@ -0,0 +1,131 @@ +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/constants.dart'; +import 'package:pilipala/http/live.dart'; +import 'package:pilipala/models/live/quality.dart'; +import 'package:pilipala/models/live/room_info.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; +import '../../models/live/room_info_h5.dart'; +import '../../utils/storage.dart'; +import '../../utils/video_utils.dart'; + +class LiveRoomController extends GetxController { + String cover = ''; + late int roomId; + dynamic liveItem; + late String heroTag; + double volume = 0.0; + // 静音状态 + RxBool volumeOff = false.obs; + PlPlayerController plPlayerController = + PlPlayerController.getInstance(videoType: 'live'); + Rx roomInfoH5 = RoomInfoH5Model().obs; + late bool enableCDN; + late int currentQn; + int? tempCurrentQn; + late List> acceptQnList; + RxString currentQnDesc = ''.obs; + + @override + void onInit() { + super.onInit(); + currentQn = setting.get(SettingBoxKey.defaultLiveQa, + defaultValue: LiveQuality.values.last.code); + roomId = int.parse(Get.parameters['roomid']!); + if (Get.arguments != null) { + liveItem = Get.arguments['liveItem']; + heroTag = Get.arguments['heroTag'] ?? ''; + if (liveItem != null && liveItem.pic != null && liveItem.pic != '') { + cover = liveItem.pic; + } + if (liveItem != null && liveItem.cover != null && liveItem.cover != '') { + cover = liveItem.cover; + } + } + // CDN优化 + enableCDN = setting.get(SettingBoxKey.enableCDN, defaultValue: true); + } + + playerInit(source) async { + await plPlayerController.setDataSource( + DataSource( + videoSource: source, + audioSource: null, + type: DataSourceType.network, + httpHeaders: { + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Safari/605.1.15', + 'referer': HttpString.baseUrl + }, + ), + // 硬解 + enableHA: true, + autoplay: true, + ); + } + + Future queryLiveInfo() async { + var res = await LiveHttp.liveRoomInfo(roomId: roomId, qn: currentQn); + if (res['status']) { + List codec = + res['data'].playurlInfo.playurl.stream.first.format.first.codec; + CodecItem item = codec.first; + // 以服务端返回的码率为准 + currentQn = item.currentQn!; + if (tempCurrentQn != null && tempCurrentQn == currentQn) { + SmartDialog.showToast('画质切换失败,请检查登录状态'); + } + List acceptQn = item.acceptQn!; + acceptQnList = acceptQn.map((e) { + return { + 'code': e, + 'desc': LiveQuality.values + .firstWhere((element) => element.code == e) + .description, + }; + }).toList(); + currentQnDesc.value = LiveQuality.values + .firstWhere((element) => element.code == currentQn) + .description; + String videoUrl = enableCDN + ? VideoUtils.getCdnUrl(item) + : (item.urlInfo?.first.host)! + + item.baseUrl! + + item.urlInfo!.first.extra!; + await playerInit(videoUrl); + return res; + } + } + + void setVolumn(value) { + if (value == 0) { + // 设置音量 + volumeOff.value = false; + } else { + // 取消音量 + volume = value; + volumeOff.value = true; + } + } + + Future queryLiveInfoH5() async { + var res = await LiveHttp.liveRoomInfoH5(roomId: roomId); + if (res['status']) { + roomInfoH5.value = res['data']; + } + return res; + } + + // 修改画质 + void changeQn(int qn) async { + tempCurrentQn = currentQn; + if (currentQn == qn) { + return; + } + currentQn = qn; + currentQnDesc.value = LiveQuality.values + .firstWhere((element) => element.code == currentQn) + .description; + await queryLiveInfo(); + } +} diff --git a/lib/pages/liveRoom/index.dart b/lib/pages/live_room/index.dart similarity index 100% rename from lib/pages/liveRoom/index.dart rename to lib/pages/live_room/index.dart diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart new file mode 100644 index 00000000..1e5c29c5 --- /dev/null +++ b/lib/pages/live_room/view.dart @@ -0,0 +1,211 @@ +import 'dart:io'; + +import 'package:floating/floating.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/plugin/pl_player/index.dart'; + +import 'controller.dart'; +import 'widgets/bottom_control.dart'; + +class LiveRoomPage extends StatefulWidget { + const LiveRoomPage({super.key}); + + @override + State createState() => _LiveRoomPageState(); +} + +class _LiveRoomPageState extends State { + final LiveRoomController _liveRoomController = Get.put(LiveRoomController()); + PlPlayerController? plPlayerController; + late Future? _futureBuilder; + late Future? _futureBuilderFuture; + + bool isShowCover = true; + bool isPlay = true; + Floating? floating; + + @override + void initState() { + super.initState(); + if (Platform.isAndroid) { + floating = Floating(); + } + videoSourceInit(); + _futureBuilderFuture = _liveRoomController.queryLiveInfo(); + } + + Future videoSourceInit() async { + _futureBuilder = _liveRoomController.queryLiveInfoH5(); + plPlayerController = _liveRoomController.plPlayerController; + } + + @override + void dispose() { + plPlayerController!.dispose(); + if (floating != null) { + floating!.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget videoPlayerPanel = FutureBuilder( + future: _futureBuilderFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData && snapshot.data['status']) { + return PLVideoPlayer( + controller: plPlayerController!, + bottomControl: BottomControl( + controller: plPlayerController, + liveRoomCtr: _liveRoomController, + floating: floating, + ), + ); + } else { + return const SizedBox(); + } + }, + ); + + Widget childWhenDisabled = Scaffold( + primary: true, + backgroundColor: Colors.black, + body: Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Opacity( + opacity: 0.8, + child: Image.asset( + 'assets/images/live/default_bg.webp', + fit: BoxFit.cover, + // width: Get.width, + // height: Get.height, + ), + ), + ), + Obx( + () => Positioned( + left: 0, + right: 0, + bottom: 0, + child: _liveRoomController + .roomInfoH5.value.roomInfo?.appBackground != + '' && + _liveRoomController + .roomInfoH5.value.roomInfo?.appBackground != + null + ? Opacity( + opacity: 0.8, + child: NetworkImgLayer( + width: Get.width, + height: Get.height, + type: 'bg', + src: _liveRoomController + .roomInfoH5.value.roomInfo?.appBackground ?? + '', + ), + ) + : const SizedBox(), + ), + ), + Column( + children: [ + AppBar( + centerTitle: false, + titleSpacing: 0, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + toolbarHeight: + MediaQuery.of(context).orientation == Orientation.portrait + ? 56 + : 0, + title: FutureBuilder( + future: _futureBuilder, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const SizedBox(); + } + Map data = snapshot.data as Map; + if (data['status']) { + return Obx( + () => Row( + children: [ + NetworkImgLayer( + width: 34, + height: 34, + type: 'avatar', + src: _liveRoomController + .roomInfoH5.value.anchorInfo!.baseInfo!.face, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _liveRoomController.roomInfoH5.value + .anchorInfo!.baseInfo!.uname!, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 1), + if (_liveRoomController + .roomInfoH5.value.watchedShow != + null) + Text( + _liveRoomController.roomInfoH5.value + .watchedShow!['text_large'] ?? + '', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ], + ), + ); + } else { + return const SizedBox(); + } + }, + ), + ), + PopScope( + canPop: plPlayerController?.isFullScreen.value != true, + onPopInvoked: (bool didPop) { + if (plPlayerController?.isFullScreen.value == true) { + plPlayerController!.triggerFullScreen(status: false); + } + if (MediaQuery.of(context).orientation == + Orientation.landscape) { + verticalScreen(); + } + }, + child: SizedBox( + width: Get.size.width, + height: MediaQuery.of(context).orientation == + Orientation.landscape + ? Get.size.height + : Get.size.width * 9 / 16, + child: videoPlayerPanel, + ), + ), + ], + ), + ], + ), + ); + if (Platform.isAndroid) { + return PiPSwitcher( + childWhenDisabled: childWhenDisabled, + childWhenEnabled: videoPlayerPanel, + floating: floating, + ); + } else { + return childWhenDisabled; + } + } +} diff --git a/lib/pages/liveRoom/widgets/bottom_control.dart b/lib/pages/live_room/widgets/bottom_control.dart similarity index 81% rename from lib/pages/liveRoom/widgets/bottom_control.dart rename to lib/pages/live_room/widgets/bottom_control.dart index 7347a8fc..3c908d71 100644 --- a/lib/pages/liveRoom/widgets/bottom_control.dart +++ b/lib/pages/live_room/widgets/bottom_control.dart @@ -3,9 +3,10 @@ import 'dart:io'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/models/video/play/url.dart'; -import 'package:pilipala/pages/liveRoom/index.dart'; +import 'package:pilipala/pages/live_room/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'package:pilipala/utils/storage.dart'; @@ -29,7 +30,6 @@ class BottomControl extends StatefulWidget implements PreferredSizeWidget { class _BottomControlState extends State { late PlayUrlModel videoInfo; - List playSpeed = PlaySpeed.values; TextStyle subTitleStyle = const TextStyle(fontSize: 12); TextStyle titleStyle = const TextStyle(fontSize: 14); Size get preferredSize => const Size(double.infinity, kToolbarHeight); @@ -84,6 +84,30 @@ class _BottomControlState extends State { // ), // ), // const SizedBox(width: 4), + SizedBox( + width: 30, + child: PopupMenuButton( + padding: EdgeInsets.zero, + onSelected: (value) { + widget.liveRoomCtr!.changeQn(value); + }, + child: Obx( + () => Text( + widget.liveRoomCtr!.currentQnDesc.value, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + itemBuilder: (BuildContext context) { + return widget.liveRoomCtr!.acceptQnList.map((e) { + return PopupMenuItem( + value: e['code'], + child: Text(e['desc']), + ); + }).toList(); + }, + ), + ), + const SizedBox(width: 10), if (Platform.isAndroid) ...[ SizedBox( width: 34, @@ -111,7 +135,7 @@ class _BottomControlState extends State { ), ), ), - const SizedBox(width: 4), + const SizedBox(width: 10), ], ComBtn( icon: const Icon( diff --git a/lib/pages/main/controller.dart b/lib/pages/main/controller.dart index a322c5f3..ddbd364a 100644 --- a/lib/pages/main/controller.dart +++ b/lib/pages/main/controller.dart @@ -1,61 +1,38 @@ import 'dart:async'; +import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import 'package:pilipala/http/common.dart'; import 'package:pilipala/pages/dynamics/index.dart'; import 'package:pilipala/pages/home/view.dart'; import 'package:pilipala/pages/media/index.dart'; +import 'package:pilipala/pages/rank/index.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; +import '../../models/common/dynamic_badge_mode.dart'; +import '../../models/common/nav_bar_config.dart'; class MainController extends GetxController { List pages = [ const HomePage(), + const RankPage(), const DynamicsPage(), const MediaPage(), ]; - RxList navigationBars = [ - { - 'icon': const Icon( - Icons.favorite_outline, - size: 21, - ), - 'selectIcon': const Icon( - Icons.favorite, - size: 21, - ), - 'label': "首页", - }, - { - 'icon': const Icon( - Icons.motion_photos_on_outlined, - size: 21, - ), - 'selectIcon': const Icon( - Icons.motion_photos_on, - size: 21, - ), - 'label': "动态", - }, - { - 'icon': const Icon( - Icons.folder_outlined, - size: 20, - ), - 'selectIcon': const Icon( - Icons.folder, - size: 21, - ), - 'label': "媒体库", - } - ].obs; + RxList navigationBars = defaultNavigationBars.obs; final StreamController bottomBarStream = StreamController.broadcast(); Box setting = GStrorage.setting; DateTime? _lastPressedAt; late bool hideTabBar; + late PageController pageController; + int selectedIndex = 0; + Box userInfoCache = GStrorage.userInfo; + RxBool userLogin = false.obs; + late Rx dynamicBadgeType = DynamicBadgeMode.number.obs; @override void onInit() { @@ -64,17 +41,56 @@ class MainController extends GetxController { Utils.checkUpdata(); } hideTabBar = setting.get(SettingBoxKey.hideTabBar, defaultValue: true); + int defaultHomePage = + setting.get(SettingBoxKey.defaultHomePage, defaultValue: 0) as int; + selectedIndex = defaultNavigationBars + .indexWhere((item) => item['id'] == defaultHomePage); + var userInfo = userInfoCache.get('userInfoCache'); + userLogin.value = userInfo != null; + dynamicBadgeType.value = DynamicBadgeMode.values[setting.get( + SettingBoxKey.dynamicBadgeMode, + defaultValue: DynamicBadgeMode.number.code)]; + if (dynamicBadgeType.value != DynamicBadgeMode.hidden) { + getUnreadDynamic(); + } } - Future onBackPressed(BuildContext context) { + void onBackPressed(BuildContext context) { if (_lastPressedAt == null || DateTime.now().difference(_lastPressedAt!) > const Duration(seconds: 2)) { // 两次点击时间间隔超过2秒,重新记录时间戳 _lastPressedAt = DateTime.now(); + if (selectedIndex != 0) { + pageController.jumpTo(0); + } SmartDialog.showToast("再按一次退出Pili"); - return Future.value(false); // 不退出应用 + return; // 不退出应用 } - return Future.value(true); // 退出应用 + SystemNavigator.pop(); // 退出应用 + } + + void getUnreadDynamic() async { + if (!userLogin.value) { + return; + } + int dynamicItemIndex = + navigationBars.indexWhere((item) => item['label'] == "动态"); + var res = await CommonHttp.unReadDynamic(); + var data = res['data']; + if (dynamicItemIndex != -1) { + navigationBars[dynamicItemIndex]['count'] = + data == null ? 0 : data.length; // 修改 count 属性为新的值 + } + navigationBars.refresh(); + } + + void clearUnread() async { + int dynamicItemIndex = + navigationBars.indexWhere((item) => item['label'] == "动态"); + if (dynamicItemIndex != -1) { + navigationBars[dynamicItemIndex]['count'] = 0; // 修改 count 属性为新的值 + } + navigationBars.refresh(); } } diff --git a/lib/pages/main/view.dart b/lib/pages/main/view.dart index fef5620d..c551e690 100644 --- a/lib/pages/main/view.dart +++ b/lib/pages/main/view.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:pilipala/models/common/dynamic_badge_mode.dart'; import 'package:pilipala/pages/dynamics/index.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/media/index.dart'; +import 'package:pilipala/pages/rank/index.dart'; import 'package:pilipala/utils/event_bus.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; @@ -21,15 +23,10 @@ class MainApp extends StatefulWidget { class _MainAppState extends State with SingleTickerProviderStateMixin { final MainController _mainController = Get.put(MainController()); final HomeController _homeController = Get.put(HomeController()); + final RankController _rankController = Get.put(RankController()); final DynamicsController _dynamicController = Get.put(DynamicsController()); final MediaController _mediaController = Get.put(MediaController()); - PageController? _pageController; - - late AnimationController? _animationController; - late Animation? _fadeAnimation; - late Animation? _slideAnimation; - int selectedIndex = 0; int? _lastSelectTime; //上次点击时间 Box setting = GStrorage.setting; late bool enableMYBar; @@ -37,32 +34,15 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { @override void initState() { super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 800), - reverseDuration: const Duration(milliseconds: 0), - value: 1, - vsync: this, - ); - _fadeAnimation = - Tween(begin: 0.8, end: 1.0).animate(_animationController!); - _slideAnimation = - Tween(begin: 0.8, end: 1.0).animate(_animationController!); _lastSelectTime = DateTime.now().millisecondsSinceEpoch; - _pageController = PageController(initialPage: selectedIndex); + _mainController.pageController = + PageController(initialPage: _mainController.selectedIndex); enableMYBar = setting.get(SettingBoxKey.enableMYBar, defaultValue: true); } void setIndex(int value) async { feedBack(); - if (selectedIndex != value) { - selectedIndex = value; - _animationController!.reverse().then((_) { - selectedIndex = value; - _animationController!.forward(); - }); - setState(() {}); - } - _pageController!.jumpToPage(value); + _mainController.pageController.jumpToPage(value); var currentPage = _mainController.pages[value]; if (currentPage is HomePage) { if (_homeController.flag) { @@ -79,6 +59,21 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { _homeController.flag = false; } + if (currentPage is RankPage) { + if (_rankController.flag) { + // 单击返回顶部 双击并刷新 + if (DateTime.now().millisecondsSinceEpoch - _lastSelectTime! < 500) { + _rankController.onRefresh(); + } else { + _rankController.animateToTop(); + } + _lastSelectTime = DateTime.now().millisecondsSinceEpoch; + } + _rankController.flag = true; + } else { + _rankController.flag = false; + } + if (currentPage is DynamicsPage) { if (_dynamicController.flag) { // 单击返回顶部 双击并刷新 @@ -90,6 +85,7 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { _lastSelectTime = DateTime.now().millisecondsSinceEpoch; } _dynamicController.flag = true; + _mainController.clearUnread(); } else { _dynamicController.flag = false; } @@ -110,38 +106,26 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { Widget build(BuildContext context) { Box localCache = GStrorage.localCache; double statusBarHeight = MediaQuery.of(context).padding.top; - double sheetHeight = MediaQuery.of(context).size.height - + double sheetHeight = MediaQuery.sizeOf(context).height - MediaQuery.of(context).padding.top - - MediaQuery.of(context).size.width * 9 / 16; + MediaQuery.sizeOf(context).width * 9 / 16; localCache.put('sheetHeight', sheetHeight); localCache.put('statusBarHeight', statusBarHeight); return PopScope( - onPopInvoked: (bool status) => _mainController.onBackPressed(context), + canPop: false, + onPopInvoked: (bool didPop) async { + _mainController.onBackPressed(context); + }, child: Scaffold( extendBody: true, - body: FadeTransition( - opacity: _fadeAnimation!, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.5), - end: Offset.zero, - ).animate( - CurvedAnimation( - parent: _slideAnimation!, - curve: Curves.fastOutSlowIn, - reverseCurve: Curves.linear, - ), - ), - child: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: _pageController, - onPageChanged: (index) { - selectedIndex = index; - setState(() {}); - }, - children: _mainController.pages, - ), - ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: _mainController.pageController, + onPageChanged: (index) { + _mainController.selectedIndex = index; + setState(() {}); + }, + children: _mainController.pages, ), bottomNavigationBar: StreamBuilder( stream: _mainController.hideTabBar @@ -153,36 +137,68 @@ class _MainAppState extends State with SingleTickerProviderStateMixin { curve: Curves.easeInOutCubicEmphasized, duration: const Duration(milliseconds: 500), offset: Offset(0, snapshot.data ? 0 : 1), - child: enableMYBar - ? NavigationBar( - onDestinationSelected: (value) => setIndex(value), - selectedIndex: selectedIndex, - destinations: [ - ..._mainController.navigationBars.map((e) { - return NavigationDestination( - icon: e['icon'], - selectedIcon: e['selectIcon'], - label: e['label'], - ); - }).toList(), - ], - ) - : BottomNavigationBar( - currentIndex: selectedIndex, - onTap: (value) => setIndex(value), - iconSize: 16, - selectedFontSize: 12, - unselectedFontSize: 12, - items: [ - ..._mainController.navigationBars.map((e) { - return BottomNavigationBarItem( - icon: e['icon'], - activeIcon: e['selectIcon'], - label: e['label'], - ); - }).toList(), - ], - ), + child: Obx( + () => enableMYBar + ? NavigationBar( + onDestinationSelected: (value) => setIndex(value), + selectedIndex: _mainController.selectedIndex, + destinations: [ + ..._mainController.navigationBars.map((e) { + return NavigationDestination( + icon: Obx( + () => Badge( + label: + _mainController.dynamicBadgeType.value == + DynamicBadgeMode.number + ? Text(e['count'].toString()) + : null, + padding: + const EdgeInsets.fromLTRB(6, 0, 6, 0), + isLabelVisible: + _mainController.dynamicBadgeType.value != + DynamicBadgeMode.hidden && + e['count'] > 0, + child: e['icon'], + ), + ), + selectedIcon: e['selectIcon'], + label: e['label'], + ); + }).toList(), + ], + ) + : BottomNavigationBar( + currentIndex: _mainController.selectedIndex, + onTap: (value) => setIndex(value), + iconSize: 16, + selectedFontSize: 12, + unselectedFontSize: 12, + items: [ + ..._mainController.navigationBars.map((e) { + return BottomNavigationBarItem( + icon: Obx( + () => Badge( + label: + _mainController.dynamicBadgeType.value == + DynamicBadgeMode.number + ? Text(e['count'].toString()) + : null, + padding: + const EdgeInsets.fromLTRB(6, 0, 6, 0), + isLabelVisible: + _mainController.dynamicBadgeType.value != + DynamicBadgeMode.hidden && + e['count'] > 0, + child: e['icon'], + ), + ), + activeIcon: e['selectIcon'], + label: e['label'], + ); + }).toList(), + ], + ), + ), ); }, ), diff --git a/lib/pages/media/controller.dart b/lib/pages/media/controller.dart index 8b875511..757d5ac9 100644 --- a/lib/pages/media/controller.dart +++ b/lib/pages/media/controller.dart @@ -28,6 +28,11 @@ class MediaController extends GetxController { 'title': '我的收藏', 'onTap': () => Get.toNamed('/fav'), }, + { + 'icon': Icons.subscriptions_outlined, + 'title': '我的订阅', + 'onTap': () => Get.toNamed('/subscription'), + }, { 'icon': Icons.watch_later_outlined, 'title': '稍后再看', diff --git a/lib/pages/media/view.dart b/lib/pages/media/view.dart index 9bfac15c..460c5648 100644 --- a/lib/pages/media/view.dart +++ b/lib/pages/media/view.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:get/get.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/user/fav_folder.dart'; import 'package:pilipala/pages/main/index.dart'; @@ -102,7 +103,11 @@ class _MediaPageState extends State ], Obx(() => mediaController.userLogin.value ? favFolder(mediaController, context) - : const SizedBox()) + : const SizedBox()), + SizedBox( + height: MediaQuery.of(context).padding.bottom + + kBottomNavigationBarHeight, + ) ], ), ), @@ -163,7 +168,7 @@ class _MediaPageState extends State // const SizedBox(height: 10), SizedBox( width: double.infinity, - height: 200 * MediaQuery.of(context).textScaleFactor, + height: MediaQuery.textScalerOf(context).scale(200), child: FutureBuilder( future: _futureBuilderFuture, builder: (context, snapshot) { diff --git a/lib/pages/member/controller.dart b/lib/pages/member/controller.dart index 70169e3d..0aa7166f 100644 --- a/lib/pages/member/controller.dart +++ b/lib/pages/member/controller.dart @@ -20,7 +20,7 @@ class MemberController extends GetxController { Box userInfoCache = GStrorage.userInfo; late int ownerMid; // 投稿列表 - RxList? archiveList = [VListItemModel()].obs; + RxList? archiveList = [].obs; dynamic userInfo; RxInt attribute = (-1).obs; RxString attributeText = '关注'.obs; diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index 785be4ee..c8a9f406 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -41,7 +41,7 @@ class _MemberPageState extends State _memberCoinsFuture = _memberController.getRecentCoinVideo(); _extendNestCtr.addListener( () { - double offset = _extendNestCtr.position.pixels; + final double offset = _extendNestCtr.position.pixels; if (offset > 100) { appbarStream.add(true); } else { @@ -67,7 +67,7 @@ class _MemberPageState extends State title: StreamBuilder( stream: appbarStream.stream, initialData: false, - builder: (context, AsyncSnapshot snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { return AnimatedOpacity( opacity: snapshot.data ? 1 : 0, curve: Curves.easeOut, @@ -105,7 +105,7 @@ class _MemberPageState extends State actions: [ IconButton( onPressed: () => Get.toNamed( - '/memberSearch?mid=${Get.parameters['mid']}&uname=${_memberController.memberInfo.value.name!}'), + '/memberSearch?mid=$mid&uname=${_memberController.memberInfo.value.name!}'), icon: const Icon(Icons.search_outlined), ), PopupMenuButton( @@ -281,8 +281,8 @@ class _MemberPageState extends State future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data!; - if (data['status']) { + Map? data = snapshot.data; + if (data != null && data['status']) { return Obx( () => Stack( alignment: AlignmentDirectional.center, @@ -302,7 +302,14 @@ class _MemberPageState extends State style: Theme.of(context) .textTheme .titleMedium! - .copyWith(fontWeight: FontWeight.bold), + .copyWith( + fontWeight: FontWeight.bold, + color: _memberController.memberInfo.value + .vip!.nicknameColor != + null + ? Color(_memberController.memberInfo + .value.vip!.nicknameColor!) + : null), )), const SizedBox(width: 2), if (_memberController.memberInfo.value.sex == '女') diff --git a/lib/pages/member/widgets/seasons.dart b/lib/pages/member/widgets/seasons.dart index 68c4077f..125c978f 100644 --- a/lib/pages/member/widgets/seasons.dart +++ b/lib/pages/member/widgets/seasons.dart @@ -18,45 +18,32 @@ class MemberSeasonsPanel extends StatelessWidget { itemBuilder: (context, index) { MemberSeasonsList item = data!.seasonsList![index]; return Padding( - padding: const EdgeInsets.only(bottom: 12, right: 4), + padding: const EdgeInsets.only(bottom: 12), child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(bottom: 12, left: 4), - child: Row( - children: [ - Text( - item.meta!.name!, - maxLines: 1, - style: Theme.of(context).textTheme.titleSmall!, - ), - const SizedBox(width: 10), - PBadge( - stack: 'relative', - size: 'small', - text: item.meta!.total.toString(), - ), - const Spacer(), - SizedBox( - width: 35, - height: 35, - child: IconButton( - onPressed: () => Get.toNamed( - '/memberSeasons?mid=${item.meta!.mid}&seasonId=${item.meta!.seasonId}'), - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - ), - icon: const Icon( - Icons.arrow_forward, - size: 20, - ), - ), - ) - ], + ListTile( + onTap: () => Get.toNamed( + '/memberSeasons?mid=${item.meta!.mid}&seasonId=${item.meta!.seasonId}'), + title: Text( + item.meta!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall!, + ), + dense: true, + leading: PBadge( + stack: 'relative', + size: 'small', + text: item.meta!.total.toString(), + ), + trailing: const Icon( + Icons.arrow_forward, + size: 20, ), ), + const SizedBox(height: 10), LayoutBuilder( builder: (context, boxConstraints) { return GridView.builder( diff --git a/lib/pages/member_archive/controller.dart b/lib/pages/member_archive/controller.dart index 785cd764..4c41de4c 100644 --- a/lib/pages/member_archive/controller.dart +++ b/lib/pages/member_archive/controller.dart @@ -25,7 +25,7 @@ class MemberArchiveController extends GetxController { // 获取用户投稿 Future getMemberArchive(type) async { - if (type == 'onRefresh') { + if (type == 'init') { pn = 1; } var res = await MemberHttp.memberArchive( @@ -34,7 +34,12 @@ class MemberArchiveController extends GetxController { order: currentOrder['type']!, ); if (res['status']) { - archivesList.addAll(res['data'].list.vlist); + if (type == 'init') { + archivesList.value = res['data'].list.vlist; + } + if (type == 'onLoad') { + archivesList.addAll(res['data'].list.vlist); + } count = res['data'].page['count']; pn += 1; } @@ -42,13 +47,14 @@ class MemberArchiveController extends GetxController { } toggleSort() async { - pn = 1; - int index = orderList.indexOf(currentOrder); + List typeList = orderList.map((e) => e['type']!).toList(); + int index = typeList.indexOf(currentOrder['type']!); if (index == orderList.length - 1) { currentOrder.value = orderList.first; } else { currentOrder.value = orderList[index + 1]; } + getMemberArchive('init'); } // 上拉加载 diff --git a/lib/pages/member_archive/view.dart b/lib/pages/member_archive/view.dart index dd4c5cba..3103683a 100644 --- a/lib/pages/member_archive/view.dart +++ b/lib/pages/member_archive/view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/video_card_h.dart'; import 'package:pilipala/utils/utils.dart'; +import '../../common/widgets/http_error.dart'; import 'controller.dart'; class MemberArchivePage extends StatefulWidget { @@ -25,8 +26,7 @@ class _MemberArchivePageState extends State { final String heroTag = Utils.makeHeroTag(mid); _memberArchivesController = Get.put(MemberArchiveController(), tag: heroTag); - _futureBuilderFuture = - _memberArchivesController.getMemberArchive('onRefresh'); + _futureBuilderFuture = _memberArchivesController.getMemberArchive('init'); scrollController = _memberArchivesController.scrollController; scrollController.addListener( () { @@ -45,47 +45,26 @@ class _MemberArchivePageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('他的投稿'), - // actions: [ - // Obx( - // () => PopupMenuButton( - // padding: EdgeInsets.zero, - // tooltip: '投稿排序', - // icon: Icon( - // Icons.more_vert_outlined, - // color: Theme.of(context).colorScheme.outline, - // ), - // position: PopupMenuPosition.under, - // onSelected: (String type) {}, - // itemBuilder: (BuildContext context) => >[ - // for (var i in _memberArchivesController.orderList) ...[ - // PopupMenuItem( - // onTap: () {}, - // value: _memberArchivesController.currentOrder['label'], - // child: Row( - // mainAxisSize: MainAxisSize.min, - // children: [ - // Text(i['label']!), - // if (_memberArchivesController.currentOrder['label'] == - // i['label']) ...[ - // const SizedBox(width: 10), - // const Icon(Icons.done, size: 20), - // ], - // ], - // ), - // ), - // ] - // ], - // ), - // ), - // ], + titleSpacing: 0, + centerTitle: false, + title: Text('他的投稿', style: Theme.of(context).textTheme.titleMedium), + actions: [ + Obx( + () => TextButton.icon( + icon: const Icon(Icons.sort, size: 20), + onPressed: _memberArchivesController.toggleSort, + label: Text(_memberArchivesController.currentOrder['label']!), + ), + ), + const SizedBox(width: 6), + ], ), body: CustomScrollView( controller: _memberArchivesController.scrollController, slivers: [ FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.data != null) { Map data = snapshot.data as Map; @@ -95,7 +74,7 @@ class _MemberArchivePageState extends State { () => list.isNotEmpty ? SliverList( delegate: SliverChildBuilderDelegate( - (context, index) { + (BuildContext context, index) { return VideoCardH( videoItem: list[index], showOwner: false, @@ -108,10 +87,16 @@ class _MemberArchivePageState extends State { : const SliverToBoxAdapter(), ); } else { - return const SliverToBoxAdapter(); + return HttpError( + errMsg: snapshot.data['msg'], + fn: () {}, + ); } } else { - return const SliverToBoxAdapter(); + return HttpError( + errMsg: snapshot.data['msg'], + fn: () {}, + ); } } else { return const SliverToBoxAdapter(); diff --git a/lib/pages/member_dynamics/view.dart b/lib/pages/member_dynamics/view.dart index a22c94e1..68aa72d7 100644 --- a/lib/pages/member_dynamics/view.dart +++ b/lib/pages/member_dynamics/view.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:pilipala/pages/member_dynamics/index.dart'; import 'package:pilipala/utils/utils.dart'; +import '../../common/widgets/http_error.dart'; import '../dynamics/widgets/dynamic_panel.dart'; class MemberDynamicsPage extends StatefulWidget { @@ -52,7 +53,9 @@ class _MemberDynamicsPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('他的动态'), + titleSpacing: 0, + centerTitle: false, + title: Text('他的动态', style: Theme.of(context).textTheme.titleMedium), ), body: CustomScrollView( controller: _memberDynamicController.scrollController, @@ -78,10 +81,16 @@ class _MemberDynamicsPageState extends State { : const SliverToBoxAdapter(), ); } else { - return const SliverToBoxAdapter(); + return HttpError( + errMsg: snapshot.data['msg'], + fn: () {}, + ); } } else { - return const SliverToBoxAdapter(); + return HttpError( + errMsg: snapshot.data['msg'], + fn: () {}, + ); } } else { return const SliverToBoxAdapter(); diff --git a/lib/pages/member_seasons/view.dart b/lib/pages/member_seasons/view.dart index 97a43358..06944f10 100644 --- a/lib/pages/member_seasons/view.dart +++ b/lib/pages/member_seasons/view.dart @@ -41,7 +41,9 @@ class _MemberSeasonsPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('他的专栏'), + titleSpacing: 0, + centerTitle: false, + title: Text('他的专栏', style: Theme.of(context).textTheme.titleMedium), ), body: Padding( padding: const EdgeInsets.only( diff --git a/lib/pages/member_seasons/widgets/item.dart b/lib/pages/member_seasons/widgets/item.dart index 157adb66..6398c5eb 100644 --- a/lib/pages/member_seasons/widgets/item.dart +++ b/lib/pages/member_seasons/widgets/item.dart @@ -25,7 +25,7 @@ class MemberSeasonsItem extends StatelessWidget { child: InkWell( onTap: () async { int cid = - await SearchHttp.ab2c(aid: seasonItem.aid, bvid: seasonItem.bvid); + await SearchHttp.ab2c(aid: seasonItem.aid, bvid: seasonItem.bvid); Get.toNamed('/video?bvid=${seasonItem.bvid}&cid=$cid', arguments: {'videoItem': seasonItem, 'heroTag': heroTag}); }, @@ -46,12 +46,13 @@ class MemberSeasonsItem extends StatelessWidget { height: maxHeight, ), ), - if (seasonItem.duration != null) + if (seasonItem.pubdate != null) PBadge( bottom: 6, right: 6, type: 'gray', - text: Utils.timeFormat(seasonItem.duration), + text: Utils.CustomStamp_str( + timestamp: seasonItem.pubdate, date: 'YY-MM-DD'), ) ], ); @@ -78,7 +79,7 @@ class MemberSeasonsItem extends StatelessWidget { const Spacer(), Text( Utils.CustomStamp_str( - timestamp: seasonItem.pubdate, date: 'MM-DD'), + timestamp: seasonItem.pubdate, date: 'YY-MM-DD'), style: TextStyle( fontSize: 11, color: Theme.of(context).colorScheme.outline, diff --git a/lib/pages/mine/controller.dart b/lib/pages/mine/controller.dart index d1e17a83..5ad9e852 100644 --- a/lib/pages/mine/controller.dart +++ b/lib/pages/mine/controller.dart @@ -119,7 +119,7 @@ class MineController extends GetxController { SmartDialog.showToast('账号未登录'); return; } - Get.toNamed('/follow?mid=${userInfo.value.mid}'); + Get.toNamed('/follow?mid=${userInfo.value.mid}', preventDuplicates: false); } pushFans() { @@ -127,7 +127,7 @@ class MineController extends GetxController { SmartDialog.showToast('账号未登录'); return; } - Get.toNamed('/fan?mid=${userInfo.value.mid}'); + Get.toNamed('/fan?mid=${userInfo.value.mid}', preventDuplicates: false); } pushDynamic() { @@ -135,6 +135,7 @@ class MineController extends GetxController { SmartDialog.showToast('账号未登录'); return; } - Get.toNamed('/memberDynamics?mid=${userInfo.value.mid}'); + Get.toNamed('/memberDynamics?mid=${userInfo.value.mid}', + preventDuplicates: false); } } diff --git a/lib/pages/mine/view.dart b/lib/pages/mine/view.dart index 06c375da..091b2149 100644 --- a/lib/pages/mine/view.dart +++ b/lib/pages/mine/view.dart @@ -64,7 +64,7 @@ class _MinePageState extends State { ), ), IconButton( - onPressed: () => Get.toNamed('/setting'), + onPressed: () => Get.toNamed('/setting', preventDuplicates: false), icon: const Icon( CupertinoIcons.slider_horizontal_3, ), diff --git a/lib/pages/preview/view.dart b/lib/pages/preview/view.dart index 1c37c833..13868d37 100644 --- a/lib/pages/preview/view.dart +++ b/lib/pages/preview/view.dart @@ -102,15 +102,12 @@ class _ImagePreviewState extends State ); } - // 设置状态栏图标透明 + // 隐藏状态栏,避免遮挡图片内容 setStatusBar() async { - if (Platform.isIOS) { + if (Platform.isIOS || Platform.isAndroid) { await StatusBarControl.setHidden(true, animation: StatusBarAnimation.SLIDE); } - if (Platform.isAndroid) { - await StatusBarControl.setColor(Colors.transparent); - } } @override @@ -138,115 +135,103 @@ class _ImagePreviewState extends State ), body: Stack( children: [ - DismissiblePage( - backgroundColor: Colors.transparent, - onDismissed: () { - Navigator.of(context).pop(); - }, - // Note that scrollable widget inside DismissiblePage might limit the functionality - // If scroll direction matches DismissiblePage direction - direction: DismissiblePageDismissDirection.down, - disabled: _dismissDisabled, - isFullScreen: true, - child: GestureDetector( - onLongPress: () => onOpenMenu(), - child: ExtendedImageGesturePageView.builder( - controller: ExtendedPageController( - initialPage: _previewController.initialPage.value, - pageSpacing: 0, - ), - onPageChanged: (int index) => - _previewController.onChange(index), - canScrollPage: (GestureDetails? gestureDetails) => - gestureDetails!.totalScale! <= 1.0, - itemCount: widget.imgList!.length, - itemBuilder: (BuildContext context, int index) { - return ExtendedImage.network( - widget.imgList![index], - fit: BoxFit.contain, - mode: ExtendedImageMode.gesture, - onDoubleTap: (ExtendedImageGestureState state) { - final Offset? pointerDownPosition = - state.pointerDownPosition; - final double? begin = state.gestureDetails!.totalScale; - double end; - - //remove old - _doubleClickAnimation - ?.removeListener(_doubleClickAnimationListener); - - //stop pre - _doubleClickAnimationController.stop(); - - //reset to use - _doubleClickAnimationController.reset(); - - if (begin == doubleTapScales[0]) { - setState(() { - _dismissDisabled = true; - }); - end = doubleTapScales[1]; - } else { - setState(() { - _dismissDisabled = false; - }); - end = doubleTapScales[0]; - } - - _doubleClickAnimationListener = () { - state.handleDoubleTap( - scale: _doubleClickAnimation!.value, - doubleTapPosition: pointerDownPosition); - }; - _doubleClickAnimation = _doubleClickAnimationController - .drive(Tween(begin: begin, end: end)); - - _doubleClickAnimation! - .addListener(_doubleClickAnimationListener); - - _doubleClickAnimationController.forward(); - }, - // ignore: body_might_complete_normally_nullable - loadStateChanged: (ExtendedImageState state) { - if (state.extendedImageLoadState == LoadState.loading) { - final ImageChunkEvent? loadingProgress = - state.loadingProgress; - final double? progress = - loadingProgress?.expectedTotalBytes != null - ? loadingProgress!.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 150.0, - child: LinearProgressIndicator( - value: progress, - color: Colors.white, - ), - ), - // const SizedBox(height: 10.0), - // Text('${((progress ?? 0.0) * 100).toInt()}%',), - ], - ), - ); - } - }, - initGestureConfigHandler: (ExtendedImageState state) { - return GestureConfig( - inPageView: true, - initialScale: 1.0, - maxScale: 5.0, - animationMaxScale: 6.0, - initialAlignment: InitialAlignment.center, - ); - }, - ); - }, + GestureDetector( + onLongPress: () => onOpenMenu(), + child: ExtendedImageGesturePageView.builder( + controller: ExtendedPageController( + initialPage: _previewController.initialPage.value, + pageSpacing: 0, ), + onPageChanged: (int index) => _previewController.onChange(index), + canScrollPage: (GestureDetails? gestureDetails) => + gestureDetails!.totalScale! <= 1.0, + itemCount: widget.imgList!.length, + itemBuilder: (BuildContext context, int index) { + return ExtendedImage.network( + widget.imgList![index], + fit: BoxFit.contain, + mode: ExtendedImageMode.gesture, + onDoubleTap: (ExtendedImageGestureState state) { + final Offset? pointerDownPosition = + state.pointerDownPosition; + final double? begin = state.gestureDetails!.totalScale; + double end; + + //remove old + _doubleClickAnimation + ?.removeListener(_doubleClickAnimationListener); + + //stop pre + _doubleClickAnimationController.stop(); + + //reset to use + _doubleClickAnimationController.reset(); + + if (begin == doubleTapScales[0]) { + setState(() { + _dismissDisabled = true; + }); + end = doubleTapScales[1]; + } else { + setState(() { + _dismissDisabled = false; + }); + end = doubleTapScales[0]; + } + + _doubleClickAnimationListener = () { + state.handleDoubleTap( + scale: _doubleClickAnimation!.value, + doubleTapPosition: pointerDownPosition); + }; + _doubleClickAnimation = _doubleClickAnimationController + .drive(Tween(begin: begin, end: end)); + + _doubleClickAnimation! + .addListener(_doubleClickAnimationListener); + + _doubleClickAnimationController.forward(); + }, + // ignore: body_might_complete_normally_nullable + loadStateChanged: (ExtendedImageState state) { + if (state.extendedImageLoadState == LoadState.loading) { + final ImageChunkEvent? loadingProgress = + state.loadingProgress; + final double? progress = + loadingProgress?.expectedTotalBytes != null + ? loadingProgress!.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 150.0, + child: LinearProgressIndicator( + value: progress, + color: Colors.white, + ), + ), + // const SizedBox(height: 10.0), + // Text('${((progress ?? 0.0) * 100).toInt()}%',), + ], + ), + ); + } + }, + initGestureConfigHandler: (ExtendedImageState state) { + return GestureConfig( + inPageView: true, + initialScale: 1.0, + maxScale: 5.0, + animationMaxScale: 6.0, + initialAlignment: InitialAlignment.center, + ); + }, + ); + }, ), ), Positioned( @@ -254,33 +239,49 @@ class _ImagePreviewState extends State right: 0, bottom: 0, child: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 30), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black87, + padding: EdgeInsets.only( + left: 20, + right: 20, + bottom: MediaQuery.of(context).padding.bottom + 30), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black87, + ], + tileMode: TileMode.mirror, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + widget.imgList!.length > 1 + ? Obx( + () => Text.rich( + textAlign: TextAlign.center, + TextSpan( + style: const TextStyle( + color: Colors.white, fontSize: 16), + children: [ + TextSpan( + text: _previewController.currentPage + .toString()), + const TextSpan(text: ' / '), + TextSpan( + text: + widget.imgList!.length.toString()), + ]), + ), + ) + : const SizedBox(), + IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.white), + ), ], - tileMode: TileMode.mirror, - ), - ), - child: Obx( - () => Text.rich( - textAlign: TextAlign.center, - TextSpan( - style: const TextStyle(color: Colors.white, fontSize: 15), - children: [ - TextSpan( - text: _previewController.currentPage.toString()), - const TextSpan(text: ' / '), - TextSpan(text: widget.imgList!.length.toString()), - ]), - ), - ), - ), + )), ), ], ), diff --git a/lib/pages/rank/controller.dart b/lib/pages/rank/controller.dart new file mode 100644 index 00000000..6fe3d424 --- /dev/null +++ b/lib/pages/rank/controller.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/models/common/rank_type.dart'; +import 'package:pilipala/utils/storage.dart'; + +class RankController extends GetxController with GetTickerProviderStateMixin { + bool flag = false; + late RxList tabs = [].obs; + RxInt initialIndex = 0.obs; + late TabController tabController; + late List tabsCtrList; + late List tabsPageList; + Box setting = GStrorage.setting; + late final StreamController searchBarStream = + StreamController.broadcast(); + late bool enableGradientBg; + + @override + void onInit() { + super.onInit(); + enableGradientBg = + setting.get(SettingBoxKey.enableGradientBg, defaultValue: true); + // 进行tabs配置 + setTabConfig(); + } + + void onRefresh() { + int index = tabController.index; + var ctr = tabsCtrList[index]; + ctr().onRefresh(); + } + + void animateToTop() { + int index = tabController.index; + var ctr = tabsCtrList[index]; + ctr().animateToTop(); + } + + void setTabConfig() async { + tabs.value = tabsConfig; + initialIndex.value = 0; + tabsCtrList = tabs.map((e) => e['ctr']).toList(); + tabsPageList = tabs.map((e) => e['page']).toList(); + + tabController = TabController( + initialIndex: initialIndex.value, + length: tabs.length, + vsync: this, + ); + } +} diff --git a/lib/pages/rank/index.dart b/lib/pages/rank/index.dart new file mode 100644 index 00000000..eaac0a34 --- /dev/null +++ b/lib/pages/rank/index.dart @@ -0,0 +1,4 @@ +library rank; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/rank/view.dart b/lib/pages/rank/view.dart new file mode 100644 index 00000000..7b5b4906 --- /dev/null +++ b/lib/pages/rank/view.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/utils/feed_back.dart'; +import './controller.dart'; + +class RankPage extends StatefulWidget { + const RankPage({Key? key}) : super(key: key); + + @override + State createState() => _RankPageState(); +} + +class _RankPageState extends State + with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { + final RankController _rankController = Get.put(RankController()); + List videoList = []; + late Stream stream; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + stream = _rankController.searchBarStream.stream; + } + + @override + Widget build(BuildContext context) { + super.build(context); + Brightness currentBrightness = MediaQuery.of(context).platformBrightness; + // 设置状态栏图标的亮度 + if (_rankController.enableGradientBg) { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarIconBrightness: currentBrightness == Brightness.light + ? Brightness.dark + : Brightness.light, + )); + } + return Scaffold( + extendBody: true, + extendBodyBehindAppBar: false, + appBar: _rankController.enableGradientBg + ? null + : AppBar(toolbarHeight: 0, elevation: 0), + body: Stack( + children: [ + // gradient background + if (_rankController.enableGradientBg) ...[ + Align( + alignment: Alignment.topLeft, + child: Opacity( + opacity: 0.6, + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context) + .colorScheme + .primary + .withOpacity(0.9), + Theme.of(context) + .colorScheme + .primary + .withOpacity(0.5), + Theme.of(context).colorScheme.surface + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: const [0, 0.0034, 0.34]), + ), + ), + ), + ), + ], + Column( + children: [ + const CustomAppBar(), + if (_rankController.tabs.length > 1) ...[ + const SizedBox(height: 4), + SizedBox( + width: double.infinity, + height: 42, + child: Align( + alignment: Alignment.center, + child: TabBar( + controller: _rankController.tabController, + tabs: [ + for (var i in _rankController.tabs) + Tab(text: i['label']) + ], + isScrollable: true, + dividerColor: Colors.transparent, + enableFeedback: true, + splashBorderRadius: BorderRadius.circular(10), + tabAlignment: TabAlignment.center, + onTap: (value) { + feedBack(); + if (_rankController.initialIndex.value == value) { + _rankController.tabsCtrList[value]().animateToTop(); + } + _rankController.initialIndex.value = value; + }, + ), + ), + ), + ] else ...[ + const SizedBox(height: 6), + ], + Expanded( + child: TabBarView( + controller: _rankController.tabController, + children: _rankController.tabsPageList, + ), + ), + ], + ), + ], + ), + ); + } +} + +class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { + final double height; + + const CustomAppBar({ + super.key, + this.height = kToolbarHeight, + }); + + @override + Size get preferredSize => Size.fromHeight(height); + + @override + Widget build(BuildContext context) { + final double top = MediaQuery.of(context).padding.top; + return Container( + width: MediaQuery.of(context).size.width, + height: top, + color: Colors.transparent, + ); + } +} diff --git a/lib/pages/rank/zone/controller.dart b/lib/pages/rank/zone/controller.dart new file mode 100644 index 00000000..f9f4dc6e --- /dev/null +++ b/lib/pages/rank/zone/controller.dart @@ -0,0 +1,53 @@ +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:pilipala/http/video.dart'; +import 'package:pilipala/models/model_hot_video_item.dart'; + +class ZoneController extends GetxController { + final ScrollController scrollController = ScrollController(); + RxList videoList = [].obs; + bool isLoadingMore = false; + bool flag = false; + OverlayEntry? popupDialog; + int zoneID = 0; + + // 获取推荐 + Future queryRankFeed(type, rid) async { + zoneID = rid; + var res = await VideoHttp.getRankVideoList(zoneID); + if (res['status']) { + if (type == 'init') { + videoList.value = res['data']; + } else if (type == 'onRefresh') { + videoList.clear(); + videoList.addAll(res['data']); + } else if (type == 'onLoad') { + videoList.clear(); + videoList.addAll(res['data']); + } + } + isLoadingMore = false; + return res; + } + + // 下拉刷新 + Future onRefresh() async { + queryRankFeed('onRefresh', zoneID); + } + + // 上拉加载 + Future onLoad() async { + queryRankFeed('onLoad', zoneID); + } + + // 返回顶部并刷新 + void animateToTop() async { + if (scrollController.offset >= + MediaQuery.of(Get.context!).size.height * 5) { + scrollController.jumpTo(0); + } else { + await scrollController.animateTo(0, + duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + } + } +} diff --git a/lib/pages/rank/zone/index.dart b/lib/pages/rank/zone/index.dart new file mode 100644 index 00000000..8f535736 --- /dev/null +++ b/lib/pages/rank/zone/index.dart @@ -0,0 +1,4 @@ +library rank.zone; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/rank/zone/view.dart b/lib/pages/rank/zone/view.dart new file mode 100644 index 00000000..fbf8a524 --- /dev/null +++ b/lib/pages/rank/zone/view.dart @@ -0,0 +1,154 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/widgets/animated_dialog.dart'; +import 'package:pilipala/common/widgets/overlay_pop.dart'; +import 'package:pilipala/common/skeleton/video_card_h.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/video_card_h.dart'; +import 'package:pilipala/pages/home/index.dart'; +import 'package:pilipala/pages/main/index.dart'; +import 'package:pilipala/pages/rank/zone/index.dart'; + +class ZonePage extends StatefulWidget { + const ZonePage({Key? key, required this.rid}) : super(key: key); + + final int rid; + + @override + State createState() => _ZonePageState(); +} + +class _ZonePageState extends State + with AutomaticKeepAliveClientMixin { + late ZoneController _zoneController; + List videoList = []; + Future? _futureBuilderFuture; + late ScrollController scrollController; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _zoneController = Get.put(ZoneController(), tag: widget.rid.toString()); + _futureBuilderFuture = _zoneController.queryRankFeed('init', widget.rid); + scrollController = _zoneController.scrollController; + StreamController mainStream = + Get.find().bottomBarStream; + StreamController searchBarStream = + Get.find().searchBarStream; + scrollController.addListener( + () { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 200) { + if (!_zoneController.isLoadingMore) { + _zoneController.isLoadingMore = true; + _zoneController.onLoad(); + } + } + + final ScrollDirection direction = + scrollController.position.userScrollDirection; + if (direction == ScrollDirection.forward) { + mainStream.add(true); + searchBarStream.add(true); + } else if (direction == ScrollDirection.reverse) { + mainStream.add(false); + searchBarStream.add(false); + } + }, + ); + } + + @override + void dispose() { + scrollController.removeListener(() {}); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return RefreshIndicator( + onRefresh: () async { + return await _zoneController.onRefresh(); + }, + child: CustomScrollView( + controller: _zoneController.scrollController, + slivers: [ + SliverPadding( + // 单列布局 EdgeInsets.zero + padding: + const EdgeInsets.fromLTRB(0, StyleString.safeSpace - 5, 0, 0), + sliver: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data as Map; + if (data['status']) { + return Obx( + () => SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return VideoCardH( + videoItem: _zoneController.videoList[index], + showPubdate: true, + longPress: () { + _zoneController.popupDialog = _createPopupDialog( + _zoneController.videoList[index]); + Overlay.of(context) + .insert(_zoneController.popupDialog!); + }, + longPressEnd: () { + _zoneController.popupDialog?.remove(); + }, + ); + }, childCount: _zoneController.videoList.length), + ), + ); + } else { + return HttpError( + errMsg: data['msg'], + fn: () { + setState(() { + _futureBuilderFuture = + _zoneController.queryRankFeed('init', widget.rid); + }); + }, + ); + } + } else { + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoCardHSkeleton(); + }, childCount: 10), + ); + } + }, + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).padding.bottom + 10, + ), + ) + ], + ), + ); + } + + OverlayEntry _createPopupDialog(videoItem) { + return OverlayEntry( + builder: (context) => AnimatedDialog( + closeFn: _zoneController.popupDialog?.remove, + child: OverlayPop( + videoItem: videoItem, closeFn: _zoneController.popupDialog?.remove), + ), + ); + } +} diff --git a/lib/pages/rcmd/controller.dart b/lib/pages/rcmd/controller.dart index ef5da13b..28ff055b 100644 --- a/lib/pages/rcmd/controller.dart +++ b/lib/pages/rcmd/controller.dart @@ -3,90 +3,61 @@ import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/http/video.dart'; import 'package:pilipala/models/home/rcmd/result.dart'; -// import 'package:pilipala/models/model_rec_video_item.dart'; +import 'package:pilipala/models/model_rec_video_item.dart'; import 'package:pilipala/utils/storage.dart'; class RcmdController extends GetxController { final ScrollController scrollController = ScrollController(); int _currentPage = 0; - RxList videoList = [].obs; - // RxList videoList = [].obs; + // RxList appVideoList = [].obs; + // RxList webVideoList = [].obs; bool isLoadingMore = true; OverlayEntry? popupDialog; - Box recVideo = GStrorage.recVideo; Box setting = GStrorage.setting; RxInt crossAxisCount = 2.obs; late bool enableSaveLastData; + late String defaultRcmdType = 'web'; + late RxList videoList; @override void onInit() { super.onInit(); crossAxisCount.value = setting.get(SettingBoxKey.customRows, defaultValue: 2); - // 读取app端缓存内容 - if (recVideo.get('cacheList') != null && - recVideo.get('cacheList').isNotEmpty) { - List list = []; - for (var i in recVideo.get('cacheList')) { - list.add(i); - } - videoList.value = list; - } enableSaveLastData = setting.get(SettingBoxKey.enableSaveLastData, defaultValue: false); + defaultRcmdType = + setting.get(SettingBoxKey.defaultRcmdType, defaultValue: 'web'); + if (defaultRcmdType == 'web') { + videoList = [].obs; + } else { + videoList = [].obs; + } } // 获取推荐 Future queryRcmdFeed(type) async { - return await queryRcmdFeedApp(type); - } - - // 获取app端推荐 - Future queryRcmdFeedApp(type) async { if (isLoadingMore == false) { return; } if (type == 'onRefresh') { _currentPage = 0; } - var res = await VideoHttp.rcmdVideoListApp( - freshIdx: _currentPage, - ); - if (res['status']) { - if (type == 'init') { - if (videoList.isNotEmpty) { - videoList.addAll(res['data']); - } else { - videoList.value = res['data']; - } - } else if (type == 'onRefresh') { - if (enableSaveLastData) { - videoList.insertAll(0, res['data']); - } else { - videoList.value = res['data']; - } - } else if (type == 'onLoad') { - videoList.addAll(res['data']); - } - recVideo.put('cacheList', res['data']); - _currentPage += 1; + late final Map res; + switch (defaultRcmdType) { + case 'app': + case 'notLogin': + res = await VideoHttp.rcmdVideoListApp( + loginStatus: defaultRcmdType != 'notLogin', + freshIdx: _currentPage, + ); + break; + default: //'web' + res = await VideoHttp.rcmdVideoList( + freshIdx: _currentPage, + ps: 20, + ); } - isLoadingMore = false; - return res; - } - - // 获取web端推荐 - Future queryRcmdFeedWeb(type) async { - if (isLoadingMore == false) { - return; - } - if (type == 'onRefresh') { - _currentPage = 0; - } - var res = await VideoHttp.rcmdVideoList( - ps: 20, - freshIdx: _currentPage, - ); if (res['status']) { if (type == 'init') { if (videoList.isNotEmpty) { @@ -104,6 +75,11 @@ class RcmdController extends GetxController { videoList.addAll(res['data']); } _currentPage += 1; + // 若videoList数量太小,可能会影响翻页,此时再次请求 + // 为避免请求到的数据太少时还在反复请求,要求本次返回数据大于1条才触发 + if (res['data'].length > 1 && videoList.length < 10) { + queryRcmdFeed('onLoad'); + } } isLoadingMore = false; return res; @@ -120,7 +96,7 @@ class RcmdController extends GetxController { queryRcmdFeed('onLoad'); } - // 返回顶部并刷新 + // 返回顶部 void animateToTop() async { if (scrollController.offset >= MediaQuery.of(Get.context!).size.height * 5) { diff --git a/lib/pages/rcmd/view.dart b/lib/pages/rcmd/view.dart index 717ea9f7..d732f370 100644 --- a/lib/pages/rcmd/view.dart +++ b/lib/pages/rcmd/view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; @@ -45,7 +44,7 @@ class _RcmdPageState extends State if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) { EasyThrottle.throttle( - 'my-throttler', const Duration(milliseconds: 500), () { + 'my-throttler', const Duration(milliseconds: 200), () { _rcmdController.isLoadingMore = true; _rcmdController.onLoad(); }); @@ -97,19 +96,24 @@ class _RcmdPageState extends State if (snapshot.connectionState == ConnectionState.done) { Map data = snapshot.data as Map; if (data['status']) { - return Platform.isAndroid || Platform.isIOS - ? Obx(() => contentGrid( - _rcmdController, _rcmdController.videoList)) - : SliverLayoutBuilder( - builder: (context, boxConstraints) { - return Obx(() => contentGrid( - _rcmdController, _rcmdController.videoList)); - }); + return Obx( + () { + if (_rcmdController.isLoadingMore && + _rcmdController.videoList.isEmpty) { + return contentGrid(_rcmdController, []); + } else { + // 显示视频列表 + return contentGrid( + _rcmdController, _rcmdController.videoList); + } + }, + ); } else { return HttpError( errMsg: data['msg'], fn: () { setState(() { + _rcmdController.isLoadingMore = true; _futureBuilderFuture = _rcmdController.queryRcmdFeed('init'); }); @@ -117,20 +121,11 @@ class _RcmdPageState extends State ); } } else { - // 缓存数据 - if (_rcmdController.videoList.isNotEmpty) { - return contentGrid( - _rcmdController, _rcmdController.videoList); - } - // 骨架屏 - else { - return contentGrid(_rcmdController, []); - } + return contentGrid(_rcmdController, []); } }, ), ), - LoadingMore(ctr: _rcmdController) ], ), ), @@ -193,33 +188,3 @@ class _RcmdPageState extends State ); } } - -class LoadingMore extends StatelessWidget { - final dynamic ctr; - const LoadingMore({super.key, this.ctr}); - - @override - Widget build(BuildContext context) { - return SliverToBoxAdapter( - child: Container( - height: MediaQuery.of(context).padding.bottom + 80, - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), - child: GestureDetector( - onTap: () { - if (ctr != null) { - ctr!.isLoadingMore = true; - ctr!.onLoad(); - } - }, - child: Center( - child: Text( - '点击加载更多 👇', - style: TextStyle( - color: Theme.of(context).colorScheme.outline, fontSize: 13), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/search/controller.dart b/lib/pages/search/controller.dart index 59d51a41..1853c238 100644 --- a/lib/pages/search/controller.dart +++ b/lib/pages/search/controller.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/http/index.dart'; import 'package:pilipala/http/search.dart'; import 'package:pilipala/models/search/hot.dart'; import 'package:pilipala/models/search/suggest.dart'; @@ -16,20 +15,17 @@ class SSearchController extends GetxController { Box histiryWord = GStrorage.historyword; List historyCacheList = []; RxList historyList = [].obs; - RxList searchSuggestList = [SearchSuggestItem()].obs; + RxList searchSuggestList = [].obs; final _debouncer = Debouncer(delay: const Duration(milliseconds: 200)); // 设置延迟时间 String hintText = '搜索'; - RxString defaultSearch = '输入关键词搜索'.obs; + RxString defaultSearch = ''.obs; Box setting = GStrorage.setting; bool enableHotKey = true; @override void onInit() { super.onInit(); - if (setting.get(SettingBoxKey.enableSearchWord, defaultValue: true)) { - searchDefault(); - } // 其他页面跳转过来 if (Get.parameters.keys.isNotEmpty) { if (Get.parameters['keyword'] != null) { @@ -119,7 +115,7 @@ class SSearchController extends GetxController { onLongSelect(word) { int index = historyList.indexOf(word); - historyList.value = historyList.removeAt(index); + historyList.removeAt(index); historyList.refresh(); histiryWord.put('cacheList', historyList); } @@ -130,12 +126,4 @@ class SSearchController extends GetxController { historyList.refresh(); histiryWord.put('cacheList', []); } - - void searchDefault() async { - var res = await Request().get(Api.searchDefault); - if (res.data['code'] == 0) { - searchKeyWord.value = - hintText = defaultSearch.value = res.data['data']['name']; - } - } } diff --git a/lib/pages/search/view.dart b/lib/pages/search/view.dart index 55ee3b22..95d3134e 100644 --- a/lib/pages/search/view.dart +++ b/lib/pages/search/view.dart @@ -1,6 +1,5 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:animations/animations.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'controller.dart'; @@ -42,120 +41,62 @@ class _SearchPageState extends State with RouteAware { @override Widget build(BuildContext context) { - return OpenContainer( - closedElevation: 0, - openElevation: 0, - onClosed: (_) => _searchController.onClear(), - openColor: Theme.of(context).colorScheme.background, - middleColor: Theme.of(context).colorScheme.background, - closedColor: Theme.of(context).colorScheme.background, - closedShape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(30.0))), - openShape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(30.0))), - closedBuilder: (BuildContext context, VoidCallback openContainer) { - return Container( - width: 250, - height: 44, - clipBehavior: Clip.hardEdge, - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(25)), + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + shape: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withOpacity(0.08), + width: 1, ), - child: Material( - color: - Theme.of(context).colorScheme.secondaryContainer.withAlpha(115), - child: InkWell( - splashColor: Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(0.3), - onTap: openContainer, - child: Row( - children: [ - const SizedBox(width: 14), - Icon( - Icons.search_outlined, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Obx( - () => Text( - _searchController.defaultSearch.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - ), - ], - ), - ), + ), + titleSpacing: 0, + actions: [ + IconButton( + onPressed: () => _searchController.submit(), + icon: const Icon(CupertinoIcons.search, size: 22), ), - ); - }, - openBuilder: (BuildContext context, VoidCallback _) { - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - shape: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor.withOpacity(0.08), - width: 1, - ), - ), - titleSpacing: 0, - actions: [ - Hero( - tag: 'searchTag', - child: IconButton( - onPressed: () => _searchController.submit(), - icon: const Icon(CupertinoIcons.search, size: 22)), - ), - const SizedBox(width: 10) - ], - title: Obx( - () => TextField( - autofocus: true, - focusNode: _searchController.searchFocusNode, - controller: _searchController.controller.value, - textInputAction: TextInputAction.search, - onChanged: (value) => _searchController.onChange(value), - decoration: InputDecoration( - hintText: _searchController.hintText, - border: InputBorder.none, - suffixIcon: IconButton( - icon: Icon( - Icons.clear, - size: 22, - color: Theme.of(context).colorScheme.outline, - ), - onPressed: () => _searchController.onClear(), - ), + const SizedBox(width: 10) + ], + title: Obx( + () => TextField( + autofocus: true, + focusNode: _searchController.searchFocusNode, + controller: _searchController.controller.value, + textInputAction: TextInputAction.search, + onChanged: (value) => _searchController.onChange(value), + decoration: InputDecoration( + hintText: _searchController.hintText, + border: InputBorder.none, + suffixIcon: IconButton( + icon: Icon( + Icons.clear, + size: 22, + color: Theme.of(context).colorScheme.outline, ), - onSubmitted: (String value) => _searchController.submit(), + onPressed: () => _searchController.onClear(), ), ), + onSubmitted: (String value) => _searchController.submit(), ), - body: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 12), - // 搜索建议 - _searchSuggest(), - // 热搜 - Visibility( - visible: _searchController.enableHotKey, - child: hotSearch(_searchController)), - // 搜索历史 - _history() - ], + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 12), + // 搜索建议 + _searchSuggest(), + // 热搜 + Visibility( + visible: _searchController.enableHotKey, + child: hotSearch(_searchController), ), - ), - ); - }, + // 搜索历史 + _history() + ], + ), + ), ); } @@ -246,9 +187,13 @@ class _SearchPageState extends State with RouteAware { ), ); } else { - return HttpError( - errMsg: data['msg'], - fn: () => setState(() {}), + return CustomScrollView( + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ) + ], ); } } else { @@ -299,25 +244,24 @@ class _SearchPageState extends State with RouteAware { ], ), ), - // if (_searchController.historyList.isNotEmpty) - Obx(() => Wrap( - spacing: 8, - runSpacing: 8, - direction: Axis.horizontal, - textDirection: TextDirection.ltr, - children: [ - for (int i = 0; - i < _searchController.historyList.length; - i++) - SearchText( - searchText: _searchController.historyList[i], - searchTextIdx: i, - onSelect: (value) => _searchController.onSelect(value), - onLongSelect: (value) => - _searchController.onLongSelect(value), - ) - ], - )), + Obx( + () => Wrap( + spacing: 8, + runSpacing: 8, + direction: Axis.horizontal, + textDirection: TextDirection.ltr, + children: [ + for (int i = 0; i < _searchController.historyList.length; i++) + SearchText( + searchText: _searchController.historyList[i], + searchTextIdx: i, + onSelect: (value) => _searchController.onSelect(value), + onLongSelect: (value) => + _searchController.onLongSelect(value), + ) + ], + ), + ), ], ), ), diff --git a/lib/pages/searchPanel/controller.dart b/lib/pages/search_panel/controller.dart similarity index 100% rename from lib/pages/searchPanel/controller.dart rename to lib/pages/search_panel/controller.dart diff --git a/lib/pages/searchPanel/index.dart b/lib/pages/search_panel/index.dart similarity index 100% rename from lib/pages/searchPanel/index.dart rename to lib/pages/search_panel/index.dart diff --git a/lib/pages/searchPanel/view.dart b/lib/pages/search_panel/view.dart similarity index 64% rename from lib/pages/searchPanel/view.dart rename to lib/pages/search_panel/view.dart index 10e52a54..c5824d70 100644 --- a/lib/pages/searchPanel/view.dart +++ b/lib/pages/search_panel/view.dart @@ -43,7 +43,7 @@ class _SearchPanelState extends State keyword: widget.keyword, searchType: widget.searchType, ), - tag: widget.searchType!.type, + tag: widget.searchType!.type + widget.keyword!, ); scrollController = _searchPanelController.scrollController; scrollController.addListener(() async { @@ -74,37 +74,57 @@ class _SearchPanelState extends State future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data; - var ctr = _searchPanelController; - RxList list = ctr.resultList; - if (data['status']) { - return Obx(() { - switch (widget.searchType) { - case SearchType.video: - return SearchVideoPanel( - ctr: _searchPanelController, - // ignore: invalid_use_of_protected_member - list: list.value, - ); - case SearchType.media_bangumi: - return searchMbangumiPanel(context, ctr, list); - case SearchType.bili_user: - return searchUserPanel(context, ctr, list); - case SearchType.live_room: - return searchLivePanel(context, ctr, list); - case SearchType.article: - return searchArticlePanel(context, ctr, list); - default: - return const SizedBox(); - } - }); + if (snapshot.data != null) { + Map data = snapshot.data; + var ctr = _searchPanelController; + RxList list = ctr.resultList; + if (data['status']) { + return Obx(() { + switch (widget.searchType) { + case SearchType.video: + return SearchVideoPanel( + ctr: _searchPanelController, + // ignore: invalid_use_of_protected_member + list: list.value, + ); + case SearchType.media_bangumi: + return searchMbangumiPanel(context, ctr, list); + case SearchType.bili_user: + return searchUserPanel(context, ctr, list); + case SearchType.live_room: + return searchLivePanel(context, ctr, list); + case SearchType.article: + return searchArticlePanel(context, ctr, list); + default: + return const SizedBox(); + } + }); + } else { + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + HttpError( + errMsg: data['msg'], + fn: () { + setState(() { + _searchPanelController.onSearch(); + }); + }, + ), + ], + ); + } } else { return CustomScrollView( physics: const NeverScrollableScrollPhysics(), slivers: [ HttpError( - errMsg: data['msg'], - fn: () => setState(() {}), + errMsg: '没有相关数据', + fn: () { + setState(() { + _searchPanelController.onSearch(); + }); + }, ), ], ); diff --git a/lib/pages/searchPanel/widgets/article_panel.dart b/lib/pages/search_panel/widgets/article_panel.dart similarity index 96% rename from lib/pages/searchPanel/widgets/article_panel.dart rename to lib/pages/search_panel/widgets/article_panel.dart index 6e73151a..c7074229 100644 --- a/lib/pages/searchPanel/widgets/article_panel.dart +++ b/lib/pages/search_panel/widgets/article_panel.dart @@ -25,17 +25,17 @@ Widget searchArticlePanel(BuildContext context, ctr, list) { padding: const EdgeInsets.fromLTRB( StyleString.safeSpace, 5, StyleString.safeSpace, 5), child: LayoutBuilder(builder: (context, boxConstraints) { - double width = (boxConstraints.maxWidth - + final double width = (boxConstraints.maxWidth - StyleString.cardSpace * 6 / - MediaQuery.of(context).textScaleFactor) / + MediaQuery.textScalerOf(context).scale(1.0)) / 2; return Container( constraints: const BoxConstraints(minHeight: 88), height: width / StyleString.aspectRatio, child: Row( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ if (list[index].imageUrls != null && list[index].imageUrls.isNotEmpty) AspectRatio( diff --git a/lib/pages/searchPanel/widgets/live_panel.dart b/lib/pages/search_panel/widgets/live_panel.dart similarity index 97% rename from lib/pages/searchPanel/widgets/live_panel.dart rename to lib/pages/search_panel/widgets/live_panel.dart index 6cd9f4c2..6fb5f5b8 100644 --- a/lib/pages/searchPanel/widgets/live_panel.dart +++ b/lib/pages/search_panel/widgets/live_panel.dart @@ -16,8 +16,8 @@ Widget searchLivePanel(BuildContext context, ctr, list) { crossAxisSpacing: StyleString.cardSpace + 2, mainAxisSpacing: StyleString.cardSpace + 3, mainAxisExtent: - MediaQuery.of(context).size.width / 2 / StyleString.aspectRatio + - 66 * MediaQuery.of(context).textScaleFactor), + MediaQuery.sizeOf(context).width / 2 / StyleString.aspectRatio + + MediaQuery.textScalerOf(context).scale(66.0)), itemCount: list.length, itemBuilder: (context, index) { return LiveItem(liveItem: list![index]); diff --git a/lib/pages/searchPanel/widgets/media_bangumi_panel.dart b/lib/pages/search_panel/widgets/media_bangumi_panel.dart similarity index 96% rename from lib/pages/searchPanel/widgets/media_bangumi_panel.dart rename to lib/pages/search_panel/widgets/media_bangumi_panel.dart index b8ae8d2e..18799d3a 100644 --- a/lib/pages/searchPanel/widgets/media_bangumi_panel.dart +++ b/lib/pages/search_panel/widgets/media_bangumi_panel.dart @@ -67,11 +67,11 @@ Widget searchMbangumiPanel(BuildContext context, ctr, list) { TextSpan( text: i['text'], style: TextStyle( - fontSize: Theme.of(context) + fontSize: MediaQuery.textScalerOf(context) + .scale(Theme.of(context) .textTheme .titleSmall! - .fontSize! * - MediaQuery.of(context).textScaleFactor, + .fontSize!), fontWeight: FontWeight.bold, color: i['type'] == 'em' ? Theme.of(context).colorScheme.primary diff --git a/lib/pages/searchPanel/widgets/user_panel.dart b/lib/pages/search_panel/widgets/user_panel.dart similarity index 100% rename from lib/pages/searchPanel/widgets/user_panel.dart rename to lib/pages/search_panel/widgets/user_panel.dart diff --git a/lib/pages/searchPanel/widgets/video_panel.dart b/lib/pages/search_panel/widgets/video_panel.dart similarity index 94% rename from lib/pages/searchPanel/widgets/video_panel.dart rename to lib/pages/search_panel/widgets/video_panel.dart index 6cdc7868..b96ff004 100644 --- a/lib/pages/searchPanel/widgets/video_panel.dart +++ b/lib/pages/search_panel/widgets/video_panel.dart @@ -3,7 +3,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/widgets/video_card_h.dart'; import 'package:pilipala/models/common/search_type.dart'; -import 'package:pilipala/pages/searchPanel/index.dart'; +import 'package:pilipala/pages/search_panel/index.dart'; class SearchVideoPanel extends StatelessWidget { SearchVideoPanel({ @@ -35,7 +35,7 @@ class SearchVideoPanel extends StatelessWidget { padding: index == 0 ? const EdgeInsets.only(top: 2) : EdgeInsets.zero, - child: VideoCardH(videoItem: i), + child: VideoCardH(videoItem: i, showPubdate: true), ); }, ), @@ -70,7 +70,7 @@ class SearchVideoPanel extends StatelessWidget { controller.selectedType.value = i['type']; ctr!.order.value = i['type'].toString().split('.').last; - SmartDialog.showLoading(msg: 'loooad'); + SmartDialog.showLoading(msg: 'loading'); await ctr!.onRefresh(); SmartDialog.dismiss(); }, @@ -90,7 +90,7 @@ class SearchVideoPanel extends StatelessWidget { style: ButtonStyle( padding: MaterialStateProperty.all(EdgeInsets.zero), ), - onPressed: () => controller.onShowFilterDialog(), + onPressed: () => controller.onShowFilterDialog(ctr), icon: Icon( Icons.filter_list_outlined, size: 18, @@ -175,7 +175,7 @@ class VideoPanelController extends GetxController { super.onInit(); } - onShowFilterDialog() { + onShowFilterDialog(searchPanelCtr) { SmartDialog.show( animationType: SmartAnimationType.centerFade_otherSlide, builder: (BuildContext context) { @@ -199,9 +199,10 @@ class VideoPanelController extends GetxController { SmartDialog.dismiss(); SmartDialog.showToast("「${i['label']}」的筛选结果"); SearchPanelController ctr = - Get.find(tag: 'video'); + Get.find( + tag: 'video${searchPanelCtr.keyword!}'); ctr.duration.value = i['value']; - SmartDialog.showLoading(msg: 'loooad'); + SmartDialog.showLoading(msg: 'loading'); await ctr.onRefresh(); SmartDialog.dismiss(); }, diff --git a/lib/pages/searchResult/controller.dart b/lib/pages/search_result/controller.dart similarity index 100% rename from lib/pages/searchResult/controller.dart rename to lib/pages/search_result/controller.dart diff --git a/lib/pages/searchResult/index.dart b/lib/pages/search_result/index.dart similarity index 100% rename from lib/pages/searchResult/index.dart rename to lib/pages/search_result/index.dart diff --git a/lib/pages/searchResult/view.dart b/lib/pages/search_result/view.dart similarity index 95% rename from lib/pages/searchResult/view.dart rename to lib/pages/search_result/view.dart index ceb77190..ff5bf780 100644 --- a/lib/pages/searchResult/view.dart +++ b/lib/pages/search_result/view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/models/common/search_type.dart'; -import 'package:pilipala/pages/searchPanel/index.dart'; +import 'package:pilipala/pages/search_panel/index.dart'; import 'controller.dart'; class SearchResultPage extends StatefulWidget { @@ -86,7 +86,8 @@ class _SearchResultPageState extends State onTap: (index) { if (index == _searchResultController!.tabIndex) { Get.find( - tag: SearchType.values[index].type) + tag: SearchType.values[index].type + + _searchResultController!.keyword!) .animateToTop(); } diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart index 0e1505a5..1fbd7efb 100644 --- a/lib/pages/setting/controller.dart +++ b/lib/pages/setting/controller.dart @@ -7,6 +7,10 @@ import 'package:pilipala/models/common/theme_type.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/login.dart'; import 'package:pilipala/utils/storage.dart'; +import '../../models/common/dynamic_badge_mode.dart'; +import '../../models/common/nav_bar_config.dart'; +import '../main/index.dart'; +import 'widgets/select_dialog.dart'; class SettingController extends GetxController { Box userInfoCache = GStrorage.userInfo; @@ -15,9 +19,12 @@ class SettingController extends GetxController { RxBool userLogin = false.obs; RxBool feedBackEnable = false.obs; + RxDouble toastOpacity = (1.0).obs; RxInt picQuality = 10.obs; Rx themeType = ThemeType.system.obs; var userInfo; + Rx dynamicBadgeType = DynamicBadgeMode.number.obs; + RxInt defaultHomePage = 0.obs; @override void onInit() { @@ -26,10 +33,17 @@ class SettingController extends GetxController { userLogin.value = userInfo != null; feedBackEnable.value = setting.get(SettingBoxKey.feedBackEnable, defaultValue: false); + toastOpacity.value = + setting.get(SettingBoxKey.defaultToastOp, defaultValue: 1.0); picQuality.value = setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10); themeType.value = ThemeType.values[setting.get(SettingBoxKey.themeMode, defaultValue: ThemeType.system.code)]; + dynamicBadgeType.value = DynamicBadgeMode.values[setting.get( + SettingBoxKey.dynamicBadgeMode, + defaultValue: DynamicBadgeMode.number.code)]; + defaultHomePage.value = + setting.get(SettingBoxKey.defaultHomePage, defaultValue: 0); } loginOut() async { @@ -73,4 +87,51 @@ class SettingController extends GetxController { feedBackEnable.value = !feedBackEnable.value; setting.put(SettingBoxKey.feedBackEnable, feedBackEnable.value); } + + // 设置动态未读标记 + setDynamicBadgeMode(BuildContext context) async { + DynamicBadgeMode? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '动态未读标记', + value: dynamicBadgeType.value, + values: DynamicBadgeMode.values.map((e) { + return {'title': e.description, 'value': e}; + }).toList(), + ); + }, + ); + if (result != null) { + dynamicBadgeType.value = result; + setting.put(SettingBoxKey.dynamicBadgeMode, result.code); + MainController mainController = Get.put(MainController()); + mainController.dynamicBadgeType.value = + DynamicBadgeMode.values[result.code]; + if (mainController.dynamicBadgeType.value != DynamicBadgeMode.hidden) { + mainController.getUnreadDynamic(); + } + SmartDialog.showToast('设置成功'); + } + } + + // 设置默认启动页 + seteDefaultHomePage(BuildContext context) async { + int? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '首页启动页', + value: defaultHomePage.value, + values: defaultNavigationBars.map((e) { + return {'title': e['label'], 'value': e['id']}; + }).toList()); + }, + ); + if (result != null) { + defaultHomePage.value = result; + setting.put(SettingBoxKey.defaultHomePage, result); + SmartDialog.showToast('设置成功,重启生效'); + } + } } diff --git a/lib/pages/setting/extra_setting.dart b/lib/pages/setting/extra_setting.dart index 57c33ff4..aaaa8b84 100644 --- a/lib/pages/setting/extra_setting.dart +++ b/lib/pages/setting/extra_setting.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/models/common/dynamics_type.dart'; import 'package:pilipala/models/common/reply_sort_type.dart'; import 'package:pilipala/pages/setting/widgets/select_dialog.dart'; import 'package:pilipala/utils/storage.dart'; +import '../home/index.dart'; import 'widgets/switch_item.dart'; class ExtraSetting extends StatefulWidget { @@ -23,6 +25,7 @@ class _ExtraSettingState extends State { late dynamic enableSystemProxy; late String defaultSystemProxyHost; late String defaultSystemProxyPort; + bool userLogin = false; @override void initState() { @@ -30,6 +33,10 @@ class _ExtraSettingState extends State { // 默认优先显示最新评论 defaultReplySort = setting.get(SettingBoxKey.replySortType, defaultValue: 0); + if (defaultReplySort == 2) { + setting.put(SettingBoxKey.replySortType, 0); + defaultReplySort = 0; + } // 优先展示全部动态 all defaultDynamicType = setting.get(SettingBoxKey.defaultDynamicType, defaultValue: 0); @@ -133,24 +140,20 @@ class _ExtraSettingState extends State { ), body: ListView( children: [ - SetSwitchItem( + const SetSwitchItem( title: '大家都在搜', subTitle: '是否展示「大家都在搜」', setKey: SettingBoxKey.enableHotKey, defaultVal: true, - callFn: (val) => {SmartDialog.showToast('下次启动时生效')}, ), - const SetSwitchItem( + SetSwitchItem( title: '搜索默认词', subTitle: '是否展示搜索框默认词', setKey: SettingBoxKey.enableSearchWord, defaultVal: true, - ), - const SetSwitchItem( - title: '推荐动态', - subTitle: '是否在推荐内容中展示动态', - setKey: SettingBoxKey.enableRcmdDynamic, - defaultVal: true, + callFn: (val) { + Get.find().defaultSearch.value = ''; + }, ), const SetSwitchItem( title: '快速收藏', @@ -164,18 +167,18 @@ class _ExtraSettingState extends State { setKey: SettingBoxKey.enableWordRe, defaultVal: false, ), - const SetSwitchItem( - title: '首页推荐刷新', - subTitle: '下拉刷新时保留上次内容', - setKey: SettingBoxKey.enableSaveLastData, - defaultVal: false, - ), const SetSwitchItem( title: '启用ai总结', subTitle: '视频详情页开启ai总结', setKey: SettingBoxKey.enableAi, defaultVal: true, ), + const SetSwitchItem( + title: '相关视频推荐', + subTitle: '视频详情页推荐相关视频', + setKey: SettingBoxKey.enableRelatedVideo, + defaultVal: true, + ), ListTile( dense: false, title: Text('评论展示', style: titleStyle), @@ -187,9 +190,12 @@ class _ExtraSettingState extends State { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '评论展示', value: defaultReplySort, values: ReplySortType.values.map((e) { - return {'title': e.titles, 'value': e.index}; - }).toList()); + return SelectDialog( + title: '评论展示', + value: defaultReplySort, + values: ReplySortType.values.map((e) { + return {'title': e.titles, 'value': e.index}; + }).toList()); }, ); if (result != null) { @@ -210,9 +216,12 @@ class _ExtraSettingState extends State { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '动态展示', value: defaultDynamicType, values: DynamicsType.values.map((e) { - return {'title': e.labels, 'value': e.index}; - }).toList()); + return SelectDialog( + title: '动态展示', + value: defaultDynamicType, + values: DynamicsType.values.map((e) { + return {'title': e.labels, 'value': e.index}; + }).toList()); }, ); if (result != null) { diff --git a/lib/pages/setting/pages/home_tabbar_set.dart b/lib/pages/setting/pages/home_tabbar_set.dart new file mode 100644 index 00000000..63e87d02 --- /dev/null +++ b/lib/pages/setting/pages/home_tabbar_set.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/models/common/tab_type.dart'; +import 'package:pilipala/utils/storage.dart'; + +class TabbarSetPage extends StatefulWidget { + const TabbarSetPage({super.key}); + + @override + State createState() => _TabbarSetPageState(); +} + +class _TabbarSetPageState extends State { + Box settingStorage = GStrorage.setting; + late List defaultTabs; + late List tabbarSort; + + @override + void initState() { + super.initState(); + defaultTabs = tabsConfig; + tabbarSort = settingStorage.get(SettingBoxKey.tabbarSort, + defaultValue: ['live', 'rcmd', 'hot', 'bangumi']); + // 对 tabData 进行排序 + defaultTabs.sort((a, b) { + int indexA = tabbarSort.indexOf((a['type'] as TabType).id); + int indexB = tabbarSort.indexOf((b['type'] as TabType).id); + + // 如果类型在 sortOrder 中不存在,则放在末尾 + if (indexA == -1) indexA = tabbarSort.length; + if (indexB == -1) indexB = tabbarSort.length; + + return indexA.compareTo(indexB); + }); + } + + void saveEdit() { + List sortedTabbar = defaultTabs + .where((i) => tabbarSort.contains((i['type'] as TabType).id)) + .map((i) => (i['type'] as TabType).id) + .toList(); + settingStorage.put(SettingBoxKey.tabbarSort, sortedTabbar); + SmartDialog.showToast('保存成功,下次启动时生效'); + } + + void onReorder(int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final tabsItem = defaultTabs.removeAt(oldIndex); + defaultTabs.insert(newIndex, tabsItem); + }); + } + + @override + Widget build(BuildContext context) { + final listTiles = [ + for (int i = 0; i < defaultTabs.length; i++) ...[ + CheckboxListTile( + key: Key(defaultTabs[i]['label']), + value: tabbarSort.contains((defaultTabs[i]['type'] as TabType).id), + onChanged: (bool? newValue) { + String tabTypeId = (defaultTabs[i]['type'] as TabType).id; + if (!newValue!) { + tabbarSort.remove(tabTypeId); + } else { + tabbarSort.add(tabTypeId); + } + setState(() {}); + }, + title: Text(defaultTabs[i]['label']), + secondary: const Icon(Icons.drag_indicator_rounded), + ) + ] + ]; + + return Scaffold( + appBar: AppBar( + title: const Text('Tabbar编辑'), + actions: [ + TextButton(onPressed: () => saveEdit(), child: const Text('保存')), + const SizedBox(width: 12) + ], + ), + body: ReorderableListView( + onReorder: onReorder, + physics: const NeverScrollableScrollPhysics(), + footer: SizedBox( + height: MediaQuery.of(context).padding.bottom + 30, + ), + children: listTiles, + ), + ); + } +} diff --git a/lib/pages/setting/pages/logs.dart b/lib/pages/setting/pages/logs.dart new file mode 100644 index 00000000..0958edb8 --- /dev/null +++ b/lib/pages/setting/pages/logs.dart @@ -0,0 +1,201 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../services/loggeer.dart'; + +class LogsPage extends StatefulWidget { + const LogsPage({super.key}); + + @override + State createState() => _LogsPageState(); +} + +class _LogsPageState extends State { + late File logsPath; + late String fileContent; + List logsContent = []; + + @override + void initState() { + getPath(); + super.initState(); + } + + void getPath() async { + logsPath = await getLogsPath(); + fileContent = await logsPath.readAsString(); + logsContent = await parseLogs(fileContent); + setState(() {}); + } + + Future>> parseLogs(String fileContent) async { + const String splitToken = + '======================================================================'; + List contentList = fileContent.split(splitToken).map((item) { + return item + .replaceAll( + '============================== CATCHER 2 LOG ==============================', + 'Pilipala错误日志 \n ********************') + .replaceAll('DEVICE INFO', '设备信息') + .replaceAll('APP INFO', '应用信息') + .replaceAll('ERROR', '错误信息') + .replaceAll('STACK TRACE', '错误堆栈'); + }).toList(); + List> result = []; + for (String i in contentList) { + DateTime? date; + String body = i + .split("\n") + .map((l) { + if (l.startsWith("Crash occurred on")) { + date = DateTime.parse( + l.split("Crash occurred on")[1].trim().split('.')[0], + ); + return ""; + } + return l; + }) + .where((dynamic l) => l.replaceAll("\n", "").trim().isNotEmpty) + .join("\n"); + if (date != null || body != '') { + result.add({'date': date, 'body': body, 'expand': false}); + } + } + return result.reversed.toList(); + } + + void copyLogs() async { + await Clipboard.setData(ClipboardData(text: fileContent)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('复制成功')), + ); + } + } + + void feedback() { + launchUrl( + Uri.parse('https://github.com/guozhigq/pilipala/issues'), + // 系统自带浏览器打开 + mode: LaunchMode.externalApplication, + ); + } + + void clearLogsHandle() async { + if (await clearLogs()) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已清空')), + ); + logsContent = []; + setState(() {}); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text('日志', style: Theme.of(context).textTheme.titleMedium), + actions: [ + PopupMenuButton( + onSelected: (String type) { + // 处理菜单项选择的逻辑 + switch (type) { + case 'copy': + copyLogs(); + break; + case 'feedback': + feedback(); + break; + case 'clear': + clearLogsHandle(); + break; + default: + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 'copy', + child: Text('复制日志'), + ), + const PopupMenuItem( + value: 'feedback', + child: Text('错误反馈'), + ), + const PopupMenuItem( + value: 'clear', + child: Text('清空日志'), + ), + ], + ), + const SizedBox(width: 6), + ], + ), + body: logsContent.isNotEmpty + ? ListView.builder( + itemCount: logsContent.length, + itemBuilder: (context, index) { + final log = logsContent[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + log['date'].toString(), + style: Theme.of(context).textTheme.titleMedium, + ), + ), + TextButton.icon( + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: log['body']), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '已将 ${log['date'].toString()} 复制至剪贴板', + ), + ), + ); + } + }, + icon: const Icon(Icons.copy_outlined, size: 16), + label: const Text('复制'), + ) + ], + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + elevation: 1, + clipBehavior: Clip.antiAliasWithSaveLayer, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: SelectableText(log['body']), + ), + ), + ), + const Divider(indent: 12, endIndent: 12), + ], + ); + }, + ) + : const CustomScrollView( + slivers: [ + NoData(), + ], + ), + ); + } +} diff --git a/lib/pages/setting/pages/play_gesture_set.dart b/lib/pages/setting/pages/play_gesture_set.dart new file mode 100644 index 00000000..f688c43c --- /dev/null +++ b/lib/pages/setting/pages/play_gesture_set.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/utils/global_data.dart'; + +import '../../../models/common/gesture_mode.dart'; +import '../../../utils/storage.dart'; +import '../widgets/select_dialog.dart'; +import '../widgets/switch_item.dart'; + +class PlayGesturePage extends StatefulWidget { + const PlayGesturePage({super.key}); + + @override + State createState() => _PlayGesturePageState(); +} + +class _PlayGesturePageState extends State { + Box setting = GStrorage.setting; + late int fullScreenGestureMode; + + @override + void initState() { + super.initState(); + fullScreenGestureMode = setting.get(SettingBoxKey.fullScreenGestureMode, + defaultValue: FullScreenGestureMode.values.last.index); + } + + @override + Widget build(BuildContext context) { + TextStyle titleStyle = Theme.of(context).textTheme.titleMedium!; + TextStyle subTitleStyle = Theme.of(context) + .textTheme + .labelMedium! + .copyWith(color: Theme.of(context).colorScheme.outline); + return Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text( + '手势设置', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + body: ListView( + children: [ + ListTile( + dense: false, + title: Text('全屏手势', style: titleStyle), + subtitle: Text( + '通过手势快速进入全屏', + style: subTitleStyle, + ), + onTap: () async { + String? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '全屏手势', + value: FullScreenGestureMode + .values[fullScreenGestureMode].values, + values: FullScreenGestureMode.values.map((e) { + return {'title': e.labels, 'value': e.values}; + }).toList()); + }, + ); + if (result != null) { + GlobalData().fullScreenGestureMode = FullScreenGestureMode + .values + .firstWhere((element) => element.values == result); + fullScreenGestureMode = + GlobalData().fullScreenGestureMode.index; + setting.put( + SettingBoxKey.fullScreenGestureMode, fullScreenGestureMode); + setState(() {}); + } + }, + ), + const SetSwitchItem( + title: '双击快退/快进', + subTitle: '左侧双击快退,右侧双击快进', + setKey: SettingBoxKey.enableQuickDouble, + defaultVal: true, + ), + ], + ), + ); + } +} diff --git a/lib/pages/setting/pages/play_speed_set.dart b/lib/pages/setting/pages/play_speed_set.dart index ceff07ed..eb81f586 100644 --- a/lib/pages/setting/pages/play_speed_set.dart +++ b/lib/pages/setting/pages/play_speed_set.dart @@ -17,6 +17,7 @@ class _PlaySpeedPageState extends State { Box videoStorage = GStrorage.video; Box settingStorage = GStrorage.setting; late double playSpeedDefault; + late List playSpeedSystem; late double longPressSpeedDefault; late List customSpeedsList; late bool enableAutoLongPressSpeed; @@ -53,6 +54,9 @@ class _PlaySpeedPageState extends State { @override void initState() { super.initState(); + // 系统预设倍速 + playSpeedSystem = + videoStorage.get(VideoBoxKey.playSpeedSystem, defaultValue: playSpeed); // 默认倍速 playSpeedDefault = videoStorage.get(VideoBoxKey.playSpeedDefault, defaultValue: 1.0); @@ -64,6 +68,7 @@ class _PlaySpeedPageState extends State { videoStorage.get(VideoBoxKey.customSpeedsList, defaultValue: []); enableAutoLongPressSpeed = settingStorage .get(SettingBoxKey.enableAutoLongPressSpeed, defaultValue: false); + // 开启动态长按倍速时不展示 if (enableAutoLongPressSpeed) { Map newItem = sheetMenu[1]; newItem['show'] = false; @@ -123,7 +128,7 @@ class _PlaySpeedPageState extends State { } // 设定倍速弹窗 - void showBottomSheet(type, i) { + void showBottomSheet(String type, int i) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -159,18 +164,11 @@ class _PlaySpeedPageState extends State { } // - void menuAction(type, index, id) async { + void menuAction(type, int index, id) async { double chooseSpeed = 1.0; - if (type == 'system' && id == -1) { - SmartDialog.showToast('系统预设倍速不支持删除'); - return; - } // 获取当前选中的倍速值 - if (type == 'system') { - chooseSpeed = PlaySpeed.values[index].value; - } else { - chooseSpeed = customSpeedsList[index]; - } + chooseSpeed = + type == 'system' ? playSpeedSystem[index] : customSpeedsList[index]; // 设置 if (id == 1) { // 设置默认倍速 @@ -182,17 +180,22 @@ class _PlaySpeedPageState extends State { videoStorage.put( VideoBoxKey.longPressSpeedDefault, longPressSpeedDefault); } else if (id == -1) { - if (customSpeedsList[index] == playSpeedDefault) { - playSpeedDefault = 1.0; - videoStorage.put(VideoBoxKey.playSpeedDefault, playSpeedDefault); + late List speedsList = + type == 'system' ? playSpeedSystem : customSpeedsList; + if (speedsList[index] == playSpeedDefault) { + SmartDialog.showToast('默认倍速不可删除'); } - if (customSpeedsList[index] == longPressSpeedDefault) { + if (speedsList[index] == longPressSpeedDefault) { longPressSpeedDefault = 2.0; videoStorage.put( VideoBoxKey.longPressSpeedDefault, longPressSpeedDefault); } - customSpeedsList.removeAt(index); - await videoStorage.put(VideoBoxKey.customSpeedsList, customSpeedsList); + speedsList.removeAt(index); + await videoStorage.put( + type == 'system' + ? VideoBoxKey.playSpeedSystem + : VideoBoxKey.customSpeedsList, + speedsList); } setState(() {}); SmartDialog.showToast('操作成功'); @@ -249,38 +252,40 @@ class _PlaySpeedPageState extends State { subtitle: Text(longPressSpeedDefault.toString()), ) : const SizedBox(), - Padding( - padding: const EdgeInsets.only( - left: 14, - right: 14, - bottom: 10, - top: 20, + if (playSpeedSystem.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only( + left: 14, + right: 14, + bottom: 10, + top: 20, + ), + child: Text( + '系统预设倍速', + style: Theme.of(context).textTheme.titleMedium, + ), ), - child: Text( - '系统预设倍速', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Padding( - padding: const EdgeInsets.only( - left: 18, - right: 18, - bottom: 30, - ), - child: Wrap( - alignment: WrapAlignment.start, - spacing: 8, - runSpacing: 2, - children: [ - for (var i in PlaySpeed.values) ...[ - FilledButton.tonal( - onPressed: () => showBottomSheet('system', i.index), - child: Text(i.description), - ), - ] - ], - ), - ), + Padding( + padding: const EdgeInsets.only( + left: 18, + right: 18, + bottom: 30, + ), + child: Wrap( + alignment: WrapAlignment.start, + spacing: 8, + runSpacing: 2, + children: [ + for (int i = 0; i < playSpeedSystem.length; i++) ...[ + FilledButton.tonal( + onPressed: () => showBottomSheet('system', i), + child: Text(playSpeedSystem[i].toString()), + ), + ] + ], + ), + ) + ], Padding( padding: const EdgeInsets.only( left: 14, diff --git a/lib/pages/setting/play_setting.dart b/lib/pages/setting/play_setting.dart index 4dd76a7a..07d736e3 100644 --- a/lib/pages/setting/play_setting.dart +++ b/lib/pages/setting/play_setting.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; @@ -5,8 +7,10 @@ import 'package:pilipala/models/video/play/quality.dart'; import 'package:pilipala/pages/setting/widgets/select_dialog.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'package:pilipala/services/service_locator.dart'; +import 'package:pilipala/utils/global_data.dart'; import 'package:pilipala/utils/storage.dart'; +import '../../models/live/quality.dart'; import 'widgets/switch_item.dart'; class PlaySetting extends StatefulWidget { @@ -19,6 +23,7 @@ class PlaySetting extends StatefulWidget { class _PlaySettingState extends State { Box setting = GStrorage.setting; late dynamic defaultVideoQa; + late dynamic defaultLiveQa; late dynamic defaultAudioQa; late dynamic defaultDecode; late int defaultFullScreenMode; @@ -29,6 +34,8 @@ class _PlaySettingState extends State { super.initState(); defaultVideoQa = setting.get(SettingBoxKey.defaultVideoQa, defaultValue: VideoQuality.values.last.code); + defaultLiveQa = setting.get(SettingBoxKey.defaultLiveQa, + defaultValue: LiveQuality.values.last.code); defaultAudioQa = setting.get(SettingBoxKey.defaultAudioQa, defaultValue: AudioQuality.values.last.code); defaultDecode = setting.get(SettingBoxKey.defaultDecode, @@ -71,6 +78,12 @@ class _PlaySettingState extends State { title: Text('倍速设置', style: titleStyle), subtitle: Text('设置视频播放速度', style: subTitleStyle), ), + ListTile( + dense: false, + onTap: () => Get.toNamed('/playerGestureSet'), + title: Text('手势设置', style: titleStyle), + subtitle: Text('设置播放器手势', style: subTitleStyle), + ), const SetSwitchItem( title: '开启1080P', subTitle: '免登录查看1080P视频', @@ -95,12 +108,13 @@ class _PlaySettingState extends State { setKey: SettingBoxKey.enableBackgroundPlay, defaultVal: false, ), - const SetSwitchItem( - title: '自动PiP播放', - subTitle: '进入后台时画中画播放', - setKey: SettingBoxKey.autoPiP, - defaultVal: false, - ), + if (Platform.isAndroid) + const SetSwitchItem( + title: '自动PiP播放', + subTitle: '进入后台时画中画播放', + setKey: SettingBoxKey.autoPiP, + defaultVal: false, + ), const SetSwitchItem( title: '自动全屏', subTitle: '视频开始播放时进入全屏', @@ -131,32 +145,37 @@ class _PlaySettingState extends State { setKey: SettingBoxKey.enableAutoBrightness, defaultVal: false, ), - const SetSwitchItem( - title: '双击快退/快进', - subTitle: '左侧双击快退,右侧双击快进', - setKey: SettingBoxKey.enableQuickDouble, - defaultVal: true, - ), const SetSwitchItem( title: '弹幕开关', subTitle: '展示弹幕', setKey: SettingBoxKey.enableShowDanmaku, defaultVal: false, ), + SetSwitchItem( + title: '控制栏动画', + subTitle: '播放器控制栏显示动画效果', + setKey: SettingBoxKey.enablePlayerControlAnimation, + defaultVal: true, + callFn: (bool val) { + GlobalData().enablePlayerControlAnimation = val; + }), ListTile( dense: false, - title: Text('默认画质', style: titleStyle), + title: Text('默认视频画质', style: titleStyle), subtitle: Text( - '当前画质${VideoQualityCode.fromCode(defaultVideoQa)!.description!}', + '当前默认画质${VideoQualityCode.fromCode(defaultVideoQa)!.description!}', style: subTitleStyle, ), onTap: () async { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '默认画质', value: defaultVideoQa, values: VideoQuality.values.reversed.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return SelectDialog( + title: '默认视频画质', + value: defaultVideoQa, + values: VideoQuality.values.reversed.map((e) { + return {'title': e.description, 'value': e.code}; + }).toList()); }, ); if (result != null) { @@ -166,6 +185,32 @@ class _PlaySettingState extends State { } }, ), + ListTile( + dense: false, + title: Text('默认直播画质', style: titleStyle), + subtitle: Text( + '当前默认画质${LiveQualityCode.fromCode(defaultLiveQa)!.description!}', + style: subTitleStyle, + ), + onTap: () async { + int? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '默认直播画质', + value: defaultLiveQa, + values: LiveQuality.values.reversed.map((e) { + return {'title': e.description, 'value': e.code}; + }).toList()); + }, + ); + if (result != null) { + defaultLiveQa = result; + setting.put(SettingBoxKey.defaultLiveQa, result); + setState(() {}); + } + }, + ), ListTile( dense: false, title: Text('默认音质', style: titleStyle), @@ -177,9 +222,12 @@ class _PlaySettingState extends State { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '默认音质', value: defaultAudioQa, values: AudioQuality.values.reversed.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return SelectDialog( + title: '默认音质', + value: defaultAudioQa, + values: AudioQuality.values.reversed.map((e) { + return {'title': e.description, 'value': e.code}; + }).toList()); }, ); if (result != null) { @@ -200,9 +248,12 @@ class _PlaySettingState extends State { String? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '默认解码格式', value: defaultDecode, values: VideoDecodeFormats.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return SelectDialog( + title: '默认解码格式', + value: defaultDecode, + values: VideoDecodeFormats.values.map((e) { + return {'title': e.description, 'value': e.code}; + }).toList()); }, ); if (result != null) { @@ -223,9 +274,12 @@ class _PlaySettingState extends State { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '默认全屏方式', value: defaultFullScreenMode, values: FullScreenMode.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return SelectDialog( + title: '默认全屏方式', + value: defaultFullScreenMode, + values: FullScreenMode.values.map((e) { + return {'title': e.description, 'value': e.code}; + }).toList()); }, ); if (result != null) { @@ -246,9 +300,12 @@ class _PlaySettingState extends State { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '底部进度条展示', value: defaultBtmProgressBehavior, values: BtmProgresBehavior.values.map((e) { - return {'title': e.description, 'value': e.code}; - }).toList()); + return SelectDialog( + title: '底部进度条展示', + value: defaultBtmProgressBehavior, + values: BtmProgresBehavior.values.map((e) { + return {'title': e.description, 'value': e.code}; + }).toList()); }, ); if (result != null) { diff --git a/lib/pages/setting/recommend_setting.dart b/lib/pages/setting/recommend_setting.dart new file mode 100644 index 00000000..ab8ec063 --- /dev/null +++ b/lib/pages/setting/recommend_setting.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/http/member.dart'; +import 'package:pilipala/models/common/rcmd_type.dart'; +import 'package:pilipala/pages/setting/widgets/select_dialog.dart'; +import 'package:pilipala/utils/recommend_filter.dart'; +import 'package:pilipala/utils/storage.dart'; + +import 'widgets/switch_item.dart'; + +class RecommendSetting extends StatefulWidget { + const RecommendSetting({super.key}); + + @override + State createState() => _RecommendSettingState(); +} + +class _RecommendSettingState extends State { + Box setting = GStrorage.setting; + static Box localCache = GStrorage.localCache; + late dynamic defaultRcmdType; + Box userInfoCache = GStrorage.userInfo; + late dynamic userInfo; + bool userLogin = false; + late dynamic accessKeyInfo; + // late int filterUnfollowedRatio; + late int minDurationForRcmd; + late int minLikeRatioForRecommend; + + @override + void initState() { + super.initState(); + // 首页默认推荐类型 + defaultRcmdType = + setting.get(SettingBoxKey.defaultRcmdType, defaultValue: 'web'); + userInfo = userInfoCache.get('userInfoCache'); + userLogin = userInfo != null; + accessKeyInfo = localCache.get(LocalCacheKey.accessKey, defaultValue: null); + // filterUnfollowedRatio = setting + // .get(SettingBoxKey.filterUnfollowedRatio, defaultValue: 0); + minDurationForRcmd = + setting.get(SettingBoxKey.minDurationForRcmd, defaultValue: 0); + minLikeRatioForRecommend = + setting.get(SettingBoxKey.minLikeRatioForRecommend, defaultValue: 0); + } + + @override + Widget build(BuildContext context) { + TextStyle titleStyle = Theme.of(context).textTheme.titleMedium!; + TextStyle subTitleStyle = Theme.of(context) + .textTheme + .labelMedium! + .copyWith(color: Theme.of(context).colorScheme.outline); + return Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text( + '推荐设置', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + body: ListView( + children: [ + ListTile( + dense: false, + title: Text('首页推荐类型', style: titleStyle), + subtitle: Text( + '当前使用「$defaultRcmdType端」推荐¹', + style: subTitleStyle, + ), + onTap: () async { + String? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '推荐类型', + value: defaultRcmdType, + values: RcmdType.values.map((e) { + return {'title': e.labels, 'value': e.values}; + }).toList(), + ); + }, + ); + if (result != null) { + if (result == 'app') { + // app端推荐需要access_key + if (accessKeyInfo == null) { + if (!userLogin) { + SmartDialog.showToast('请先登录'); + return; + } + // 显示一个确认框,告知用户可能会导致账号被风控 + SmartDialog.show( + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (context) { + return AlertDialog( + title: const Text('提示'), + content: const Text( + '使用app端推荐需获取access_key,有小概率触发风控导致账号退出(在官方版本app重新登录即可解除),是否继续?'), + actions: [ + TextButton( + onPressed: () { + result = null; + SmartDialog.dismiss(); + }, + child: const Text('取消'), + ), + TextButton( + onPressed: () async { + SmartDialog.dismiss(); + await MemberHttp.cookieToKey(); + }, + child: const Text('确定'), + ), + ], + ); + }); + } + } + if (result != null) { + defaultRcmdType = result; + setting.put(SettingBoxKey.defaultRcmdType, result); + SmartDialog.showToast('下次启动时生效'); + setState(() {}); + } + } + }, + ), + const SetSwitchItem( + title: '推荐动态', + subTitle: '是否在推荐内容中展示动态(仅app端)', + setKey: SettingBoxKey.enableRcmdDynamic, + defaultVal: true, + ), + const SetSwitchItem( + title: '首页推荐刷新', + subTitle: '下拉刷新时保留上次内容', + setKey: SettingBoxKey.enableSaveLastData, + defaultVal: false, + ), + // 分割线 + const Divider(height: 1), + ListTile( + dense: false, + title: Text('点赞率过滤', style: titleStyle), + subtitle: Text( + '过滤掉点赞数/播放量「小于$minLikeRatioForRecommend%」的推荐视频(仅web端)', + style: subTitleStyle, + ), + onTap: () async { + int? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '选择点赞率(0即不过滤)', + value: minLikeRatioForRecommend, + values: [0, 1, 2, 3, 4].map((e) { + return {'title': '$e %', 'value': e}; + }).toList()); + }, + ); + if (result != null) { + minLikeRatioForRecommend = result; + setting.put(SettingBoxKey.minLikeRatioForRecommend, result); + RecommendFilter.update(); + setState(() {}); + } + }, + ), + ListTile( + dense: false, + title: Text('视频时长过滤', style: titleStyle), + subtitle: Text( + '过滤掉时长「小于$minDurationForRcmd秒」的推荐视频', + style: subTitleStyle, + ), + onTap: () async { + int? result = await showDialog( + context: context, + builder: (context) { + return SelectDialog( + title: '选择时长(0即不过滤)', + value: minDurationForRcmd, + values: [0, 30, 60, 90, 120].map((e) { + return {'title': '$e 秒', 'value': e}; + }).toList()); + }, + ); + if (result != null) { + minDurationForRcmd = result; + setting.put(SettingBoxKey.minDurationForRcmd, result); + RecommendFilter.update(); + setState(() {}); + } + }, + ), + SetSwitchItem( + title: '已关注Up豁免推荐过滤', + subTitle: '推荐中已关注用户发布的内容不会被过滤', + setKey: SettingBoxKey.exemptFilterForFollowed, + defaultVal: true, + callFn: (_) => {RecommendFilter.update}, + ), + // ListTile( + // dense: false, + // title: Text('按比例过滤未关注Up', style: titleStyle), + // subtitle: Text( + // '滤除推荐中占比「$filterUnfollowedRatio%」的未关注用户发布的内容', + // style: subTitleStyle, + // ), + // onTap: () async { + // int? result = await showDialog( + // context: context, + // builder: (context) { + // return SelectDialog( + // title: '选择滤除比例(0即不过滤)', + // value: filterUnfollowedRatio, + // values: [0, 16, 32, 48, 64].map((e) { + // return {'title': '$e %', 'value': e}; + // }).toList()); + // }, + // ); + // if (result != null) { + // filterUnfollowedRatio = result; + // setting.put( + // SettingBoxKey.filterUnfollowedRatio, result); + // RecommendFilter.update(); + // setState(() {}); + // } + // }, + // ), + SetSwitchItem( + title: '过滤器也应用于相关视频', + subTitle: '视频详情页的相关视频也进行过滤²', + setKey: SettingBoxKey.applyFilterToRelatedVideos, + defaultVal: true, + callFn: (_) => {RecommendFilter.update}, + ), + ListTile( + dense: true, + subtitle: Text( + '¹ 若默认web端推荐不太符合预期,可尝试切换至app端。\n' + '¹ 选择“模拟未登录(notLogin)”,将以空的key请求推荐接口,但播放页仍会携带用户信息,保证账号能正常记录进度、点赞投币等。\n\n' + '² 由于接口未提供关注信息,无法豁免相关视频中的已关注Up。\n\n' + '* 其它(如热门视频、手动搜索、链接跳转等)均不受过滤器影响。\n' + '* 设定较严苛的条件可导致推荐项数锐减或多次请求,请酌情选择。\n' + '* 后续可能会增加更多过滤条件,敬请期待。', + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: Theme.of(context).colorScheme.outline.withOpacity(0.7)), + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/setting/style_setting.dart b/lib/pages/setting/style_setting.dart index 277919dd..30b9a30f 100644 --- a/lib/pages/setting/style_setting.dart +++ b/lib/pages/setting/style_setting.dart @@ -1,13 +1,18 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/models/common/theme_type.dart'; import 'package:pilipala/pages/setting/pages/color_select.dart'; import 'package:pilipala/pages/setting/widgets/select_dialog.dart'; +import 'package:pilipala/pages/setting/widgets/slide_dialog.dart'; +import 'package:pilipala/utils/global_data.dart'; import 'package:pilipala/utils/storage.dart'; +import '../../models/common/dynamic_badge_mode.dart'; +import '../../models/common/nav_bar_config.dart'; import 'controller.dart'; import 'widgets/switch_item.dart'; @@ -20,7 +25,8 @@ class StyleSetting extends StatefulWidget { class _StyleSettingState extends State { final SettingController settingController = Get.put(SettingController()); - final ColorSelectController colorSelectController = Get.put(ColorSelectController()); + final ColorSelectController colorSelectController = + Get.put(ColorSelectController()); Box setting = GStrorage.setting; late int picQuality; @@ -76,12 +82,6 @@ class _StyleSettingState extends State { ), ), ), - const SetSwitchItem( - title: 'iOS路由切换', - subTitle: 'iOS路由切换样式,需重启', - setKey: SettingBoxKey.iosTransition, - defaultVal: false, - ), const SetSwitchItem( title: 'MD3样式底栏', subTitle: '符合Material You设计规范的底栏', @@ -102,14 +102,23 @@ class _StyleSettingState extends State { defaultVal: true, needReboot: true, ), + const SetSwitchItem( + title: '首页底栏背景渐变', + setKey: SettingBoxKey.enableGradientBg, + defaultVal: true, + needReboot: true, + ), ListTile( onTap: () async { int? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '自定义列数', value: defaultCustomRows, values: [1, 2, 3, 4, 5].map((e) { - return {'title': '$e 列', 'value': e}; - }).toList()); + return SelectDialog( + title: '自定义列数', + value: defaultCustomRows, + values: [1, 2, 3, 4, 5].map((e) { + return {'title': '$e 列', 'value': e}; + }).toList()); }, ); if (result != null) { @@ -167,6 +176,8 @@ class _StyleSettingState extends State { SettingBoxKey.defaultPicQa, picQuality); Get.back(); settingController.picQuality.value = picQuality; + GlobalData().imgQuality = picQuality; + SmartDialog.showToast('设置成功'); }, child: const Text('确定'), ) @@ -189,22 +200,48 @@ class _StyleSettingState extends State { ), ), ), + ListTile( + dense: false, + onTap: () async { + double? result = await showDialog( + context: context, + builder: (context) { + return SlideDialog( + title: 'Toast不透明度', + value: settingController.toastOpacity.value, + min: 0.0, + max: 1.0, + divisions: 10, + ); + }, + ); + if (result != null) { + settingController.toastOpacity.value = result; + SmartDialog.showToast('设置成功'); + setting.put(SettingBoxKey.defaultToastOp, result); + } + }, + title: Text('Toast不透明度', style: titleStyle), + subtitle: Text('自定义Toast不透明度', style: subTitleStyle), + ), ListTile( dense: false, onTap: () async { ThemeType? result = await showDialog( context: context, builder: (context) { - return SelectDialog(title: '主题模式', value: _tempThemeValue, values: ThemeType.values.map((e) { - return {'title': e.description, 'value': e}; - }).toList()); + return SelectDialog( + title: '主题模式', + value: _tempThemeValue, + values: ThemeType.values.map((e) { + return {'title': e.description, 'value': e}; + }).toList()); }, ); if (result != null) { _tempThemeValue = result; settingController.themeType.value = result; - setting.put( - SettingBoxKey.themeMode, result.code); + setting.put(SettingBoxKey.themeMode, result.code); Get.forceAppUpdate(); } }, @@ -213,12 +250,28 @@ class _StyleSettingState extends State { '当前模式:${settingController.themeType.value.description}', style: subTitleStyle)), ), + ListTile( + dense: false, + onTap: () => settingController.setDynamicBadgeMode(context), + title: Text('动态未读标记', style: titleStyle), + subtitle: Obx(() => Text( + '当前标记样式:${settingController.dynamicBadgeType.value.description}', + style: subTitleStyle)), + ), ListTile( dense: false, onTap: () => Get.toNamed('/colorSetting'), title: Text('应用主题', style: titleStyle), subtitle: Obx(() => Text( - '当前主题:${colorSelectController.type.value == 0 ? '动态取色': '指定颜色'}', + '当前主题:${colorSelectController.type.value == 0 ? '动态取色' : '指定颜色'}', + style: subTitleStyle)), + ), + ListTile( + dense: false, + onTap: () => settingController.seteDefaultHomePage(context), + title: Text('默认启动页', style: titleStyle), + subtitle: Obx(() => Text( + '当前启动页:${defaultNavigationBars.firstWhere((e) => e['id'] == settingController.defaultHomePage.value)['label']}', style: subTitleStyle)), ), ListTile( @@ -226,6 +279,11 @@ class _StyleSettingState extends State { onTap: () => Get.toNamed('/fontSizeSetting'), title: Text('字体大小', style: titleStyle), ), + ListTile( + dense: false, + onTap: () => Get.toNamed('/tabbarSetting'), + title: Text('首页tabbar', style: titleStyle), + ), if (Platform.isAndroid) ListTile( dense: false, diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index 677a4546..19cdedaf 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -24,6 +24,11 @@ class SettingPage extends StatelessWidget { dense: false, title: const Text('隐私设置'), ), + ListTile( + onTap: () => Get.toNamed('/recommendSetting'), + dense: false, + title: const Text('推荐设置'), + ), ListTile( onTap: () => Get.toNamed('/playSetting'), dense: false, diff --git a/lib/pages/setting/widgets/select_dialog.dart b/lib/pages/setting/widgets/select_dialog.dart index dffa4571..72119755 100644 --- a/lib/pages/setting/widgets/select_dialog.dart +++ b/lib/pages/setting/widgets/select_dialog.dart @@ -1,18 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:pilipala/models/common/theme_type.dart'; class SelectDialog extends StatefulWidget { final T value; final String title; final List values; - const SelectDialog({super.key, required this.value, required this.values, required this.title}); + const SelectDialog( + {super.key, + required this.value, + required this.values, + required this.title}); @override _SelectDialogState createState() => _SelectDialogState(); } class _SelectDialogState extends State> { - late T _tempValue; @override @@ -28,40 +30,39 @@ class _SelectDialogState extends State> { return AlertDialog( title: Text(widget.title), contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 12), - content: StatefulBuilder( - builder: (context, StateSetter setState) { - return SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i in widget.values) ...[ - RadioListTile( - value: i['value'], - title: Text(i['title'], style: titleStyle), - groupValue: _tempValue, - onChanged: (value) { - print(value); - setState(() { - _tempValue = value as T; - }); - }, - ), - ] - ], - ), - ); - }), + content: StatefulBuilder(builder: (context, StateSetter setState) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i in widget.values) ...[ + RadioListTile( + value: i['value'], + title: Text(i['title'], style: titleStyle), + groupValue: _tempValue, + onChanged: (value) { + setState(() { + _tempValue = value as T; + }); + }, + ), + ] + ], + ), + ); + }), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - '取消', - style: TextStyle( - color: Theme.of(context).colorScheme.outline), - )), + onPressed: () => Navigator.pop(context), + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), TextButton( - onPressed: () => Navigator.pop(context, _tempValue), - child: const Text('确定')) + onPressed: () => Navigator.pop(context, _tempValue), + child: const Text('确定'), + ) ], ); } diff --git a/lib/pages/setting/widgets/slide_dialog.dart b/lib/pages/setting/widgets/slide_dialog.dart new file mode 100644 index 00000000..7fa6eeab --- /dev/null +++ b/lib/pages/setting/widgets/slide_dialog.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +// import 'package:pilipala/models/common/theme_type.dart'; + +class SlideDialog extends StatefulWidget { + final T value; + final String title; + final double min; + final double max; + final int? divisions; + final String? suffix; + + const SlideDialog({ + super.key, + required this.value, + required this.title, + required this.min, + required this.max, + this.divisions, + this.suffix, + }); + + @override + _SlideDialogState createState() => _SlideDialogState(); +} + +class _SlideDialogState extends State> { + late double _tempValue; + + @override + void initState() { + super.initState(); + _tempValue = widget.value.toDouble(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + contentPadding: + const EdgeInsets.only(top: 20, left: 8, right: 8, bottom: 8), + content: SizedBox( + height: 40, + child: Slider( + value: _tempValue, + min: widget.min, + max: widget.max, + divisions: widget.divisions ?? 10, + label: '$_tempValue${widget.suffix ?? ''}', + onChanged: (double value) { + setState(() { + _tempValue = value; + }); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () => Navigator.pop(context, _tempValue as T), + child: const Text('确定'), + ) + ], + ); + } +} diff --git a/lib/pages/subscription/controller.dart b/lib/pages/subscription/controller.dart new file mode 100644 index 00000000..7be8d22c --- /dev/null +++ b/lib/pages/subscription/controller.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/http/user.dart'; +import 'package:pilipala/models/user/info.dart'; +import 'package:pilipala/utils/storage.dart'; + +import '../../models/user/sub_folder.dart'; + +class SubController extends GetxController { + final ScrollController scrollController = ScrollController(); + Rx subFolderData = SubFolderModelData().obs; + Box userInfoCache = GStrorage.userInfo; + UserInfoData? userInfo; + int currentPage = 1; + int pageSize = 20; + RxBool hasMore = true.obs; + + Future querySubFolder({type = 'init'}) async { + userInfo = userInfoCache.get('userInfoCache'); + if (userInfo == null) { + return {'status': false, 'msg': '账号未登录'}; + } + var res = await UserHttp.userSubFolder( + pn: currentPage, + ps: pageSize, + mid: userInfo!.mid!, + ); + if (res['status']) { + if (type == 'init') { + subFolderData.value = res['data']; + } else { + if (res['data'].list.isNotEmpty) { + subFolderData.value.list!.addAll(res['data'].list); + subFolderData.update((val) {}); + } + } + currentPage++; + } else { + SmartDialog.showToast(res['msg']); + } + return res; + } + + Future onLoad() async { + querySubFolder(type: 'onload'); + } + + // 取消订阅 + Future cancelSub(SubFolderItemData subFolderItem) async { + showDialog( + context: Get.context!, + builder: (context) => AlertDialog( + title: const Text('提示'), + content: const Text('确定取消订阅吗?'), + actions: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () async { + var res = await UserHttp.cancelSub(seasonId: subFolderItem.id!); + if (res['status']) { + subFolderData.value.list!.remove(subFolderItem); + subFolderData.update((val) {}); + SmartDialog.showToast('取消订阅成功'); + } else { + SmartDialog.showToast(res['msg']); + } + Get.back(); + }, + child: const Text('确定'), + ), + ], + ), + ); + } +} diff --git a/lib/pages/subscription/index.dart b/lib/pages/subscription/index.dart new file mode 100644 index 00000000..4d034396 --- /dev/null +++ b/lib/pages/subscription/index.dart @@ -0,0 +1,4 @@ +library sub; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/subscription/view.dart b/lib/pages/subscription/view.dart new file mode 100644 index 00000000..2d7d0cb5 --- /dev/null +++ b/lib/pages/subscription/view.dart @@ -0,0 +1,85 @@ +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'controller.dart'; +import 'widgets/item.dart'; + +class SubPage extends StatefulWidget { + const SubPage({super.key}); + + @override + State createState() => _SubPageState(); +} + +class _SubPageState extends State { + final SubController _subController = Get.put(SubController()); + late Future _futureBuilderFuture; + late ScrollController scrollController; + + @override + void initState() { + super.initState(); + _futureBuilderFuture = _subController.querySubFolder(); + scrollController = _subController.scrollController; + scrollController.addListener( + () { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 300) { + EasyThrottle.throttle('history', const Duration(seconds: 1), () { + _subController.onLoad(); + }); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: false, + titleSpacing: 0, + title: Text( + '我的订阅', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + body: FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map? data = snapshot.data; + if (data != null && data['status']) { + return Obx( + () => ListView.builder( + controller: scrollController, + itemCount: _subController.subFolderData.value.list!.length, + itemBuilder: (context, index) { + return SubItem( + subFolderItem: + _subController.subFolderData.value.list![index], + cancelSub: _subController.cancelSub); + }, + ), + ); + } else { + return CustomScrollView( + physics: const NeverScrollableScrollPhysics(), + slivers: [ + HttpError( + errMsg: data?['msg'], + fn: () => setState(() {}), + ), + ], + ); + } + } else { + // 骨架屏 + return const Text('请求中'); + } + }, + ), + ); + } +} diff --git a/lib/pages/subscription/widgets/item.dart b/lib/pages/subscription/widgets/item.dart new file mode 100644 index 00000000..5b2a0134 --- /dev/null +++ b/lib/pages/subscription/widgets/item.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/utils/utils.dart'; + +import '../../../models/user/sub_folder.dart'; + +class SubItem extends StatelessWidget { + final SubFolderItemData subFolderItem; + final Function(SubFolderItemData) cancelSub; + const SubItem({ + super.key, + required this.subFolderItem, + required this.cancelSub, + }); + + @override + Widget build(BuildContext context) { + String heroTag = Utils.makeHeroTag(subFolderItem.id); + return InkWell( + onTap: () => Get.toNamed( + '/subDetail', + arguments: subFolderItem, + parameters: { + 'heroTag': heroTag, + 'seasonId': subFolderItem.id.toString(), + }, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 7, 12, 7), + child: LayoutBuilder( + builder: (context, boxConstraints) { + double width = + (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; + return SizedBox( + height: width / StyleString.aspectRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Hero( + tag: heroTag, + child: NetworkImgLayer( + src: subFolderItem.cover, + width: maxWidth, + height: maxHeight, + ), + ); + }, + ), + ), + VideoContent( + subFolderItem: subFolderItem, + cancelSub: cancelSub, + ) + ], + ), + ); + }, + ), + ), + ); + } +} + +class VideoContent extends StatelessWidget { + final SubFolderItemData subFolderItem; + final Function(SubFolderItemData)? cancelSub; + const VideoContent({super.key, required this.subFolderItem, this.cancelSub}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subFolderItem.title!, + textAlign: TextAlign.start, + style: const TextStyle( + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 2), + Text( + '合集 UP主:${subFolderItem.upper!.name!}', + textAlign: TextAlign.start, + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 2), + Text( + '${subFolderItem.mediaCount}个视频', + textAlign: TextAlign.start, + style: TextStyle( + fontSize: Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + height: 35, + width: 35, + child: IconButton( + onPressed: () => cancelSub?.call(subFolderItem), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.outline, + padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + ), + icon: const Icon(Icons.delete_outline, size: 18), + ), + ) + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/subscription_detail/controller.dart b/lib/pages/subscription_detail/controller.dart new file mode 100644 index 00000000..6ecb894e --- /dev/null +++ b/lib/pages/subscription_detail/controller.dart @@ -0,0 +1,60 @@ +import 'package:get/get.dart'; +import 'package:pilipala/http/user.dart'; + +import '../../models/user/sub_detail.dart'; +import '../../models/user/sub_folder.dart'; + +class SubDetailController extends GetxController { + late SubFolderItemData item; + + late int seasonId; + late String heroTag; + int currentPage = 1; + bool isLoadingMore = false; + Rx subInfo = DetailInfo().obs; + RxList subList = [].obs; + RxString loadingText = '加载中...'.obs; + int mediaCount = 0; + + @override + void onInit() { + item = Get.arguments; + if (Get.parameters.keys.isNotEmpty) { + seasonId = int.parse(Get.parameters['seasonId']!); + heroTag = Get.parameters['heroTag']!; + } + super.onInit(); + } + + Future queryUserSubFolderDetail({type = 'init'}) async { + if (type == 'onLoad' && subList.length >= mediaCount) { + loadingText.value = '没有更多了'; + return; + } + isLoadingMore = true; + var res = await UserHttp.userSubFolderDetail( + seasonId: seasonId, + ps: 20, + pn: currentPage, + ); + if (res['status']) { + subInfo.value = res['data'].info; + if (currentPage == 1 && type == 'init') { + subList.value = res['data'].medias; + mediaCount = res['data'].info.mediaCount; + } else if (type == 'onLoad') { + subList.addAll(res['data'].medias); + } + if (subList.length >= mediaCount) { + loadingText.value = '没有更多了'; + } + } + currentPage += 1; + isLoadingMore = false; + return res; + } + + onLoad() { + queryUserSubFolderDetail(type: 'onLoad'); + } +} diff --git a/lib/pages/subscription_detail/index.dart b/lib/pages/subscription_detail/index.dart new file mode 100644 index 00000000..71df4b24 --- /dev/null +++ b/lib/pages/subscription_detail/index.dart @@ -0,0 +1,4 @@ +library sub_detail; + +export './controller.dart'; +export './view.dart'; diff --git a/lib/pages/subscription_detail/view.dart b/lib/pages/subscription_detail/view.dart new file mode 100644 index 00000000..d56125cd --- /dev/null +++ b/lib/pages/subscription_detail/view.dart @@ -0,0 +1,257 @@ +import 'dart:async'; + +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/skeleton/video_card_h.dart'; +import 'package:pilipala/common/widgets/http_error.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/common/widgets/no_data.dart'; + +import '../../models/user/sub_folder.dart'; +import '../../utils/utils.dart'; +import 'controller.dart'; +import 'widget/sub_video_card.dart'; + +class SubDetailPage extends StatefulWidget { + const SubDetailPage({super.key}); + + @override + State createState() => _SubDetailPageState(); +} + +class _SubDetailPageState extends State { + late final ScrollController _controller = ScrollController(); + final SubDetailController _subDetailController = + Get.put(SubDetailController()); + late StreamController titleStreamC; // a + late Future _futureBuilderFuture; + late String seasonId; + + @override + void initState() { + super.initState(); + seasonId = Get.parameters['seasonId']!; + _futureBuilderFuture = _subDetailController.queryUserSubFolderDetail(); + titleStreamC = StreamController(); + _controller.addListener( + () { + if (_controller.offset > 160) { + titleStreamC.add(true); + } else if (_controller.offset <= 160) { + titleStreamC.add(false); + } + + if (_controller.position.pixels >= + _controller.position.maxScrollExtent - 200) { + EasyThrottle.throttle('subDetail', const Duration(seconds: 1), () { + _subDetailController.onLoad(); + }); + } + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + controller: _controller, + slivers: [ + SliverAppBar( + expandedHeight: 260 - MediaQuery.of(context).padding.top, + pinned: true, + titleSpacing: 0, + title: StreamBuilder( + stream: titleStreamC.stream, + initialData: false, + builder: (context, AsyncSnapshot snapshot) { + return AnimatedOpacity( + opacity: snapshot.data ? 1 : 0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 500), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _subDetailController.item.title!, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '共${_subDetailController.item.mediaCount!}条视频', + style: Theme.of(context).textTheme.labelMedium, + ) + ], + ) + ], + ), + ); + }, + ), + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withOpacity(0.2), + ), + ), + ), + padding: EdgeInsets.only( + top: kTextTabBarHeight + + MediaQuery.of(context).padding.top + + 30, + left: 20, + right: 20), + child: SizedBox( + height: 200, + child: Row( + // mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Hero( + tag: _subDetailController.heroTag, + child: NetworkImgLayer( + width: 180, + height: 110, + src: _subDetailController.item.cover, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + _subDetailController.item.title!, + style: TextStyle( + fontSize: Theme.of(context) + .textTheme + .titleMedium! + .fontSize, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + GestureDetector( + onTap: () { + SubFolderItemData item = + _subDetailController.item; + Get.toNamed( + '/member?mid=${item.upper!.mid}', + arguments: { + 'face': item.upper!.face, + }, + ); + }, + child: Text( + _subDetailController.item.upper!.name!, + style: TextStyle( + color: + Theme.of(context).colorScheme.primary), + ), + ), + const SizedBox(height: 4), + Text( + '${Utils.numFormat(_subDetailController.item.viewCount)}次播放', + style: TextStyle( + fontSize: Theme.of(context) + .textTheme + .labelSmall! + .fontSize, + color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 15, bottom: 8, left: 14), + child: Obx( + () => Text( + '共${_subDetailController.subList.length}条视频', + style: TextStyle( + fontSize: + Theme.of(context).textTheme.labelMedium!.fontSize, + color: Theme.of(context).colorScheme.outline, + letterSpacing: 1), + ), + ), + ), + ), + FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data; + if (data['status']) { + if (_subDetailController.item.mediaCount == 0) { + return const NoData(); + } else { + List subList = _subDetailController.subList; + return Obx( + () => subList.isEmpty + ? const SliverToBoxAdapter(child: SizedBox()) + : SliverList( + delegate: + SliverChildBuilderDelegate((context, index) { + return SubVideoCardH( + videoItem: subList[index], + ); + }, childCount: subList.length), + ), + ); + } + } else { + return HttpError( + errMsg: data['msg'], + fn: () => setState(() {}), + ); + } + } else { + // 骨架屏 + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const VideoCardHSkeleton(); + }, childCount: 10), + ); + } + }, + ), + SliverToBoxAdapter( + child: Container( + height: MediaQuery.of(context).padding.bottom + 60, + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom), + child: Center( + child: Obx( + () => Text( + _subDetailController.loadingText.value, + style: TextStyle( + color: Theme.of(context).colorScheme.outline, + fontSize: 13), + ), + ), + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/subscription_detail/widget/sub_video_card.dart b/lib/pages/subscription_detail/widget/sub_video_card.dart new file mode 100644 index 00000000..11aebc39 --- /dev/null +++ b/lib/pages/subscription_detail/widget/sub_video_card.dart @@ -0,0 +1,168 @@ +import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'package:pilipala/common/constants.dart'; +import 'package:pilipala/common/widgets/stat/danmu.dart'; +import 'package:pilipala/common/widgets/stat/view.dart'; +import 'package:pilipala/http/search.dart'; +import 'package:pilipala/models/common/search_type.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import '../../../common/widgets/badge.dart'; +import '../../../models/user/sub_detail.dart'; + +// 收藏视频卡片 - 水平布局 +class SubVideoCardH extends StatelessWidget { + final SubDetailMediaItem videoItem; + final int? searchType; + + const SubVideoCardH({ + Key? key, + required this.videoItem, + this.searchType, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + int id = videoItem.id!; + String bvid = videoItem.bvid!; + String heroTag = Utils.makeHeroTag(id); + return InkWell( + onTap: () async { + int cid = await SearchHttp.ab2c(bvid: bvid); + Map parameters = { + 'bvid': bvid, + 'cid': cid.toString(), + }; + + Get.toNamed('/video', parameters: parameters, arguments: { + 'videoItem': videoItem, + 'heroTag': heroTag, + 'videoType': SearchType.video, + }); + }, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + StyleString.safeSpace, 5, StyleString.safeSpace, 5), + child: LayoutBuilder( + builder: (context, boxConstraints) { + double width = + (boxConstraints.maxWidth - StyleString.cardSpace * 6) / 2; + return SizedBox( + height: width / StyleString.aspectRatio, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + double maxWidth = boxConstraints.maxWidth; + double maxHeight = boxConstraints.maxHeight; + return Stack( + children: [ + Hero( + tag: heroTag, + child: NetworkImgLayer( + src: videoItem.cover, + width: maxWidth, + height: maxHeight, + ), + ), + PBadge( + text: Utils.timeFormat(videoItem.duration!), + right: 6.0, + bottom: 6.0, + type: 'gray', + ), + // if (videoItem.ogv != null) ...[ + // PBadge( + // text: videoItem.ogv['type_name'], + // top: 6.0, + // right: 6.0, + // bottom: null, + // left: null, + // ), + // ], + ], + ); + }, + ), + ), + VideoContent( + videoItem: videoItem, + searchType: searchType, + ) + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class VideoContent extends StatelessWidget { + final dynamic videoItem; + final int? searchType; + const VideoContent({ + super.key, + required this.videoItem, + this.searchType, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 2, 6, 0), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + videoItem.title, + textAlign: TextAlign.start, + style: const TextStyle( + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Spacer(), + Text( + Utils.dateFormat(videoItem.pubtime), + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + StatView( + theme: 'gray', + view: videoItem.cntInfo['play'], + ), + const SizedBox(width: 8), + StatDanMu( + theme: 'gray', danmu: videoItem.cntInfo['danmaku']), + const Spacer(), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/video/detail/controller.dart b/lib/pages/video/detail/controller.dart index c3bb7b4b..5c4ac14b 100644 --- a/lib/pages/video/detail/controller.dart +++ b/lib/pages/video/detail/controller.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; +import 'package:ns_danmaku/ns_danmaku.dart'; import 'package:pilipala/http/constants.dart'; import 'package:pilipala/http/video.dart'; import 'package:pilipala/models/common/reply_type.dart'; @@ -12,13 +13,16 @@ import 'package:pilipala/models/common/search_type.dart'; import 'package:pilipala/models/video/play/quality.dart'; import 'package:pilipala/models/video/play/url.dart'; import 'package:pilipala/models/video/reply/item.dart'; -import 'package:pilipala/pages/video/detail/replyReply/index.dart'; +import 'package:pilipala/pages/video/detail/reply_reply/index.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/utils/video_utils.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import '../../../models/video/subTitile/content.dart'; +import '../../../http/danmaku.dart'; +import '../../../utils/id_utils.dart'; import 'widgets/header_control.dart'; class VideoDetailController extends GetxController @@ -61,7 +65,7 @@ class VideoDetailController extends GetxController Box localCache = GStrorage.localCache; Box setting = GStrorage.setting; - int oid = 0; + RxInt oid = 0.obs; // 评论id 请求楼中楼评论使用 int fRpid = 0; @@ -89,10 +93,16 @@ class VideoDetailController extends GetxController late String cacheDecode; late int cacheAudioQa; + PersistentBottomSheetController? replyReplyBottomSheetCtr; + RxList subtitleContents = + [].obs; + late bool enableRelatedVideo; + List subtitles = []; + @override void onInit() { super.onInit(); - Map argMap = Get.arguments; + final Map argMap = Get.arguments; userInfo = userInfoCache.get('userInfoCache'); var keys = argMap.keys.toList(); if (keys.isNotEmpty) { @@ -110,7 +120,8 @@ class VideoDetailController extends GetxController autoPlay.value = setting.get(SettingBoxKey.autoPlayEnable, defaultValue: true); enableHA.value = setting.get(SettingBoxKey.enableHA, defaultValue: true); - + enableRelatedVideo = + setting.get(SettingBoxKey.enableRelatedVideo, defaultValue: true); if (userInfo == null || localCache.get(LocalCacheKey.historyPause) == true) { enableHeart = false; @@ -125,6 +136,8 @@ class VideoDetailController extends GetxController controller: plPlayerController, videoDetailCtr: this, floating: floating, + bvid: bvid, + videoType: videoType, ); // CDN优化 enableCDN = setting.get(SettingBoxKey.enableCDN, defaultValue: true); @@ -135,13 +148,15 @@ class VideoDetailController extends GetxController defaultValue: VideoDecodeFormats.values.last.code); cacheAudioQa = setting.get(SettingBoxKey.defaultAudioQa, defaultValue: AudioQuality.hiRes.code); + oid.value = IdUtils.bv2av(Get.parameters['bvid']!); + getSubtitle(); } showReplyReplyPanel() { - PersistentBottomSheetController? ctr = + replyReplyBottomSheetCtr = scaffoldKey.currentState?.showBottomSheet((BuildContext context) { return VideoReplyReplyPanel( - oid: oid, + oid: oid.value, rpid: fRpid, closePanel: () => { fRpid = 0, @@ -151,7 +166,7 @@ class VideoDetailController extends GetxController source: 'videoDetail', ); }); - ctr?.closed.then((value) { + replyReplyBottomSheetCtr?.closed.then((value) { fRpid = 0; }); } @@ -188,8 +203,8 @@ class VideoDetailController extends GetxController /// 根据currentAudioQa 重新设置audioUrl if (currentAudioQa != null) { - AudioItem firstAudio = data.dash!.audio!.firstWhere( - (i) => i.id == currentAudioQa!.code, + final AudioItem firstAudio = data.dash!.audio!.firstWhere( + (AudioItem i) => i.id == currentAudioQa!.code, orElse: () => data.dash!.audio!.first, ); audioUrl = firstAudio.baseUrl ?? ''; @@ -227,9 +242,11 @@ class VideoDetailController extends GetxController seekTo: seekToTime ?? defaultST, duration: duration ?? Duration(milliseconds: data.timeLength ?? 0), // 宽>高 水平 否则 垂直 - direction: (firstVideo.width! - firstVideo.height!) > 0 - ? 'horizontal' - : 'vertical', + direction: firstVideo.width != null && firstVideo.height != null + ? ((firstVideo.width! - firstVideo.height!) > 0 + ? 'horizontal' + : 'vertical') + : null, bvid: bvid, cid: cid.value, enableHeart: enableHeart, @@ -239,6 +256,8 @@ class VideoDetailController extends GetxController /// 开启自动全屏时,在player初始化完成后立即传入headerControl plPlayerController.headerControl = headerControl; + + plPlayerController.subtitles.value = subtitles; } // 视频链接 @@ -246,7 +265,22 @@ class VideoDetailController extends GetxController var result = await VideoHttp.videoUrl(cid: cid.value, bvid: bvid); if (result['status']) { data = result['data']; - List allVideosList = data.dash!.video!; + if (data.acceptDesc!.isNotEmpty && data.acceptDesc!.contains('试看')) { + SmartDialog.showToast( + '该视频为专属视频,仅提供试看', + displayTime: const Duration(seconds: 3), + ); + videoUrl = data.durl!.first.url!; + audioUrl = ''; + defaultST = Duration.zero; + firstVideo = VideoItem(); + if (autoPlay.value) { + await playerInit(); + isShowCover.value = false; + } + return result; + } + final List allVideosList = data.dash!.video!; try { // 当前可播放的最高质量视频 int currentHighVideoQa = allVideosList.first.quality!.code; @@ -255,7 +289,7 @@ class VideoDetailController extends GetxController int resVideoQa = currentHighVideoQa; if (cacheVideoQa! <= currentHighVideoQa) { // 如果预设的画质低于当前最高 - List numbers = data.acceptQuality! + final List numbers = data.acceptQuality! .where((e) => e <= currentHighVideoQa) .toList(); resVideoQa = Utils.findClosestNumber(cacheVideoQa!, numbers); @@ -263,13 +297,13 @@ class VideoDetailController extends GetxController currentVideoQa = VideoQualityCode.fromCode(resVideoQa)!; /// 取出符合当前画质的videoList - List videosList = + final List videosList = allVideosList.where((e) => e.quality!.code == resVideoQa).toList(); /// 优先顺序 设置中指定解码格式 -> 当前可选的首个解码格式 - List supportFormats = data.supportFormats!; + final List supportFormats = data.supportFormats!; // 根据画质选编码格式 - List supportDecodeFormats = + final List supportDecodeFormats = supportFormats.firstWhere((e) => e.quality == resVideoQa).codecs!; // 默认从设置中取AVC currentDecodeFormats = VideoDecodeFormatsCode.fromString(cacheDecode)!; @@ -304,7 +338,7 @@ class VideoDetailController extends GetxController /// 优先顺序 设置中指定质量 -> 当前可选的最高质量 late AudioItem? firstAudio; - List audiosList = data.dash!.audio!; + final List audiosList = data.dash!.audio!; try { if (data.dash!.dolby?.audio?.isNotEmpty == true) { @@ -318,7 +352,7 @@ class VideoDetailController extends GetxController } if (audiosList.isNotEmpty) { - List numbers = audiosList.map((map) => map.id!).toList(); + final List numbers = audiosList.map((map) => map.id!).toList(); int closestNumber = Utils.findClosestNumber(cacheAudioQa, numbers); if (!numbers.contains(cacheAudioQa) && numbers.any((e) => e > cacheAudioQa)) { @@ -353,4 +387,132 @@ class VideoDetailController extends GetxController } return result; } + + // mob端全屏状态关闭二级回复 + hiddenReplyReplyPanel() { + replyReplyBottomSheetCtr != null + ? replyReplyBottomSheetCtr!.close() + : print('replyReplyBottomSheetCtr is null'); + } + + // 获取字幕配置 + Future getSubtitle() async { + var result = await VideoHttp.getSubtitle(bvid: bvid, cid: cid.value); + if (result['status']) { + if (result['data'].subtitles.isNotEmpty) { + subtitles = result['data'].subtitles; + if (subtitles.isNotEmpty) { + for (var i in subtitles) { + final Map res = await VideoHttp.getSubtitleContent( + i.subtitleUrl, + ); + i.content = res['content']; + i.body = res['body']; + } + } + } + return result['data']; + } + } + + // 获取字幕内容 + // Future getSubtitleContent(String url) async { + // var res = await Request().get('https:$url'); + // subtitleContents.value = res.data['body'].map((e) { + // return SubTitileContentModel.fromJson(e); + // }).toList(); + // setSubtitleContent(); + // } + + setSubtitleContent() { + plPlayerController.subtitleContent.value = ''; + plPlayerController.subtitles.value = subtitles; + } + + clearSubtitleContent() { + plPlayerController.subtitleContent.value = ''; + plPlayerController.subtitles.value = []; + } + + /// 发送弹幕 + void showShootDanmakuSheet() { + final TextEditingController textController = TextEditingController(); + bool isSending = false; // 追踪是否正在发送 + showDialog( + context: Get.context!, + builder: (BuildContext context) { + // TODO: 支持更多类型和颜色的弹幕 + return AlertDialog( + title: const Text('发送弹幕'), + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return TextField( + controller: textController, + ); + }), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return TextButton( + onPressed: isSending + ? null + : () async { + final String msg = textController.text; + if (msg.isEmpty) { + SmartDialog.showToast('弹幕内容不能为空'); + return; + } else if (msg.length > 100) { + SmartDialog.showToast('弹幕内容不能超过100个字符'); + return; + } + setState(() { + isSending = true; // 开始发送,更新状态 + }); + //修改按钮文字 + // SmartDialog.showToast('弹幕发送中,\n$msg'); + final dynamic res = await DanmakaHttp.shootDanmaku( + oid: cid.value, + msg: textController.text, + bvid: bvid, + progress: + plPlayerController.position.value.inMilliseconds, + type: 1, + ); + setState(() { + isSending = false; // 发送结束,更新状态 + }); + if (res['status']) { + SmartDialog.showToast('发送成功'); + // 发送成功,自动预览该弹幕,避免重新请求 + // TODO: 暂停状态下预览弹幕仍会移动与计时,可考虑添加到dmSegList或其他方式实现 + plPlayerController.danmakuController?.addItems([ + DanmakuItem( + msg, + color: Colors.white, + time: plPlayerController + .position.value.inMilliseconds, + type: DanmakuItemType.scroll, + isSend: true, + ) + ]); + Get.back(); + } else { + SmartDialog.showToast('发送失败,错误信息为${res['msg']}'); + } + }, + child: Text(isSending ? '发送中...' : '发送'), + ); + }) + ], + ); + }, + ); + } } diff --git a/lib/pages/video/detail/introduction/controller.dart b/lib/pages/video/detail/introduction/controller.dart index 14fb30ce..8114bdaf 100644 --- a/lib/pages/video/detail/introduction/controller.dart +++ b/lib/pages/video/detail/introduction/controller.dart @@ -18,18 +18,13 @@ import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:share_plus/share_plus.dart'; +import '../related/index.dart'; import 'widgets/group_panel.dart'; class VideoIntroController extends GetxController { + VideoIntroController({required this.bvid}); // 视频bvid - String bvid = Get.parameters['bvid']!; - - // 是否预渲染 骨架屏 - bool preRender = false; - - // 视频详情 上个页面传入 - Map? videoItem = {}; - + String bvid; // 请求状态 RxBool isLoading = false.obs; @@ -72,26 +67,6 @@ class VideoIntroController extends GetxController { try { heroTag = Get.arguments['heroTag']; } catch (_) {} - if (Get.arguments.isNotEmpty) { - if (Get.arguments.containsKey('videoItem')) { - preRender = true; - var args = Get.arguments['videoItem']; - var keys = Get.arguments.keys.toList(); - videoItem!['pic'] = args.pic; - if (args.title is String) { - videoItem!['title'] = args.title; - } else { - String str = ''; - for (Map map in args.title) { - str += map['text']; - } - videoItem!['title'] = str; - } - videoItem!['stat'] = keys.contains('stat') && args.stat; - videoItem!['pubdate'] = keys.contains('pubdate') && args.pubdate; - videoItem!['owner'] = keys.contains('owner') && args.owner; - } - } userLogin = userInfo != null; lastPlayCid.value = int.parse(Get.parameters['cid']!); isShowOnlineTotal = @@ -110,10 +85,9 @@ class VideoIntroController extends GetxController { if (videoDetail.value.pages!.isNotEmpty && lastPlayCid.value == 0) { lastPlayCid.value = videoDetail.value.pages!.first.cid!; } - // Get.find(tag: heroTag).tabs.value = [ - // '简介', - // '评论 ${result['data']!.stat!.reply}' - // ]; + final VideoDetailController videoDetailCtr = + Get.find(tag: heroTag); + videoDetailCtr.tabs.value = ['简介', '评论 ${result['data']?.stat?.reply}']; // 获取到粉丝数再返回 await queryUserStat(); } @@ -148,7 +122,9 @@ class VideoIntroController extends GetxController { // 获取投币状态 Future queryHasCoinVideo() async { var result = await VideoHttp.hasCoinVideo(bvid: bvid); - hasCoin.value = result["data"]['multiply'] == 0 ? false : true; + if (result['status']) { + hasCoin.value = result["data"]['multiply'] == 0 ? false : true; + } } // 获取收藏状态 @@ -208,6 +184,10 @@ class VideoIntroController extends GetxController { // (取消)点赞 Future actionLikeVideo() async { + if (userInfo == null) { + SmartDialog.showToast('账号未登录'); + return; + } var result = await VideoHttp.likeVideo(bvid: bvid, type: !hasLike.value); if (result['status']) { // hasLike.value = result["data"] == 1 ? true : false; @@ -292,18 +272,17 @@ class VideoIntroController extends GetxController { await queryVideoInFolder(); int defaultFolderId = favFolderData.value.list!.first.id!; int favStatus = favFolderData.value.list!.first.favState!; - print('favStatus: $favStatus'); var result = await VideoHttp.favVideo( aid: IdUtils.bv2av(bvid), addIds: favStatus == 0 ? '$defaultFolderId' : '', delIds: favStatus == 1 ? '$defaultFolderId' : '', ); if (result['status']) { - if (result['data']['prompt']) { - // 重新获取收藏状态 - await queryHasFavVideo(); - SmartDialog.showToast('✅ 操作成功'); - } + // 重新获取收藏状态 + await queryHasFavVideo(); + SmartDialog.showToast('✅ 操作成功'); + } else { + SmartDialog.showToast(result['msg']); } return; } @@ -326,14 +305,14 @@ class VideoIntroController extends GetxController { delIds: delMediaIdsNew.join(',')); SmartDialog.dismiss(); if (result['status']) { - if (result['data']['prompt']) { - addMediaIdsNew = []; - delMediaIdsNew = []; - Get.back(); - // 重新获取收藏状态 - await queryHasFavVideo(); - SmartDialog.showToast('✅ 操作成功'); - } + addMediaIdsNew = []; + delMediaIdsNew = []; + Get.back(); + // 重新获取收藏状态 + await queryHasFavVideo(); + SmartDialog.showToast('✅ 操作成功'); + } else { + SmartDialog.showToast(result['msg']); } } @@ -389,7 +368,7 @@ class VideoIntroController extends GetxController { SmartDialog.showToast('账号未登录'); return; } - int currentStatus = followStatus['attribute']; + final int currentStatus = followStatus['attribute']; int actionStatus = 0; switch (currentStatus) { case 0: @@ -411,8 +390,12 @@ class VideoIntroController extends GetxController { content: Text(currentStatus == 0 ? '关注UP主?' : '取消关注UP主?'), actions: [ TextButton( - onPressed: () => SmartDialog.dismiss(), - child: const Text('点错了')), + onPressed: () => SmartDialog.dismiss(), + child: Text( + '点错了', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), TextButton( onPressed: () async { var result = await VideoHttp.relationMod( @@ -463,16 +446,21 @@ class VideoIntroController extends GetxController { // 修改分P或番剧分集 Future changeSeasonOrbangu(bvid, cid, aid) async { // 重新获取视频资源 - VideoDetailController videoDetailCtr = + final VideoDetailController videoDetailCtr = Get.find(tag: heroTag); + final ReleatedController releatedCtr = + Get.find(tag: heroTag); videoDetailCtr.bvid = bvid; + videoDetailCtr.oid.value = aid ?? IdUtils.bv2av(bvid); videoDetailCtr.cid.value = cid; videoDetailCtr.danmakuCid.value = cid; videoDetailCtr.queryVideoUrl(); + releatedCtr.bvid = bvid; + releatedCtr.queryRelatedVideo(); // 重新请求评论 try { /// 未渲染回复组件时可能异常 - VideoReplyController videoReplyCtr = + final VideoReplyController videoReplyCtr = Get.find(tag: heroTag); videoReplyCtr.aid = aid; videoReplyCtr.queryReplyList(type: 'init'); @@ -513,29 +501,27 @@ class VideoIntroController extends GetxController { /// 列表循环或者顺序播放时,自动播放下一个 void nextPlay() { - late List episodes; + final List episodes = []; bool isPages = false; if (videoDetail.value.ugcSeason != null) { - UgcSeason ugcSeason = videoDetail.value.ugcSeason!; - List sections = ugcSeason.sections!; - episodes = []; - + final UgcSeason ugcSeason = videoDetail.value.ugcSeason!; + final List sections = ugcSeason.sections!; for (int i = 0; i < sections.length; i++) { - List episodesList = sections[i].episodes!; + final List episodesList = sections[i].episodes!; episodes.addAll(episodesList); } } else if (videoDetail.value.pages != null) { isPages = true; - List pages = videoDetail.value.pages!; - episodes = []; + final List pages = videoDetail.value.pages!; episodes.addAll(pages); } - int currentIndex = episodes.indexWhere((e) => e.cid == lastPlayCid.value); + final int currentIndex = + episodes.indexWhere((e) => e.cid == lastPlayCid.value); int nextIndex = currentIndex + 1; - VideoDetailController videoDetailCtr = + final VideoDetailController videoDetailCtr = Get.find(tag: heroTag); - PlayRepeat platRepeat = videoDetailCtr.plPlayerController.playRepeat; + final PlayRepeat platRepeat = videoDetailCtr.plPlayerController.playRepeat; // 列表循环 if (nextIndex >= episodes.length) { @@ -546,9 +532,9 @@ class VideoIntroController extends GetxController { return; } } - int cid = episodes[nextIndex].cid!; - String rBvid = isPages ? bvid : episodes[nextIndex].bvid; - int rAid = isPages ? IdUtils.bv2av(bvid) : episodes[nextIndex].aid!; + final int cid = episodes[nextIndex].cid!; + final String rBvid = isPages ? bvid : episodes[nextIndex].bvid; + final int rAid = isPages ? IdUtils.bv2av(bvid) : episodes[nextIndex].aid!; changeSeasonOrbangu(rBvid, cid, rAid); } @@ -563,15 +549,17 @@ class VideoIntroController extends GetxController { // ai总结 Future aiConclusion() async { SmartDialog.showLoading(msg: '正在生产ai总结'); - var res = await VideoHttp.aiConclusion( + final res = await VideoHttp.aiConclusion( bvid: bvid, cid: lastPlayCid.value, upMid: videoDetail.value.owner!.mid!, ); + SmartDialog.dismiss(); if (res['status']) { modelResult = res['data'].modelResult; + } else { + SmartDialog.showToast("当前视频可能暂不支持AI视频总结"); } - SmartDialog.dismiss(); return res; } } diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index a0d4adde..a990aab8 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -1,5 +1,4 @@ -import 'dart:io'; - +import 'package:expandable/expandable.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; @@ -17,16 +16,18 @@ import 'package:pilipala/pages/video/detail/widgets/ai_detail.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; - +import '../../../../http/user.dart'; import 'widgets/action_item.dart'; -import 'widgets/action_row_item.dart'; import 'widgets/fav_panel.dart'; import 'widgets/intro_detail.dart'; import 'widgets/page.dart'; import 'widgets/season.dart'; class VideoIntroPanel extends StatefulWidget { - const VideoIntroPanel({Key? key}) : super(key: key); + final String bvid; + final String? cid; + + const VideoIntroPanel({super.key, required this.bvid, this.cid}); @override State createState() => _VideoIntroPanelState(); @@ -49,7 +50,8 @@ class _VideoIntroPanelState extends State /// fix 全屏时参数丢失 heroTag = Get.arguments['heroTag']; - videoIntroController = Get.put(VideoIntroController(), tag: heroTag); + videoIntroController = + Get.put(VideoIntroController(bvid: widget.bvid), tag: heroTag); _futureBuilderFuture = videoIntroController.queryVideoIntro(); videoIntroController.videoDetail.listen((value) { videoDetail = value; @@ -76,9 +78,9 @@ class _VideoIntroPanelState extends State // 请求成功 return Obx( () => VideoInfo( - loadingStatus: false, videoDetail: videoIntroController.videoDetail.value, heroTag: heroTag, + bvid: widget.bvid, ), ); } else { @@ -93,10 +95,13 @@ class _VideoIntroPanelState extends State ); } } else { - return VideoInfo( - loadingStatus: true, - videoDetail: videoDetail, - heroTag: heroTag, + return const SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: Center( + child: CircularProgressIndicator(), + ), + ), ); } }, @@ -105,31 +110,28 @@ class _VideoIntroPanelState extends State } class VideoInfo extends StatefulWidget { - final bool loadingStatus; final VideoDetailData? videoDetail; final String? heroTag; + final String bvid; - const VideoInfo( - {Key? key, this.loadingStatus = false, this.videoDetail, this.heroTag}) - : super(key: key); + const VideoInfo({ + Key? key, + this.videoDetail, + this.heroTag, + required this.bvid, + }) : super(key: key); @override State createState() => _VideoInfoState(); } class _VideoInfoState extends State with TickerProviderStateMixin { - // final String heroTag = Get.arguments['heroTag']; late String heroTag; late final VideoIntroController videoIntroController; late final VideoDetailController videoDetailCtr; - late final Map videoItem; - - Box localCache = GStrorage.localCache; - Box setting = GStrorage.setting; + final Box localCache = GStrorage.localCache; + final Box setting = GStrorage.setting; late double sheetHeight; - - late final bool loadingStatus; // 加载状态 - late final dynamic owner; late final dynamic follower; late final dynamic followStatus; @@ -137,29 +139,33 @@ class _VideoInfoState extends State with TickerProviderStateMixin { late String memberHeroTag; late bool enableAi; bool isProcessing = false; + RxBool isExpand = false.obs; + late ExpandableController _expandableCtr; + void Function()? handleState(Future Function() action) { - return isProcessing ? null : () async { - setState(() => isProcessing = true); - await action(); - setState(() => isProcessing = false); - }; + return isProcessing + ? null + : () async { + setState(() => isProcessing = true); + await action(); + setState(() => isProcessing = false); + }; } + @override void initState() { super.initState(); heroTag = widget.heroTag!; - videoIntroController = Get.put(VideoIntroController(), tag: heroTag); + videoIntroController = + Get.put(VideoIntroController(bvid: widget.bvid), tag: heroTag); videoDetailCtr = Get.find(tag: heroTag); - videoItem = videoIntroController.videoItem!; sheetHeight = localCache.get('sheetHeight'); - loadingStatus = widget.loadingStatus; - owner = loadingStatus ? videoItem['owner'] : widget.videoDetail!.owner; - follower = loadingStatus - ? '-' - : Utils.numFormat(videoIntroController.userStat['follower']); + owner = widget.videoDetail!.owner; + follower = Utils.numFormat(videoIntroController.userStat['follower']); followStatus = videoIntroController.followStatus; enableAi = setting.get(SettingBoxKey.enableAi, defaultValue: true); + _expandableCtr = ExpandableController(initialExpanded: false); } // 收藏 @@ -168,7 +174,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { SmartDialog.showToast('账号未登录'); return; } - bool enableDragQuickFav = + final bool enableDragQuickFav = setting.get(SettingBoxKey.enableQuickFav, defaultValue: false); // 快速收藏 & // 点按 收藏至默认文件夹 @@ -182,7 +188,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { context: context, useRootNavigator: true, isScrollControlled: true, - builder: (context) { + builder: (BuildContext context) { return FavPanel(ctr: videoIntroController); }, ); @@ -192,7 +198,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { context: context, useRootNavigator: true, isScrollControlled: true, - builder: (context) { + builder: (BuildContext context) { return FavPanel(ctr: videoIntroController); }, ); @@ -202,7 +208,7 @@ class _VideoInfoState extends State with TickerProviderStateMixin { context: context, useRootNavigator: true, isScrollControlled: true, - builder: (context) { + builder: (BuildContext context) { return FavPanel(ctr: videoIntroController); }, ); @@ -211,29 +217,17 @@ class _VideoInfoState extends State with TickerProviderStateMixin { // 视频介绍 showIntroDetail() { - if (loadingStatus) { - return; - } feedBack(); - showBottomSheet( - context: context, - enableDrag: true, - builder: (BuildContext context) { - return IntroDetail(videoDetail: widget.videoDetail!); - }, - ); + isExpand.value = !(isExpand.value); + _expandableCtr.toggle(); } // 用户主页 onPushMember() { feedBack(); - mid = !loadingStatus - ? widget.videoDetail!.owner!.mid - : videoItem['owner'].mid; + mid = widget.videoDetail!.owner!.mid!; memberHeroTag = Utils.makeHeroTag(mid); - String face = !loadingStatus - ? widget.videoDetail!.owner!.face - : videoItem['owner'].face; + String face = widget.videoDetail!.owner!.face!; Get.toNamed('/member?mid=$mid', arguments: {'face': face, 'heroTag': memberHeroTag}); } @@ -249,236 +243,245 @@ class _VideoInfoState extends State with TickerProviderStateMixin { ); } + @override + void dispose() { + _expandableCtr.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - ThemeData t = Theme.of(context); - Color outline = t.colorScheme.outline; + final ThemeData t = Theme.of(context); + final Color outline = t.colorScheme.outline; return SliverPadding( padding: const EdgeInsets.only( - left: StyleString.safeSpace, right: StyleString.safeSpace, top: 10), + left: StyleString.safeSpace, + right: StyleString.safeSpace, + top: 16, + ), sliver: SliverToBoxAdapter( - child: !loadingStatus - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => showIntroDetail(), - child: Text( - !loadingStatus - ? widget.videoDetail!.title - : videoItem['title'], - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - Stack( - children: [ - GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => showIntroDetail(), - child: Padding( - padding: const EdgeInsets.only(top: 7, bottom: 6), - child: Row( - children: [ - StatView( - theme: 'gray', - view: !loadingStatus - ? widget.videoDetail!.stat!.view - : videoItem['stat'].view, - size: 'medium', - ), - const SizedBox(width: 10), - StatDanMu( - theme: 'gray', - danmu: !loadingStatus - ? widget.videoDetail!.stat!.danmaku - : videoItem['stat'].danmaku, - size: 'medium', - ), - const SizedBox(width: 10), - Text( - Utils.dateFormat( - !loadingStatus - ? widget.videoDetail!.pubdate - : videoItem['pubdate'], - formatType: 'detail'), - style: TextStyle( - fontSize: 12, - color: t.colorScheme.outline, - ), - ), - const SizedBox(width: 10), - if (videoIntroController.isShowOnlineTotal) - Obx( - () => Text( - '${videoIntroController.total.value}人在看', - style: TextStyle( - fontSize: 12, - color: t.colorScheme.outline, - ), - ), - ), - ], - ), - ), - ), - if (enableAi) - Positioned( - right: 10, - top: 6, - child: GestureDetector( - onTap: () async { - var res = - await videoIntroController.aiConclusion(); - if (res['status']) { - showAiBottomSheet(); - } - }, - child: - Image.asset('assets/images/ai.png', height: 22), - ), - ) - ], - ), - // 点赞收藏转发 布局样式1 - // SingleChildScrollView( - // padding: const EdgeInsets.only(top: 7, bottom: 7), - // scrollDirection: Axis.horizontal, - // child: actionRow( - // context, - // videoIntroController, - // videoDetailCtr, - // ), - // ), - // 点赞收藏转发 布局样式2 - actionGrid(context, videoIntroController), - // 合集 - if (!loadingStatus && - widget.videoDetail!.ugcSeason != null) ...[ - Obx( - () => SeasonPanel( - ugcSeason: widget.videoDetail!.ugcSeason!, - cid: videoIntroController.lastPlayCid.value != 0 - ? videoIntroController.lastPlayCid.value - : widget.videoDetail!.pages!.first.cid, - sheetHeight: sheetHeight, - changeFuc: (bvid, cid, aid) => videoIntroController - .changeSeasonOrbangu(bvid, cid, aid), - ), - ) - ], - if (!loadingStatus && - widget.videoDetail!.pages != null && - widget.videoDetail!.pages!.length > 1) ...[ - Obx(() => PagesPanel( - pages: widget.videoDetail!.pages!, - cid: videoIntroController.lastPlayCid.value, - sheetHeight: sheetHeight, - changeFuc: (cid) => - videoIntroController.changeSeasonOrbangu( - videoIntroController.bvid, cid, null), - )) - ], - GestureDetector( - onTap: onPushMember, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 4), - child: Row( - children: [ - NetworkImgLayer( - type: 'avatar', - src: loadingStatus - ? owner.face - : widget.videoDetail!.owner!.face, - width: 34, - height: 34, - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - ), - const SizedBox(width: 10), - Text(owner.name, - style: const TextStyle(fontSize: 13)), - const SizedBox(width: 6), - Text( - follower, - style: TextStyle( - fontSize: t.textTheme.labelSmall!.fontSize, - color: outline, - ), - ), - const Spacer(), - AnimatedOpacity( - opacity: loadingStatus ? 0 : 1, - duration: const Duration(milliseconds: 150), - child: SizedBox( - height: 32, - child: Obx( - () => - videoIntroController.followStatus.isNotEmpty - ? TextButton( - onPressed: videoIntroController - .actionRelationMod, - style: TextButton.styleFrom( - padding: const EdgeInsets.only( - left: 8, right: 8), - foregroundColor: - followStatus['attribute'] != 0 - ? outline - : t.colorScheme.onPrimary, - backgroundColor: - followStatus['attribute'] != 0 - ? t.colorScheme - .onInverseSurface - : t.colorScheme - .primary, // 设置按钮背景色 - ), - child: Text( - followStatus['attribute'] != 0 - ? '已关注' - : '关注', - style: TextStyle( - fontSize: t.textTheme - .labelMedium!.fontSize), - ), - ) - : ElevatedButton( - onPressed: videoIntroController - .actionRelationMod, - child: const Text('关注'), - ), - ), - ), - ), - ], - ), - ), - ), - ], - ) - : const SizedBox( - height: 100, - child: Center( - child: CircularProgressIndicator(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => showIntroDetail(), + child: ExpandablePanel( + controller: _expandableCtr, + collapsed: Text( + widget.videoDetail!.title!, + softWrap: true, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, ), ), - ), + expanded: Text( + widget.videoDetail!.title!, + softWrap: true, + maxLines: 4, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + theme: const ExpandableThemeData( + animationDuration: Duration(milliseconds: 300), + scrollAnimationDuration: Duration(milliseconds: 300), + crossFadePoint: 0, + fadeCurve: Curves.ease, + sizeCurve: Curves.linear, + ), + ), + ), + Stack( + children: [ + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => showIntroDetail(), + child: Padding( + padding: const EdgeInsets.only(top: 7, bottom: 6), + child: Row( + children: [ + StatView( + theme: 'gray', + view: widget.videoDetail!.stat!.view, + size: 'medium', + ), + const SizedBox(width: 10), + StatDanMu( + theme: 'gray', + danmu: widget.videoDetail!.stat!.danmaku, + size: 'medium', + ), + const SizedBox(width: 10), + Text( + Utils.dateFormat(widget.videoDetail!.pubdate, + formatType: 'detail'), + style: TextStyle( + fontSize: 12, + color: t.colorScheme.outline, + ), + ), + const SizedBox(width: 10), + if (videoIntroController.isShowOnlineTotal) + Obx( + () => Text( + '${videoIntroController.total.value}人在看', + style: TextStyle( + fontSize: 12, + color: t.colorScheme.outline, + ), + ), + ), + ], + ), + ), + ), + if (enableAi) + Positioned( + right: 10, + top: 6, + child: GestureDetector( + onTap: () async { + final res = await videoIntroController.aiConclusion(); + if (res['status']) { + showAiBottomSheet(); + } + }, + child: Image.asset('assets/images/ai.png', height: 22), + ), + ) + ], + ), + + /// 视频简介 + ExpandablePanel( + controller: _expandableCtr, + collapsed: const SizedBox(height: 0), + expanded: IntroDetail(videoDetail: widget.videoDetail!), + theme: const ExpandableThemeData( + animationDuration: Duration(milliseconds: 300), + scrollAnimationDuration: Duration(milliseconds: 300), + crossFadePoint: 0, + fadeCurve: Curves.ease, + sizeCurve: Curves.linear, + ), + ), + + /// 点赞收藏转发 + actionGrid(context, videoIntroController), + // 合集 + if (widget.videoDetail!.ugcSeason != null) ...[ + Obx( + () => SeasonPanel( + ugcSeason: widget.videoDetail!.ugcSeason!, + cid: videoIntroController.lastPlayCid.value != 0 + ? videoIntroController.lastPlayCid.value + : widget.videoDetail!.pages!.first.cid, + sheetHeight: sheetHeight, + changeFuc: (bvid, cid, aid) => + videoIntroController.changeSeasonOrbangu(bvid, cid, aid), + ), + ) + ], + if (widget.videoDetail!.pages != null && + widget.videoDetail!.pages!.length > 1) ...[ + Obx(() => PagesPanel( + pages: widget.videoDetail!.pages!, + cid: videoIntroController.lastPlayCid.value, + sheetHeight: sheetHeight, + changeFuc: (cid) => videoIntroController.changeSeasonOrbangu( + videoIntroController.bvid, cid, null), + )) + ], + GestureDetector( + onTap: onPushMember, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + child: Row( + children: [ + NetworkImgLayer( + type: 'avatar', + src: widget.videoDetail!.owner!.face, + width: 34, + height: 34, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + ), + const SizedBox(width: 10), + Text(owner.name, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 6), + Text( + follower, + style: TextStyle( + fontSize: t.textTheme.labelSmall!.fontSize, + color: outline, + ), + ), + const Spacer(), + Obx(() => AnimatedOpacity( + opacity: + videoIntroController.followStatus.isEmpty ? 0 : 1, + duration: const Duration(milliseconds: 50), + child: SizedBox( + height: 32, + child: Obx( + () => videoIntroController.followStatus.isNotEmpty + ? TextButton( + onPressed: + videoIntroController.actionRelationMod, + style: TextButton.styleFrom( + padding: const EdgeInsets.only( + left: 8, right: 8), + foregroundColor: + followStatus['attribute'] != 0 + ? outline + : t.colorScheme.onPrimary, + backgroundColor: + followStatus['attribute'] != 0 + ? t.colorScheme.onInverseSurface + : t.colorScheme + .primary, // 设置按钮背景色 + ), + child: Text( + followStatus['attribute'] != 0 + ? '已关注' + : '关注', + style: TextStyle( + fontSize: t + .textTheme.labelMedium!.fontSize), + ), + ) + : ElevatedButton( + onPressed: + videoIntroController.actionRelationMod, + child: const Text('关注'), + ), + ), + ), + )), + ], + ), + ), + ), + ], + )), ); } Widget actionGrid(BuildContext context, videoIntroController) { - return LayoutBuilder(builder: (context, constraints) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { return Container( margin: const EdgeInsets.only(top: 6, bottom: 4), height: constraints.maxWidth / 5 * 0.8, child: GridView.count( + physics: const NeverScrollableScrollPhysics(), primary: false, - padding: const EdgeInsets.all(0), + padding: EdgeInsets.zero, crossAxisCount: 5, childAspectRatio: 1.25, children: [ @@ -488,117 +491,46 @@ class _VideoInfoState extends State with TickerProviderStateMixin { selectIcon: const Icon(FontAwesomeIcons.solidThumbsUp), onTap: handleState(videoIntroController.actionLikeVideo), selectStatus: videoIntroController.hasLike.value, - loadingStatus: loadingStatus, - text: !loadingStatus - ? widget.videoDetail!.stat!.like!.toString() - : '-'), - ), - // ActionItem( - // icon: const Icon(FontAwesomeIcons.clock), - // onTap: () => videoIntroController.actionShareVideo(), - // selectStatus: false, - // loadingStatus: loadingStatus, - // text: '稍后再看'), - Obx( - () => ActionItem( - icon: const Icon(FontAwesomeIcons.b), - selectIcon: const Icon(FontAwesomeIcons.b), - onTap: handleState(videoIntroController.actionCoinVideo), - selectStatus: videoIntroController.hasCoin.value, - loadingStatus: loadingStatus, - text: !loadingStatus - ? widget.videoDetail!.stat!.coin!.toString() - : '-'), + text: widget.videoDetail!.stat!.like!.toString()), ), Obx( () => ActionItem( - icon: const Icon(FontAwesomeIcons.star), - selectIcon: const Icon(FontAwesomeIcons.solidStar), - onTap: () => showFavBottomSheet(), - onLongPress: () => showFavBottomSheet(type: 'longPress'), - selectStatus: videoIntroController.hasFav.value, - loadingStatus: loadingStatus, - text: !loadingStatus - ? widget.videoDetail!.stat!.favorite!.toString() - : '-'), + icon: const Icon(FontAwesomeIcons.b), + selectIcon: const Icon(FontAwesomeIcons.b), + onTap: handleState(videoIntroController.actionCoinVideo), + selectStatus: videoIntroController.hasCoin.value, + text: widget.videoDetail!.stat!.coin!.toString(), + ), + ), + Obx( + () => ActionItem( + icon: const Icon(FontAwesomeIcons.star), + selectIcon: const Icon(FontAwesomeIcons.solidStar), + onTap: () => showFavBottomSheet(), + onLongPress: () => showFavBottomSheet(type: 'longPress'), + selectStatus: videoIntroController.hasFav.value, + text: widget.videoDetail!.stat!.favorite!.toString(), + ), ), ActionItem( - icon: const Icon(FontAwesomeIcons.comment), - onTap: () => videoDetailCtr.tabCtr.animateTo(1), - selectStatus: false, - loadingStatus: loadingStatus, - text: !loadingStatus - ? widget.videoDetail!.stat!.reply!.toString() - : '评论'), + icon: const Icon(FontAwesomeIcons.clock), + onTap: () async { + final res = + await UserHttp.toViewLater(bvid: widget.videoDetail!.bvid); + SmartDialog.showToast(res['msg']); + }, + selectStatus: false, + text: '稍后看', + ), ActionItem( - icon: const Icon(FontAwesomeIcons.shareFromSquare), - onTap: () => videoIntroController.actionShareVideo(), - selectStatus: false, - loadingStatus: loadingStatus, - text: '分享'), + icon: const Icon(FontAwesomeIcons.shareFromSquare), + onTap: () => videoIntroController.actionShareVideo(), + selectStatus: false, + text: '分享', + ), ], ), ); }); } - - Widget actionRow(BuildContext context, videoIntroController, videoDetailCtr) { - return Row(children: [ - Obx( - () => ActionRowItem( - icon: const Icon(FontAwesomeIcons.thumbsUp), - onTap: handleState(videoIntroController.actionLikeVideo), - selectStatus: videoIntroController.hasLike.value, - loadingStatus: loadingStatus, - text: - !loadingStatus ? widget.videoDetail!.stat!.like!.toString() : '-', - ), - ), - const SizedBox(width: 8), - Obx( - () => ActionRowItem( - icon: const Icon(FontAwesomeIcons.b), - onTap: handleState(videoIntroController.actionCoinVideo), - selectStatus: videoIntroController.hasCoin.value, - loadingStatus: loadingStatus, - text: - !loadingStatus ? widget.videoDetail!.stat!.coin!.toString() : '-', - ), - ), - const SizedBox(width: 8), - Obx( - () => ActionRowItem( - icon: const Icon(FontAwesomeIcons.heart), - onTap: () => showFavBottomSheet(), - onLongPress: () => showFavBottomSheet(type: 'longPress'), - selectStatus: videoIntroController.hasFav.value, - loadingStatus: loadingStatus, - text: !loadingStatus - ? widget.videoDetail!.stat!.favorite!.toString() - : '-', - ), - ), - const SizedBox(width: 8), - ActionRowItem( - icon: const Icon(FontAwesomeIcons.comment), - onTap: () { - videoDetailCtr.tabCtr.animateTo(1); - }, - selectStatus: false, - loadingStatus: loadingStatus, - text: - !loadingStatus ? widget.videoDetail!.stat!.reply!.toString() : '-', - ), - const SizedBox(width: 8), - ActionRowItem( - icon: const Icon(FontAwesomeIcons.share), - onTap: () => videoIntroController.actionShareVideo(), - selectStatus: false, - loadingStatus: loadingStatus, - // text: !loadingStatus - // ? widget.videoDetail!.stat!.share!.toString() - // : '-', - text: '转发'), - ]); - } } diff --git a/lib/pages/video/detail/introduction/widgets/action_item.dart b/lib/pages/video/detail/introduction/widgets/action_item.dart index 95ac103b..022d9223 100644 --- a/lib/pages/video/detail/introduction/widgets/action_item.dart +++ b/lib/pages/video/detail/introduction/widgets/action_item.dart @@ -7,7 +7,6 @@ class ActionItem extends StatelessWidget { final Icon? selectIcon; final Function? onTap; final Function? onLongPress; - final bool? loadingStatus; final String? text; final bool selectStatus; @@ -17,7 +16,6 @@ class ActionItem extends StatelessWidget { this.selectIcon, this.onTap, this.onLongPress, - this.loadingStatus, this.text, this.selectStatus = false, }) : super(key: key); @@ -43,25 +41,15 @@ class ActionItem extends StatelessWidget { : Icon(icon!.icon!, size: 18, color: Theme.of(context).colorScheme.outline), const SizedBox(height: 6), - AnimatedOpacity( - opacity: loadingStatus! ? 0 : 1, - duration: const Duration(milliseconds: 200), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, Animation animation) { - return ScaleTransition(scale: animation, child: child); - }, - child: Text( - text ?? '', - key: ValueKey(text ?? ''), - style: TextStyle( - color: selectStatus - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - fontSize: Theme.of(context).textTheme.labelSmall!.fontSize), - ), + Text( + text ?? '', + style: TextStyle( + color: selectStatus + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + fontSize: Theme.of(context).textTheme.labelSmall!.fontSize, ), - ), + ) ], ), ); diff --git a/lib/pages/video/detail/introduction/widgets/fav_panel.dart b/lib/pages/video/detail/introduction/widgets/fav_panel.dart index 68f53772..517caeaa 100644 --- a/lib/pages/video/detail/introduction/widgets/fav_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/fav_panel.dart @@ -6,15 +6,15 @@ import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; class FavPanel extends StatefulWidget { - final dynamic ctr; const FavPanel({super.key, this.ctr}); + final dynamic ctr; @override State createState() => _FavPanelState(); } class _FavPanelState extends State { - Box localCache = GStrorage.localCache; + final Box localCache = GStrorage.localCache; late double sheetHeight; late Future _futureBuilderFuture; @@ -45,7 +45,7 @@ class _FavPanelState extends State { child: Material( child: FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { Map data = snapshot.data as Map; if (data['status']) { @@ -109,7 +109,7 @@ class _FavPanelState extends State { ), child: Row( mainAxisAlignment: MainAxisAlignment.end, - children: [ + children: [ TextButton( onPressed: () => Get.back(), style: TextButton.styleFrom( diff --git a/lib/pages/video/detail/introduction/widgets/group_panel.dart b/lib/pages/video/detail/introduction/widgets/group_panel.dart index 0a105f9d..64ff913d 100644 --- a/lib/pages/video/detail/introduction/widgets/group_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/group_panel.dart @@ -17,7 +17,7 @@ class GroupPanel extends StatefulWidget { } class _GroupPanelState extends State { - Box localCache = GStrorage.localCache; + final Box localCache = GStrorage.localCache; late double sheetHeight; late Future _futureBuilderFuture; late List tagsList; @@ -33,17 +33,20 @@ class _GroupPanelState extends State { void onSave() async { feedBack(); // 是否有选中的 有选中的带id,没选使用默认0 - bool anyHasChecked = tagsList.any((e) => e.checked == true); + final bool anyHasChecked = + tagsList.any((MemberTagItemModel e) => e.checked == true); late String tagids; if (anyHasChecked) { - List checkedList = tagsList.where((e) => e.checked == true).toList(); - List tagidList = checkedList.map((e) => e.tagid).toList(); + final List checkedList = + tagsList.where((MemberTagItemModel e) => e.checked == true).toList(); + final List tagidList = + checkedList.map((e) => e.tagid!).toList(); tagids = tagidList.join(','); } else { tagids = '0'; } // 保存 - var res = await MemberHttp.addUsers(widget.mid, tagids); + final res = await MemberHttp.addUsers(widget.mid, tagids); SmartDialog.showToast(res['msg']); if (res['status']) { Get.back(); @@ -56,7 +59,7 @@ class _GroupPanelState extends State { height: sheetHeight, color: Theme.of(context).colorScheme.background, child: Column( - children: [ + children: [ AppBar( centerTitle: false, elevation: 0, @@ -70,7 +73,7 @@ class _GroupPanelState extends State { child: Material( child: FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { Map data = snapshot.data as Map; if (data['status']) { diff --git a/lib/pages/video/detail/introduction/widgets/intro_detail.dart b/lib/pages/video/detail/introduction/widgets/intro_detail.dart index 1db23a1d..1e9bb842 100644 --- a/lib/pages/video/detail/introduction/widgets/intro_detail.dart +++ b/lib/pages/video/detail/introduction/widgets/intro_detail.dart @@ -1,141 +1,70 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:hive/hive.dart'; -import 'package:pilipala/common/widgets/stat/danmu.dart'; -import 'package:pilipala/common/widgets/stat/view.dart'; -import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/utils.dart'; -Box localCache = GStrorage.localCache; -late double sheetHeight; - class IntroDetail extends StatelessWidget { - final dynamic videoDetail; - const IntroDetail({ - Key? key, + super.key, this.videoDetail, - }) : super(key: key); + }); + final dynamic videoDetail; @override Widget build(BuildContext context) { - sheetHeight = localCache.get('sheetHeight'); - return Container( - color: Theme.of(context).colorScheme.background, - padding: const EdgeInsets.only(left: 14, right: 14), - height: sheetHeight, + return SizedBox( + width: double.infinity, + child: SelectableRegion( + focusNode: FocusNode(), + selectionControls: MaterialTextSelectionControls(), child: Column( - children: [ - InkWell( - onTap: () => Get.back(), - child: Container( - height: 35, - padding: const EdgeInsets.only(bottom: 2), - child: Center( - child: Container( - width: 32, - height: 3, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: - const BorderRadius.all(Radius.circular(3))), - ), - ), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: videoDetail!.bvid!)); + SmartDialog.showToast('已复制'); + }, + child: Text( + videoDetail!.bvid!, + style: TextStyle( + fontSize: 13, color: Theme.of(context).colorScheme.primary), ), ), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - videoDetail!.title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 6), - Row( - children: [ - StatView( - theme: 'gray', - view: videoDetail!.stat!.view, - size: 'medium', - ), - const SizedBox(width: 10), - StatDanMu( - theme: 'gray', - danmu: videoDetail!.stat!.danmaku, - size: 'medium', - ), - const SizedBox(width: 10), - Text( - Utils.dateFormat(videoDetail!.pubdate, - formatType: 'detail'), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: SelectableRegion( - magnifierConfiguration: - const TextMagnifierConfiguration(), - focusNode: FocusNode(), - selectionControls: MaterialTextSelectionControls(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - videoDetail!.bvid!, - style: const TextStyle(fontSize: 13), - ), - const SizedBox(height: 4), - Text.rich( - style: const TextStyle( - height: 1.4, - // fontSize: 13, - ), - TextSpan( - children: [ - buildContent(context, videoDetail!), - ], - ), - ), - ], - ), - ), - ), - ], - ), + const SizedBox(height: 4), + Text.rich( + style: const TextStyle(height: 1.4), + TextSpan( + children: [ + buildContent(context, videoDetail!), + ], ), - ) + ), ], - )); + ), + ), + ); } InlineSpan buildContent(BuildContext context, content) { - List descV2 = content.descV2; + final List descV2 = content.descV2; // type // 1 普通文本 // 2 @用户 - List spanChilds = List.generate(descV2.length, (index) { + final List spanChilds = List.generate(descV2.length, (index) { final currentDesc = descV2[index]; switch (currentDesc.type) { case 1: - List spanChildren = []; - RegExp urlRegExp = RegExp(r'https?://\S+\b'); - Iterable matches = urlRegExp.allMatches(currentDesc.rawText); + final List spanChildren = []; + final RegExp urlRegExp = RegExp(r'https?://\S+\b'); + final Iterable matches = + urlRegExp.allMatches(currentDesc.rawText); int previousEndIndex = 0; - for (Match match in matches) { + for (final Match match in matches) { if (match.start > previousEndIndex) { spanChildren.add(TextSpan( text: currentDesc.rawText @@ -172,11 +101,12 @@ class IntroDetail extends StatelessWidget { text: currentDesc.rawText.substring(previousEndIndex))); } - TextSpan result = TextSpan(children: spanChildren); + final TextSpan result = TextSpan(children: spanChildren); return result; case 2: - final colorSchemePrimary = Theme.of(context).colorScheme.primary; - final heroTag = Utils.makeHeroTag(currentDesc.bizId); + final Color colorSchemePrimary = + Theme.of(context).colorScheme.primary; + final String heroTag = Utils.makeHeroTag(currentDesc.bizId); return TextSpan( text: '@${currentDesc.rawText}', style: TextStyle(color: colorSchemePrimary), diff --git a/lib/pages/video/detail/introduction/widgets/menu_row.dart b/lib/pages/video/detail/introduction/widgets/menu_row.dart index 6f9cf51b..c175aff1 100644 --- a/lib/pages/video/detail/introduction/widgets/menu_row.dart +++ b/lib/pages/video/detail/introduction/widgets/menu_row.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:pilipala/utils/feed_back.dart'; class MenuRow extends StatelessWidget { - final bool? loadingStatus; const MenuRow({ - Key? key, + super.key, this.loadingStatus, - }) : super(key: key); + }); + final bool? loadingStatus; @override Widget build(BuildContext context) { @@ -50,7 +50,7 @@ class MenuRow extends StatelessWidget { } Widget actionRowLineItem( - context, Function? onTap, bool? loadingStatus, String? text, + BuildContext context, Function? onTap, bool? loadingStatus, String? text, {bool selectStatus = false}) { return Material( color: selectStatus @@ -97,18 +97,18 @@ class MenuRow extends StatelessWidget { } class ActionRowLineItem extends StatelessWidget { + const ActionRowLineItem({ + super.key, + this.selectStatus, + this.onTap, + this.text, + this.loadingStatus = false, + }); final bool? selectStatus; final Function? onTap; final bool? loadingStatus; final String? text; - const ActionRowLineItem( - {super.key, - this.selectStatus, - this.onTap, - this.text, - this.loadingStatus = false}); - @override Widget build(BuildContext context) { return Material( diff --git a/lib/pages/video/detail/introduction/widgets/page.dart b/lib/pages/video/detail/introduction/widgets/page.dart index e1fdaf09..8d296050 100644 --- a/lib/pages/video/detail/introduction/widgets/page.dart +++ b/lib/pages/video/detail/introduction/widgets/page.dart @@ -4,11 +4,6 @@ import 'package:pilipala/models/video_detail_res.dart'; import 'package:pilipala/pages/video/detail/index.dart'; class PagesPanel extends StatefulWidget { - final List pages; - final int? cid; - final double? sheetHeight; - final Function? changeFuc; - const PagesPanel({ super.key, required this.pages, @@ -16,6 +11,10 @@ class PagesPanel extends StatefulWidget { this.sheetHeight, this.changeFuc, }); + final List pages; + final int? cid; + final double? sheetHeight; + final Function? changeFuc; @override State createState() => _PagesPanelState(); @@ -25,7 +24,7 @@ class _PagesPanelState extends State { late List episodes; late int cid; late int currentIndex; - String heroTag = Get.arguments['heroTag']; + final String heroTag = Get.arguments['heroTag']; late VideoDetailController _videoDetailController; final ScrollController _scrollController = ScrollController(); @@ -35,11 +34,11 @@ class _PagesPanelState extends State { cid = widget.cid!; episodes = widget.pages; _videoDetailController = Get.find(tag: heroTag); - currentIndex = episodes.indexWhere((e) => e.cid == cid); - _videoDetailController.cid.listen((p0) { + currentIndex = episodes.indexWhere((Part e) => e.cid == cid); + _videoDetailController.cid.listen((int p0) { cid = p0; setState(() {}); - currentIndex = episodes.indexWhere((e) => e.cid == cid); + currentIndex = episodes.indexWhere((Part e) => e.cid == cid); }); } @@ -57,10 +56,41 @@ class _PagesPanelState extends State { super.dispose(); } + Widget buildEpisodeListItem( + Part episode, + int index, + bool isCurrentIndex, + ) { + Color primary = Theme.of(context).colorScheme.primary; + return ListTile( + onTap: () { + changeFucCall(episode, index); + Get.back(); + }, + dense: false, + leading: isCurrentIndex + ? Image.asset( + 'assets/images/live.gif', + color: primary, + height: 12, + ) + : null, + title: Text( + episode.pagePart!, + style: TextStyle( + fontSize: 14, + color: isCurrentIndex + ? primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } + @override Widget build(BuildContext context) { return Column( - children: [ + children: [ Padding( padding: const EdgeInsets.only(top: 10, bottom: 2), child: Row( @@ -132,38 +162,25 @@ class _PagesPanelState extends State { child: Material( child: ListView.builder( controller: _scrollController, - itemCount: episodes.length, - itemBuilder: (context, index) { - return ListTile( - onTap: () { - changeFucCall( - episodes[index], index); - Get.back(); - }, - dense: false, - leading: index == currentIndex - ? Image.asset( - 'assets/images/live.gif', - color: Theme.of(context) - .colorScheme - .primary, - height: 12, - ) - : null, - title: Text( - episodes[index].pagePart!, - style: TextStyle( - fontSize: 14, - color: index == currentIndex - ? Theme.of(context) - .colorScheme - .primary - : Theme.of(context) - .colorScheme - .onSurface, - ), - ), - ); + itemCount: episodes.length + 1, + itemBuilder: + (BuildContext context, int index) { + bool isLastItem = + index == episodes.length; + bool isCurrentIndex = + currentIndex == index; + return isLastItem + ? SizedBox( + height: MediaQuery.of(context) + .padding + .bottom + + 20, + ) + : buildEpisodeListItem( + episodes[index], + index, + isCurrentIndex, + ); }, ), ), @@ -191,7 +208,8 @@ class _PagesPanelState extends State { scrollDirection: Axis.horizontal, itemCount: widget.pages.length, itemExtent: 150, - itemBuilder: ((context, i) { + itemBuilder: (BuildContext context, int i) { + bool isCurrentIndex = currentIndex == i; return Container( width: 150, margin: const EdgeInsets.only(right: 10), @@ -205,8 +223,8 @@ class _PagesPanelState extends State { padding: const EdgeInsets.symmetric( vertical: 8, horizontal: 8), child: Row( - children: [ - if (i == currentIndex) ...[ + children: [ + if (isCurrentIndex) ...[ Image.asset( 'assets/images/live.gif', color: Theme.of(context).colorScheme.primary, @@ -220,7 +238,7 @@ class _PagesPanelState extends State { maxLines: 1, style: TextStyle( fontSize: 13, - color: i == currentIndex + color: isCurrentIndex ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface), overflow: TextOverflow.ellipsis, @@ -231,7 +249,7 @@ class _PagesPanelState extends State { ), ), ); - }), + }, ), ) ], diff --git a/lib/pages/video/detail/introduction/widgets/season.dart b/lib/pages/video/detail/introduction/widgets/season.dart index cb83f69c..0f3884ed 100644 --- a/lib/pages/video/detail/introduction/widgets/season.dart +++ b/lib/pages/video/detail/introduction/widgets/season.dart @@ -6,11 +6,6 @@ import 'package:pilipala/utils/id_utils.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class SeasonPanel extends StatefulWidget { - final UgcSeason ugcSeason; - final int? cid; - final double? sheetHeight; - final Function? changeFuc; - const SeasonPanel({ super.key, required this.ugcSeason, @@ -18,6 +13,10 @@ class SeasonPanel extends StatefulWidget { this.sheetHeight, this.changeFuc, }); + final UgcSeason ugcSeason; + final int? cid; + final double? sheetHeight; + final Function? changeFuc; @override State createState() => _SeasonPanelState(); @@ -27,7 +26,7 @@ class _SeasonPanelState extends State { late List episodes; late int cid; late int currentIndex; - String heroTag = Get.arguments['heroTag']; + final String heroTag = Get.arguments['heroTag']; late VideoDetailController _videoDetailController; final ScrollController _scrollController = ScrollController(); final ItemScrollController itemScrollController = ItemScrollController(); @@ -41,9 +40,9 @@ class _SeasonPanelState extends State { /// 根据 cid 找到对应集,找到对应 episodes /// 有多个episodes时,只显示其中一个 /// TODO 同时显示多个合集 - List sections = widget.ugcSeason.sections!; + final List sections = widget.ugcSeason.sections!; for (int i = 0; i < sections.length; i++) { - List episodesList = sections[i].episodes!; + final List episodesList = sections[i].episodes!; for (int j = 0; j < episodesList.length; j++) { if (episodesList[j].cid == cid) { episodes = episodesList; @@ -56,22 +55,21 @@ class _SeasonPanelState extends State { // episodes = widget.ugcSeason.sections! // .firstWhere((e) => e.seasonId == widget.ugcSeason.id) // .episodes!; - currentIndex = episodes.indexWhere((e) => e.cid == cid); - _videoDetailController.cid.listen((p0) { + currentIndex = episodes.indexWhere((EpisodeItem e) => e.cid == cid); + _videoDetailController.cid.listen((int p0) { cid = p0; setState(() {}); - currentIndex = episodes.indexWhere((e) => e.cid == cid); + currentIndex = episodes.indexWhere((EpisodeItem e) => e.cid == cid); }); } - void changeFucCall(item, i) async { + void changeFucCall(item, int i) async { await widget.changeFuc!( IdUtils.av2bv(item.aid), item.cid, item.aid, ); currentIndex = i; - setState(() {}); Get.back(); setState(() {}); } @@ -82,9 +80,37 @@ class _SeasonPanelState extends State { super.dispose(); } + Widget buildEpisodeListItem( + EpisodeItem episode, + int index, + bool isCurrentIndex, + ) { + Color primary = Theme.of(context).colorScheme.primary; + return ListTile( + onTap: () => changeFucCall(episode, index), + dense: false, + leading: isCurrentIndex + ? Image.asset( + 'assets/images/live.gif', + color: primary, + height: 12, + ) + : null, + title: Text( + episode.title!, + style: TextStyle( + fontSize: 14, + color: isCurrentIndex + ? primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + ); + } + @override Widget build(BuildContext context) { - return Builder(builder: (context) { + return Builder(builder: (BuildContext context) { return Container( margin: const EdgeInsets.only( top: 8, @@ -135,32 +161,23 @@ class _SeasonPanelState extends State { Expanded( child: Material( child: ScrollablePositionedList.builder( - itemCount: episodes.length, - itemBuilder: (context, index) => ListTile( - onTap: () => - changeFucCall(episodes[index], index), - dense: false, - leading: index == currentIndex - ? Image.asset( - 'assets/images/live.gif', - color: Theme.of(context) - .colorScheme - .primary, - height: 12, + itemCount: episodes.length + 1, + itemBuilder: (BuildContext context, int index) { + bool isLastItem = index == episodes.length; + bool isCurrentIndex = currentIndex == index; + return isLastItem + ? SizedBox( + height: MediaQuery.of(context) + .padding + .bottom + + 20, ) - : null, - title: Text( - episodes[index].title!, - style: TextStyle( - fontSize: 14, - color: index == currentIndex - ? Theme.of(context).colorScheme.primary - : Theme.of(context) - .colorScheme - .onSurface, - ), - ), - ), + : buildEpisodeListItem( + episodes[index], + index, + isCurrentIndex, + ); + }, itemScrollController: itemScrollController, ), ), @@ -174,7 +191,7 @@ class _SeasonPanelState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(8, 12, 8, 12), child: Row( - children: [ + children: [ Expanded( child: Text( '合集:${widget.ugcSeason.title!}', diff --git a/lib/pages/video/detail/related/controller.dart b/lib/pages/video/detail/related/controller.dart index eb1a14d5..0578bba2 100644 --- a/lib/pages/video/detail/related/controller.dart +++ b/lib/pages/video/detail/related/controller.dart @@ -1,14 +1,22 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pilipala/http/video.dart'; +import '../../../../models/model_hot_video_item.dart'; class ReleatedController extends GetxController { // 视频aid - String bvid = Get.parameters['bvid']!; + String bvid = Get.parameters['bvid'] ?? ""; // 推荐视频列表 - List relatedVideoList = []; + RxList relatedVideoList = [].obs; OverlayEntry? popupDialog; - Future queryRelatedVideo() => VideoHttp.relatedVideoList(bvid: bvid); + Future queryRelatedVideo() async { + return VideoHttp.relatedVideoList(bvid: bvid).then((value) { + if (value['status']) { + relatedVideoList.value = value['data']; + } + return value; + }); + } } diff --git a/lib/pages/video/detail/related/view.dart b/lib/pages/video/detail/related/view.dart index e26df4d9..0912724e 100644 --- a/lib/pages/video/detail/related/view.dart +++ b/lib/pages/video/detail/related/view.dart @@ -9,50 +9,71 @@ import './controller.dart'; class RelatedVideoPanel extends StatefulWidget { const RelatedVideoPanel({super.key}); + @override State createState() => _RelatedVideoPanelState(); } -class _RelatedVideoPanelState extends State { - final ReleatedController _releatedController = - Get.put(ReleatedController(), tag: Get.arguments['heroTag']); +class _RelatedVideoPanelState extends State + with AutomaticKeepAliveClientMixin { + late ReleatedController _releatedController; + late Future _futureBuilder; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _releatedController = + Get.put(ReleatedController(), tag: Get.arguments?['heroTag']); + _futureBuilder = _releatedController.queryRelatedVideo(); + } @override Widget build(BuildContext context) { + super.build(context); return FutureBuilder( - future: _releatedController.queryRelatedVideo(), - builder: (context, snapshot) { + future: _futureBuilder, + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { if (snapshot.data == null) { return const SliverToBoxAdapter(child: SizedBox()); } - if (snapshot.data!['status']) { + if (snapshot.data!['status'] && snapshot.data != null) { + RxList relatedVideoList = _releatedController.relatedVideoList; // 请求成功 - return SliverList( + return Obx( + () => SliverList( delegate: SliverChildBuilderDelegate((context, index) { - if (index == snapshot.data['data'].length) { - return SizedBox(height: MediaQuery.of(context).padding.bottom); - } else { - return Material( - child: VideoCardH( - videoItem: snapshot.data['data'][index], - longPress: () { - try { - _releatedController.popupDialog = - _createPopupDialog(snapshot.data['data'][index]); - Overlay.of(context) - .insert(_releatedController.popupDialog!); - } catch (err) { - return {}; - } - }, - longPressEnd: () { - _releatedController.popupDialog?.remove(); - }, - ), - ); - } - }, childCount: snapshot.data['data'].length + 1)); + if (index == relatedVideoList.length) { + return SizedBox( + height: MediaQuery.of(context).padding.bottom); + } else { + return Material( + child: VideoCardH( + videoItem: relatedVideoList[index], + showPubdate: true, + longPress: () { + try { + _releatedController.popupDialog = + _createPopupDialog(_releatedController + .relatedVideoList[index]); + Overlay.of(context) + .insert(_releatedController.popupDialog!); + } catch (err) { + return {}; + } + }, + longPressEnd: () { + _releatedController.popupDialog?.remove(); + }, + ), + ); + } + }, childCount: relatedVideoList.length + 1), + ), + ); } else { // 请求错误 return HttpError(errMsg: '出错了', fn: () {}); @@ -71,7 +92,7 @@ class _RelatedVideoPanelState extends State { OverlayEntry _createPopupDialog(videoItem) { return OverlayEntry( - builder: (context) => AnimatedDialog( + builder: (BuildContext context) => AnimatedDialog( closeFn: _releatedController.popupDialog?.remove, child: OverlayPop( videoItem: videoItem, diff --git a/lib/pages/video/detail/reply/controller.dart b/lib/pages/video/detail/reply/controller.dart index 40c26875..06ce26ff 100644 --- a/lib/pages/video/detail/reply/controller.dart +++ b/lib/pages/video/detail/reply/controller.dart @@ -22,7 +22,7 @@ class VideoReplyController extends GetxController { String? replyLevel; // rpid 请求楼中楼回复 String? rpid; - RxList replyList = [ReplyItemModel()].obs; + RxList replyList = [].obs; // 当前页 int currentPage = 0; bool isLoadingMore = false; @@ -42,21 +42,30 @@ class VideoReplyController extends GetxController { void onInit() { super.onInit(); int deaultReplySortIndex = - setting.get(SettingBoxKey.replySortType, defaultValue: 0); + setting.get(SettingBoxKey.replySortType, defaultValue: 0) as int; + if (deaultReplySortIndex == 2) { + setting.put(SettingBoxKey.replySortType, 0); + deaultReplySortIndex = 0; + } _sortType = ReplySortType.values[deaultReplySortIndex]; sortTypeTitle.value = _sortType.titles; sortTypeLabel.value = _sortType.labels; } Future queryReplyList({type = 'init'}) async { + if (isLoadingMore) { + return; + } isLoadingMore = true; if (type == 'init') { currentPage = 0; + noMore.value = ''; } if (noMore.value == '没有更多了') { + isLoadingMore = false; return; } - var res = await ReplyHttp.replyList( + final res = await ReplyHttp.replyList( oid: aid!, pageNum: currentPage + 1, ps: ps, @@ -64,7 +73,7 @@ class VideoReplyController extends GetxController { sort: _sortType.index, ); if (res['status']) { - List replies = res['data'].replies; + final List replies = res['data'].replies; if (replies.isNotEmpty) { noMore.value = '加载中...'; @@ -84,9 +93,8 @@ class VideoReplyController extends GetxController { if (type == 'init') { // 添加置顶回复 if (res['data'].upper.top != null) { - bool flag = res['data'] - .topReplies - .any((reply) => reply.rpid == res['data'].upper.top.rpid); + final bool flag = res['data'].topReplies.any((ReplyItemModel reply) => + reply.rpid == res['data'].upper.top.rpid) as bool; if (!flag) { replies.insert(0, res['data'].upper.top); } @@ -116,9 +124,6 @@ class VideoReplyController extends GetxController { _sortType = ReplySortType.like; break; case ReplySortType.like: - _sortType = ReplySortType.reply; - break; - case ReplySortType.reply: _sortType = ReplySortType.time; break; default: diff --git a/lib/pages/video/detail/reply/view.dart b/lib/pages/video/detail/reply/view.dart index 827fc3f9..2a167fe9 100644 --- a/lib/pages/video/detail/reply/view.dart +++ b/lib/pages/video/detail/reply/view.dart @@ -8,7 +8,7 @@ import 'package:pilipala/common/skeleton/video_reply.dart'; import 'package:pilipala/common/widgets/http_error.dart'; import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/pages/video/detail/index.dart'; -import 'package:pilipala/pages/video/detail/replyNew/index.dart'; +import 'package:pilipala/pages/video/detail/reply_new/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; import 'controller.dart'; @@ -16,11 +16,13 @@ import 'widgets/reply_item.dart'; class VideoReplyPanel extends StatefulWidget { final String? bvid; + final int? oid; final int rpid; final String? replyLevel; const VideoReplyPanel({ this.bvid, + this.oid, this.rpid = 0, this.replyLevel, super.key, @@ -48,16 +50,17 @@ class _VideoReplyPanelState extends State @override void initState() { super.initState(); - int oid = widget.bvid != null ? IdUtils.bv2av(widget.bvid!) : 0; + // int oid = widget.bvid != null ? IdUtils.bv2av(widget.bvid!) : 0; heroTag = Get.arguments['heroTag']; replyLevel = widget.replyLevel ?? '1'; if (replyLevel == '2') { _videoReplyController = Get.put( - VideoReplyController(oid, widget.rpid.toString(), replyLevel), + VideoReplyController(widget.oid, widget.rpid.toString(), replyLevel), tag: widget.rpid.toString()); } else { - _videoReplyController = - Get.put(VideoReplyController(oid, '', replyLevel), tag: heroTag); + _videoReplyController = Get.put( + VideoReplyController(widget.oid, '', replyLevel), + tag: heroTag); } fabAnimationCtr = AnimationController( @@ -75,7 +78,8 @@ class _VideoReplyPanelState extends State () { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 300) { - EasyThrottle.throttle('replylist', const Duration(seconds: 2), () { + EasyThrottle.throttle('replylist', const Duration(milliseconds: 200), + () { _videoReplyController.onLoad(); }); } @@ -107,10 +111,10 @@ class _VideoReplyPanelState extends State // 展示二级回复 void replyReply(replyItem) { - VideoDetailController videoDetailCtr = + final VideoDetailController videoDetailCtr = Get.find(tag: heroTag); if (replyItem != null) { - videoDetailCtr.oid = replyItem.oid; + videoDetailCtr.oid.value = replyItem.oid; videoDetailCtr.fRpid = replyItem.rpid!; videoDetailCtr.firstFloor = replyItem; videoDetailCtr.showReplyReplyPanel(); @@ -130,13 +134,13 @@ class _VideoReplyPanelState extends State super.build(context); return RefreshIndicator( onRefresh: () async { - _videoReplyController.currentPage = 0; - return await _videoReplyController.queryReplyList(); + return await _videoReplyController.queryReplyList(type: 'init'); }, child: Stack( children: [ CustomScrollView( controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), key: const PageStorageKey('评论'), slivers: [ SliverPersistentHeader( @@ -144,34 +148,16 @@ class _VideoReplyPanelState extends State floating: true, delegate: _MySliverPersistentHeaderDelegate( child: Container( - height: 45, + height: 40, padding: const EdgeInsets.fromLTRB(12, 0, 6, 0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background, - border: Border( - bottom: BorderSide( - color: Theme.of(context) - .colorScheme - .outline - .withOpacity(0.1)), - ), - ), + color: Theme.of(context).colorScheme.surface, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Obx( - () => AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - transitionBuilder: - (Widget child, Animation animation) { - return ScaleTransition( - scale: animation, child: child); - }, - child: Text( - '共${_videoReplyController.count.value}条回复', - key: ValueKey( - _videoReplyController.count.value), - ), + () => Text( + '${_videoReplyController.sortTypeLabel.value}评论', + style: const TextStyle(fontSize: 13), ), ), SizedBox( @@ -180,10 +166,12 @@ class _VideoReplyPanelState extends State onPressed: () => _videoReplyController.queryBySort(), icon: const Icon(Icons.sort, size: 16), - label: Obx(() => Text( - _videoReplyController.sortTypeLabel.value, - style: const TextStyle(fontSize: 13), - )), + label: Obx( + () => Text( + _videoReplyController.sortTypeLabel.value, + style: const TextStyle(fontSize: 13), + ), + ), ), ) ], @@ -193,7 +181,7 @@ class _VideoReplyPanelState extends State ), FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { var data = snapshot.data; if (data['status']) { @@ -203,13 +191,13 @@ class _VideoReplyPanelState extends State _videoReplyController.replyList.isEmpty ? SliverList( delegate: SliverChildBuilderDelegate( - (context, index) { + (BuildContext context, index) { return const VideoReplySkeleton(); }, childCount: 5), ) : SliverList( delegate: SliverChildBuilderDelegate( - (context, index) { + (BuildContext context, index) { double bottom = MediaQuery.of(context).padding.bottom; if (index == @@ -256,13 +244,19 @@ class _VideoReplyPanelState extends State // 请求错误 return HttpError( errMsg: data['msg'], - fn: () => setState(() {}), + fn: () { + setState(() { + _futureBuilderFuture = + _videoReplyController.queryReplyList(); + }); + }, ); } } else { // 骨架屏 return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { + delegate: SliverChildBuilderDelegate( + (BuildContext context, index) { return const VideoReplySkeleton(); }, childCount: 5), ); @@ -318,11 +312,10 @@ class _VideoReplyPanelState extends State } class _MySliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate { - final double _minExtent = 45; - final double _maxExtent = 45; - final Widget child; - _MySliverPersistentHeaderDelegate({required this.child}); + final double _minExtent = 40; + final double _maxExtent = 40; + final Widget child; @override Widget build( diff --git a/lib/pages/video/detail/reply/widgets/reply_item.dart b/lib/pages/video/detail/reply/widgets/reply_item.dart index 58acd8ab..50fe20d4 100644 --- a/lib/pages/video/detail/reply/widgets/reply_item.dart +++ b/lib/pages/video/detail/reply/widgets/reply_item.dart @@ -1,6 +1,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/common/widgets/badge.dart'; @@ -9,12 +10,12 @@ import 'package:pilipala/models/common/reply_type.dart'; import 'package:pilipala/models/video/reply/item.dart'; import 'package:pilipala/pages/preview/index.dart'; import 'package:pilipala/pages/video/detail/index.dart'; -import 'package:pilipala/pages/video/detail/replyNew/index.dart'; +import 'package:pilipala/pages/video/detail/reply_new/index.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/id_utils.dart'; import 'package:pilipala/utils/storage.dart'; +import 'package:pilipala/utils/url_utils.dart'; import 'package:pilipala/utils/utils.dart'; - import 'zan.dart'; Box setting = GStrorage.setting; @@ -27,8 +28,8 @@ class ReplyItem extends StatelessWidget { this.showReplyRow = true, this.replyReply, this.replyType, - Key? key, - }) : super(key: key); + super.key, + }); final ReplyItemModel? replyItem; final Function? addReply; final String? replyLevel; @@ -47,6 +48,17 @@ class ReplyItem extends StatelessWidget { replyReply!(replyItem); } }, + onLongPress: () { + feedBack(); + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) { + return MorePanel(item: replyItem); + }, + ); + }, child: Column( children: [ Padding( @@ -68,7 +80,7 @@ class ReplyItem extends StatelessWidget { ); } - Widget lfAvtar(context, heroTag) { + Widget lfAvtar(BuildContext context, String heroTag) { return Stack( children: [ Hero( @@ -117,103 +129,11 @@ class ReplyItem extends StatelessWidget { ); } - Widget content(context) { - String heroTag = Utils.makeHeroTag(replyItem!.mid); + Widget content(BuildContext context) { + final String heroTag = Utils.makeHeroTag(replyItem!.mid); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 头像、昵称 - // SizedBox( - // width: double.infinity, - // child: Stack( - // children: [ - // GestureDetector( - // behavior: HitTestBehavior.opaque, - // onTap: () { - // feedBack(); - // Get.toNamed('/member?mid=${replyItem!.mid}', arguments: { - // 'face': replyItem!.member!.avatar!, - // 'heroTag': heroTag - // }); - // }, - // child: Row( - // crossAxisAlignment: CrossAxisAlignment.center, - // mainAxisSize: MainAxisSize.min, - // children: [ - // lfAvtar(context, heroTag), - // const SizedBox(width: 12), - // Text( - // replyItem!.member!.uname!, - // style: TextStyle( - // color: replyItem!.member!.vip!['vipStatus'] > 0 - // ? const Color.fromARGB(255, 251, 100, 163) - // : Theme.of(context).colorScheme.outline, - // fontSize: 13, - // ), - // ), - // const SizedBox(width: 6), - // Image.asset( - // 'assets/images/lv/lv${replyItem!.member!.level}.png', - // height: 11, - // ), - // const SizedBox(width: 6), - // if (replyItem!.isUp!) - // const PBadge( - // text: 'UP', - // size: 'small', - // stack: 'normal', - // fs: 9, - // ), - // ], - // ), - // ), - // Positioned( - // top: 0, - // left: 0, - // right: 0, - // child: Container( - // width: double.infinity, - // height: 45, - // decoration: BoxDecoration( - // image: replyItem!.member!.userSailing!.cardbg != null - // ? DecorationImage( - // alignment: Alignment.centerRight, - // fit: BoxFit.fitHeight, - // image: NetworkImage( - // replyItem!.member!.userSailing!.cardbg!['image'], - // ), - // ) - // : null, - // ), - // ), - // ), - // if (replyItem!.member!.userSailing!.cardbg != null && - // replyItem!.member!.userSailing!.cardbg!['fan']['number'] > 0) - // Positioned( - // top: 10, - // left: Get.size.width / 7 * 5.8, - // child: DefaultTextStyle( - // style: TextStyle( - // fontFamily: 'fansCard', - // fontSize: 9, - // color: Theme.of(context).colorScheme.primary, - // ), - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // mainAxisAlignment: MainAxisAlignment.center, - // children: [ - // const Text('NO.'), - // Text( - // replyItem!.member!.userSailing!.cardbg!['fan'] - // ['num_desc'], - // ), - // ], - // ), - // ), - // ), - // ], - // ), - // ), /// fix Stack内GestureDetector onTap无效 GestureDetector( behavior: HitTestBehavior.opaque, @@ -260,7 +180,7 @@ class ReplyItem extends StatelessWidget { ], ), Row( - children: [ + children: [ Text( Utils.dateFormat(replyItem!.ctime), style: TextStyle( @@ -290,31 +210,26 @@ class ReplyItem extends StatelessWidget { // title Container( margin: const EdgeInsets.only(top: 10, left: 45, right: 6, bottom: 4), - child: SelectableRegion( - magnifierConfiguration: const TextMagnifierConfiguration(), - focusNode: FocusNode(), - selectionControls: MaterialTextSelectionControls(), - child: Text.rich( - style: const TextStyle(height: 1.75), - maxLines: - replyItem!.content!.isText! && replyLevel == '1' ? 3 : 999, - overflow: TextOverflow.ellipsis, - TextSpan( - children: [ - if (replyItem!.isTop!) - const WidgetSpan( - alignment: PlaceholderAlignment.top, - child: PBadge( - text: 'TOP', - size: 'small', - stack: 'normal', - type: 'line', - fs: 9, - ), + child: Text.rich( + style: const TextStyle(height: 1.75), + maxLines: + replyItem!.content!.isText! && replyLevel == '1' ? 3 : 999, + overflow: TextOverflow.ellipsis, + TextSpan( + children: [ + if (replyItem!.isTop!) + const WidgetSpan( + alignment: PlaceholderAlignment.top, + child: PBadge( + text: 'TOP', + size: 'small', + stack: 'normal', + type: 'line', + fs: 9, ), - buildContent(context, replyItem!, replyReply, null), - ], - ), + ), + buildContent(context, replyItem!, replyReply, null), + ], ), ), ), @@ -340,9 +255,9 @@ class ReplyItem extends StatelessWidget { } // 感谢、回复、复制 - Widget bottonAction(context, replyControl) { + Widget bottonAction(BuildContext context, replyControl) { return Row( - children: [ + children: [ const SizedBox(width: 32), SizedBox( height: 32, @@ -365,7 +280,7 @@ class ReplyItem extends StatelessWidget { // 完成评论,数据添加 if (value != null && value['data'] != null) { - addReply!(value['data']) + addReply?.call(value['data']) // replyControl.replies.add(value['data']), } }); @@ -422,7 +337,7 @@ class ReplyItemRow extends StatelessWidget { this.replyItem, this.replyReply, }); - List? replies; + final List? replies; ReplyControl? replyControl; // int? f_rpid; ReplyItemModel? replyItem; @@ -430,8 +345,8 @@ class ReplyItemRow extends StatelessWidget { @override Widget build(BuildContext context) { - bool isShow = replyControl!.isShow!; - int extraRow = replyControl != null && isShow ? 1 : 0; + final bool isShow = replyControl!.isShow!; + final int extraRow = replyControl != null && isShow ? 1 : 0; return Container( margin: const EdgeInsets.only(left: 42, right: 4, top: 0), child: Material( @@ -443,10 +358,21 @@ class ReplyItemRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (replies!.isNotEmpty) - for (var i = 0; i < replies!.length; i++) ...[ + for (int i = 0; i < replies!.length; i++) ...[ InkWell( // 一楼点击评论展开评论详情 onTap: () => replyReply!(replyItem), + onLongPress: () { + feedBack(); + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + builder: (context) { + return MorePanel(item: replies![i]); + }, + ); + }, child: Container( width: double.infinity, padding: EdgeInsets.fromLTRB( @@ -472,7 +398,7 @@ class ReplyItemRow extends StatelessWidget { recognizer: TapGestureRecognizer() ..onTap = () { feedBack(); - String heroTag = + final String heroTag = Utils.makeHeroTag(replies![i].member.mid); Get.toNamed( '/member?mid=${replies![i].member.mid}', @@ -536,24 +462,14 @@ class ReplyItemRow extends StatelessWidget { InlineSpan buildContent( BuildContext context, replyItem, replyReply, fReplyItem) { + final String routePath = Get.currentRoute; + bool isVideoPage = routePath.startsWith('/video'); + // replyItem 当前回复内容 // replyReply 查看二楼回复(回复详情)回调 // fReplyItem 父级回复内容,用作二楼回复(回复详情)展示 - var content = replyItem.content; - if (content.emote.isEmpty && - content.atNameToMid.isEmpty && - content.jumpUrl.isEmpty && - content.vote.isEmpty && - content.pictures.isEmpty) { - return TextSpan( - text: content.message, - recognizer: TapGestureRecognizer() - ..onTap = - () => replyReply(replyItem.root == 0 ? replyItem : fReplyItem), - ); - } - List spanChilds = []; - bool hasMatchMember = true; + final content = replyItem.content; + final List spanChilds = []; // 投票 if (content.vote.isNotEmpty) { @@ -582,256 +498,375 @@ InlineSpan buildContent( return str; }); } - // content.message = content.message.replaceAll(RegExp(r"\{vote:.*?\}"), ' '); - if (content.message.contains('&')) { - content.message = content.message.replaceAll('&', '&'); + content.message = content.message.replaceAll(RegExp(r"\{vote:.*?\}"), ' '); + content.message = content.message + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(' ', ' '); + // 构建正则表达式 + final List specialTokens = [ + ...content.emote.keys, + ...content.topicsMeta?.keys?.map((e) => '#$e#') ?? [], + ...content.atNameToMid.keys.map((e) => '@$e'), + ]; + List jumpUrlKeysList = content.jumpUrl.keys.map((e) { + return e.replaceAllMapped( + RegExp(r'[?+*]'), (match) => '\\${match.group(0)}'); + }).toList(); + + String patternStr = specialTokens.map(RegExp.escape).join('|'); + if (patternStr.isNotEmpty) { + patternStr += "|"; } - // 匹配表情 + patternStr += r'(\b(?:\d+[::])?[0-5]?[0-9][::][0-5]?[0-9]\b)'; + if (jumpUrlKeysList.isNotEmpty) { + patternStr += '|${jumpUrlKeysList.join('|')}'; + } + RegExp bv23Regex = RegExp(r'https://b23\.tv/[a-zA-Z0-9]{7}'); + final RegExp pattern = RegExp(patternStr); + List matchedStrs = []; + void addPlainTextSpan(str) { + spanChilds.add( + TextSpan( + text: str, + recognizer: TapGestureRecognizer() + ..onTap = () => + replyReply?.call(replyItem.root == 0 ? replyItem : fReplyItem), + ), + ); + } + + // 分割文本并处理每个部分 content.message.splitMapJoin( - RegExp(r"\[.*?\]"), + pattern, onMatch: (Match match) { String matchStr = match[0]!; - if (content.emote.isNotEmpty && - matchStr.indexOf('[') == matchStr.lastIndexOf('[') && - matchStr.indexOf(']') == matchStr.lastIndexOf(']')) { - int size = content.emote[matchStr]['meta']['size']; - if (content.emote.keys.contains(matchStr)) { - spanChilds.add( - WidgetSpan( - child: NetworkImgLayer( - src: content.emote[matchStr]['url'], - type: 'emote', - width: size * 20, - height: size * 20, - ), - ), - ); - } else { - spanChilds.add(TextSpan( - text: matchStr, - recognizer: TapGestureRecognizer() - ..onTap = () => - replyReply(replyItem.root == 0 ? replyItem : fReplyItem))); - return matchStr; - } - } else { - spanChilds.add(TextSpan( + if (content.emote.containsKey(matchStr)) { + // 处理表情 + final int size = content.emote[matchStr]['meta']['size']; + spanChilds.add(WidgetSpan( + child: NetworkImgLayer( + src: content.emote[matchStr]['url'], + type: 'emote', + width: size * 20, + height: size * 20, + ), + )); + } else if (matchStr.startsWith("@") && + content.atNameToMid.containsKey(matchStr.substring(1))) { + // 处理@用户 + final String userName = matchStr.substring(1); + final int userId = content.atNameToMid[userName]; + spanChilds.add( + TextSpan( text: matchStr, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), recognizer: TapGestureRecognizer() - ..onTap = () => - replyReply(replyItem.root == 0 ? replyItem : fReplyItem))); - return matchStr; - } - return ''; - }, - onNonMatch: (String str) { - // 匹配@用户 - String matchMember = str; - if (content.atNameToMid.isNotEmpty) { - matchMember = str.splitMapJoin( - RegExp(r"@.*( |:)"), - onMatch: (Match match) { - if (match[0] != null) { - hasMatchMember = false; - content.atNameToMid.forEach((key, value) { - spanChilds.add( - TextSpan( - text: '@$key ', - style: TextStyle( - fontSize: - Theme.of(context).textTheme.titleSmall!.fontSize, - color: Theme.of(context).colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - String heroTag = Utils.makeHeroTag(value); - Get.toNamed( - '/member?mid=$value', - arguments: {'face': '', 'heroTag': heroTag}, - ); - }, - ), + ..onTap = () { + final String heroTag = Utils.makeHeroTag(userId); + Get.toNamed( + '/member?mid=$userId', + arguments: {'face': '', 'heroTag': heroTag}, ); - }); - } - return ''; - }, - onNonMatch: (String str) { - spanChilds.add(TextSpan(text: str)); - return str; - }, + }, + ), ); - } else { - matchMember = str; - } - - // 匹配 jumpUrl - String matchUrl = matchMember; - if (content.jumpUrl.isNotEmpty && hasMatchMember) { - List urlKeys = content.jumpUrl.keys.toList().reversed.toList(); - for (var index = 0; index < urlKeys.length; index++) { - var i = urlKeys[index]; - if (i.contains('?')) { - urlKeys[index] = i.replaceAll('?', '\\?'); - } - } - matchUrl = matchMember.splitMapJoin( - /// RegExp.escape() 转义特殊字符 - RegExp(urlKeys.map((key) => key).join("|")), - // RegExp('What does the fox say\\?'), - onMatch: (Match match) { - String matchStr = match[0]!; - String appUrlSchema = ''; - if (content.jumpUrl[matchStr] != null) { - appUrlSchema = content.jumpUrl[matchStr]['app_url_schema']; - } - // 默认不显示关键词 - bool enableWordRe = - setting.get(SettingBoxKey.enableWordRe, defaultValue: false); - if (content.jumpUrl[matchStr] != null) { - spanChilds.add( - TextSpan( - text: content.jumpUrl[matchStr]['title'], - style: TextStyle( - color: enableWordRe - ? Theme.of(context).colorScheme.primary - : null, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - if (appUrlSchema == '') { - String str = Uri.parse(matchStr).pathSegments[0]; - Map matchRes = IdUtils.matchAvorBv(input: str); - List matchKeys = matchRes.keys.toList(); - if (matchKeys.isNotEmpty) { - if (matchKeys.first == 'BV') { - Get.toNamed( - '/searchResult', - parameters: {'keyword': matchRes['BV']}, - ); - } - } else { - Get.toNamed( - '/webview', - parameters: { - 'url': matchStr, - 'type': 'url', - 'pageTitle': '' - }, - ); - } - } else { - if (appUrlSchema.startsWith('bilibili://search') && - enableWordRe) { - Get.toNamed('/searchResult', parameters: { - 'keyword': content.jumpUrl[matchStr]['title'] - }); - } - } - }, - ), - ); - } - - if (appUrlSchema.startsWith('bilibili://search') && enableWordRe) { - spanChilds.add( - WidgetSpan( - child: Icon( - FontAwesomeIcons.magnifyingGlass, - size: 9, + } else if (RegExp(r'^\b(?:\d+[::])?[0-5]?[0-9][::][0-5]?[0-9]\b$') + .hasMatch(matchStr)) { + matchStr = matchStr.replaceAll(':', ':'); + spanChilds.add( + TextSpan( + text: ' $matchStr ', + style: isVideoPage + ? TextStyle( color: Theme.of(context).colorScheme.primary, - ), - alignment: PlaceholderAlignment.top, - ), - ); - } - return ''; - }, - onNonMatch: (String str) { - spanChilds.add(TextSpan( - text: str, - recognizer: TapGestureRecognizer() - ..onTap = () => replyReply( - replyItem.root == 0 ? replyItem : fReplyItem))); - return str; - }, - ); - } - str = matchUrl.splitMapJoin( - RegExp(r'\b\d{2}:\d{2}\b'), - onMatch: (Match match) { - String matchStr = match[0]!; - spanChilds.add( - TextSpan( - text: ' $matchStr ', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - // 跳转到指定位置 + ) + : null, + recognizer: TapGestureRecognizer() + ..onTap = () { + // 跳转到指定位置 + if (isVideoPage) { try { + SmartDialog.showToast('跳转至:$matchStr'); Get.find( tag: Get.arguments['heroTag']) .plPlayerController .seekTo( Duration(seconds: Utils.duration(matchStr)), ); - } catch (_) {} + } catch (e) { + SmartDialog.showToast('跳转失败: $e'); + } + } + }, + ), + ); + } else { + String appUrlSchema = ''; + final bool enableWordRe = setting.get(SettingBoxKey.enableWordRe, + defaultValue: false) as bool; + if (content.jumpUrl[matchStr] != null && + !matchedStrs.contains(matchStr)) { + appUrlSchema = content.jumpUrl[matchStr]['app_url_schema']; + if (appUrlSchema.startsWith('bilibili://search') && !enableWordRe) { + addPlainTextSpan(matchStr); + return ""; + } + spanChilds.addAll( + [ + if (content.jumpUrl[matchStr]?['prefix_icon'] != null) ...[ + WidgetSpan( + child: Image.network( + content.jumpUrl[matchStr]['prefix_icon'], + height: 19, + color: Theme.of(context).colorScheme.primary, + ), + ) + ], + TextSpan( + text: content.jumpUrl[matchStr]['title'], + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + final String title = content.jumpUrl[matchStr]['title']; + if (appUrlSchema == '') { + if (matchStr.startsWith('BV')) { + UrlUtils.matchUrlPush( + matchStr, + title, + '', + ); + } else { + final String redirectUrl = + await UrlUtils.parseRedirectUrl(matchStr); + if (redirectUrl == matchStr) { + Clipboard.setData(ClipboardData(text: matchStr)); + SmartDialog.showToast('地址可能有误'); + return; + } + final String pathSegment = Uri.parse(redirectUrl).path; + final String lastPathSegment = + pathSegment.split('/').last; + if (lastPathSegment.startsWith('BV')) { + UrlUtils.matchUrlPush( + lastPathSegment, + title, + redirectUrl, + ); + } else { + Get.toNamed( + '/webview', + parameters: { + 'url': redirectUrl, + 'type': 'url', + 'pageTitle': title + }, + ); + } + } + } else { + if (appUrlSchema.startsWith('bilibili://search')) { + Get.toNamed('/searchResult', + parameters: {'keyword': title}); + } else if (matchStr.startsWith('https://b23.tv')) { + final String redirectUrl = + await UrlUtils.parseRedirectUrl(matchStr); + final String pathSegment = Uri.parse(redirectUrl).path; + final String lastPathSegment = + pathSegment.split('/').last; + if (lastPathSegment.startsWith('BV')) { + UrlUtils.matchUrlPush( + lastPathSegment, + title, + redirectUrl, + ); + } else { + Get.toNamed( + '/webview', + parameters: { + 'url': redirectUrl, + 'type': 'url', + 'pageTitle': title + }, + ); + } + } else { + Get.toNamed( + '/webview', + parameters: { + 'url': matchStr, + 'type': 'url', + 'pageTitle': title + }, + ); + } + } + }, + ) + ], + ); + // 只显示一次 + matchedStrs.add(matchStr); + } else if (content + .topicsMeta[matchStr.substring(1, matchStr.length - 1)] != + null) { + spanChilds.add( + TextSpan( + text: matchStr, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + final String topic = + matchStr.substring(1, matchStr.length - 1); + Get.toNamed('/searchResult', parameters: {'keyword': topic}); }, ), ); + } else { + addPlainTextSpan(matchStr); + } + } + return ''; + }, + onNonMatch: (String nonMatchStr) { + return nonMatchStr.splitMapJoin( + bv23Regex, + onMatch: (Match match) { + String matchStr = match[0]!; + spanChilds.add( + TextSpan( + text: ' $matchStr ', + style: isVideoPage + ? TextStyle( + color: Theme.of(context).colorScheme.primary, + ) + : null, + recognizer: TapGestureRecognizer() + ..onTap = () => Get.toNamed( + '/webview', + parameters: { + 'url': matchStr, + 'type': 'url', + 'pageTitle': matchStr + }, + ), + ), + ); return ''; }, - onNonMatch: (str) { - return str; + onNonMatch: (String nonMatchOtherStr) { + addPlainTextSpan(nonMatchOtherStr); + return nonMatchOtherStr; }, ); - - if (content.atNameToMid.isEmpty && content.jumpUrl.isEmpty) { - if (str != '') { - spanChilds.add(TextSpan( - text: str, - recognizer: TapGestureRecognizer() - ..onTap = () => - replyReply(replyItem.root == 0 ? replyItem : fReplyItem))); - } - } - return str; }, ); + if (content.jumpUrl.keys.isNotEmpty) { + List unmatchedItems = content.jumpUrl.keys + .toList() + .where((item) => !content.message.contains(item)) + .toList(); + if (unmatchedItems.isNotEmpty) { + for (int i = 0; i < unmatchedItems.length; i++) { + String patternStr = unmatchedItems[i]; + spanChilds.addAll( + [ + if (content.jumpUrl[patternStr]?['prefix_icon'] != null) ...[ + WidgetSpan( + child: Image.network( + content.jumpUrl[patternStr]['prefix_icon'], + height: 19, + color: Theme.of(context).colorScheme.primary, + ), + ) + ], + TextSpan( + text: content.jumpUrl[patternStr]['title'], + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + Get.toNamed( + '/webview', + parameters: { + 'url': patternStr, + 'type': 'url', + 'pageTitle': content.jumpUrl[patternStr]['title'] + }, + ); + }, + ) + ], + ); + } + } + } // 图片渲染 if (content.pictures.isNotEmpty) { - List picList = []; - int len = content.pictures.length; + final List picList = []; + final int len = content.pictures.length; + spanChilds.add(const TextSpan(text: '\n')); if (len == 1) { Map pictureItem = content.pictures.first; picList.add(pictureItem['img_src']); - spanChilds.add(const TextSpan(text: '\n')); spanChilds.add( WidgetSpan( child: LayoutBuilder( - builder: (context, BoxConstraints box) { + builder: (BuildContext context, BoxConstraints box) { + double maxHeight = box.maxWidth * 0.6; // 设置最大高度 + // double width = (box.maxWidth / 2).truncateToDouble(); + double height = 100; + try { + height = ((box.maxWidth / + 2 * + pictureItem['img_height'] / + pictureItem['img_width'])) + .truncateToDouble(); + } catch (_) {} + return GestureDetector( onTap: () { showDialog( useSafeArea: false, context: context, - builder: (context) { + builder: (BuildContext context) { return ImagePreview(initialPage: 0, imgList: picList); }, ); }, - child: Padding( + child: Container( padding: const EdgeInsets.only(top: 4), - child: NetworkImgLayer( - src: pictureItem['img_src'], - width: box.maxWidth / 2, - height: box.maxWidth * - 0.5 * - pictureItem['img_height'] / - pictureItem['img_width'], + constraints: BoxConstraints(maxHeight: maxHeight), + width: box.maxWidth / 2, + height: height, + child: Stack( + children: [ + Positioned.fill( + child: NetworkImgLayer( + src: pictureItem['img_src'], + width: box.maxWidth / 2, + height: height, + ), + ), + height > Get.size.height * 0.9 + ? const PBadge( + text: '长图', + right: 8, + bottom: 8, + ) + : const SizedBox(), + ], ), ), ); @@ -839,8 +874,7 @@ InlineSpan buildContent( ), ), ); - } - if (len > 1) { + } else if (len > 1) { List list = []; for (var i = 0; i < len; i++) { picList.add(content.pictures[i]['img_src']); @@ -858,10 +892,11 @@ InlineSpan buildContent( ); }, child: NetworkImgLayer( - src: content.pictures[i]['img_src'], - width: box.maxWidth, - height: box.maxWidth, - ), + src: content.pictures[i]['img_src'], + width: box.maxWidth, + height: box.maxWidth, + origAspectRatio: content.pictures[i]['img_width'] / + content.pictures[i]['img_height']), ); }, ), @@ -922,3 +957,100 @@ InlineSpan buildContent( // spanChilds.add(TextSpan(text: matchMember)); return TextSpan(children: spanChilds); } + +class MorePanel extends StatelessWidget { + final dynamic item; + const MorePanel({super.key, required this.item}); + + Future menuActionHandler(String type) async { + String message = item.content.message ?? item.content; + switch (type) { + case 'copyAll': + await Clipboard.setData(ClipboardData(text: message)); + SmartDialog.showToast('已复制'); + Get.back(); + break; + case 'copyFreedom': + Get.back(); + showDialog( + context: Get.context!, + builder: (context) { + return AlertDialog( + title: const Text('自由复制'), + content: SelectableText(message), + ); + }, + ); + break; + // case 'block': + // SmartDialog.showToast('加入黑名单'); + // break; + // case 'report': + // SmartDialog.showToast('举报'); + // break; + // case 'delete': + // SmartDialog.showToast('删除'); + // break; + default: + } + } + + @override + Widget build(BuildContext context) { + Color errorColor = Theme.of(context).colorScheme.error; + return Container( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => Get.back(), + child: Container( + height: 35, + padding: const EdgeInsets.only(bottom: 2), + child: Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.outline, + borderRadius: const BorderRadius.all(Radius.circular(3))), + ), + ), + ), + ), + ListTile( + onTap: () async => await menuActionHandler('copyAll'), + minLeadingWidth: 0, + leading: const Icon(Icons.copy_all_outlined, size: 19), + title: Text('复制全部', style: Theme.of(context).textTheme.titleSmall), + ), + ListTile( + onTap: () async => await menuActionHandler('copyFreedom'), + minLeadingWidth: 0, + leading: const Icon(Icons.copy_outlined, size: 19), + title: Text('自由复制', style: Theme.of(context).textTheme.titleSmall), + ), + // ListTile( + // onTap: () async => await menuActionHandler('block'), + // minLeadingWidth: 0, + // leading: Icon(Icons.block_outlined, color: errorColor), + // title: Text('加入黑名单', style: TextStyle(color: errorColor)), + // ), + // ListTile( + // onTap: () async => await menuActionHandler('report'), + // minLeadingWidth: 0, + // leading: Icon(Icons.report_outlined, color: errorColor), + // title: Text('举报', style: TextStyle(color: errorColor)), + // ), + // ListTile( + // onTap: () async => await menuActionHandler('del'), + // minLeadingWidth: 0, + // leading: Icon(Icons.delete_outline, color: errorColor), + // title: Text('删除', style: TextStyle(color: errorColor)), + // ), + ], + ), + ); + } +} diff --git a/lib/pages/video/detail/reply/widgets/zan.dart b/lib/pages/video/detail/reply/widgets/zan.dart index b9ead14e..234550be 100644 --- a/lib/pages/video/detail/reply/widgets/zan.dart +++ b/lib/pages/video/detail/reply/widgets/zan.dart @@ -25,12 +25,12 @@ class _ZanButtonState extends State { Future onLikeReply() async { feedBack(); // SmartDialog.showLoading(msg: 'pilipala ...'); - ReplyItemModel replyItem = widget.replyItem!; - int oid = replyItem.oid!; - int rpid = replyItem.rpid!; + final ReplyItemModel replyItem = widget.replyItem!; + final int oid = replyItem.oid!; + final int rpid = replyItem.rpid!; // 1 已点赞 2 不喜欢 0 未操作 - int action = replyItem.action == 0 ? 1 : 0; - var res = await ReplyHttp.likeReply( + final int action = replyItem.action == 0 ? 1 : 0; + final res = await ReplyHttp.likeReply( type: widget.replyType!.index, oid: oid, rpid: rpid, action: action); // SmartDialog.dismiss(); if (res['status']) { @@ -47,19 +47,23 @@ class _ZanButtonState extends State { SmartDialog.showToast(res['msg']); } } + bool isProcessing = false; void Function()? handleState(Future Function() action) { - return isProcessing ? null : () async { - setState(() => isProcessing = true); - await action(); - setState(() => isProcessing = false); - }; + return isProcessing + ? null + : () async { + setState(() => isProcessing = true); + await action(); + setState(() => isProcessing = false); + }; } @override Widget build(BuildContext context) { - var color = Theme.of(context).colorScheme.outline; - var primary = Theme.of(context).colorScheme.primary; + final ThemeData t = Theme.of(context); + final Color color = t.colorScheme.outline; + final Color primary = t.colorScheme.primary; return SizedBox( height: 32, child: TextButton( @@ -79,12 +83,14 @@ class _ZanButtonState extends State { transitionBuilder: (Widget child, Animation animation) { return ScaleTransition(scale: animation, child: child); }, - child: Text(widget.replyItem!.like.toString(), - key: ValueKey(widget.replyItem!.like!), - style: TextStyle( - color: widget.replyItem!.action == 1 ? primary : color, - fontSize: - Theme.of(context).textTheme.labelSmall!.fontSize)), + child: Text( + widget.replyItem!.like.toString(), + key: ValueKey(widget.replyItem!.like!), + style: TextStyle( + color: widget.replyItem!.action == 1 ? primary : color, + fontSize: t.textTheme.labelSmall!.fontSize, + ), + ), ), ], ), diff --git a/lib/pages/video/detail/replyNew/view.dart b/lib/pages/video/detail/replyNew/view.dart deleted file mode 100644 index 6f538e4e..00000000 --- a/lib/pages/video/detail/replyNew/view.dart +++ /dev/null @@ -1,225 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get.dart'; -import 'package:hive/hive.dart'; -import 'package:pilipala/http/video.dart'; -import 'package:pilipala/models/common/reply_type.dart'; -import 'package:pilipala/models/video/reply/item.dart'; -import 'package:pilipala/utils/feed_back.dart'; -import 'package:pilipala/utils/storage.dart'; - -class VideoReplyNewDialog extends StatefulWidget { - final int? oid; - final int? root; - final int? parent; - final ReplyType? replyType; - final ReplyItemModel? replyItem; - - const VideoReplyNewDialog({ - super.key, - this.oid, - this.root, - this.parent, - this.replyType, - this.replyItem, - }); - - @override - State createState() => _VideoReplyNewDialogState(); -} - -class _VideoReplyNewDialogState extends State - with WidgetsBindingObserver { - final TextEditingController _replyContentController = TextEditingController(); - final FocusNode replyContentFocusNode = FocusNode(); - final GlobalKey _formKey = GlobalKey(); - double _keyboardHeight = 0.0; // 键盘高度 - final _debouncer = Debouncer(milliseconds: 100); // 设置延迟时间 - bool ableClean = false; - Timer? timer; - Box localCache = GStrorage.localCache; - late double sheetHeight; - - @override - void initState() { - super.initState(); - // 监听输入框聚焦 - // replyContentFocusNode.addListener(_onFocus); - _replyContentController.addListener(_printLatestValue); - // 界面观察者 必须 - WidgetsBinding.instance.addObserver(this); - // 自动聚焦 - _autoFocus(); - - sheetHeight = localCache.get('sheetHeight'); - } - - _autoFocus() async { - await Future.delayed(const Duration(milliseconds: 300)); - if (context.mounted) { - FocusScope.of(context).requestFocus(replyContentFocusNode); - } - } - - _printLatestValue() { - setState(() { - ableClean = _replyContentController.text != ''; - }); - } - - Future submitReplyAdd() async { - feedBack(); - String message = _replyContentController.text; - var result = await VideoHttp.replyAdd( - type: widget.replyType ?? ReplyType.video, - oid: widget.oid!, - root: widget.root!, - parent: widget.parent!, - message: widget.replyItem != null && widget.replyItem!.root != 0 - ? ' 回复 @${widget.replyItem!.member!.uname!} : $message' - : message, - ); - if (result['status']) { - SmartDialog.showToast(result['data']['success_toast']); - Get.back(result: { - 'data': ReplyItemModel.fromJson(result['data']['reply'], ''), - }); - } else { - SmartDialog.showToast(result['msg']); - } - } - - @override - void didChangeMetrics() { - super.didChangeMetrics(); - WidgetsBinding.instance.addPostFrameCallback((_) { - // 键盘高度 - final viewInsets = EdgeInsets.fromViewPadding( - View.of(context).viewInsets, View.of(context).devicePixelRatio); - _debouncer.run(() { - if (mounted) { - setState(() { - _keyboardHeight = - _keyboardHeight == 0.0 ? viewInsets.bottom : _keyboardHeight; - }); - } - }); - }); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _replyContentController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - height: 500, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - color: Theme.of(context).colorScheme.background, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.only( - top: 6, right: 15, left: 15, bottom: 10), - child: Form( - key: _formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: TextField( - controller: _replyContentController, - minLines: 1, - maxLines: null, - autofocus: false, - focusNode: replyContentFocusNode, - decoration: const InputDecoration( - hintText: "输入回复内容", - border: InputBorder.none, - hintStyle: TextStyle( - fontSize: 14, - )), - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ), - ), - Divider( - height: 1, - color: Theme.of(context).dividerColor.withOpacity(0.1), - ), - Container( - height: 52, - padding: const EdgeInsets.only(left: 12, right: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - width: 36, - height: 36, - child: IconButton( - onPressed: () { - FocusScope.of(context) - .requestFocus(replyContentFocusNode); - }, - icon: Icon(Icons.keyboard, - size: 22, - color: Theme.of(context).colorScheme.onBackground), - highlightColor: - Theme.of(context).colorScheme.onInverseSurface, - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - backgroundColor: - MaterialStateProperty.resolveWith((states) { - return Theme.of(context).highlightColor; - }), - )), - ), - const Spacer(), - TextButton( - onPressed: () => submitReplyAdd(), child: const Text('发送')) - ], - ), - ), - AnimatedSize( - curve: Curves.easeInOut, - duration: const Duration(milliseconds: 300), - child: SizedBox( - width: double.infinity, - height: _keyboardHeight, - ), - ), - ], - ), - ); - } -} - -typedef DebounceCallback = void Function(); - -class Debouncer { - DebounceCallback? callback; - final int? milliseconds; - Timer? _timer; - - Debouncer({this.milliseconds}); - - run(DebounceCallback callback) { - if (_timer != null) { - _timer!.cancel(); - } - _timer = Timer(Duration(milliseconds: milliseconds!), () { - callback(); - }); - } -} diff --git a/lib/pages/video/detail/replyNew/index.dart b/lib/pages/video/detail/reply_new/index.dart similarity index 100% rename from lib/pages/video/detail/replyNew/index.dart rename to lib/pages/video/detail/reply_new/index.dart diff --git a/lib/pages/video/detail/reply_new/toolbar_icon_button.dart b/lib/pages/video/detail/reply_new/toolbar_icon_button.dart new file mode 100644 index 00000000..c4390796 --- /dev/null +++ b/lib/pages/video/detail/reply_new/toolbar_icon_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class ToolbarIconButton extends StatelessWidget { + final VoidCallback onPressed; + final Icon icon; + final String toolbarType; + final bool selected; + + const ToolbarIconButton({ + super.key, + required this.onPressed, + required this.icon, + required this.toolbarType, + required this.selected, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + onPressed: onPressed, + icon: icon, + highlightColor: Theme.of(context).colorScheme.secondaryContainer, + color: selected + ? Theme.of(context).colorScheme.onSecondaryContainer + : Theme.of(context).colorScheme.outline, + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + backgroundColor: MaterialStateProperty.resolveWith((states) { + return selected + ? Theme.of(context).colorScheme.secondaryContainer + : null; + }), + ), + ), + ); + } +} diff --git a/lib/pages/video/detail/reply_new/view.dart b/lib/pages/video/detail/reply_new/view.dart new file mode 100644 index 00000000..a94b6071 --- /dev/null +++ b/lib/pages/video/detail/reply_new/view.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/http/video.dart'; +import 'package:pilipala/models/common/reply_type.dart'; +import 'package:pilipala/models/video/reply/emote.dart'; +import 'package:pilipala/models/video/reply/item.dart'; +import 'package:pilipala/pages/emote/index.dart'; +import 'package:pilipala/utils/feed_back.dart'; + +import 'toolbar_icon_button.dart'; + +class VideoReplyNewDialog extends StatefulWidget { + final int? oid; + final int? root; + final int? parent; + final ReplyType? replyType; + final ReplyItemModel? replyItem; + + const VideoReplyNewDialog({ + super.key, + this.oid, + this.root, + this.parent, + this.replyType, + this.replyItem, + }); + + @override + State createState() => _VideoReplyNewDialogState(); +} + +class _VideoReplyNewDialogState extends State + with WidgetsBindingObserver { + final TextEditingController _replyContentController = TextEditingController(); + final FocusNode replyContentFocusNode = FocusNode(); + final GlobalKey _formKey = GlobalKey(); + late double emoteHeight = 0.0; + double keyboardHeight = 0.0; // 键盘高度 + final _debouncer = Debouncer(milliseconds: 200); // 设置延迟时间 + String toolbarType = 'input'; + + @override + void initState() { + super.initState(); + // 监听输入框聚焦 + // replyContentFocusNode.addListener(_onFocus); + // 界面观察者 必须 + WidgetsBinding.instance.addObserver(this); + // 自动聚焦 + _autoFocus(); + // 监听聚焦状态 + _focuslistener(); + } + + _autoFocus() async { + await Future.delayed(const Duration(milliseconds: 300)); + if (context.mounted) { + FocusScope.of(context).requestFocus(replyContentFocusNode); + } + } + + _focuslistener() { + replyContentFocusNode.addListener(() { + if (replyContentFocusNode.hasFocus) { + setState(() { + toolbarType = 'input'; + }); + } + }); + } + + Future submitReplyAdd() async { + feedBack(); + String message = _replyContentController.text; + var result = await VideoHttp.replyAdd( + type: widget.replyType ?? ReplyType.video, + oid: widget.oid!, + root: widget.root!, + parent: widget.parent!, + message: widget.replyItem != null && widget.replyItem!.root != 0 + ? ' 回复 @${widget.replyItem!.member!.uname!} : $message' + : message, + ); + if (result['status']) { + SmartDialog.showToast(result['data']['success_toast']); + Get.back(result: { + 'data': ReplyItemModel.fromJson(result['data']['reply'], ''), + }); + } else { + SmartDialog.showToast(result['msg']); + } + } + + void onChooseEmote(PackageItem package, Emote emote) { + final int cursorPosition = _replyContentController.selection.baseOffset; + final String currentText = _replyContentController.text; + final String newText = currentText.substring(0, cursorPosition) + + emote.text! + + currentText.substring(cursorPosition); + _replyContentController.value = TextEditingValue( + text: newText, + selection: + TextSelection.collapsed(offset: cursorPosition + emote.text!.length), + ); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + final String routePath = Get.currentRoute; + if (mounted && + (routePath.startsWith('/video') || + routePath.startsWith('/dynamicDetail'))) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // 键盘高度 + final viewInsets = EdgeInsets.fromViewPadding( + View.of(context).viewInsets, View.of(context).devicePixelRatio); + _debouncer.run(() { + if (mounted) { + if (keyboardHeight == 0 && emoteHeight == 0) { + setState(() { + emoteHeight = keyboardHeight = + keyboardHeight == 0.0 ? viewInsets.bottom : keyboardHeight; + }); + } + } + }); + }); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _replyContentController.dispose(); + replyContentFocusNode.removeListener(() {}); + replyContentFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + double _keyboardHeight = EdgeInsets.fromViewPadding( + View.of(context).viewInsets, View.of(context).devicePixelRatio) + .bottom; + print('_keyboardHeight: $_keyboardHeight'); + return Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + color: Theme.of(context).colorScheme.background, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + minHeight: 120, + ), + child: Container( + padding: const EdgeInsets.only( + top: 12, right: 15, left: 15, bottom: 10), + child: SingleChildScrollView( + child: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: TextField( + controller: _replyContentController, + minLines: 1, + maxLines: null, + autofocus: false, + focusNode: replyContentFocusNode, + decoration: const InputDecoration( + hintText: "输入回复内容", + border: InputBorder.none, + hintStyle: TextStyle( + fontSize: 14, + )), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ), + ), + ), + Divider( + height: 1, + color: Theme.of(context).dividerColor.withOpacity(0.1), + ), + Container( + height: 52, + padding: const EdgeInsets.only(left: 12, right: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ToolbarIconButton( + onPressed: () { + if (toolbarType == 'emote') { + setState(() { + toolbarType = 'input'; + }); + } + FocusScope.of(context).requestFocus(replyContentFocusNode); + }, + icon: const Icon(Icons.keyboard, size: 22), + toolbarType: toolbarType, + selected: toolbarType == 'input', + ), + const SizedBox(width: 20), + ToolbarIconButton( + onPressed: () { + if (toolbarType == 'input') { + setState(() { + toolbarType = 'emote'; + }); + } + FocusScope.of(context).unfocus(); + }, + icon: const Icon(Icons.emoji_emotions, size: 22), + toolbarType: toolbarType, + selected: toolbarType == 'emote', + ), + const Spacer(), + TextButton( + onPressed: () => submitReplyAdd(), child: const Text('发送')) + ], + ), + ), + AnimatedSize( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300), + child: SizedBox( + width: double.infinity, + height: toolbarType == 'input' + ? (_keyboardHeight > keyboardHeight + ? _keyboardHeight + : keyboardHeight) + : emoteHeight, + child: EmotePanel( + onChoose: (package, emote) => onChooseEmote(package, emote), + ), + ), + ), + ], + ), + ); + } +} + +typedef DebounceCallback = void Function(); + +class Debouncer { + DebounceCallback? callback; + final int? milliseconds; + Timer? _timer; + + Debouncer({this.milliseconds}); + + run(DebounceCallback callback) { + if (_timer != null) { + _timer!.cancel(); + } + _timer = Timer(Duration(milliseconds: milliseconds!), () { + callback(); + }); + } +} diff --git a/lib/pages/video/detail/replyReply/controller.dart b/lib/pages/video/detail/reply_reply/controller.dart similarity index 69% rename from lib/pages/video/detail/replyReply/controller.dart rename to lib/pages/video/detail/reply_reply/controller.dart index 6ce3722f..e94aaea5 100644 --- a/lib/pages/video/detail/replyReply/controller.dart +++ b/lib/pages/video/detail/reply_reply/controller.dart @@ -12,7 +12,7 @@ class VideoReplyReplyController extends GetxController { // rpid 请求楼中楼回复 String? rpid; ReplyType replyType = ReplyType.video; - RxList replyList = [ReplyItemModel()].obs; + RxList replyList = [].obs; // 当前页 int currentPage = 0; bool isLoadingMore = false; @@ -30,18 +30,21 @@ class VideoReplyReplyController extends GetxController { if (type == 'init') { currentPage = 0; } + if (isLoadingMore) { + return; + } isLoadingMore = true; - var res = await ReplyHttp.replyReplyList( + final res = await ReplyHttp.replyReplyList( oid: aid!, root: rpid!, pageNum: currentPage + 1, type: replyType.index, ); if (res['status']) { - List replies = res['data'].replies; + final List replies = res['data'].replies; if (replies.isNotEmpty) { noMore.value = '加载中...'; - if (replyList.length == res['data'].page.count) { + if (replies.length == res['data'].page.count) { noMore.value = '没有更多了'; } currentPage++; @@ -50,21 +53,6 @@ class VideoReplyReplyController extends GetxController { noMore.value = currentPage == 0 ? '还没有评论' : '没有更多了'; } if (type == 'init') { - // List replies = res['data'].replies; - // 添加置顶回复 - // if (res['data'].upper.top != null) { - // bool flag = false; - // for (var i = 0; i < res['data'].topReplies.length; i++) { - // if (res['data'].topReplies[i].rpid == res['data'].upper.top.rpid) { - // flag = true; - // } - // } - // if (!flag) { - // replies.insert(0, res['data'].upper.top); - // } - // } - // replies.insertAll(0, res['data'].topReplies); - // res['data'].replies = replies; replyList.value = replies; } else { // 每次回复之后,翻页请求有且只有相同的一条回复数据 diff --git a/lib/pages/video/detail/replyReply/index.dart b/lib/pages/video/detail/reply_reply/index.dart similarity index 100% rename from lib/pages/video/detail/replyReply/index.dart rename to lib/pages/video/detail/reply_reply/index.dart diff --git a/lib/pages/video/detail/replyReply/view.dart b/lib/pages/video/detail/reply_reply/view.dart similarity index 92% rename from lib/pages/video/detail/replyReply/view.dart rename to lib/pages/video/detail/reply_reply/view.dart index f2a72faf..e8754a31 100644 --- a/lib/pages/video/detail/replyReply/view.dart +++ b/lib/pages/video/detail/reply_reply/view.dart @@ -12,13 +12,6 @@ import 'package:pilipala/utils/storage.dart'; import 'controller.dart'; class VideoReplyReplyPanel extends StatefulWidget { - final int? oid; - final int? rpid; - final Function? closePanel; - final ReplyItemModel? firstFloor; - final String? source; - final ReplyType? replyType; - const VideoReplyReplyPanel({ this.oid, this.rpid, @@ -28,6 +21,12 @@ class VideoReplyReplyPanel extends StatefulWidget { this.replyType, super.key, }); + final int? oid; + final int? rpid; + final Function? closePanel; + final ReplyItemModel? firstFloor; + final String? source; + final ReplyType? replyType; @override State createState() => _VideoReplyReplyPanelState(); @@ -36,7 +35,7 @@ class VideoReplyReplyPanel extends StatefulWidget { class _VideoReplyReplyPanelState extends State { late VideoReplyReplyController _videoReplyReplyController; late AnimationController replyAnimationCtl; - Box localCache = GStrorage.localCache; + final Box localCache = GStrorage.localCache; late double sheetHeight; Future? _futureBuilderFuture; late ScrollController scrollController; @@ -55,7 +54,8 @@ class _VideoReplyReplyPanelState extends State { () { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 300) { - EasyThrottle.throttle('replylist', const Duration(seconds: 2), () { + EasyThrottle.throttle('replylist', const Duration(milliseconds: 200), + () { _videoReplyReplyController.queryReplyList(type: 'onLoad'); }); } @@ -87,13 +87,13 @@ class _VideoReplyReplyPanelState extends State { padding: const EdgeInsets.only(left: 12, right: 2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ const Text('评论详情'), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () { _videoReplyReplyController.currentPage = 0; - widget.closePanel!(); + widget.closePanel?.call; Navigator.pop(context); }, ), @@ -138,15 +138,15 @@ class _VideoReplyReplyPanelState extends State { ], FutureBuilder( future: _futureBuilderFuture, - builder: (context, snapshot) { + builder: (BuildContext context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data as Map; + final Map data = snapshot.data as Map; if (data['status']) { // 请求成功 return Obx( () => SliverList( delegate: SliverChildBuilderDelegate( - (context, index) { + (BuildContext context, int index) { if (index == _videoReplyReplyController .replyList.length) { @@ -185,6 +185,8 @@ class _VideoReplyReplyPanelState extends State { .add(replyItem); }, replyType: widget.replyType, + replyReply: (replyItem) => + replyReply(replyItem), ); } }, @@ -204,8 +206,8 @@ class _VideoReplyReplyPanelState extends State { } else { // 骨架屏 return SliverList( - delegate: - SliverChildBuilderDelegate((context, index) { + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { return const VideoReplySkeleton(); }, childCount: 8), ); diff --git a/lib/pages/video/detail/view.dart b/lib/pages/video/detail/view.dart index de35ddaf..c2379f20 100644 --- a/lib/pages/video/detail/view.dart +++ b/lib/pages/video/detail/view.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:floating/floating.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; @@ -21,8 +24,8 @@ import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; import 'package:pilipala/services/service_locator.dart'; import 'package:pilipala/utils/storage.dart'; -import 'package:pilipala/plugin/pl_player/utils/fullscreen.dart'; -import 'widgets/header_control.dart'; +import '../../../services/shutdown_timer_service.dart'; +import 'widgets/app_bar.dart'; class VideoDetailPage extends StatefulWidget { const VideoDetailPage({Key? key}) : super(key: key); @@ -34,8 +37,8 @@ class VideoDetailPage extends StatefulWidget { } class _VideoDetailPageState extends State - with TickerProviderStateMixin, RouteAware, WidgetsBindingObserver { - late VideoDetailController videoDetailController; + with TickerProviderStateMixin, RouteAware { + late VideoDetailController vdCtr; PlPlayerController? plPlayerController; final ScrollController _extendNestCtr = ScrollController(); late StreamController appbarStream; @@ -46,33 +49,34 @@ class _VideoDetailPageState extends State PlayerStatus playerStatus = PlayerStatus.playing; double doubleOffset = 0; - Box localCache = GStrorage.localCache; - Box setting = GStrorage.setting; + final Box localCache = GStrorage.localCache; + final Box setting = GStrorage.setting; late double statusBarHeight; - final videoHeight = Get.size.width * 9 / 16; + final double videoHeight = Get.size.width * 9 / 16; late Future _futureBuilderFuture; // 自动退出全屏 late bool autoExitFullcreen; late bool autoPlayEnable; late bool autoPiP; - final floating = Floating(); + late Floating floating; + bool isShowing = true; @override void initState() { super.initState(); heroTag = Get.arguments['heroTag']; - videoDetailController = Get.put(VideoDetailController(), tag: heroTag); - videoIntroController = Get.put(VideoIntroController(), tag: heroTag); + vdCtr = Get.put(VideoDetailController(), tag: heroTag); + videoIntroController = Get.put( + VideoIntroController(bvid: Get.parameters['bvid']!), + tag: heroTag); videoIntroController.videoDetail.listen((value) { - videoPlayerServiceHandler.onVideoDetailChange( - value, videoDetailController.cid.value); + videoPlayerServiceHandler.onVideoDetailChange(value, vdCtr.cid.value); }); bangumiIntroController = Get.put(BangumiIntroController(), tag: heroTag); bangumiIntroController.bangumiDetail.listen((value) { - videoPlayerServiceHandler.onVideoDetailChange( - value, videoDetailController.cid.value); + videoPlayerServiceHandler.onVideoDetailChange(value, vdCtr.cid.value); }); - videoDetailController.cid.listen((p0) { + vdCtr.cid.listen((p0) { videoPlayerServiceHandler.onVideoDetailChange( bangumiIntroController.bangumiDetail.value, p0); }); @@ -85,14 +89,18 @@ class _VideoDetailPageState extends State videoSourceInit(); appbarStreamListen(); - WidgetsBinding.instance.addObserver(this); + fullScreenStatusListener(); + if (Platform.isAndroid) { + floating = vdCtr.floating!; + autoEnterPip(); + } } // 获取视频资源,初始化播放器 Future videoSourceInit() async { - _futureBuilderFuture = videoDetailController.queryVideoUrl(); - if (videoDetailController.autoPlay.value) { - plPlayerController = videoDetailController.plPlayerController; + _futureBuilderFuture = vdCtr.queryVideoUrl(); + if (vdCtr.autoPlay.value) { + plPlayerController = vdCtr.plPlayerController; plPlayerController!.addStatusLister(playerListener); } } @@ -102,7 +110,7 @@ class _VideoDetailPageState extends State appbarStream = StreamController(); _extendNestCtr.addListener( () { - double offset = _extendNestCtr.position.pixels; + final double offset = _extendNestCtr.position.pixels; appbarStream.add(offset); }, ); @@ -116,14 +124,15 @@ class _VideoDetailPageState extends State if (autoExitFullcreen) { plPlayerController!.triggerFullScreen(status: false); } + shutdownTimerService.handleWaitingFinished(); /// 顺序播放 列表循环 if (plPlayerController!.playRepeat != PlayRepeat.pause && plPlayerController!.playRepeat != PlayRepeat.singleCycle) { - if (videoDetailController.videoType == SearchType.video) { + if (vdCtr.videoType == SearchType.video) { videoIntroController.nextPlay(); } - if (videoDetailController.videoType == SearchType.media_bangumi) { + if (vdCtr.videoType == SearchType.media_bangumi) { bangumiIntroController.nextPlay(); } } @@ -135,13 +144,16 @@ class _VideoDetailPageState extends State } // 播放完展示控制栏 try { - PiPStatus currentStatus = - await videoDetailController.floating!.pipStatus; + PiPStatus currentStatus = await vdCtr.floating!.pipStatus; if (currentStatus == PiPStatus.disabled) { plPlayerController!.onLockControl(false); } } catch (_) {} } + if (Platform.isAndroid) { + floating.toggleAutoPip( + autoEnter: status == PlayerStatus.playing && autoPiP); + } } // 继续播放或重新播放 @@ -153,24 +165,37 @@ class _VideoDetailPageState extends State /// 未开启自动播放时触发播放 Future handlePlay() async { - await videoDetailController.playerInit(); - plPlayerController = videoDetailController.plPlayerController; - videoDetailController.autoPlay.value = true; - videoDetailController.isShowCover.value = false; + await vdCtr.playerInit(); + plPlayerController = vdCtr.plPlayerController; + plPlayerController!.addStatusLister(playerListener); + vdCtr.autoPlay.value = true; + vdCtr.isShowCover.value = false; + } + + void fullScreenStatusListener() { + plPlayerController?.isFullScreen.listen((bool isFullScreen) { + if (isFullScreen) { + vdCtr.hiddenReplyReplyPanel(); + } + }); } @override void dispose() { + shutdownTimerService.handleWaitingFinished(); if (plPlayerController != null) { plPlayerController!.removeStatusLister(playerListener); plPlayerController!.dispose(); } - if (videoDetailController.floating != null) { - videoDetailController.floating!.dispose(); + if (vdCtr.floating != null) { + vdCtr.floating!.dispose(); } videoPlayerServiceHandler.onVideoDetailDispose(); - WidgetsBinding.instance.removeObserver(this); - floating.dispose(); + if (Platform.isAndroid) { + floating.toggleAutoPip(autoEnter: false); + floating.dispose(); + } + appbarStream.close(); super.dispose(); } @@ -178,32 +203,41 @@ class _VideoDetailPageState extends State // 离开当前页面时 void didPushNext() async { /// 开启 - if (setting.get(SettingBoxKey.enableAutoBrightness, defaultValue: false)) { - videoDetailController.brightness = plPlayerController!.brightness.value; + if (setting.get(SettingBoxKey.enableAutoBrightness, defaultValue: false) + as bool) { + vdCtr.brightness = plPlayerController!.brightness.value; } if (plPlayerController != null) { - videoDetailController.defaultST = plPlayerController!.position.value; + vdCtr.defaultST = plPlayerController!.position.value; videoIntroController.isPaused = true; plPlayerController!.removeStatusLister(playerListener); plPlayerController!.pause(); + vdCtr.clearSubtitleContent(); } + setState(() => isShowing = false); super.didPushNext(); } @override // 返回当前页面时 void didPopNext() async { - videoDetailController.isFirstTime = false; - bool autoplay = autoPlayEnable; - videoDetailController.playerInit(autoplay: autoplay); + if (plPlayerController != null && + plPlayerController!.videoPlayerController != null) { + setState(() { + vdCtr.setSubtitleContent(); + isShowing = true; + }); + } + vdCtr.isFirstTime = false; + final bool autoplay = autoPlayEnable; + vdCtr.playerInit(autoplay: autoplay); /// 未开启自动播放时,未播放跳转下一页返回/播放后跳转下一页返回 - videoDetailController.autoPlay.value = - !videoDetailController.isShowCover.value; + vdCtr.autoPlay.value = !vdCtr.isShowCover.value; videoIntroController.isPaused = false; if (_extendNestCtr.position.pixels == 0 && autoplay) { await Future.delayed(const Duration(milliseconds: 300)); - plPlayerController!.seekTo(videoDetailController.defaultST); + plPlayerController?.seekTo(vdCtr.defaultST); plPlayerController?.play(); } plPlayerController?.addStatusLister(playerListener); @@ -214,280 +248,327 @@ class _VideoDetailPageState extends State void didChangeDependencies() { super.didChangeDependencies(); VideoDetailPage.routeObserver - .subscribe(this, ModalRoute.of(context) as PageRoute); + .subscribe(this, ModalRoute.of(context)! as PageRoute); } - @override - void didChangeAppLifecycleState(AppLifecycleState lifecycleState) { - var routePath = Get.currentRoute; - if (lifecycleState == AppLifecycleState.inactive && - autoPiP && - routePath.startsWith('/video')) { - floating.enable( - aspectRatio: Rational( - videoDetailController.data.dash!.video!.first.width!, - videoDetailController.data.dash!.video!.first.height!, - )); + void autoEnterPip() { + final String routePath = Get.currentRoute; + if (autoPiP && routePath.startsWith('/video')) { + floating.toggleAutoPip(autoEnter: autoPiP); } } @override Widget build(BuildContext context) { - final videoHeight = MediaQuery.of(context).size.width * 9 / 16; + // final double videoHeight = MediaQuery.sizeOf(context).width * 9 / 16; + final sizeContext = MediaQuery.sizeOf(context); + final _context = MediaQuery.of(context); + late double defaultVideoHeight = sizeContext.width * 9 / 16; + late RxDouble videoHeight = defaultVideoHeight.obs; final double pinnedHeaderHeight = - statusBarHeight + kToolbarHeight + videoHeight; - if (MediaQuery.of(context).orientation == Orientation.landscape || - plPlayerController?.isFullScreen.value == true) { + statusBarHeight + kToolbarHeight + videoHeight.value; + // ignore: no_leading_underscores_for_local_identifiers + + // 竖屏 + final bool isPortrait = _context.orientation == Orientation.portrait; + // 横屏 + final bool isLandscape = _context.orientation == Orientation.landscape; + final Rx isFullScreen = plPlayerController?.isFullScreen ?? false.obs; + // 全屏时高度撑满 + if (isLandscape || isFullScreen.value == true) { + videoHeight.value = Get.size.height; enterFullScreen(); } else { + videoHeight.value = defaultVideoHeight; exitFullScreen(); } + + /// 播放器面板 + Widget videoPlayerPanel = FutureBuilder( + future: _futureBuilderFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData && snapshot.data['status']) { + return Obx( + () { + return !vdCtr.autoPlay.value + ? const SizedBox() + : PLVideoPlayer( + controller: plPlayerController!, + headerControl: vdCtr.headerControl, + danmuWidget: Obx( + () => PlDanmaku( + key: Key(vdCtr.danmakuCid.value.toString()), + cid: vdCtr.danmakuCid.value, + playerController: plPlayerController!, + ), + ), + ); + }, + ); + } else { + // 加载失败异常处理 + return const SizedBox(); + } + }, + ); + + /// tabbar + Widget tabbarBuild = Container( + width: double.infinity, + height: 45, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 1, + color: Theme.of(context).dividerColor.withOpacity(0.1), + ), + ), + ), + child: Material( + child: Row( + children: [ + Flexible( + flex: 1, + child: Obx( + () => TabBar( + padding: EdgeInsets.zero, + controller: vdCtr.tabCtr, + labelStyle: const TextStyle(fontSize: 13), + labelPadding: + const EdgeInsets.symmetric(horizontal: 10.0), // 设置每个标签的宽度 + dividerColor: Colors.transparent, + tabs: vdCtr.tabs + .map( + (String name) => Tab(text: name), + ) + .toList(), + ), + ), + ), + Flexible( + flex: 1, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + height: 32, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () => vdCtr.showShootDanmakuSheet(), + child: + const Text('发弹幕', style: TextStyle(fontSize: 12)), + ), + ), + SizedBox( + width: 38, + height: 38, + child: Obx( + () => IconButton( + onPressed: () { + plPlayerController?.isOpenDanmu.value = + !(plPlayerController?.isOpenDanmu.value ?? + false); + }, + icon: !(plPlayerController?.isOpenDanmu.value ?? + false) + ? SvgPicture.asset( + 'assets/images/video/danmu_close.svg', + // ignore: deprecated_member_use + color: + Theme.of(context).colorScheme.outline, + ) + : SvgPicture.asset( + 'assets/images/video/danmu_open.svg', + // ignore: deprecated_member_use + color: + Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + const SizedBox(width: 14), + ], + ), + )), + ], + ), + ), + ); + + /// 手动播放 + Widget handlePlayPanel() { + return Stack( + children: [ + GestureDetector( + onTap: () { + handlePlay(); + }, + child: NetworkImgLayer( + type: 'emote', + src: vdCtr.videoItem['pic'], + width: Get.width, + height: videoHeight.value, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: buildCustomAppBar(), + ), + Positioned( + right: 12, + bottom: 10, + child: IconButton( + tooltip: '播放', + onPressed: () => handlePlay(), + icon: Image.asset( + 'assets/images/play.png', + width: 60, + height: 60, + )), + ), + ], + ); + } + Widget childWhenDisabled = SafeArea( - top: MediaQuery.of(context).orientation == Orientation.portrait, + top: MediaQuery.of(context).orientation == Orientation.portrait && + plPlayerController?.isFullScreen.value == true, bottom: MediaQuery.of(context).orientation == Orientation.portrait && plPlayerController?.isFullScreen.value == true, - left: plPlayerController?.isFullScreen.value != true, - right: plPlayerController?.isFullScreen.value != true, + left: false, //plPlayerController?.isFullScreen.value != true, + right: false, //plPlayerController?.isFullScreen.value != true, child: Stack( children: [ Scaffold( resizeToAvoidBottomInset: false, - key: videoDetailController.scaffoldKey, + key: vdCtr.scaffoldKey, backgroundColor: Colors.black, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(0), + child: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + ), + ), body: ExtendedNestedScrollView( controller: _extendNestCtr, headerSliverBuilder: - (BuildContext context, bool innerBoxIsScrolled) { + (BuildContext context2, bool innerBoxIsScrolled) { return [ - Obx(() => SliverAppBar( - automaticallyImplyLeading: false, - pinned: false, - elevation: 0, - scrolledUnderElevation: 0, - forceElevated: innerBoxIsScrolled, - expandedHeight: MediaQuery.of(context).orientation == - Orientation.landscape || - plPlayerController?.isFullScreen.value == true - ? MediaQuery.of(context).size.height - - (MediaQuery.of(context).orientation == - Orientation.landscape - ? 0 - : statusBarHeight) - : videoHeight, - backgroundColor: Colors.black, - flexibleSpace: FlexibleSpaceBar( - background: PopScope( - canPop: - plPlayerController?.isFullScreen.value != true, - onPopInvoked: (bool didPop) { - if (plPlayerController?.isFullScreen.value == - true) { - plPlayerController! - .triggerFullScreen(status: false); - } - if (MediaQuery.of(context).orientation == - Orientation.landscape) { - verticalScreen(); - } - }, - child: LayoutBuilder( - builder: (context, boxConstraints) { - double maxWidth = boxConstraints.maxWidth; - double maxHeight = boxConstraints.maxHeight; - return Stack( - children: [ - FutureBuilder( - future: _futureBuilderFuture, - builder: ((context, snapshot) { - if (snapshot.hasData && - snapshot.data['status']) { - return Obx( - () => !videoDetailController - .autoPlay.value - ? const SizedBox() - : PLVideoPlayer( - controller: - plPlayerController!, - headerControl: - videoDetailController - .headerControl, - danmuWidget: Obx( - () => PlDanmaku( - key: Key( - videoDetailController - .danmakuCid - .value - .toString()), - cid: - videoDetailController - .danmakuCid - .value, - playerController: - plPlayerController!, - ), - ), - ), - ); - } else { - return const SizedBox(); - } - }), - ), + Obx( + () { + if (MediaQuery.of(context).orientation == + Orientation.landscape || + plPlayerController?.isFullScreen.value == true) { + enterFullScreen(); + } else { + exitFullScreen(); + } + return SliverAppBar( + automaticallyImplyLeading: false, + // 假装使用一个非空变量,避免Obx检测不到而罢工 + pinned: vdCtr.autoPlay.value, + elevation: 0, + scrolledUnderElevation: 0, + forceElevated: innerBoxIsScrolled, + expandedHeight: MediaQuery.of(context).orientation == + Orientation.landscape || + plPlayerController?.isFullScreen.value == true + ? (MediaQuery.sizeOf(context).height - + (MediaQuery.of(context).orientation == + Orientation.landscape + ? 0 + : MediaQuery.of(context).padding.top)) + : videoHeight.value, + backgroundColor: Colors.black, + flexibleSpace: FlexibleSpaceBar( + background: PopScope( + canPop: plPlayerController?.isFullScreen.value != + true, + onPopInvoked: (bool didPop) { + if (plPlayerController?.isFullScreen.value == + true) { + plPlayerController! + .triggerFullScreen(status: false); + } + if (MediaQuery.of(context).orientation == + Orientation.landscape) { + verticalScreen(); + } + }, + child: LayoutBuilder( + builder: (BuildContext context, + BoxConstraints boxConstraints) { + // final double maxWidth = + // boxConstraints.maxWidth; + // final double maxHeight = + // boxConstraints.maxHeight; + return Stack( + children: [ + if (isShowing) videoPlayerPanel, - Obx( - () => Visibility( - visible: videoDetailController - .isShowCover.value, - child: Positioned( - top: 0, - left: 0, - right: 0, - child: NetworkImgLayer( - type: 'emote', - src: videoDetailController - .videoItem['pic'], - width: maxWidth, - height: maxHeight, + /// 关闭自动播放时 手动播放 + Obx( + () => Visibility( + visible: !vdCtr.autoPlay.value && + vdCtr.isShowCover.value, + child: Positioned( + top: 0, + left: 0, + right: 0, + child: handlePlayPanel(), ), ), ), - ), - - /// 关闭自动播放时 手动播放 - Obx( - () => Visibility( - visible: videoDetailController - .isShowCover.value && - videoDetailController - .isEffective.value && - !videoDetailController - .autoPlay.value, - child: Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - child: AppBar( - primary: false, - foregroundColor: Colors.white, - backgroundColor: - Colors.transparent, - actions: [ - IconButton( - tooltip: '稍后再看', - onPressed: () async { - var res = await UserHttp - .toViewLater( - bvid: - videoDetailController - .bvid); - SmartDialog.showToast( - res['msg']); - }, - icon: const Icon(Icons - .history_outlined), - ), - const SizedBox(width: 14) - ], - ), - ), - Positioned( - right: 12, - bottom: 10, - child: TextButton.icon( - style: ButtonStyle( - backgroundColor: - MaterialStateProperty - .resolveWith( - (states) { - return Theme.of(context) - .colorScheme - .primaryContainer; - }), - ), - onPressed: () => handlePlay(), - icon: const Icon( - Icons.play_circle_outline, - size: 20, - ), - label: const Text('Play'), - ), - ), - ], - )), - ), - ], - ); - }, - )), - ))), + ], + ); + }, + )), + ), + ); + }, + ), ]; }, - // pinnedHeaderSliverHeightBuilder: () { - // return playerStatus != PlayerStatus.playing - // ? statusBarHeight + kToolbarHeight - // : pinnedHeaderHeight; - // }, + /// 不收回 pinnedHeaderSliverHeightBuilder: () { - return plPlayerController?.isFullScreen.value == true - ? MediaQuery.of(context).size.height - : pinnedHeaderHeight; + return MediaQuery.of(context).orientation == + Orientation.landscape || + plPlayerController?.isFullScreen.value == true + ? MediaQuery.sizeOf(context).height + : playerStatus != PlayerStatus.playing + ? kToolbarHeight + : pinnedHeaderHeight; }, onlyOneScrollInBody: true, - body: Container( + body: ColoredBox( key: Key(heroTag), color: Theme.of(context).colorScheme.background, child: Column( children: [ - Opacity( - opacity: 0, - child: SizedBox( - width: double.infinity, - height: 0, - child: Obx( - () => TabBar( - controller: videoDetailController.tabCtr, - dividerColor: Colors.transparent, - indicatorColor: - Theme.of(context).colorScheme.background, - tabs: videoDetailController.tabs - .map((String name) => Tab(text: name)) - .toList(), - ), - ), - ), - ), + tabbarBuild, Expanded( child: TabBarView( - controller: videoDetailController.tabCtr, - children: [ + controller: vdCtr.tabCtr, + children: [ Builder( - builder: (context) { + builder: (BuildContext context) { return CustomScrollView( key: const PageStorageKey('简介'), slivers: [ - if (videoDetailController.videoType == - SearchType.video) ...[ - const VideoIntroPanel(), - ] else if (videoDetailController.videoType == + if (vdCtr.videoType == SearchType.video) ...[ + VideoIntroPanel(bvid: vdCtr.bvid), + ] else if (vdCtr.videoType == SearchType.media_bangumi) ...[ Obx(() => BangumiIntroPanel( - cid: videoDetailController.cid.value)), + cid: vdCtr.cid.value)), ], - // if (videoDetailController.videoType == - // SearchType.video) ...[ - // SliverPersistentHeader( - // floating: true, - // pinned: true, - // delegate: SliverHeaderDelegate( - // height: 50, - // child: - // const MenuRow(loadingStatus: false), - // ), - // ), - // ], SliverToBoxAdapter( child: Divider( indent: 12, @@ -497,13 +578,18 @@ class _VideoDetailPageState extends State .withOpacity(0.06), ), ), - const RelatedVideoPanel(), + if (vdCtr.videoType == SearchType.video && + vdCtr.enableRelatedVideo) + const RelatedVideoPanel(), ], ); }, ), - VideoReplyPanel( - bvid: videoDetailController.bvid, + Obx( + () => VideoReplyPanel( + bvid: vdCtr.bvid, + oid: vdCtr.oid.value, + ), ) ], ), @@ -516,58 +602,73 @@ class _VideoDetailPageState extends State /// 重新进入会刷新 // 播放完成/暂停播放 - // StreamBuilder( - // stream: appbarStream.stream, - // initialData: 0, - // builder: ((context, snapshot) { - // return ScrollAppBar( - // snapshot.data!.toDouble(), - // () => continuePlay(), - // playerStatus, - // null, - // ); - // }), - // ) + StreamBuilder( + stream: appbarStream.stream, + initialData: 0, + builder: ((context, snapshot) { + return ScrollAppBar( + snapshot.data!.toDouble(), + () => continuePlay(), + playerStatus, + null, + ); + }), + ) ], ), ); - Widget childWhenEnabled = FutureBuilder( - key: Key(heroTag), - future: _futureBuilderFuture, - builder: ((context, snapshot) { - if (snapshot.hasData && snapshot.data['status']) { - return Obx( - () => !videoDetailController.autoPlay.value - ? const SizedBox() - : PLVideoPlayer( - controller: plPlayerController!, - headerControl: HeaderControl( - controller: plPlayerController, - videoDetailCtr: videoDetailController, - ), - danmuWidget: Obx( - () => PlDanmaku( - key: Key( - videoDetailController.danmakuCid.value.toString()), - cid: videoDetailController.danmakuCid.value, - playerController: plPlayerController!, - ), - ), - ), - ); - } else { - return const SizedBox(); - } - }), - ); + if (Platform.isAndroid) { return PiPSwitcher( childWhenDisabled: childWhenDisabled, - childWhenEnabled: childWhenEnabled, + childWhenEnabled: videoPlayerPanel, floating: floating, ); } else { return childWhenDisabled; } } + + Widget buildCustomAppBar() { + return AppBar( + backgroundColor: Colors.transparent, // 使背景透明 + foregroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + primary: false, + centerTitle: false, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Container( + height: kToolbarHeight, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.transparent, + Colors.black54, + ], + tileMode: TileMode.mirror, + )), + child: Row( + children: [ + ComBtn( + icon: const Icon(FontAwesomeIcons.arrowLeft, size: 15), + fuc: () => Get.back(), + ), + const Spacer(), + ComBtn( + icon: const Icon(Icons.history_outlined, size: 22), + fuc: () async { + var res = await UserHttp.toViewLater(bvid: vdCtr.bvid); + SmartDialog.showToast(res['msg']); + }, + ), + ], + ), + ), + ); + } } diff --git a/lib/pages/video/detail/widgets/app_bar.dart b/lib/pages/video/detail/widgets/app_bar.dart index fb7822fb..efc0b593 100644 --- a/lib/pages/video/detail/widgets/app_bar.dart +++ b/lib/pages/video/detail/widgets/app_bar.dart @@ -16,13 +16,17 @@ class ScrollAppBar extends StatelessWidget { @override Widget build(BuildContext context) { final double statusBarHeight = MediaQuery.of(context).padding.top; - final videoHeight = MediaQuery.of(context).size.width * 9 / 16; + final videoHeight = MediaQuery.sizeOf(context).width * 9 / 16; + double scrollDistance = scrollVal; + if (scrollVal > videoHeight - kToolbarHeight) { + scrollDistance = videoHeight - kToolbarHeight; + } return Positioned( - top: -videoHeight + scrollVal + kToolbarHeight + 0.5, + top: -videoHeight + scrollDistance + kToolbarHeight + 0.5, left: 0, right: 0, child: Opacity( - opacity: scrollVal / (videoHeight - kToolbarHeight), + opacity: scrollDistance / (videoHeight - kToolbarHeight), child: Container( height: statusBarHeight + kToolbarHeight, color: Theme.of(context).colorScheme.background, diff --git a/lib/pages/video/detail/widgets/expandable_section.dart b/lib/pages/video/detail/widgets/expandable_section.dart index afa68cc9..69e73e20 100644 --- a/lib/pages/video/detail/widgets/expandable_section.dart +++ b/lib/pages/video/detail/widgets/expandable_section.dart @@ -32,28 +32,14 @@ class _ExpandedSectionState extends State _runExpandCheck(); } - ///Setting up the animation - // void prepareAnimations() { - // expandController = AnimationController( - // vsync: this, duration: const Duration(milliseconds: 500)); - // animation = CurvedAnimation( - // parent: expandController, - // curve: Curves.fastOutSlowIn, - // ); - // } - void prepareAnimations() { expandController = AnimationController( vsync: this, duration: const Duration(milliseconds: 400)); Animation curve = CurvedAnimation( parent: expandController, - curve: Curves.fastOutSlowIn, + curve: Curves.linear, ); animation = Tween(begin: widget.begin, end: widget.end).animate(curve); - // animation = CurvedAnimation( - // parent: expandController, - // curve: Curves.fastOutSlowIn, - // ); } void _runExpandCheck() { @@ -67,7 +53,9 @@ class _ExpandedSectionState extends State @override void didUpdateWidget(ExpandedSection oldWidget) { super.didUpdateWidget(oldWidget); - _runExpandCheck(); + if (widget.expand != oldWidget.expand) { + _runExpandCheck(); + } } @override diff --git a/lib/pages/video/detail/widgets/header_control.dart b/lib/pages/video/detail/widgets/header_control.dart index c07afbb6..1ee65d83 100644 --- a/lib/pages/video/detail/widgets/header_control.dart +++ b/lib/pages/video/detail/widgets/header_control.dart @@ -16,18 +16,26 @@ import 'package:pilipala/pages/video/detail/introduction/widgets/menu_row.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; import 'package:pilipala/plugin/pl_player/models/play_repeat.dart'; import 'package:pilipala/utils/storage.dart'; -import 'package:pilipala/http/danmaku.dart'; +import 'package:pilipala/services/shutdown_timer_service.dart'; +import '../../../../http/danmaku.dart'; +import '../../../../models/common/search_type.dart'; +import '../../../../models/video_detail_res.dart'; +import '../introduction/index.dart'; class HeaderControl extends StatefulWidget implements PreferredSizeWidget { - final PlPlayerController? controller; - final VideoDetailController? videoDetailCtr; - final Floating? floating; const HeaderControl({ this.controller, this.videoDetailCtr, this.floating, - Key? key, - }) : super(key: key); + this.bvid, + this.videoType, + super.key, + }); + final PlPlayerController? controller; + final VideoDetailController? videoDetailCtr; + final Floating? floating; + final String? bvid; + final SearchType? videoType; @override State createState() => _HeaderControlState(); @@ -38,20 +46,38 @@ class HeaderControl extends StatefulWidget implements PreferredSizeWidget { class _HeaderControlState extends State { late PlayUrlModel videoInfo; - List playSpeed = PlaySpeed.values; - TextStyle subTitleStyle = const TextStyle(fontSize: 12); - TextStyle titleStyle = const TextStyle(fontSize: 14); + static const TextStyle subTitleStyle = TextStyle(fontSize: 12); + static const TextStyle titleStyle = TextStyle(fontSize: 14); Size get preferredSize => const Size(double.infinity, kToolbarHeight); - Box localCache = GStrorage.localCache; - Box videoStorage = GStrorage.video; - late List speedsList; + final Box localCache = GStrorage.localCache; + final Box videoStorage = GStrorage.video; + late List speedsList; double buttonSpace = 8; + RxBool isFullScreen = false.obs; + late String heroTag; + late VideoIntroController videoIntroController; + late VideoDetailData videoDetail; @override void initState() { super.initState(); videoInfo = widget.videoDetailCtr!.data; speedsList = widget.controller!.speedsList; + fullScreenStatusListener(); + heroTag = Get.arguments['heroTag']; + videoIntroController = + Get.put(VideoIntroController(bvid: widget.bvid!), tag: heroTag); + } + + void fullScreenStatusListener() { + widget.videoDetailCtr!.plPlayerController.isFullScreen.listen((bool val) { + isFullScreen.value = val; + + /// TODO setState() called after dispose() + if (mounted) { + setState(() {}); + } + }); } /// 设置面板 @@ -63,7 +89,7 @@ class _HeaderControlState extends State { builder: (_) { return Container( width: double.infinity, - height: 440, + height: 460, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: Theme.of(context).colorScheme.background, @@ -71,7 +97,7 @@ class _HeaderControlState extends State { ), margin: const EdgeInsets.all(12), child: Column( - children: [ + children: [ SizedBox( height: 35, child: Center( @@ -118,20 +144,27 @@ class _HeaderControlState extends State { // ), ListTile( onTap: () async { - var res = await UserHttp.toViewLater( + final res = await UserHttp.toViewLater( bvid: widget.videoDetailCtr!.bvid); SmartDialog.showToast(res['msg']); Get.back(); }, dense: true, leading: const Icon(Icons.watch_later_outlined, size: 20), - title: Text('添加至「稍后再看」', style: titleStyle), + title: const Text('添加至「稍后再看」', style: titleStyle), + ), + ListTile( + onTap: () => {Get.back(), scheduleExit()}, + dense: true, + leading: + const Icon(Icons.hourglass_top_outlined, size: 20), + title: const Text('定时关闭(测试)', style: titleStyle), ), ListTile( onTap: () => {Get.back(), showSetVideoQa()}, dense: true, leading: const Icon(Icons.play_circle_outline, size: 20), - title: Text('选择画质', style: titleStyle), + title: const Text('选择画质', style: titleStyle), subtitle: Text( '当前画质 ${widget.videoDetailCtr!.currentVideoQa.description}', style: subTitleStyle), @@ -141,7 +174,7 @@ class _HeaderControlState extends State { onTap: () => {Get.back(), showSetAudioQa()}, dense: true, leading: const Icon(Icons.album_outlined, size: 20), - title: Text('选择音质', style: titleStyle), + title: const Text('选择音质', style: titleStyle), subtitle: Text( '当前音质 ${widget.videoDetailCtr!.currentAudioQa!.description}', style: subTitleStyle), @@ -150,7 +183,7 @@ class _HeaderControlState extends State { onTap: () => {Get.back(), showSetDecodeFormats()}, dense: true, leading: const Icon(Icons.av_timer_outlined, size: 20), - title: Text('解码格式', style: titleStyle), + title: const Text('解码格式', style: titleStyle), subtitle: Text( '当前解码格式 ${widget.videoDetailCtr!.currentDecodeFormats.description}', style: subTitleStyle), @@ -159,7 +192,7 @@ class _HeaderControlState extends State { onTap: () => {Get.back(), showSetRepeat()}, dense: true, leading: const Icon(Icons.repeat, size: 20), - title: Text('播放顺序', style: titleStyle), + title: const Text('播放顺序', style: titleStyle), subtitle: Text(widget.controller!.playRepeat.description, style: subTitleStyle), ), @@ -167,7 +200,7 @@ class _HeaderControlState extends State { onTap: () => {Get.back(), showSetDanmaku()}, dense: true, leading: const Icon(Icons.subtitles_outlined, size: 20), - title: Text('弹幕设置', style: titleStyle), + title: const Text('弹幕设置', style: titleStyle), ), ], ), @@ -187,11 +220,12 @@ class _HeaderControlState extends State { bool isSending = false; // 追踪是否正在发送 showDialog( context: Get.context!, - builder: (context) { + builder: (BuildContext context) { // TODO: 支持更多类型和颜色的弹幕 return AlertDialog( title: const Text('发送弹幕(测试)'), - content: StatefulBuilder(builder: (context, StateSetter setState) { + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { return TextField( controller: textController, ); @@ -204,12 +238,13 @@ class _HeaderControlState extends State { style: TextStyle(color: Theme.of(context).colorScheme.outline), ), ), - StatefulBuilder(builder: (context, StateSetter setState) { + StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { return TextButton( onPressed: isSending ? null : () async { - String msg = textController.text; + final String msg = textController.text; if (msg.isEmpty) { SmartDialog.showToast('弹幕内容不能为空'); return; @@ -221,11 +256,10 @@ class _HeaderControlState extends State { isSending = true; // 开始发送,更新状态 }); //修改按钮文字 - // SmartDialog.showToast('弹幕发送中,\n$msg'); - dynamic res = await DanmakaHttp.shootDanmaku( - oid: widget.videoDetailCtr!.cid!.value, + final dynamic res = await DanmakaHttp.shootDanmaku( + oid: widget.videoDetailCtr!.cid.value, msg: textController.text, - bvid: widget.videoDetailCtr!.bvid!, + bvid: widget.videoDetailCtr!.bvid, progress: widget.controller!.position.value.inMilliseconds, type: 1, @@ -244,6 +278,7 @@ class _HeaderControlState extends State { time: widget .controller!.position.value.inMilliseconds, type: DanmakuItemType.scroll, + isSend: true, ) ]); Get.back(); @@ -260,22 +295,198 @@ class _HeaderControlState extends State { ); } + /// 定时关闭 + void scheduleExit() async { + const List scheduleTimeChoices = [ + -1, + 15, + 30, + 60, + ]; + showModalBottomSheet( + context: context, + elevation: 0, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: double.infinity, + height: 500, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.only(left: 14, right: 14), + child: SingleChildScrollView( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 0, horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 30), + const Center(child: Text('定时关闭', style: titleStyle)), + const SizedBox(height: 10), + for (final int choice in scheduleTimeChoices) ...[ + ListTile( + onTap: () { + shutdownTimerService.scheduledExitInMinutes = + choice; + shutdownTimerService.startShutdownTimer(); + Get.back(); + }, + contentPadding: const EdgeInsets.only(), + dense: true, + title: Text(choice == -1 ? "禁用" : "$choice分钟后"), + trailing: shutdownTimerService + .scheduledExitInMinutes == + choice + ? Icon( + Icons.done, + color: Theme.of(context).colorScheme.primary, + ) + : const SizedBox(), + ) + ], + const SizedBox(height: 6), + const Center( + child: SizedBox( + width: 100, + child: Divider(height: 1), + )), + const SizedBox(height: 10), + ListTile( + onTap: () { + shutdownTimerService.waitForPlayingCompleted = + !shutdownTimerService.waitForPlayingCompleted; + setState(() {}); + }, + dense: true, + contentPadding: const EdgeInsets.only(), + title: const Text("额外等待视频播放完毕", style: titleStyle), + trailing: Switch( + // thumb color (round icon) + activeColor: Theme.of(context).colorScheme.primary, + activeTrackColor: + Theme.of(context).colorScheme.primaryContainer, + inactiveThumbColor: + Theme.of(context).colorScheme.primaryContainer, + inactiveTrackColor: + Theme.of(context).colorScheme.background, + splashRadius: 10.0, + // boolean variable value + value: shutdownTimerService.waitForPlayingCompleted, + // changes the state of the switch + onChanged: (value) => setState(() => + shutdownTimerService.waitForPlayingCompleted = + value), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + const Text('倒计时结束:', style: titleStyle), + const Spacer(), + ActionRowLineItem( + onTap: () { + shutdownTimerService.exitApp = false; + setState(() {}); + // Get.back(); + }, + text: " 暂停视频 ", + selectStatus: !shutdownTimerService.exitApp, + ), + const Spacer(), + // const SizedBox(width: 10), + ActionRowLineItem( + onTap: () { + shutdownTimerService.exitApp = true; + setState(() {}); + // Get.back(); + }, + text: " 退出APP ", + selectStatus: shutdownTimerService.exitApp, + ) + ], + ), + ]), + ), + ), + ); + }); + }, + ); + } + + /// 选择字幕 + void showSubtitleDialog() async { + int tempThemeValue = widget.controller!.subTitleCode.value; + int len = widget.videoDetailCtr!.subtitles.length; + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('选择字幕'), + contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 18), + content: StatefulBuilder(builder: (context, StateSetter setState) { + return len == 0 + ? const SizedBox( + height: 60, + child: Center( + child: Text('没有字幕'), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + value: -1, + title: const Text('关闭弹幕'), + groupValue: tempThemeValue, + onChanged: (value) { + tempThemeValue = value!; + widget.controller?.toggleSubtitle(value); + Get.back(); + }, + ), + ...widget.videoDetailCtr!.subtitles + .map((e) => RadioListTile( + value: e.code, + title: Text(e.title), + groupValue: tempThemeValue, + onChanged: (value) { + tempThemeValue = value!; + widget.controller?.toggleSubtitle(value); + Get.back(); + }, + )) + .toList(), + ], + ); + }), + ); + }); + } + /// 选择倍速 void showSetSpeedSheet() { - double currentSpeed = widget.controller!.playbackSpeed; + final double currentSpeed = widget.controller!.playbackSpeed; showDialog( context: Get.context!, - builder: (context) { + builder: (BuildContext context) { return AlertDialog( title: const Text('播放速度'), - content: StatefulBuilder(builder: (context, StateSetter setState) { + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { return Wrap( - alignment: WrapAlignment.start, spacing: 8, runSpacing: 2, children: [ - for (var i in speedsList) ...[ - if (i == currentSpeed) ...[ + for (final double i in speedsList) ...[ + if (i == currentSpeed) ...[ FilledButton( onPressed: () async { // setState(() => currentSpeed = i), @@ -298,9 +509,9 @@ class _HeaderControlState extends State { ], ); }), - actions: [ + actions: [ TextButton( - onPressed: () => SmartDialog.dismiss(), + onPressed: () => Get.back(), child: Text( '取消', style: TextStyle(color: Theme.of(context).colorScheme.outline), @@ -321,18 +532,18 @@ class _HeaderControlState extends State { /// 选择画质 void showSetVideoQa() { - List videoFormat = videoInfo.supportFormats!; - VideoQuality currentVideoQa = widget.videoDetailCtr!.currentVideoQa; + final List videoFormat = videoInfo.supportFormats!; + final VideoQuality currentVideoQa = widget.videoDetailCtr!.currentVideoQa; /// 总质量分类 - int totalQaSam = videoFormat.length; + final int totalQaSam = videoFormat.length; /// 可用的质量分类 int userfulQaSam = 0; - List video = videoInfo.dash!.video!; - Set idSet = {}; - for (var item in video) { - int id = item.id!; + final List video = videoInfo.dash!.video!; + final Set idSet = {}; + for (final VideoItem item in video) { + final int id = item.id!; if (!idSet.contains(id)) { idSet.add(id); userfulQaSam++; @@ -354,7 +565,7 @@ class _HeaderControlState extends State { ), margin: const EdgeInsets.all(12), child: Column( - children: [ + children: [ SizedBox( height: 45, child: GestureDetector( @@ -364,7 +575,7 @@ class _HeaderControlState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('选择画质', style: titleStyle), + const Text('选择画质', style: titleStyle), SizedBox(width: buttonSpace), Icon( Icons.info_outline, @@ -380,7 +591,7 @@ class _HeaderControlState extends State { child: Scrollbar( child: ListView( children: [ - for (var i = 0; i < totalQaSam; i++) ...[ + for (int i = 0; i < totalQaSam; i++) ...[ ListTile( onTap: () { if (currentVideoQa.code == @@ -427,9 +638,8 @@ class _HeaderControlState extends State { /// 选择音质 void showSetAudioQa() { - AudioQuality currentAudioQa = widget.videoDetailCtr!.currentAudioQa!; - - List audio = videoInfo.dash!.audio!; + final AudioQuality currentAudioQa = widget.videoDetailCtr!.currentAudioQa!; + final List audio = videoInfo.dash!.audio!; showModalBottomSheet( context: context, elevation: 0, @@ -445,18 +655,20 @@ class _HeaderControlState extends State { ), margin: const EdgeInsets.all(12), child: Column( - children: [ - SizedBox( + children: [ + const SizedBox( height: 45, child: Center(child: Text('选择音质', style: titleStyle))), Expanded( child: Material( child: ListView( - children: [ - for (var i in audio) ...[ + children: [ + for (final AudioItem i in audio) ...[ ListTile( onTap: () { - if (currentAudioQa.code == i.id) return; + if (currentAudioQa.code == i.id) { + return; + } final int quality = i.id!; widget.videoDetailCtr!.currentAudioQa = AudioQualityCode.fromCode(quality)!; @@ -493,13 +705,13 @@ class _HeaderControlState extends State { // 选择解码格式 void showSetDecodeFormats() { // 当前选中的解码格式 - VideoDecodeFormats currentDecodeFormats = + final VideoDecodeFormats currentDecodeFormats = widget.videoDetailCtr!.currentDecodeFormats; - VideoItem firstVideo = widget.videoDetailCtr!.firstVideo; + final VideoItem firstVideo = widget.videoDetailCtr!.firstVideo; // 当前视频可用的解码格式 - List videoFormat = videoInfo.supportFormats!; - List list = videoFormat - .firstWhere((e) => e.quality == firstVideo.quality!.code) + final List videoFormat = videoInfo.supportFormats!; + final List list = videoFormat + .firstWhere((FormatItem e) => e.quality == firstVideo.quality!.code) .codecs!; showModalBottomSheet( @@ -565,15 +777,15 @@ class _HeaderControlState extends State { /// 弹幕功能 void showSetDanmaku() async { // 屏蔽类型 - List> blockTypesList = [ + final List> blockTypesList = [ {'value': 5, 'label': '顶部'}, {'value': 2, 'label': '滚动'}, {'value': 4, 'label': '底部'}, {'value': 6, 'label': '彩色'}, ]; - List blockTypes = widget.controller!.blockTypes; + final List blockTypes = widget.controller!.blockTypes; // 显示区域 - List> showAreas = [ + final List> showAreas = [ {'value': 0.25, 'label': '1/4屏'}, {'value': 0.5, 'label': '半屏'}, {'value': 0.75, 'label': '3/4屏'}, @@ -586,14 +798,18 @@ class _HeaderControlState extends State { double fontSizeVal = widget.controller!.fontSizeVal; // 弹幕速度 double danmakuDurationVal = widget.controller!.danmakuDurationVal; + // 弹幕描边 + double strokeWidth = widget.controller!.strokeWidth; - DanmakuController danmakuController = widget.controller!.danmakuController!; + final DanmakuController danmakuController = + widget.controller!.danmakuController!; await showModalBottomSheet( context: context, elevation: 0, backgroundColor: Colors.transparent, builder: (BuildContext context) { - return StatefulBuilder(builder: (context, StateSetter setState) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { return Container( width: double.infinity, height: 580, @@ -608,7 +824,7 @@ class _HeaderControlState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( + const SizedBox( height: 45, child: Center(child: Text('弹幕设置', style: titleStyle)), ), @@ -617,11 +833,13 @@ class _HeaderControlState extends State { Padding( padding: const EdgeInsets.only(top: 12, bottom: 18), child: Row( - children: [ - for (var i in blockTypesList) ...[ + children: [ + for (final Map i + in blockTypesList) ...[ ActionRowLineItem( onTap: () async { - bool isChoose = blockTypes.contains(i['value']); + final bool isChoose = + blockTypes.contains(i['value']); if (isChoose) { blockTypes.remove(i['value']); } else { @@ -630,9 +848,9 @@ class _HeaderControlState extends State { widget.controller!.blockTypes = blockTypes; setState(() {}); try { - DanmakuOption currentOption = + final DanmakuOption currentOption = danmakuController.option; - DanmakuOption updatedOption = + final DanmakuOption updatedOption = currentOption.copyWith( hideTop: blockTypes.contains(5), hideBottom: blockTypes.contains(4), @@ -655,16 +873,16 @@ class _HeaderControlState extends State { padding: const EdgeInsets.only(top: 12, bottom: 18), child: Row( children: [ - for (var i in showAreas) ...[ + for (final Map i in showAreas) ...[ ActionRowLineItem( onTap: () { showArea = i['value']; widget.controller!.showArea = showArea; setState(() {}); try { - DanmakuOption currentOption = + final DanmakuOption currentOption = danmakuController.option; - DanmakuOption updatedOption = + final DanmakuOption updatedOption = currentOption.copyWith(area: i['value']); danmakuController.updateOption(updatedOption); } catch (_) {} @@ -705,9 +923,9 @@ class _HeaderControlState extends State { widget.controller!.opacityVal = opacityVal; setState(() {}); try { - DanmakuOption currentOption = + final DanmakuOption currentOption = danmakuController.option; - DanmakuOption updatedOption = + final DanmakuOption updatedOption = currentOption.copyWith(opacity: val); danmakuController.updateOption(updatedOption); } catch (_) {} @@ -715,6 +933,44 @@ class _HeaderControlState extends State { ), ), ), + Text('描边粗细 $strokeWidth'), + Padding( + padding: const EdgeInsets.only( + top: 0, + bottom: 6, + left: 10, + right: 10, + ), + child: SliderTheme( + data: SliderThemeData( + trackShape: MSliderTrackShape(), + thumbColor: Theme.of(context).colorScheme.primary, + activeTrackColor: Theme.of(context).colorScheme.primary, + trackHeight: 10, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6.0), + ), + child: Slider( + min: 0, + max: 3, + value: strokeWidth, + divisions: 6, + label: '$strokeWidth', + onChanged: (double val) { + strokeWidth = val; + widget.controller!.strokeWidth = val; + setState(() {}); + try { + final DanmakuOption currentOption = + danmakuController.option; + final DanmakuOption updatedOption = + currentOption.copyWith(strokeWidth: val); + danmakuController.updateOption(updatedOption); + } catch (_) {} + }, + ), + ), + ), Text('字体大小 ${(fontSizeVal * 100).toStringAsFixed(1)}%'), Padding( padding: const EdgeInsets.only( @@ -743,9 +999,9 @@ class _HeaderControlState extends State { widget.controller!.fontSizeVal = fontSizeVal; setState(() {}); try { - DanmakuOption currentOption = + final DanmakuOption currentOption = danmakuController.option; - DanmakuOption updatedOption = + final DanmakuOption updatedOption = currentOption.copyWith( fontSize: (15 * fontSizeVal).toDouble(), ); @@ -780,14 +1036,14 @@ class _HeaderControlState extends State { label: danmakuDurationVal.toString(), onChanged: (double val) { danmakuDurationVal = val; - widget.controller!.danmakuDurationVal = danmakuDurationVal; + widget.controller!.danmakuDurationVal = + danmakuDurationVal; setState(() {}); try { - DanmakuOption currentOption = - danmakuController.option; - DanmakuOption updatedOption = - currentOption.copyWith(duration: - val/widget.controller!.playbackSpeed); + final DanmakuOption updatedOption = + danmakuController.option.copyWith( + duration: + val / widget.controller!.playbackSpeed); danmakuController.updateOption(updatedOption); } catch (_) {} }, @@ -827,8 +1083,8 @@ class _HeaderControlState extends State { Expanded( child: Material( child: ListView( - children: [ - for (var i in PlayRepeat.values) ...[ + children: [ + for (final PlayRepeat i in PlayRepeat.values) ...[ ListTile( onTap: () { widget.controller!.setPlayRepeat(i); @@ -860,10 +1116,12 @@ class _HeaderControlState extends State { @override Widget build(BuildContext context) { final _ = widget.controller!; - const textStyle = TextStyle( + const TextStyle textStyle = TextStyle( color: Colors.white, fontSize: 12, ); + final bool isLandscape = + MediaQuery.of(context).orientation == Orientation.landscape; return AppBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, @@ -881,34 +1139,66 @@ class _HeaderControlState extends State { size: 15, color: Colors.white, ), - fuc: () => { - if (widget.controller!.isFullScreen.value){ - widget.controller!.triggerFullScreen(status: false) - } else { - if (MediaQuery.of(context).orientation == Orientation.landscape){ - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - ]) - }, - Get.back() - } + fuc: () => >{ + if (widget.controller!.isFullScreen.value) + {widget.controller!.triggerFullScreen(status: false)} + else + { + if (MediaQuery.of(context).orientation == + Orientation.landscape) + { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + ]) + }, + Get.back() + } }, ), SizedBox(width: buttonSpace), - ComBtn( - icon: const Icon( - FontAwesomeIcons.house, - size: 15, - color: Colors.white, + if (isFullScreen.value && + isLandscape && + widget.videoType == SearchType.video) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + videoIntroController.videoDetail.value.title ?? '', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + if (videoIntroController.isShowOnlineTotal) + Text( + '${videoIntroController.total.value}人正在看', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ) + ], + ) + ] else ...[ + ComBtn( + icon: const Icon( + FontAwesomeIcons.house, + size: 15, + color: Colors.white, + ), + fuc: () async { + // 销毁播放器实例 + await widget.controller!.dispose(type: 'all'); + if (mounted) { + Navigator.popUntil( + context, (Route route) => route.isFirst); + } + }, ), - fuc: () async { - // 销毁播放器实例 - await widget.controller!.dispose(type: 'all'); - if (mounted) { - Navigator.popUntil(context, (route) => route.isFirst); - } - }, - ), + ], const Spacer(), // ComBtn( // icon: const Icon( @@ -918,43 +1208,45 @@ class _HeaderControlState extends State { // ), // fuc: () => _.screenshot(), // ), - SizedBox( - width: 56, - height: 34, - child: TextButton( - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - ), - onPressed: () => showShootDanmakuSheet(), - child: const Text( - '发弹幕', - style: textStyle, - ), - ), - ), - SizedBox( - width: 34, - height: 34, - child: Obx( - () => IconButton( + if (isFullScreen.value) ...[ + SizedBox( + width: 56, + height: 34, + child: TextButton( style: ButtonStyle( padding: MaterialStateProperty.all(EdgeInsets.zero), ), - onPressed: () { - _.isOpenDanmu.value = !_.isOpenDanmu.value; - }, - icon: Icon( - _.isOpenDanmu.value - ? Icons.subtitles_outlined - : Icons.subtitles_off_outlined, - size: 19, - color: Colors.white, + onPressed: () => showShootDanmakuSheet(), + child: const Text( + '发弹幕', + style: textStyle, ), ), ), - ), + SizedBox( + width: 34, + height: 34, + child: Obx( + () => IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () { + _.isOpenDanmu.value = !_.isOpenDanmu.value; + }, + icon: Icon( + _.isOpenDanmu.value + ? Icons.subtitles_outlined + : Icons.subtitles_off_outlined, + size: 19, + color: Colors.white, + ), + ), + ), + ), + ], SizedBox(width: buttonSpace), - if (Platform.isAndroid) ...[ + if (Platform.isAndroid) ...[ SizedBox( width: 34, height: 34, @@ -971,7 +1263,7 @@ class _HeaderControlState extends State { canUsePiP = false; } if (canUsePiP) { - final aspectRatio = Rational( + final Rational aspectRatio = Rational( widget.videoDetailCtr!.data.dash!.video!.first.width!, widget.videoDetailCtr!.data.dash!.video!.first.height!, ); @@ -987,6 +1279,31 @@ class _HeaderControlState extends State { ), SizedBox(width: buttonSpace), ], + + /// 字幕 + // SizedBox( + // width: 34, + // height: 34, + // child: IconButton( + // style: ButtonStyle( + // padding: MaterialStateProperty.all(EdgeInsets.zero), + // ), + // onPressed: () => showSubtitleDialog(), + // icon: const Icon( + // Icons.closed_caption_off, + // size: 22, + // ), + // ), + // ), + ComBtn( + icon: const Icon( + Icons.closed_caption_off, + size: 22, + color: Colors.white, + ), + fuc: () => showSubtitleDialog(), + ), + SizedBox(width: buttonSpace), Obx( () => SizedBox( width: 45, @@ -997,7 +1314,7 @@ class _HeaderControlState extends State { ), onPressed: () => showSetSpeedSheet(), child: Text( - '${_.playbackSpeed.toString()}X', + '${_.playbackSpeed}X', style: textStyle, ), ), diff --git a/lib/pages/webview/controller.dart b/lib/pages/webview/controller.dart index 2dfa9582..f26f4284 100644 --- a/lib/pages/webview/controller.dart +++ b/lib/pages/webview/controller.dart @@ -6,7 +6,6 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/http/init.dart'; -import 'package:pilipala/http/member.dart'; import 'package:pilipala/http/user.dart'; import 'package:pilipala/pages/home/index.dart'; import 'package:pilipala/pages/media/index.dart'; @@ -53,9 +52,9 @@ class WebviewController extends GetxController { loadProgress.value = progress; }, onPageStarted: (String url) { - String str = Uri.parse(url).pathSegments[0]; - Map matchRes = IdUtils.matchAvorBv(input: str); - List matchKeys = matchRes.keys.toList(); + final String str = Uri.parse(url).pathSegments[0]; + final Map matchRes = IdUtils.matchAvorBv(input: str); + final List matchKeys = matchRes.keys.toList(); if (matchKeys.isNotEmpty) { if (matchKeys.first == 'BV') { Get.offAndToNamed( @@ -102,22 +101,21 @@ class WebviewController extends GetxController { } try { await SetCookie.onSet(); - var result = await UserHttp.userInfo(); + final result = await UserHttp.userInfo(); if (result['status'] && result['data'].isLogin) { SmartDialog.showToast('登录成功'); try { Box userInfoCache = GStrorage.userInfo; await userInfoCache.put('userInfoCache', result['data']); - HomeController homeCtr = Get.find(); + final HomeController homeCtr = Get.find(); homeCtr.updateLoginStatus(true); homeCtr.userFace.value = result['data'].face; - MediaController mediaCtr = Get.find(); + final MediaController mediaCtr = Get.find(); mediaCtr.mid = result['data'].mid; await LoginUtils.refreshLoginStatus(true); - MemberHttp.cookieToKey(); } catch (err) { - SmartDialog.show(builder: (context) { + SmartDialog.show(builder: (BuildContext context) { return AlertDialog( title: const Text('登录遇到问题'), content: Text(err.toString()), @@ -133,13 +131,13 @@ class WebviewController extends GetxController { Get.back(); } else { // 获取用户信息失败 - SmartDialog.showToast(result.msg); - Clipboard.setData(ClipboardData(text: result.msg.toString())); + SmartDialog.showToast(result['msg']); + Clipboard.setData(ClipboardData(text: result['msg'])); } } catch (e) { SmartDialog.showNotify(msg: e.toString(), notifyType: NotifyType.warning); content = content + e.toString(); + Clipboard.setData(ClipboardData(text: content)); } - Clipboard.setData(ClipboardData(text: content)); } } diff --git a/lib/pages/whisper/view.dart b/lib/pages/whisper/view.dart index 4de197ca..fa7ad60b 100644 --- a/lib/pages/whisper/view.dart +++ b/lib/pages/whisper/view.dart @@ -108,9 +108,9 @@ class _WhisperPageState extends State { future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data as Map; - if (data['status']) { - List sessionList = _whisperController.sessionList; + Map? data = snapshot.data; + if (data != null && data['status']) { + RxList sessionList = _whisperController.sessionList; return Obx( () => sessionList.isEmpty ? const SizedBox() @@ -121,33 +121,35 @@ class _WhisperPageState extends State { const NeverScrollableScrollPhysics(), itemBuilder: (_, int i) { return ListTile( - onTap: () => Get.toNamed( - '/whisperDetail', - parameters: { - 'talkerId': sessionList[i] - .talkerId - .toString(), - 'name': sessionList[i] - .accountInfo - .name, - 'face': sessionList[i] - .accountInfo - .face, - 'mid': sessionList[i] - .accountInfo - .mid - .toString(), - }, - ), + onTap: () { + sessionList[i].unreadCount = 0; + sessionList.refresh(); + Get.toNamed( + '/whisperDetail', + parameters: { + 'talkerId': sessionList[i] + .talkerId + .toString(), + 'name': sessionList[i] + .accountInfo + .name, + 'face': sessionList[i] + .accountInfo + .face, + 'mid': sessionList[i] + .accountInfo + .mid + .toString(), + }, + ); + }, leading: Badge( - isLabelVisible: false, - backgroundColor: Theme.of(context) - .colorScheme - .primary, + isLabelVisible: + sessionList[i].unreadCount > 0, label: Text(sessionList[i] .unreadCount .toString()), - alignment: Alignment.bottomRight, + alignment: Alignment.topRight, child: NetworkImgLayer( width: 45, height: 45, @@ -160,20 +162,27 @@ class _WhisperPageState extends State { title: Text( sessionList[i].accountInfo.name), subtitle: Text( - sessionList[i] - .lastMsg - .content['text'] ?? - sessionList[i] - .lastMsg - .content['content'] ?? - sessionList[i] - .lastMsg - .content['title'] ?? - sessionList[i] + sessionList[i].lastMsg.content != + null && + sessionList[i] + .lastMsg + .content != + '' + ? (sessionList[i] .lastMsg - .content[ - 'reply_content'] ?? - '', + .content['text'] ?? + sessionList[i] + .lastMsg + .content['content'] ?? + sessionList[i] + .lastMsg + .content['title'] ?? + sessionList[i] + .lastMsg + .content[ + 'reply_content'] ?? + '不支持的消息类型') + : '不支持的消息类型', maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) @@ -210,11 +219,13 @@ class _WhisperPageState extends State { ); } else { // 请求错误 - return SizedBox(); + return Center( + child: Text(data?['msg'] ?? '请求异常'), + ); } } else { // 骨架屏 - return SizedBox(); + return const SizedBox(); } }, ) diff --git a/lib/pages/whisperDetail/controller.dart b/lib/pages/whisperDetail/controller.dart deleted file mode 100644 index 52a70c49..00000000 --- a/lib/pages/whisperDetail/controller.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:get/get.dart'; -import 'package:pilipala/http/msg.dart'; -import 'package:pilipala/models/msg/session.dart'; - -class WhisperDetailController extends GetxController { - late int talkerId; - late String name; - late String face; - late String mid; - RxList messageList = [].obs; - - @override - void onInit() { - super.onInit(); - talkerId = int.parse(Get.parameters['talkerId']!); - name = Get.parameters['name']!; - face = Get.parameters['face']!; - mid = Get.parameters['mid']!; - } - - Future querySessionMsg() async { - var res = await MsgHttp.sessionMsg(talkerId: talkerId); - if (res['status']) { - messageList.value = res['data'].messages; - } - return res; - } -} diff --git a/lib/pages/whisperDetail/view.dart b/lib/pages/whisperDetail/view.dart deleted file mode 100644 index 18d2d439..00000000 --- a/lib/pages/whisperDetail/view.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/pages/whisperDetail/controller.dart'; -import 'package:pilipala/utils/feed_back.dart'; - -import 'widget/chat_item.dart'; - -class WhisperDetailPage extends StatefulWidget { - const WhisperDetailPage({super.key}); - - @override - State createState() => _WhisperDetailPageState(); -} - -class _WhisperDetailPageState extends State { - final WhisperDetailController _whisperDetailController = - Get.put(WhisperDetailController()); - late Future _futureBuilderFuture; - - @override - void initState() { - super.initState(); - _futureBuilderFuture = _whisperDetailController.querySessionMsg(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - title: SizedBox( - width: double.infinity, - height: 50, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - width: 34, - height: 34, - child: IconButton( - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - backgroundColor: - MaterialStateProperty.resolveWith((states) { - return Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(0.6); - }), - ), - onPressed: () => Get.back(), - icon: Icon( - Icons.arrow_back_outlined, - size: 18, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - GestureDetector( - onTap: () { - feedBack(); - Get.toNamed( - '/member?mid=${_whisperDetailController.mid}', - arguments: { - 'face': _whisperDetailController.face, - 'heroTag': null - }, - ); - }, - child: Row( - children: [ - NetworkImgLayer( - width: 34, - height: 34, - type: 'avatar', - src: _whisperDetailController.face, - ), - const SizedBox(width: 6), - Text( - _whisperDetailController.name, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - ), - const SizedBox(width: 36, height: 36), - ], - ), - ), - ), - body: FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - if (snapshot.data == null) { - return const SizedBox(); - } - Map data = snapshot.data as Map; - if (data['status']) { - List messageList = _whisperDetailController.messageList; - return Obx( - () => messageList.isEmpty - ? const SizedBox() - : ListView.builder( - itemCount: messageList.length, - shrinkWrap: true, - reverse: true, - itemBuilder: (_, int i) { - if (i == 0) { - return Column( - children: [ - ChatItem(item: messageList[i]), - const SizedBox(height: 12), - ], - ); - } else { - return ChatItem(item: messageList[i]); - } - }, - ), - ); - } else { - // 请求错误 - return const SizedBox(); - } - } else { - // 骨架屏 - return const SizedBox(); - } - }, - ), - // resizeToAvoidBottomInset: true, - bottomNavigationBar: Container( - width: double.infinity, - height: MediaQuery.of(context).padding.bottom + 70, - padding: EdgeInsets.only( - left: 8, - right: 12, - top: 12, - bottom: MediaQuery.of(context).padding.bottom, - ), - decoration: BoxDecoration( - border: Border( - top: BorderSide( - width: 4, - color: Theme.of(context).colorScheme.primary.withOpacity(0.1), - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // IconButton( - // onPressed: () {}, - // icon: Icon( - // Icons.add_circle_outline, - // color: Theme.of(context).colorScheme.outline, - // ), - // ), - IconButton( - onPressed: () {}, - icon: Icon( - Icons.emoji_emotions_outlined, - color: Theme.of(context).colorScheme.outline, - ), - ), - Expanded( - child: Container( - height: 45, - decoration: BoxDecoration( - color: - Theme.of(context).colorScheme.primary.withOpacity(0.08), - borderRadius: BorderRadius.circular(40.0), - ), - child: TextField( - readOnly: true, - style: Theme.of(context).textTheme.titleMedium, - decoration: const InputDecoration( - border: InputBorder.none, // 移除默认边框 - hintText: '开发中 ...', // 提示文本 - contentPadding: EdgeInsets.symmetric( - horizontal: 16.0, vertical: 12.0), // 内边距 - ), - ), - ), - ), - const SizedBox(width: 16), - ], - ), - ), - ); - } -} diff --git a/lib/pages/whisperDetail/widget/chat_item.dart b/lib/pages/whisperDetail/widget/chat_item.dart deleted file mode 100644 index b2673571..00000000 --- a/lib/pages/whisperDetail/widget/chat_item.dart +++ /dev/null @@ -1,211 +0,0 @@ -// ignore_for_file: must_be_immutable - -import 'package:flutter/material.dart'; -import 'package:pilipala/common/widgets/network_img_layer.dart'; -import 'package:pilipala/utils/utils.dart'; - -class ChatItem extends StatelessWidget { - dynamic item; - - ChatItem({ - super.key, - this.item, - }); - - @override - Widget build(BuildContext context) { - bool isOwner = item.senderUid == 17340771; - bool isPic = item.msgType == 2; // 图片 - bool isText = item.msgType == 1; // 文本 - bool isAchive = item.msgType == 11; // 投稿 - bool isArticle = item.msgType == 12; // 专栏 - bool isRevoke = item.msgType == 5; // 撤回消息 - - bool isSystem = - item.msgType == 18 || item.msgType == 10 || item.msgType == 13; - int msgType = item.msgType; - dynamic content = item.content ?? ''; - return isSystem - ? (msgType == 10 - ? SystemNotice(item: item) - : msgType == 13 - ? SystemNotice2(item: item) - : const SizedBox()) - : isRevoke - ? const SizedBox() - : Row( - children: [ - if (!isOwner) const SizedBox(width: 12), - if (isOwner) const Spacer(), - Container( - constraints: const BoxConstraints( - maxWidth: 300.0, // 设置最大宽度为200.0 - ), - decoration: BoxDecoration( - color: isOwner - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(16), - topRight: const Radius.circular(16), - bottomLeft: Radius.circular(isOwner ? 16 : 6), - bottomRight: Radius.circular(isOwner ? 6 : 16), - ), - ), - margin: const EdgeInsets.only(top: 12), - padding: EdgeInsets.only( - top: 8, - bottom: 6, - left: isPic ? 8 : 12, - right: isPic ? 8 : 12, - ), - child: Column( - crossAxisAlignment: isOwner - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - isText - ? Text( - content['content'], - style: TextStyle( - color: isOwner - ? Theme.of(context) - .colorScheme - .onPrimary - : Theme.of(context) - .colorScheme - .onSecondaryContainer), - ) - : isPic - ? NetworkImgLayer( - width: 220, - height: 220 * - content['height'] / - content['width'], - src: content['url'], - ) - : const SizedBox(), - SizedBox(height: isPic ? 7 : 2), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - Utils.dateFormat(item.timestamp), - style: Theme.of(context) - .textTheme - .labelSmall! - .copyWith( - color: isOwner - ? Theme.of(context) - .colorScheme - .onPrimary - .withOpacity(0.8) - : Theme.of(context) - .colorScheme - .onSecondaryContainer - .withOpacity(0.8)), - ), - item.msgStatus == 1 - ? Text( - ' 已撤回', - style: - Theme.of(context).textTheme.labelSmall!, - ) - : const SizedBox() - ], - ) - ], - ), - ), - if (!isOwner) const Spacer(), - if (isOwner) const SizedBox(width: 12), - ], - ); - } -} - -class SystemNotice extends StatelessWidget { - dynamic item; - SystemNotice({super.key, this.item}); - - @override - Widget build(BuildContext context) { - Map content = item.content ?? ''; - return Row( - children: [ - const SizedBox(width: 12), - Container( - constraints: const BoxConstraints( - maxWidth: 300.0, // 设置最大宽度为200.0 - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .secondaryContainer - .withOpacity(0.4), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - bottomLeft: Radius.circular(6), - bottomRight: Radius.circular(16), - ), - ), - margin: const EdgeInsets.only(top: 12), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(content['title'], - style: Theme.of(context) - .textTheme - .titleMedium! - .copyWith(fontWeight: FontWeight.bold)), - Text( - Utils.dateFormat(item.timestamp), - style: Theme.of(context) - .textTheme - .labelSmall! - .copyWith(color: Theme.of(context).colorScheme.outline), - ), - Divider( - color: Theme.of(context).colorScheme.primary.withOpacity(0.05), - ), - Text( - content['text'], - ) - ], - ), - ), - const Spacer(), - ], - ); - } -} - -class SystemNotice2 extends StatelessWidget { - dynamic item; - SystemNotice2({super.key, this.item}); - - @override - Widget build(BuildContext context) { - Map content = item.content ?? ''; - return Row( - children: [ - const SizedBox(width: 12), - Container( - constraints: const BoxConstraints( - maxWidth: 300.0, // 设置最大宽度为200.0 - ), - margin: const EdgeInsets.only(top: 12), - padding: const EdgeInsets.only(bottom: 6), - child: NetworkImgLayer( - width: 320, - height: 150, - src: content['pic_url'], - ), - ), - const Spacer(), - ], - ); - } -} diff --git a/lib/pages/whisper_detail/controller.dart b/lib/pages/whisper_detail/controller.dart new file mode 100644 index 00000000..6e950f81 --- /dev/null +++ b/lib/pages/whisper_detail/controller.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/http/msg.dart'; +import 'package:pilipala/models/msg/session.dart'; +import '../../utils/feed_back.dart'; +import '../../utils/storage.dart'; + +class WhisperDetailController extends GetxController { + late int talkerId; + late String name; + late String face; + late String mid; + RxList messageList = [].obs; + //表情转换图片规则 + List? eInfos; + final TextEditingController replyContentController = TextEditingController(); + Box userInfoCache = GStrorage.userInfo; + + @override + void onInit() { + super.onInit(); + talkerId = int.parse(Get.parameters['talkerId']!); + name = Get.parameters['name']!; + face = Get.parameters['face']!; + mid = Get.parameters['mid']!; + } + + Future querySessionMsg() async { + var res = await MsgHttp.sessionMsg(talkerId: talkerId); + if (res['status']) { + messageList.value = res['data'].messages; + if (messageList.isNotEmpty) { + ackSessionMsg(); + if (res['data'].eInfos != null) { + eInfos = res['data'].eInfos; + } + } + } else { + SmartDialog.showToast(res['msg']); + } + return res; + } + + // 消息标记已读 + Future ackSessionMsg() async { + if (messageList.isEmpty) { + return; + } + await MsgHttp.ackSessionMsg( + talkerId: talkerId, + ackSeqno: messageList.last.msgSeqno, + ); + } + + Future sendMsg() async { + feedBack(); + String message = replyContentController.text; + final userInfo = userInfoCache.get('userInfoCache'); + if (userInfo == null) { + SmartDialog.showToast('请先登录'); + return; + } + if (message == '') { + SmartDialog.showToast('请输入内容'); + return; + } + var result = await MsgHttp.sendMsg( + senderUid: userInfo.mid, + receiverId: int.parse(mid), + content: {'content': message}, + msgType: 1, + ); + if (result['status']) { + SmartDialog.showToast('发送成功'); + } else { + SmartDialog.showToast(result['msg']); + } + } +} diff --git a/lib/pages/whisperDetail/index.dart b/lib/pages/whisper_detail/index.dart similarity index 100% rename from lib/pages/whisperDetail/index.dart rename to lib/pages/whisper_detail/index.dart diff --git a/lib/pages/whisper_detail/view.dart b/lib/pages/whisper_detail/view.dart new file mode 100644 index 00000000..1701be33 --- /dev/null +++ b/lib/pages/whisper_detail/view.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:hive/hive.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/pages/emote/index.dart'; +import 'package:pilipala/pages/whisper_detail/controller.dart'; +import 'package:pilipala/utils/feed_back.dart'; +import '../../utils/storage.dart'; +import 'widget/chat_item.dart'; + +class WhisperDetailPage extends StatefulWidget { + const WhisperDetailPage({super.key}); + + @override + State createState() => _WhisperDetailPageState(); +} + +class _WhisperDetailPageState extends State + with WidgetsBindingObserver { + final WhisperDetailController _whisperDetailController = + Get.put(WhisperDetailController()); + late Future _futureBuilderFuture; + late TextEditingController _replyContentController; + final FocusNode replyContentFocusNode = FocusNode(); + final _debouncer = Debouncer(milliseconds: 200); // 设置延迟时间 + late double emoteHeight = 0.0; + double keyboardHeight = 0.0; // 键盘高度 + String toolbarType = 'input'; + Box userInfoCache = GStrorage.userInfo; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _futureBuilderFuture = _whisperDetailController.querySessionMsg(); + _replyContentController = _whisperDetailController.replyContentController; + _focuslistener(); + } + + _focuslistener() { + replyContentFocusNode.addListener(() { + if (replyContentFocusNode.hasFocus) { + setState(() { + toolbarType = 'input'; + }); + } + }); + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + final String routePath = Get.currentRoute; + if (mounted && routePath.startsWith('/whisper_detail')) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // 键盘高度 + final viewInsets = EdgeInsets.fromViewPadding( + View.of(context).viewInsets, View.of(context).devicePixelRatio); + _debouncer.run(() { + if (mounted) { + if (keyboardHeight == 0) { + setState(() { + emoteHeight = keyboardHeight = + keyboardHeight == 0.0 ? viewInsets.bottom : keyboardHeight; + }); + } + } + }); + }); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + replyContentFocusNode.removeListener(() {}); + replyContentFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: SizedBox( + width: double.infinity, + height: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 34, + height: 34, + child: IconButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + backgroundColor: MaterialStateProperty.resolveWith( + (Set states) { + return Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(0.6); + }), + ), + onPressed: () => Get.back(), + icon: Icon( + Icons.arrow_back_outlined, + size: 18, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + GestureDetector( + onTap: () { + feedBack(); + Get.toNamed( + '/member?mid=${_whisperDetailController.mid}', + arguments: { + 'face': _whisperDetailController.face, + 'heroTag': null + }, + ); + }, + child: Row( + children: [ + NetworkImgLayer( + width: 34, + height: 34, + type: 'avatar', + src: _whisperDetailController.face, + ), + const SizedBox(width: 6), + Text( + _whisperDetailController.name, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + const SizedBox(width: 36, height: 36), + ], + ), + ), + ), + body: GestureDetector( + onTap: () { + FocusScope.of(context).unfocus(); + setState(() { + keyboardHeight = 0; + }); + }, + child: FutureBuilder( + future: _futureBuilderFuture, + builder: (BuildContext context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.data == null) { + return const SizedBox(); + } + final Map data = snapshot.data as Map; + if (data['status']) { + List messageList = _whisperDetailController.messageList; + return Obx( + () => messageList.isEmpty + ? const SizedBox() + : ListView.builder( + itemCount: messageList.length, + shrinkWrap: true, + reverse: true, + itemBuilder: (_, int i) { + if (i == 0) { + return Column( + children: [ + ChatItem( + item: messageList[i], + e_infos: _whisperDetailController.eInfos), + const SizedBox(height: 12), + ], + ); + } else { + return ChatItem( + item: messageList[i], + e_infos: _whisperDetailController.eInfos); + } + }, + ), + ); + } else { + // 请求错误 + return const SizedBox(); + } + } else { + // 骨架屏 + return const SizedBox(); + } + }, + ), + ), + // resizeToAvoidBottomInset: true, + bottomNavigationBar: Container( + width: double.infinity, + height: MediaQuery.of(context).padding.bottom + 70 + keyboardHeight, + padding: EdgeInsets.only( + left: 8, + right: 12, + top: 12, + bottom: MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 4, + color: Theme.of(context).colorScheme.primary.withOpacity(0.1), + ), + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // IconButton( + // onPressed: () {}, + // icon: Icon( + // Icons.add_circle_outline, + // color: Theme.of(context).colorScheme.outline, + // ), + // ), + IconButton( + onPressed: () { + // if (toolbarType == 'input') { + // setState(() { + // toolbarType = 'emote'; + // }); + // } + // FocusScope.of(context).unfocus(); + }, + icon: Icon( + Icons.emoji_emotions_outlined, + color: Theme.of(context).colorScheme.outline, + ), + ), + Expanded( + child: Container( + height: 45, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.08), + borderRadius: BorderRadius.circular(40.0), + ), + child: TextField( + readOnly: true, + style: Theme.of(context).textTheme.titleMedium, + controller: _replyContentController, + autofocus: false, + focusNode: replyContentFocusNode, + decoration: const InputDecoration( + border: InputBorder.none, // 移除默认边框 + hintText: '开发中 ...', // 提示文本 + contentPadding: EdgeInsets.symmetric( + horizontal: 16.0, vertical: 12.0), // 内边距 + ), + ), + ), + ), + IconButton( + // onPressed: _whisperDetailController.sendMsg, + onPressed: null, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.outline, + ), + ), + // const SizedBox(width: 16), + ], + ), + AnimatedSize( + curve: Curves.easeInOut, + duration: const Duration(milliseconds: 300), + child: SizedBox( + width: double.infinity, + height: toolbarType == 'input' ? keyboardHeight : emoteHeight, + child: EmotePanel( + onChoose: (package, emote) => {}, + ), + ), + ), + ], + ), + ), + ); + } +} + +typedef DebounceCallback = void Function(); + +class Debouncer { + DebounceCallback? callback; + final int? milliseconds; + Timer? _timer; + + Debouncer({this.milliseconds}); + + run(DebounceCallback callback) { + if (_timer != null) { + _timer!.cancel(); + } + _timer = Timer(Duration(milliseconds: milliseconds!), () { + callback(); + }); + } +} diff --git a/lib/pages/whisper_detail/widget/chat_item.dart b/lib/pages/whisper_detail/widget/chat_item.dart new file mode 100644 index 00000000..4fd49254 --- /dev/null +++ b/lib/pages/whisper_detail/widget/chat_item.dart @@ -0,0 +1,527 @@ +// ignore_for_file: must_be_immutable + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/utils/utils.dart'; +import 'package:pilipala/utils/storage.dart'; + +import '../../../http/search.dart'; + +enum MsgType { + invalid(value: 0, label: "空空的~"), + text(value: 1, label: "文本消息"), + pic(value: 2, label: "图片消息"), + audio(value: 3, label: "语音消息"), + share(value: 4, label: "分享消息"), + revoke(value: 5, label: "撤回消息"), + custom_face(value: 6, label: "自定义表情"), + share_v2(value: 7, label: "分享v2消息"), + sys_cancel(value: 8, label: "系统撤销"), + mini_program(value: 9, label: "小程序"), + notify_msg(value: 10, label: "业务通知"), + archive_card(value: 11, label: "投稿卡片"), + article_card(value: 12, label: "专栏卡片"), + pic_card(value: 13, label: "图片卡片"), + common_share(value: 14, label: "异形卡片"), + auto_reply_push(value: 16, label: "自动回复推送"), + notify_text(value: 18, label: "文本提示"); + + final int value; + final String label; + const MsgType({required this.value, required this.label}); + static MsgType parse(int value) { + return MsgType.values + .firstWhere((e) => e.value == value, orElse: () => MsgType.invalid); + } +} + +class ChatItem extends StatelessWidget { + dynamic item; + List? e_infos; + + ChatItem({ + super.key, + this.item, + this.e_infos, + }); + + @override + Widget build(BuildContext context) { + bool isOwner = + item.senderUid == GStrorage.userInfo.get('userInfoCache').mid; + + bool isPic = item.msgType == MsgType.pic.value; // 图片 + bool isText = item.msgType == MsgType.text.value; // 文本 + // bool isArchive = item.msgType == 11; // 投稿 + // bool isArticle = item.msgType == 12; // 专栏 + bool isRevoke = item.msgType == MsgType.revoke.value; // 撤回消息 + bool isShareV2 = item.msgType == MsgType.share_v2.value; + bool isSystem = item.msgType == MsgType.notify_text.value || + item.msgType == MsgType.notify_msg.value || + item.msgType == MsgType.pic_card.value || + item.msgType == MsgType.auto_reply_push.value; + dynamic content = item.content ?? ''; + Color textColor(BuildContext context) { + return isOwner + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSecondaryContainer; + } + + Widget richTextMessage(BuildContext context) { + var text = content['content']; + if (e_infos != null) { + final List children = []; + Map emojiMap = {}; + for (var e in e_infos!) { + emojiMap[e['text']] = e['url']; + } + text.splitMapJoin( + RegExp(r"\[.+?\]"), + onMatch: (Match match) { + final String emojiKey = match[0]!; + if (emojiMap.containsKey(emojiKey)) { + children.add(WidgetSpan( + child: NetworkImgLayer( + width: 18, + height: 18, + src: emojiMap[emojiKey]!, + ), + )); + } + return ''; + }, + onNonMatch: (String text) { + children.add(TextSpan( + text: text, + style: TextStyle( + color: textColor(context), + letterSpacing: 0.6, + height: 1.5, + ))); + return ''; + }, + ); + return RichText( + text: TextSpan( + children: children, + ), + ); + } else { + return Text( + text, + style: TextStyle( + letterSpacing: 0.6, + color: textColor(context), + height: 1.5, + ), + ); + } + } + + Widget messageContent(BuildContext context) { + switch (MsgType.parse(item.msgType)) { + case MsgType.notify_msg: + return SystemNotice(item: item); + case MsgType.pic_card: + return SystemNotice2(item: item); + case MsgType.notify_text: + return Text( + jsonDecode(content['content']) + .map((m) => m['text'] as String) + .join("\n"), + style: TextStyle( + letterSpacing: 0.6, + height: 5, + color: Theme.of(context).colorScheme.outline.withOpacity(0.8), + ), + ); + case MsgType.text: + return richTextMessage(context); + case MsgType.pic: + return NetworkImgLayer( + width: 220, + height: 220 * content['height'] / content['width'], + src: content['url'], + ); + case MsgType.share_v2: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () async { + SmartDialog.showLoading(); + var bvid = content["bvid"]; + final int cid = await SearchHttp.ab2c(bvid: bvid); + final String heroTag = Utils.makeHeroTag(bvid); + SmartDialog.dismiss().then( + (e) => Get.toNamed('/video?bvid=$bvid&cid=$cid', + arguments: { + 'pic': content['thumb'], + 'heroTag': heroTag, + }), + ); + }, + child: NetworkImgLayer( + width: 220, + height: 220 * 9 / 16, + src: content['thumb'], + ), + ), + const SizedBox(height: 6), + Text( + content['title'], + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 1), + Text( + content['author'], + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context).withOpacity(0.6), + fontSize: 12, + ), + ), + ], + ); + case MsgType.archive_card: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () async { + SmartDialog.showLoading(); + var bvid = content["bvid"]; + final int cid = await SearchHttp.ab2c(bvid: bvid); + final String heroTag = Utils.makeHeroTag(bvid); + SmartDialog.dismiss().then( + (e) => Get.toNamed('/video?bvid=$bvid&cid=$cid', + arguments: { + 'pic': content['thumb'], + 'heroTag': heroTag, + }), + ); + }, + child: NetworkImgLayer( + width: 220, + height: 220 * 9 / 16, + src: content['cover'], + ), + ), + const SizedBox(height: 6), + Text( + content['title'], + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context), + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 1), + Text( + Utils.timeFormat(content['times']), + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context).withOpacity(0.6), + fontSize: 12, + ), + ), + ], + ); + case MsgType.auto_reply_push: + return Container( + constraints: const BoxConstraints( + maxWidth: 300.0, // 设置最大宽度为200.0 + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .secondaryContainer + .withOpacity(0.4), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(16), + ), + ), + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + content['main_title'], + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context), + fontWeight: FontWeight.bold, + ), + ), + for (var i in content['sub_cards']) ...[ + const SizedBox(height: 6), + GestureDetector( + onTap: () async { + RegExp bvRegex = RegExp(r'BV[0-9A-Za-z]{10}', + caseSensitive: false); + Iterable matches = + bvRegex.allMatches(i['jump_url']); + if (matches.isNotEmpty) { + Match match = matches.first; + String bvid = match.group(0)!; + try { + SmartDialog.showLoading(); + final int cid = await SearchHttp.ab2c(bvid: bvid); + final String heroTag = Utils.makeHeroTag(bvid); + SmartDialog.dismiss().then( + (e) => Get.toNamed( + '/video?bvid=$bvid&cid=$cid', + arguments: { + 'pic': i['cover_url'], + 'heroTag': heroTag, + }), + ); + } catch (err) { + SmartDialog.dismiss(); + SmartDialog.showToast(err.toString()); + } + } else { + SmartDialog.showToast('未匹配到 BV 号'); + Get.toNamed('/webview', + arguments: {'url': i['jump_url']}); + } + }, + child: Row( + children: [ + NetworkImgLayer( + width: 130, + height: 130 * 9 / 16, + src: i['cover_url'], + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i['field1'], + maxLines: 2, + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context), + fontWeight: FontWeight.bold, + ), + ), + Text( + i['field2'], + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context).withOpacity(0.6), + fontSize: 12, + ), + ), + Text( + Utils.timeFormat(int.parse(i['field3'])), + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context).withOpacity(0.6), + fontSize: 12, + ), + ), + ], + )), + ], + )), + ], + ], + )); + default: + return Text( + content != null && content != '' + ? (content['content'] ?? content.toString()) + : '不支持的消息类型', + style: TextStyle( + letterSpacing: 0.6, + height: 1.5, + color: textColor(context), + fontWeight: FontWeight.bold, + ), + ); + } + } + + return isSystem + ? messageContent(context) + : isRevoke + ? const SizedBox() + : Row( + children: [ + if (!isOwner) const SizedBox(width: 12), + if (isOwner) const Spacer(), + Container( + constraints: const BoxConstraints( + maxWidth: 300.0, // 设置最大宽度为200.0 + ), + decoration: BoxDecoration( + color: isOwner + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isOwner ? 16 : 6), + bottomRight: Radius.circular(isOwner ? 6 : 16), + ), + ), + margin: const EdgeInsets.only(top: 12), + padding: EdgeInsets.only( + top: 8, + bottom: 6, + left: isPic ? 8 : 12, + right: isPic ? 8 : 12, + ), + child: Column( + crossAxisAlignment: isOwner + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + messageContent(context), + SizedBox(height: isPic ? 7 : 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Utils.dateFormat(item.timestamp), + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith( + color: isOwner + ? Theme.of(context) + .colorScheme + .onPrimary + .withOpacity(0.8) + : Theme.of(context) + .colorScheme + .onSecondaryContainer + .withOpacity(0.8)), + ), + item.msgStatus == 1 + ? Text( + ' 已撤回', + style: + Theme.of(context).textTheme.labelSmall!, + ) + : const SizedBox() + ], + ) + ], + ), + ), + if (!isOwner) const Spacer(), + if (isOwner) const SizedBox(width: 12), + ], + ); + } +} + +class SystemNotice extends StatelessWidget { + dynamic item; + SystemNotice({super.key, this.item}); + + @override + Widget build(BuildContext context) { + Map content = item.content ?? ''; + return Row( + children: [ + const SizedBox(width: 12), + Container( + constraints: const BoxConstraints( + maxWidth: 300.0, // 设置最大宽度为200.0 + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .secondaryContainer + .withOpacity(0.4), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(16), + ), + ), + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(content['title'], + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.bold)), + Text( + Utils.dateFormat(item.timestamp), + style: Theme.of(context) + .textTheme + .labelSmall! + .copyWith(color: Theme.of(context).colorScheme.outline), + ), + Divider( + color: Theme.of(context).colorScheme.primary.withOpacity(0.05), + ), + Text( + content['text'], + ) + ], + ), + ), + const Spacer(), + ], + ); + } +} + +class SystemNotice2 extends StatelessWidget { + dynamic item; + SystemNotice2({super.key, this.item}); + + @override + Widget build(BuildContext context) { + Map content = item.content ?? ''; + return Row( + children: [ + const SizedBox(width: 12), + Container( + constraints: const BoxConstraints( + maxWidth: 300.0, // 设置最大宽度为200.0 + ), + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.only(bottom: 6), + child: NetworkImgLayer( + width: 320, + height: 150, + src: content['pic_url'], + ), + ), + const Spacer(), + ], + ); + } +} diff --git a/lib/plugin/pl_player/controller.dart b/lib/plugin/pl_player/controller.dart index eee652b9..b385fca8 100644 --- a/lib/plugin/pl_player/controller.dart +++ b/lib/plugin/pl_player/controller.dart @@ -21,6 +21,8 @@ import 'package:pilipala/utils/storage.dart'; import 'package:screen_brightness/screen_brightness.dart'; import 'package:status_bar_control/status_bar_control.dart'; import 'package:universal_platform/universal_platform.dart'; +import '../../models/video/subTitile/content.dart'; +import '../../models/video/subTitile/result.dart'; // import 'package:wakelock_plus/wakelock_plus.dart'; Box videoStorage = GStrorage.video; @@ -73,6 +75,8 @@ class PlPlayerController { final Rx _doubleSpeedStatus = false.obs; final Rx _controlsLock = false.obs; final Rx _isFullScreen = false.obs; + final Rx _subTitleOpen = false.obs; + final Rx _subTitleCode = (-1).obs; // 默认投稿视频格式 static Rx _videoType = 'archive'.obs; @@ -118,6 +122,7 @@ class PlPlayerController { PreferredSizeWidget? headerControl; PreferredSizeWidget? bottomControl; Widget? danmuWidget; + late RxList subtitles; /// 数据加载监听 Stream get onDataStatusChanged => dataStatus.status.stream; @@ -147,6 +152,11 @@ class PlPlayerController { Rx get mute => _mute; Stream get onMuteChanged => _mute.stream; + /// 字幕开启状态 + Rx get subTitleOpen => _subTitleOpen; + Rx get subTitleCode => _subTitleCode; + // Stream get onSubTitleOpenChanged => _subTitleOpen.stream; + /// [videoPlayerController] instace of Player Player? get videoPlayerController => _videoPlayerController; @@ -221,8 +231,9 @@ class PlPlayerController { late double showArea; late double opacityVal; late double fontSizeVal; + late double strokeWidth; late double danmakuDurationVal; - late List speedsList; + late List speedsList; // 缓存 double? defaultDuration; late bool enableAutoLongPressSpeed = false; @@ -230,30 +241,38 @@ class PlPlayerController { // 播放顺序相关 PlayRepeat playRepeat = PlayRepeat.pause; + RxList subtitleContents = + [].obs; + RxString subtitleContent = ''.obs; + void updateSliderPositionSecond() { int newSecond = _sliderPosition.value.inSeconds; if (sliderPositionSeconds.value != newSecond) { sliderPositionSeconds.value = newSecond; } } + void updatePositionSecond() { int newSecond = _position.value.inSeconds; if (positionSeconds.value != newSecond) { positionSeconds.value = newSecond; } } + void updateDurationSecond() { int newSecond = _duration.value.inSeconds; if (durationSeconds.value != newSecond) { durationSeconds.value = newSecond; } } + void updateBufferedSecond() { int newSecond = _buffered.value.inSeconds; if (bufferedSeconds.value != newSecond) { bufferedSeconds.value = newSecond; } } + // 添加一个私有构造函数 PlPlayerController._() { _videoType = videoType; @@ -271,6 +290,8 @@ class PlPlayerController { // 弹幕时间 danmakuDurationVal = localCache.get(LocalCacheKey.danmakuDuration, defaultValue: 4.0); + // 描边粗细 + strokeWidth = localCache.get(LocalCacheKey.strokeWidth, defaultValue: 1.5); playRepeat = PlayRepeat.values.toList().firstWhere( (e) => e.value == @@ -285,12 +306,19 @@ class PlPlayerController { _longPressSpeed.value = videoStorage .get(VideoBoxKey.longPressSpeedDefault, defaultValue: 2.0); } - List speedsListTemp = - videoStorage.get(VideoBoxKey.customSpeedsList, defaultValue: []); - speedsList = List.from(speedsListTemp); - for (var i in PlaySpeed.values) { - speedsList.add(i.value); - } + // 自定义倍速集合 + speedsList = List.from(videoStorage + .get(VideoBoxKey.customSpeedsList, defaultValue: [])); + // 默认倍速 + speedsList = List.from(videoStorage + .get(VideoBoxKey.customSpeedsList, defaultValue: [])); + //playSpeedSystem + final List playSpeedSystem = + videoStorage.get(VideoBoxKey.playSpeedSystem, defaultValue: playSpeed); + + // for (final PlaySpeed i in PlaySpeed.values) { + speedsList.addAll(playSpeedSystem); + // } // _playerEventSubs = onPlayerStatusChanged.listen((PlayerStatus status) { // if (status == PlayerStatus.playing) { @@ -336,6 +364,8 @@ class PlPlayerController { bool enableHeart = true, // 是否首次加载 bool isFirstTime = true, + // 是否开启字幕 + bool enableSubTitle = false, }) async { try { _autoPlay = autoplay; @@ -350,7 +380,9 @@ class PlPlayerController { _cid = cid; _enableHeart = enableHeart; _isFirstTime = isFirstTime; - + _subTitleOpen.value = enableSubTitle; + subtitles = [].obs; + subtitleContent.value = ''; if (_videoPlayerController != null && _videoPlayerController!.state.playing) { await pause(notify: false); @@ -528,8 +560,10 @@ class PlPlayerController { if (event) { playerStatus.status.value = PlayerStatus.playing; } else { - // playerStatus.status.value = PlayerStatus.paused; + playerStatus.status.value = PlayerStatus.paused; } + videoPlayerServiceHandler.onStatusChange( + playerStatus.status.value, isBuffering.value); /// 触发回调事件 for (var element in _statusListeners) { @@ -559,6 +593,8 @@ class PlPlayerController { _sliderPosition.value = event; updateSliderPositionSecond(); } + querySubtitleContent( + videoPlayerController!.state.position.inSeconds.toDouble()); /// 触发回调事件 for (var element in _positionListeners) { @@ -593,6 +629,10 @@ class PlPlayerController { const Duration(seconds: 1), () => videoPlayerServiceHandler.onPositionChange(event)); }), + + // onSubTitleOpenChanged.listen((bool event) { + // toggleSubtitle(event ? subTitleCode.value : -1); + // }) ], ); } @@ -618,7 +658,7 @@ class PlPlayerController { if (duration.value.inSeconds != 0) { if (type != 'slider') { /// 拖动进度条调节时,不等待第一帧,防止抖动 - await _videoPlayerController!.stream.buffer.first; + await _videoPlayerController?.stream.buffer.first; } await _videoPlayerController?.seek(position); // if (playerStatus.stopped) { @@ -668,18 +708,6 @@ class PlPlayerController { _playbackSpeed.value = speed; } - /// 设置倍速 - // Future togglePlaybackSpeed() async { - // List allowedSpeeds = - // PlaySpeed.values.map((e) => e.value).toList(); - // int index = allowedSpeeds.indexOf(_playbackSpeed.value); - // if (index < allowedSpeeds.length - 1) { - // setPlaybackSpeed(allowedSpeeds[index + 1]); - // } else { - // setPlaybackSpeed(allowedSpeeds[0]); - // } - // } - /// 播放视频 /// TODO _duration.value丢失 Future play( @@ -729,6 +757,9 @@ class PlPlayerController { /// 隐藏控制条 void _hideTaskControls() { + if (_timer != null) { + _timer!.cancel(); + } _timer = Timer(const Duration(milliseconds: 3000), () { if (!isSliderMoving.value) { controls = false; @@ -780,7 +811,7 @@ class PlPlayerController { volume.value = volumeNew; try { - FlutterVolumeController.showSystemUI = false; + FlutterVolumeController.updateShowSystemUI(false); await FlutterVolumeController.setVolume(volumeNew); } catch (err) { print(err); @@ -937,9 +968,10 @@ class PlPlayerController { if (!isFullScreen.value && status) { /// 按照视频宽高比决定全屏方向 toggleFullScreen(true); + /// 进入全屏 await enterFullScreen(); - if(mode == FullScreenMode.vertical || + if (mode == FullScreenMode.vertical || (mode == FullScreenMode.auto && direction.value == 'vertical')) { await verticalScreen(); } else { @@ -1039,12 +1071,61 @@ class PlPlayerController { } } + /// 字幕 + void toggleSubtitle(int code) { + _subTitleOpen.value = code != -1; + _subTitleCode.value = code; + // if (code == -1) { + // // 关闭字幕 + // _subTitleOpen.value = false; + // _subTitleCode.value = code; + // _videoPlayerController?.setSubtitleTrack(SubtitleTrack.no()); + // return; + // } + // final SubTitlteItemModel? subtitle = subtitles?.firstWhereOrNull( + // (element) => element.code == code, + // ); + // _subTitleOpen.value = true; + // _subTitleCode.value = code; + // _videoPlayerController?.setSubtitleTrack( + // SubtitleTrack.data( + // subtitle!.content!, + // title: subtitle.title, + // language: subtitle.lan, + // ), + // ); + } + + void querySubtitleContent(double progress) { + if (subTitleCode.value == -1) { + subtitleContent.value = ''; + return; + } + if (subtitles.isEmpty) { + return; + } + final SubTitlteItemModel? subtitle = subtitles.firstWhereOrNull( + (element) => element.code == subTitleCode.value, + ); + if (subtitle != null && subtitle.body!.isNotEmpty) { + for (var content in subtitle.body!) { + if (progress >= content['from']! && progress <= content['to']!) { + subtitleContent.value = content['content']!; + return; + } + } + } + } + setPlayRepeat(PlayRepeat type) { playRepeat = type; videoStorage.put(VideoBoxKey.playRepeat, type.value); } Future dispose({String type = 'single'}) async { + print('dispose'); + print('dispose: ${playerCount.value}'); + // 每次减1,最后销毁 if (type == 'single' && playerCount.value > 1) { _playerCount.value -= 1; @@ -1054,6 +1135,7 @@ class PlPlayerController { } _playerCount.value = 0; try { + print('dispose dispose ---------'); _timer?.cancel(); _timerForVolume?.cancel(); _timerForGettingVolume?.cancel(); @@ -1079,12 +1161,14 @@ class PlPlayerController { localCache.put(LocalCacheKey.danmakuOpacity, opacityVal); localCache.put(LocalCacheKey.danmakuFontScale, fontSizeVal); localCache.put(LocalCacheKey.danmakuDuration, danmakuDurationVal); - - var pp = _videoPlayerController!.platform as NativePlayer; - await pp.setProperty('audio-files', ''); - removeListeners(); - await _videoPlayerController?.dispose(); - _videoPlayerController = null; + localCache.put(LocalCacheKey.strokeWidth, strokeWidth); + if (_videoPlayerController != null) { + var pp = _videoPlayerController!.platform as NativePlayer; + await pp.setProperty('audio-files', ''); + removeListeners(); + await _videoPlayerController?.dispose(); + _videoPlayerController = null; + } _instance = null; // 关闭所有视频页面恢复亮度 resetBrightness(); diff --git a/lib/plugin/pl_player/models/bottom_control_type.dart b/lib/plugin/pl_player/models/bottom_control_type.dart new file mode 100644 index 00000000..739e1d38 --- /dev/null +++ b/lib/plugin/pl_player/models/bottom_control_type.dart @@ -0,0 +1,11 @@ +enum BottomControlType { + pre, + playOrPause, + next, + time, + space, + fit, + speed, + fullscreen, + custom, +} diff --git a/lib/plugin/pl_player/models/bottom_progress_behavior.dart b/lib/plugin/pl_player/models/bottom_progress_behavior.dart index c632669c..c7f1453d 100644 --- a/lib/plugin/pl_player/models/bottom_progress_behavior.dart +++ b/lib/plugin/pl_player/models/bottom_progress_behavior.dart @@ -3,14 +3,15 @@ enum BtmProgresBehavior { alwaysShow, alwaysHide, onlyShowFullScreen, + onlyHideFullScreen, } extension BtmProgresBehaviorDesc on BtmProgresBehavior { - String get description => ['始终展示', '始终隐藏', '仅全屏时展示'][index]; + String get description => ['始终展示', '始终隐藏', '仅全屏时展示', '仅全屏时隐藏'][index]; } extension BtmProgresBehaviorCode on BtmProgresBehavior { - static final List _codeList = [0, 1, 2]; + static final List _codeList = [0, 1, 2, 3]; int get code => _codeList[index]; static BtmProgresBehavior? fromCode(int code) { diff --git a/lib/plugin/pl_player/models/play_repeat.dart b/lib/plugin/pl_player/models/play_repeat.dart index e68196c7..89ee68e4 100644 --- a/lib/plugin/pl_player/models/play_repeat.dart +++ b/lib/plugin/pl_player/models/play_repeat.dart @@ -6,13 +6,13 @@ enum PlayRepeat { } extension PlayRepeatExtension on PlayRepeat { - static final List _descList = [ + static final List _descList = [ '播完暂停', '顺序播放', '单个循环', '列表循环', ]; - get description => _descList[index]; + String get description => _descList[index]; static final List _valueList = [ 1, @@ -20,6 +20,6 @@ extension PlayRepeatExtension on PlayRepeat { 3, 4, ]; - get value => _valueList[index]; - get defaultValue => _valueList[1]; + double get value => _valueList[index]; + double get defaultValue => _valueList[1]; } diff --git a/lib/plugin/pl_player/models/play_speed.dart b/lib/plugin/pl_player/models/play_speed.dart index 533e0254..8bb25118 100644 --- a/lib/plugin/pl_player/models/play_speed.dart +++ b/lib/plugin/pl_player/models/play_speed.dart @@ -1,39 +1,15 @@ -enum PlaySpeed { - pointTwoFive, - pointFive, - pointSevenFive, +List generatePlaySpeedList() { + List playSpeed = []; + double startSpeed = 0.25; + double endSpeed = 2.0; + double increment = 0.25; - one, - onePointTwoFive, - onePointFive, - onePointSevenFive, + for (double speed = startSpeed; speed <= endSpeed; speed += increment) { + playSpeed.add(speed); + } - two, + return playSpeed; } -extension PlaySpeedExtension on PlaySpeed { - static final List _descList = [ - '0.25', - '0.5', - '0.75', - '正常', - '1.25', - '1.5', - '1.75', - '2.0', - ]; - get description => _descList[index]; - - static final List _valueList = [ - 0.25, - 0.5, - 0.75, - 1.0, - 1.25, - 1.5, - 1.75, - 2.0, - ]; - get value => _valueList[index]; - get defaultValue => _valueList[3]; -} +// 导出 playSpeed 列表 +List playSpeed = generatePlaySpeedList(); diff --git a/lib/plugin/pl_player/view.dart b/lib/plugin/pl_player/view.dart index 89b85607..be24b105 100644 --- a/lib/plugin/pl_player/view.dart +++ b/lib/plugin/pl_player/view.dart @@ -1,46 +1,55 @@ import 'dart:async'; import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; +import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_volume_controller/flutter_volume_controller.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; +import 'package:pilipala/models/common/gesture_mode.dart'; import 'package:pilipala/plugin/pl_player/controller.dart'; import 'package:pilipala/plugin/pl_player/models/duration.dart'; import 'package:pilipala/plugin/pl_player/models/fullscreen_mode.dart'; -import 'package:pilipala/plugin/pl_player/models/play_status.dart'; import 'package:pilipala/plugin/pl_player/utils.dart'; import 'package:pilipala/utils/feed_back.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:screen_brightness/screen_brightness.dart'; +import '../../utils/global_data.dart'; +import 'models/bottom_control_type.dart'; import 'models/bottom_progress_behavior.dart'; -import 'utils/fullscreen.dart'; import 'widgets/app_bar_ani.dart'; import 'widgets/backward_seek.dart'; import 'widgets/bottom_control.dart'; import 'widgets/common_btn.dart'; import 'widgets/forward_seek.dart'; -import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'widgets/play_pause_btn.dart'; class PLVideoPlayer extends StatefulWidget { - final PlPlayerController controller; - final PreferredSizeWidget? headerControl; - final PreferredSizeWidget? bottomControl; - final Widget? danmuWidget; - const PLVideoPlayer({ required this.controller, this.headerControl, this.bottomControl, this.danmuWidget, + this.bottomList, + this.customWidget, + this.customWidgets, super.key, }); + final PlPlayerController controller; + final PreferredSizeWidget? headerControl; + final PreferredSizeWidget? bottomControl; + final Widget? danmuWidget; + final List? bottomList; + // List or Widget + + final Widget? customWidget; + final List? customWidgets; + @override State createState() => _PLVideoPlayerState(); } @@ -49,26 +58,22 @@ class _PLVideoPlayerState extends State with TickerProviderStateMixin { late AnimationController animationController; late VideoController videoController; - final PLVideoPlayerController _ctr = Get.put(PLVideoPlayerController()); - // bool _mountSeekBackwardButton = false; - // bool _mountSeekForwardButton = false; - // bool _hideSeekBackwardButton = false; - // bool _hideSeekForwardButton = false; + final RxBool _mountSeekBackwardButton = false.obs; + final RxBool _mountSeekForwardButton = false.obs; + final RxBool _hideSeekBackwardButton = false.obs; + final RxBool _hideSeekForwardButton = false.obs; - // double _brightnessValue = 0.0; - // bool _brightnessIndicator = false; + final RxDouble _brightnessValue = 0.0.obs; + final RxBool _brightnessIndicator = false.obs; Timer? _brightnessTimer; - // double _volumeValue = 0.0; - // bool _volumeIndicator = false; + final RxDouble _volumeValue = 0.0.obs; + final RxBool _volumeIndicator = false.obs; Timer? _volumeTimer; - double _distance = 0.0; - // 初始手指落下位置 - double _initTapPositoin = 0.0; - - // bool _volumeInterceptEventStream = false; + final RxDouble _distance = 0.0.obs; + final RxBool _volumeInterceptEventStream = false.obs; Box setting = GStrorage.setting; late FullScreenMode mode; @@ -76,29 +81,27 @@ class _PLVideoPlayerState extends State late bool enableQuickDouble; late bool enableBackgroundPlay; late double screenWidth; + final FullScreenGestureMode fullScreenGestureMode = + GlobalData().fullScreenGestureMode; // 用于记录上一次全屏切换手势触发时间,避免误触 DateTime? lastFullScreenToggleTime; void onDoubleTapSeekBackward() { - _ctr.onDoubleTapSeekBackward(); + _mountSeekBackwardButton.value = true; } void onDoubleTapSeekForward() { - _ctr.onDoubleTapSeekForward(); + _mountSeekForwardButton.value = true; } // 双击播放、暂停 void onDoubleTapCenter() { - final _ = widget.controller; - if (_.videoPlayerController!.state.playing) { - _.pause(); - } else { - _.play(); - } + final PlPlayerController _ = widget.controller; + _.videoPlayerController!.playOrPause(); } - doubleTapFuc(String type) { + void doubleTapFuc(String type) { if (!enableQuickDouble) { onDoubleTapCenter(); return; @@ -123,7 +126,11 @@ class _PLVideoPlayerState extends State super.initState(); screenWidth = Get.size.width; animationController = AnimationController( - vsync: this, duration: const Duration(milliseconds: 300)); + vsync: this, + duration: GlobalData().enablePlayerControlAnimation + ? const Duration(milliseconds: 150) + : const Duration(milliseconds: 10), + ); videoController = widget.controller.videoController!; widget.controller.headerControl = widget.headerControl; widget.controller.bottomControl = widget.bottomControl; @@ -136,11 +143,11 @@ class _PLVideoPlayerState extends State setting.get(SettingBoxKey.enableBackgroundPlay, defaultValue: false); Future.microtask(() async { try { - FlutterVolumeController.showSystemUI = true; - _ctr.volumeValue.value = (await FlutterVolumeController.getVolume())!; - FlutterVolumeController.addListener((value) { - if (mounted && !_ctr.volumeInterceptEventStream.value) { - _ctr.volumeValue.value = value; + FlutterVolumeController.updateShowSystemUI(true); + _volumeValue.value = (await FlutterVolumeController.getVolume())!; + FlutterVolumeController.addListener((double value) { + if (mounted && !_volumeInterceptEventStream.value) { + _volumeValue.value = value; } }); } catch (_) {} @@ -148,10 +155,10 @@ class _PLVideoPlayerState extends State Future.microtask(() async { try { - _ctr.brightnessValue.value = await ScreenBrightness().current; - ScreenBrightness().onCurrentBrightnessChanged.listen((value) { + _brightnessValue.value = await ScreenBrightness().current; + ScreenBrightness().onCurrentBrightnessChanged.listen((double value) { if (mounted) { - _ctr.brightnessValue.value = value; + _brightnessValue.value = value; } }); } catch (_) {} @@ -160,17 +167,17 @@ class _PLVideoPlayerState extends State Future setVolume(double value) async { try { - FlutterVolumeController.showSystemUI = false; + FlutterVolumeController.updateShowSystemUI(false); await FlutterVolumeController.setVolume(value); } catch (_) {} - _ctr.volumeValue.value = value; - _ctr.volumeIndicator.value = true; - _ctr.volumeInterceptEventStream.value = true; + _volumeValue.value = value; + _volumeIndicator.value = true; + _volumeInterceptEventStream.value = true; _volumeTimer?.cancel(); _volumeTimer = Timer(const Duration(milliseconds: 200), () { if (mounted) { - _ctr.volumeIndicator.value = false; - _ctr.volumeInterceptEventStream.value = false; + _volumeIndicator.value = false; + _volumeInterceptEventStream.value = false; } }); } @@ -179,11 +186,11 @@ class _PLVideoPlayerState extends State try { await ScreenBrightness().setScreenBrightness(value); } catch (_) {} - _ctr.brightnessIndicator.value = true; + _brightnessIndicator.value = true; _brightnessTimer?.cancel(); _brightnessTimer = Timer(const Duration(milliseconds: 200), () { if (mounted) { - _ctr.brightnessIndicator.value = false; + _brightnessIndicator.value = false; } }); widget.controller.brightness.value = value; @@ -196,11 +203,148 @@ class _PLVideoPlayerState extends State super.dispose(); } + // 动态构建底部控制条 + List buildBottomControl() { + const TextStyle textStyle = TextStyle( + color: Colors.white, + fontSize: 12, + ); + final PlPlayerController _ = widget.controller; + Map videoProgressWidgets = { + /// 上一集 + BottomControlType.pre: ComBtn( + icon: const Icon( + Icons.skip_previous_outlined, + size: 15, + color: Colors.white, + ), + fuc: () {}, + ), + + /// 播放暂停 + BottomControlType.playOrPause: PlayOrPauseButton( + controller: _, + ), + + /// 下一集 + BottomControlType.next: ComBtn( + icon: const Icon( + Icons.last_page_outlined, + size: 15, + color: Colors.white, + ), + fuc: () {}, + ), + + /// 时间进度 + BottomControlType.time: Row( + children: [ + Obx(() { + return Text( + _.durationSeconds.value >= 3600 + ? printDurationWithHours( + Duration(seconds: _.positionSeconds.value)) + : printDuration(Duration(seconds: _.positionSeconds.value)), + style: textStyle, + ); + }), + const SizedBox(width: 2), + const Text('/', style: textStyle), + const SizedBox(width: 2), + Obx( + () => Text( + _.durationSeconds.value >= 3600 + ? printDurationWithHours( + Duration(seconds: _.durationSeconds.value)) + : printDuration(Duration(seconds: _.durationSeconds.value)), + style: textStyle, + ), + ), + ], + ), + + /// 空白占位 + BottomControlType.space: const Spacer(), + + /// 画面比例 + BottomControlType.fit: SizedBox( + height: 30, + child: TextButton( + onPressed: () => _.toggleVideoFit(), + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + child: Obx( + () => Text( + _.videoFitDEsc.value, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + ), + ), + + /// 播放速度 + BottomControlType.speed: SizedBox( + width: 45, + height: 34, + child: TextButton( + style: ButtonStyle( + padding: MaterialStateProperty.all(EdgeInsets.zero), + ), + onPressed: () {}, + child: Obx( + () => Text( + '${_.playbackSpeed.toString()}X', + style: textStyle, + ), + ), + ), + ), + + /// 字幕 + /// 全屏 + BottomControlType.fullscreen: ComBtn( + icon: Obx( + () => Icon( + _.isFullScreen.value + ? FontAwesomeIcons.compress + : FontAwesomeIcons.expand, + size: 15, + color: Colors.white, + ), + ), + fuc: () => _.triggerFullScreen(), + ), + }; + final List list = []; + List userSpecifyItem = widget.bottomList ?? + [ + BottomControlType.playOrPause, + BottomControlType.time, + BottomControlType.space, + BottomControlType.fit, + BottomControlType.fullscreen, + ]; + for (var i = 0; i < userSpecifyItem.length; i++) { + if (userSpecifyItem[i] == BottomControlType.custom) { + if (widget.customWidget != null && widget.customWidget is Widget) { + list.add(widget.customWidget!); + } + if (widget.customWidgets != null && widget.customWidgets!.isNotEmpty) { + list.addAll(widget.customWidgets!); + } + } else { + list.add(videoProgressWidgets[userSpecifyItem[i]]!); + } + } + return list; + } + @override Widget build(BuildContext context) { - final _ = widget.controller; - Color colorTheme = Theme.of(context).colorScheme.primary; - TextStyle subTitleStyle = const TextStyle( + final PlPlayerController _ = widget.controller; + final Color colorTheme = Theme.of(context).colorScheme.primary; + const TextStyle subTitleStyle = TextStyle( height: 1.5, fontSize: 40.0, letterSpacing: 0.0, @@ -209,24 +353,23 @@ class _PLVideoPlayerState extends State fontWeight: FontWeight.normal, backgroundColor: Color(0xaa000000), ); - const textStyle = TextStyle( + const TextStyle textStyle = TextStyle( color: Colors.white, fontSize: 12, ); return Stack( - clipBehavior: Clip.hardEdge, fit: StackFit.passthrough, - children: [ + children: [ Obx( () => Video( + key: ValueKey(_.videoFit.value), controller: videoController, controls: NoVideoControls, pauseUponEnteringBackgroundMode: !enableBackgroundPlay, resumeUponEnteringForegroundMode: true, - subtitleViewConfiguration: SubtitleViewConfiguration( + subtitleViewConfiguration: const SubtitleViewConfiguration( style: subTitleStyle, - textAlign: TextAlign.center, - padding: const EdgeInsets.all(24.0), + padding: EdgeInsets.all(24.0), ), fit: _.videoFit.value, ), @@ -315,10 +458,9 @@ class _PLVideoPlayerState extends State /// 音量🔊 控制条展示 Obx( () => Align( - alignment: Alignment.center, child: AnimatedOpacity( curve: Curves.easeInOut, - opacity: _ctr.volumeIndicator.value ? 1.0 : 0.0, + opacity: _volumeIndicator.value ? 1.0 : 0.0, duration: const Duration(milliseconds: 150), child: Container( alignment: Alignment.center, @@ -331,16 +473,15 @@ class _PLVideoPlayerState extends State child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ + children: [ Container( height: 34.0, width: 28.0, alignment: Alignment.centerRight, child: Icon( - _ctr.volumeValue.value == 0.0 + _volumeValue.value == 0.0 ? Icons.volume_off - : _ctr.volumeValue.value < 0.5 + : _volumeValue.value < 0.5 ? Icons.volume_down : Icons.volume_up, color: const Color(0xFFFFFFFF), @@ -349,7 +490,7 @@ class _PLVideoPlayerState extends State ), Expanded( child: Text( - '${(_ctr.volumeValue.value * 100.0).round()}%', + '${(_volumeValue.value * 100.0).round()}%', textAlign: TextAlign.center, style: const TextStyle( fontSize: 13.0, @@ -368,10 +509,9 @@ class _PLVideoPlayerState extends State /// 亮度🌞 控制条展示 Obx( () => Align( - alignment: Alignment.center, child: AnimatedOpacity( curve: Curves.easeInOut, - opacity: _ctr.brightnessIndicator.value ? 1.0 : 0.0, + opacity: _brightnessIndicator.value ? 1.0 : 0.0, duration: const Duration(milliseconds: 150), child: Container( alignment: Alignment.center, @@ -384,16 +524,15 @@ class _PLVideoPlayerState extends State child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ + children: [ Container( height: 30.0, width: 28.0, alignment: Alignment.centerRight, child: Icon( - _ctr.brightnessValue.value < 1.0 / 3.0 + _brightnessValue.value < 1.0 / 3.0 ? Icons.brightness_low - : _ctr.brightnessValue.value < 2.0 / 3.0 + : _brightnessValue.value < 2.0 / 3.0 ? Icons.brightness_medium : Icons.brightness_high, color: const Color(0xFFFFFFFF), @@ -403,7 +542,7 @@ class _PLVideoPlayerState extends State const SizedBox(width: 2.0), Expanded( child: Text( - '${(_ctr.brightnessValue.value * 100.0).round()}%', + '${(_brightnessValue.value * 100.0).round()}%', textAlign: TextAlign.center, style: const TextStyle( fontSize: 13.0, @@ -441,6 +580,45 @@ class _PLVideoPlayerState extends State if (widget.danmuWidget != null) Positioned.fill(top: 4, child: widget.danmuWidget!), + /// 开启且有字幕时展示 + Stack( + children: [ + Positioned( + left: 0, + right: 0, + bottom: 30, + child: Align( + alignment: Alignment.center, + child: Obx( + () => Visibility( + visible: widget.controller.subTitleCode.value != -1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: widget.controller.subtitleContent.value != '' + ? Colors.black.withOpacity(0.6) + : Colors.transparent, + ), + padding: widget.controller.subTitleCode.value != -1 + ? const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ) + : EdgeInsets.zero, + child: Text( + widget.controller.subtitleContent.value, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + )), + ), + ), + ), + ], + ), + /// 手势 Positioned.fill( left: 16, @@ -451,14 +629,14 @@ class _PLVideoPlayerState extends State onTap: () { _.controls = !_.showControls.value; }, - onDoubleTapDown: (details) { + onDoubleTapDown: (TapDownDetails details) { // live模式下禁用 锁定时🔒禁用 if (_.videoType.value == 'live' || _.controlsLock.value) { return; } - final totalWidth = MediaQuery.of(context).size.width; - final tapPosition = details.localPosition.dx; - final sectionWidth = totalWidth / 3; + final double totalWidth = MediaQuery.sizeOf(context).width; + final double tapPosition = details.localPosition.dx; + final double sectionWidth = totalWidth / 3; String type = 'left'; if (tapPosition < sectionWidth) { type = 'left'; @@ -469,11 +647,11 @@ class _PLVideoPlayerState extends State } doubleTapFuc(type); }, - onLongPressStart: (detail) { + onLongPressStart: (LongPressStartDetails detail) { feedBack(); _.setDoubleSpeedStatus(true); }, - onLongPressEnd: (details) { + onLongPressEnd: (LongPressEndDetails details) { _.setDoubleSpeedStatus(false); }, @@ -483,16 +661,17 @@ class _PLVideoPlayerState extends State if (_.videoType.value == 'live' || _.controlsLock.value) { return; } - final tapPosition = details.localPosition.dx; - int curSliderPosition = _.sliderPosition.value.inMilliseconds; - double scale = 90000 / MediaQuery.of(context).size.width; - Duration pos = Duration( + // final double tapPosition = details.localPosition.dx; + final int curSliderPosition = + _.sliderPosition.value.inMilliseconds; + final double scale = 90000 / MediaQuery.sizeOf(context).width; + final Duration pos = Duration( milliseconds: curSliderPosition + (details.delta.dx * scale).round()); - Duration result = pos.clamp(Duration.zero, _.duration.value); + final Duration result = + pos.clamp(Duration.zero, _.duration.value); _.onUpdatedSliderProgress(result); _.onChangedSliderStart(); - _initTapPositoin = tapPosition; }, onHorizontalDragEnd: (DragEndDetails details) { if (_.videoType.value == 'live' || _.controlsLock.value) { @@ -503,10 +682,11 @@ class _PLVideoPlayerState extends State }, // 垂直方向 音量/亮度调节 onVerticalDragUpdate: (DragUpdateDetails details) async { - final totalWidth = MediaQuery.of(context).size.width; - final tapPosition = details.localPosition.dx; - final sectionWidth = totalWidth / 3; - final delta = details.delta.dy; + final double totalWidth = MediaQuery.sizeOf(context).width; + final double tapPosition = details.localPosition.dx; + final double sectionWidth = totalWidth / 3; + final double delta = details.delta.dy; + /// 锁定时禁用 if (_.controlsLock.value) { return; @@ -518,42 +698,48 @@ class _PLVideoPlayerState extends State } if (tapPosition < sectionWidth) { // 左边区域 👈 - double level = (_.isFullScreen.value + final double level = (_.isFullScreen.value ? Get.size.height : screenWidth * 9 / 16) * 3; - final brightness = _ctr.brightnessValue.value - delta / level; - final result = brightness.clamp(0.0, 1.0); + final double brightness = + _brightnessValue.value - delta / level; + final double result = brightness.clamp(0.0, 1.0); setBrightness(result); } else if (tapPosition < sectionWidth * 2) { // 全屏 final double dy = details.delta.dy; const double threshold = 7.0; // 滑动阈值 - if (dy > _distance && dy > threshold) { - if (_.isFullScreen.value) { + final bool flag = + fullScreenGestureMode != FullScreenGestureMode.values.last; + if (dy > _distance.value && dy > threshold) { + if (_.isFullScreen.value ^ flag) { lastFullScreenToggleTime = DateTime.now(); // 下滑退出全屏 - await widget.controller.triggerFullScreen(status: false); + await widget.controller.triggerFullScreen(status: flag); } - _distance = 0.0; - } else if (dy < _distance && dy < -threshold) { - if (!_.isFullScreen.value) { + _distance.value = 0.0; + } else if (dy < _distance.value && dy < -threshold) { + if (!_.isFullScreen.value ^ flag) { lastFullScreenToggleTime = DateTime.now(); // 上滑进入全屏 - await widget.controller.triggerFullScreen(); + await widget.controller.triggerFullScreen(status: !flag); } - _distance = 0.0; + _distance.value = 0.0; } - _distance = dy; + _distance.value = dy; } else { // 右边区域 👈 - double level = (_.isFullScreen.value - ? Get.size.height - : screenWidth * 9 / 16) * - 3; - final volume = _ctr.volumeValue.value - delta / level; - final result = volume.clamp(0.0, 1.0); - setVolume(result); + EasyThrottle.throttle( + 'setVolume', const Duration(milliseconds: 20), () { + final double level = (_.isFullScreen.value + ? Get.size.height + : screenWidth * 9 / 16); + final double volume = _volumeValue.value - + double.parse(delta.toStringAsFixed(1)) / level; + final double result = volume.clamp(0.0, 1.0); + setVolume(result); + }); } }, onVerticalDragEnd: (DragEndDetails details) {}, @@ -569,7 +755,6 @@ class _PLVideoPlayerState extends State children: [ if (widget.headerControl != null || _.headerControl != null) ClipRect( - clipBehavior: Clip.hardEdge, child: AppBarAni( controller: animationController, visible: !_.controlsLock.value && _.showControls.value, @@ -579,16 +764,16 @@ class _PLVideoPlayerState extends State ), const Spacer(), ClipRect( - clipBehavior: Clip.hardEdge, child: AppBarAni( controller: animationController, visible: !_.controlsLock.value && _.showControls.value, position: 'bottom', child: widget.bottomControl ?? BottomControl( - controller: widget.controller, - triggerFullScreen: - widget.controller.triggerFullScreen), + controller: widget.controller, + triggerFullScreen: _.triggerFullScreen, + buildBottomControl: buildBottomControl(), + ), ), ), ], @@ -597,26 +782,34 @@ class _PLVideoPlayerState extends State ), /// 进度条 live模式下禁用 + Obx( () { final int value = _.sliderPositionSeconds.value; final int max = _.durationSeconds.value; final int buffer = _.bufferedSeconds.value; + if (_.showControls.value) { + return Container(); + } if (defaultBtmProgressBehavior == BtmProgresBehavior.alwaysHide.code) { - return Container(); + return const SizedBox(); } if (defaultBtmProgressBehavior == BtmProgresBehavior.onlyShowFullScreen.code && !_.isFullScreen.value) { - return Container(); + return const SizedBox(); + } else if (defaultBtmProgressBehavior == + BtmProgresBehavior.onlyHideFullScreen.code && + _.isFullScreen.value) { + return const SizedBox(); } if (_.videoType.value == 'live') { - return Container(); + return const SizedBox(); } if (value > max || max <= 0) { - return Container(); + return const SizedBox(); } return Positioned( bottom: -1.5, @@ -629,7 +822,7 @@ class _PLVideoPlayerState extends State progressBarColor: colorTheme, baseBarColor: Colors.white.withOpacity(0.2), bufferedBarColor: - Theme.of(context).colorScheme.primary.withOpacity(0.4), + Theme.of(context).colorScheme.primary.withOpacity(0.4), timeLabelLocation: TimeLabelLocation.none, thumbColor: colorTheme, barHeight: 3, @@ -665,7 +858,7 @@ class _PLVideoPlayerState extends State // 锁 Obx( () => Visibility( - visible: _.videoType.value != 'live', + visible: _.videoType.value != 'live' && _.isFullScreen.value, child: Align( alignment: Alignment.centerLeft, child: FractionalTranslation( @@ -696,7 +889,6 @@ class _PLVideoPlayerState extends State decoration: const BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( - center: Alignment.center, colors: [Colors.black26, Colors.transparent], ), ), @@ -707,46 +899,45 @@ class _PLVideoPlayerState extends State ), ); } else { - return Container(); + return const SizedBox(); } }), /// 点击 快进/快退 Obx( () => Visibility( - visible: _ctr.mountSeekBackwardButton.value || - _ctr.mountSeekForwardButton.value, + visible: + _mountSeekBackwardButton.value || _mountSeekForwardButton.value, child: Positioned.fill( child: Row( children: [ Expanded( - child: _ctr.mountSeekBackwardButton.value + child: _mountSeekBackwardButton.value ? TweenAnimationBuilder( tween: Tween( begin: 0.0, - end: - _ctr.hideSeekBackwardButton.value ? 0.0 : 1.0, + end: _hideSeekBackwardButton.value ? 0.0 : 1.0, ), duration: const Duration(milliseconds: 500), - builder: (context, value, child) => Opacity( + builder: (BuildContext context, double value, + Widget? child) => + Opacity( opacity: value, child: child, ), onEnd: () { - if (_ctr.hideSeekBackwardButton.value) { - _ctr.hideSeekBackwardButton.value = false; - _ctr.mountSeekBackwardButton.value = false; + if (_hideSeekBackwardButton.value) { + _hideSeekBackwardButton.value = false; + _mountSeekBackwardButton.value = false; } }, child: BackwardSeekIndicator( - onChanged: (value) { - // _seekBarDeltaValueNotifier.value = -value; - }, - onSubmitted: (value) { - _ctr.hideSeekBackwardButton.value = true; - Player player = + onChanged: (Duration value) => {}, + onSubmitted: (Duration value) { + _hideSeekBackwardButton.value = true; + final Player player = widget.controller.videoPlayerController!; - var result = player.state.position - value; + Duration result = player.state.position - value; result = result.clamp( Duration.zero, player.state.duration, @@ -760,36 +951,36 @@ class _PLVideoPlayerState extends State ), Expanded( child: SizedBox( - width: MediaQuery.of(context).size.width / 4, + width: MediaQuery.sizeOf(context).width / 4, ), ), Expanded( - child: _ctr.mountSeekForwardButton.value + child: _mountSeekForwardButton.value ? TweenAnimationBuilder( tween: Tween( begin: 0.0, - end: _ctr.hideSeekForwardButton.value ? 0.0 : 1.0, + end: _hideSeekForwardButton.value ? 0.0 : 1.0, ), duration: const Duration(milliseconds: 500), - builder: (context, value, child) => Opacity( + builder: (BuildContext context, double value, + Widget? child) => + Opacity( opacity: value, child: child, ), onEnd: () { - if (_ctr.hideSeekForwardButton.value) { - _ctr.hideSeekForwardButton.value = false; - _ctr.mountSeekForwardButton.value = false; + if (_hideSeekForwardButton.value) { + _hideSeekForwardButton.value = false; + _mountSeekForwardButton.value = false; } }, child: ForwardSeekIndicator( - onChanged: (value) { - // _seekBarDeltaValueNotifier.value = value; - }, - onSubmitted: (value) { - _ctr.hideSeekForwardButton.value = true; - Player player = + onChanged: (Duration value) => {}, + onSubmitted: (Duration value) { + _hideSeekForwardButton.value = true; + final Player player = widget.controller.videoPlayerController!; - var result = player.state.position + value; + Duration result = player.state.position + value; result = result.clamp( Duration.zero, player.state.duration, @@ -810,31 +1001,3 @@ class _PLVideoPlayerState extends State ); } } - -class PLVideoPlayerController extends GetxController { - RxBool mountSeekBackwardButton = false.obs; - RxBool mountSeekForwardButton = false.obs; - RxBool hideSeekBackwardButton = false.obs; - RxBool hideSeekForwardButton = false.obs; - - RxDouble brightnessValue = 0.0.obs; - RxBool brightnessIndicator = false.obs; - - RxDouble volumeValue = 0.0.obs; - RxBool volumeIndicator = false.obs; - - RxDouble distance = 0.0.obs; - // 初始手指落下位置 - RxDouble initTapPositoin = 0.0.obs; - - RxBool volumeInterceptEventStream = false.obs; - - // 双击快进 展示样式 - void onDoubleTapSeekForward() { - mountSeekForwardButton.value = true; - } - - void onDoubleTapSeekBackward() { - mountSeekBackwardButton.value = true; - } -} diff --git a/lib/plugin/pl_player/widgets/app_bar_ani.dart b/lib/plugin/pl_player/widgets/app_bar_ani.dart index 9a3af267..53eaad16 100644 --- a/lib/plugin/pl_player/widgets/app_bar_ani.dart +++ b/lib/plugin/pl_player/widgets/app_bar_ani.dart @@ -19,11 +19,11 @@ class AppBarAni extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - visible ? controller.reverse() : controller.forward(); + visible ? controller.forward() : controller.reverse(); return SlideTransition( position: Tween( - begin: Offset.zero, - end: Offset(0, position! == 'top' ? -1 : 1), + begin: Offset(0, position! == 'top' ? -1 : 1), + end: Offset.zero, ).animate(CurvedAnimation( parent: controller, curve: Curves.linear, diff --git a/lib/plugin/pl_player/widgets/bottom_control.dart b/lib/plugin/pl_player/widgets/bottom_control.dart index 1fd8aa65..ebb71b54 100644 --- a/lib/plugin/pl_player/widgets/bottom_control.dart +++ b/lib/plugin/pl_player/widgets/bottom_control.dart @@ -1,16 +1,20 @@ import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; +import 'package:nil/nil.dart'; import 'package:pilipala/plugin/pl_player/index.dart'; -import 'package:pilipala/plugin/pl_player/widgets/play_pause_btn.dart'; import 'package:pilipala/utils/feed_back.dart'; class BottomControl extends StatelessWidget implements PreferredSizeWidget { final PlPlayerController? controller; final Function? triggerFullScreen; - const BottomControl({this.controller, this.triggerFullScreen, Key? key}) - : super(key: key); + final List? buildBottomControl; + const BottomControl({ + this.controller, + this.triggerFullScreen, + this.buildBottomControl, + Key? key, + }) : super(key: key); @override Size get preferredSize => const Size(double.infinity, kToolbarHeight); @@ -19,11 +23,6 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { Color colorTheme = Theme.of(context).colorScheme.primary; final _ = controller!; - const textStyle = TextStyle( - color: Colors.white, - fontSize: 12, - ); - return Container( color: Colors.transparent, height: 90, @@ -37,7 +36,7 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { final int max = _.durationSeconds.value; final int buffer = _.bufferedSeconds.value; if (value > max || max <= 0) { - return Container(); + return nil; } return Padding( padding: const EdgeInsets.only(left: 7, right: 7, bottom: 6), @@ -70,82 +69,89 @@ class BottomControl extends StatelessWidget implements PreferredSizeWidget { }, ), Row( - children: [ - PlayOrPauseButton( - controller: _, - ), - const SizedBox(width: 4), - // 播放时间 - Obx(() { - return Text( - _.durationSeconds.value >= 3600 - ? printDurationWithHours(Duration(seconds: _.positionSeconds.value)) - : printDuration(Duration(seconds: _.positionSeconds.value)), - style: textStyle, - ); - }), - const SizedBox(width: 2), - const Text('/', style: textStyle), - const SizedBox(width: 2), - Obx( - () => Text( - _.durationSeconds.value >= 3600 - ? printDurationWithHours(Duration(seconds: _.durationSeconds.value)) - : printDuration(Duration(seconds: _.durationSeconds.value)), - style: textStyle, - ), - ), - const Spacer(), - // 倍速 - // Obx( - // () => SizedBox( - // width: 45, - // height: 34, - // child: TextButton( - // style: ButtonStyle( - // padding: MaterialStateProperty.all(EdgeInsets.zero), - // ), - // onPressed: () { - // _.togglePlaybackSpeed(); - // }, - // child: Text( - // '${_.playbackSpeed.toString()}X', - // style: textStyle, - // ), - // ), - // ), - // ), - SizedBox( - height: 30, - child: TextButton( - onPressed: () => _.toggleVideoFit(), - style: ButtonStyle( - padding: MaterialStateProperty.all(EdgeInsets.zero), - ), - child: Obx( - () => Text( - _.videoFitDEsc.value, - style: const TextStyle(color: Colors.white, fontSize: 13), - ), - ), - ), - ), - const SizedBox(width: 10), - // 全屏 - Obx( - () => ComBtn( - icon: Icon( - _.isFullScreen.value - ? FontAwesomeIcons.compress - : FontAwesomeIcons.expand, - size: 15, - color: Colors.white, - ), - fuc: () => triggerFullScreen!(), - ), - ), - ], + children: [...buildBottomControl!], ), + // Row( + // children: [ + // PlayOrPauseButton( + // controller: _, + // ), + // const SizedBox(width: 4), + // // 播放时间 + // Obx(() { + // return Text( + // _.durationSeconds.value >= 3600 + // ? printDurationWithHours( + // Duration(seconds: _.positionSeconds.value)) + // : printDuration( + // Duration(seconds: _.positionSeconds.value)), + // style: textStyle, + // ); + // }), + // const SizedBox(width: 2), + // const Text('/', style: textStyle), + // const SizedBox(width: 2), + // Obx( + // () => Text( + // _.durationSeconds.value >= 3600 + // ? printDurationWithHours( + // Duration(seconds: _.durationSeconds.value)) + // : printDuration( + // Duration(seconds: _.durationSeconds.value)), + // style: textStyle, + // ), + // ), + // const Spacer(), + // // 倍速 + // // Obx( + // // () => SizedBox( + // // width: 45, + // // height: 34, + // // child: TextButton( + // // style: ButtonStyle( + // // padding: MaterialStateProperty.all(EdgeInsets.zero), + // // ), + // // onPressed: () { + // // _.togglePlaybackSpeed(); + // // }, + // // child: Text( + // // '${_.playbackSpeed.toString()}X', + // // style: textStyle, + // // ), + // // ), + // // ), + // // ), + // SizedBox( + // height: 30, + // child: TextButton( + // onPressed: () => _.toggleVideoFit(), + // style: ButtonStyle( + // padding: MaterialStateProperty.all(EdgeInsets.zero), + // ), + // child: Obx( + // () => Text( + // _.videoFitDEsc.value, + // style: const TextStyle(color: Colors.white, fontSize: 13), + // ), + // ), + // ), + // ), + // const SizedBox(width: 10), + // // 全屏 + // Obx( + // () => ComBtn( + // icon: Icon( + // _.isFullScreen.value + // ? FontAwesomeIcons.compress + // : FontAwesomeIcons.expand, + // size: 15, + // color: Colors.white, + // ), + // fuc: () => triggerFullScreen!(), + // ), + // ), + // ], + // ), const SizedBox(height: 12), ], ), diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 9fa926ee..1f1ea31e 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -3,55 +3,61 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/pages/about/index.dart'; -import 'package:pilipala/pages/blacklist/index.dart'; -import 'package:pilipala/pages/dynamics/deatil/index.dart'; -import 'package:pilipala/pages/dynamics/index.dart'; -import 'package:pilipala/pages/fan/index.dart'; -import 'package:pilipala/pages/fav/index.dart'; -import 'package:pilipala/pages/favDetail/index.dart'; -import 'package:pilipala/pages/fav_search/index.dart'; -import 'package:pilipala/pages/follow/index.dart'; -import 'package:pilipala/pages/history/index.dart'; -import 'package:pilipala/pages/history_search/index.dart'; -import 'package:pilipala/pages/home/index.dart'; -import 'package:pilipala/pages/hot/index.dart'; -import 'package:pilipala/pages/html/index.dart'; -import 'package:pilipala/pages/later/index.dart'; -import 'package:pilipala/pages/liveRoom/view.dart'; -import 'package:pilipala/pages/login/index.dart'; -import 'package:pilipala/pages/member/index.dart'; -import 'package:pilipala/pages/member_archive/index.dart'; -import 'package:pilipala/pages/member_coin/index.dart'; -import 'package:pilipala/pages/member_dynamics/index.dart'; -import 'package:pilipala/pages/member_like/index.dart'; -import 'package:pilipala/pages/member_search/index.dart'; -import 'package:pilipala/pages/member_seasons/index.dart'; -import 'package:pilipala/pages/search/index.dart'; -import 'package:pilipala/pages/searchResult/index.dart'; -import 'package:pilipala/pages/setting/extra_setting.dart'; -import 'package:pilipala/pages/setting/pages/color_select.dart'; -import 'package:pilipala/pages/setting/pages/display_mode.dart'; -import 'package:pilipala/pages/setting/pages/font_size_select.dart'; -import 'package:pilipala/pages/setting/pages/play_speed_set.dart'; -import 'package:pilipala/pages/setting/play_setting.dart'; -import 'package:pilipala/pages/setting/privacy_setting.dart'; -import 'package:pilipala/pages/setting/style_setting.dart'; -import 'package:pilipala/pages/video/detail/index.dart'; -import 'package:pilipala/pages/video/detail/replyReply/index.dart'; -import 'package:pilipala/pages/webview/index.dart'; -import 'package:pilipala/pages/setting/index.dart'; -import 'package:pilipala/pages/media/index.dart'; -import 'package:pilipala/pages/whisper/index.dart'; -import 'package:pilipala/pages/whisperDetail/index.dart'; -import 'package:pilipala/utils/storage.dart'; +import 'package:pilipala/pages/follow_search/view.dart'; +import 'package:pilipala/pages/setting/pages/logs.dart'; -Box setting = GStrorage.setting; -bool iosTransition = - setting.get(SettingBoxKey.iosTransition, defaultValue: false); +import '../pages/about/index.dart'; +import '../pages/blacklist/index.dart'; +import '../pages/dynamics/detail/index.dart'; +import '../pages/dynamics/index.dart'; +import '../pages/fan/index.dart'; +import '../pages/fav/index.dart'; +import '../pages/fav_detail/index.dart'; +import '../pages/fav_search/index.dart'; +import '../pages/follow/index.dart'; +import '../pages/history/index.dart'; +import '../pages/history_search/index.dart'; +import '../pages/home/index.dart'; +import '../pages/hot/index.dart'; +import '../pages/html/index.dart'; +import '../pages/later/index.dart'; +import '../pages/live_room/view.dart'; +import '../pages/login/index.dart'; +import '../pages/media/index.dart'; +import '../pages/member/index.dart'; +import '../pages/member_archive/index.dart'; +import '../pages/member_coin/index.dart'; +import '../pages/member_dynamics/index.dart'; +import '../pages/member_like/index.dart'; +import '../pages/member_search/index.dart'; +import '../pages/member_seasons/index.dart'; +import '../pages/search/index.dart'; +import '../pages/search_result/index.dart'; +import '../pages/setting/extra_setting.dart'; +import '../pages/setting/index.dart'; +import '../pages/setting/pages/color_select.dart'; +import '../pages/setting/pages/display_mode.dart'; +import '../pages/setting/pages/font_size_select.dart'; +import '../pages/setting/pages/home_tabbar_set.dart'; +import '../pages/setting/pages/play_gesture_set.dart'; +import '../pages/setting/pages/play_speed_set.dart'; +import '../pages/setting/recommend_setting.dart'; +import '../pages/setting/play_setting.dart'; +import '../pages/setting/privacy_setting.dart'; +import '../pages/setting/style_setting.dart'; +import '../pages/subscription/index.dart'; +import '../pages/subscription_detail/index.dart'; +import '../pages/video/detail/index.dart'; +import '../pages/video/detail/reply_reply/index.dart'; +import '../pages/webview/index.dart'; +import '../pages/whisper/index.dart'; +import '../pages/whisper_detail/index.dart'; +import '../utils/storage.dart'; + +Box setting = GStrorage.setting; class Routes { - static final List getPages = [ + static final List> getPages = [ // 首页(推荐) CustomGetPage(name: '/', page: () => const HomePage()), // 热门 @@ -101,7 +107,9 @@ class Routes { // 二级回复 CustomGetPage( name: '/replyReply', page: () => const VideoReplyReplyPanel()), - + // 推荐设置 + CustomGetPage( + name: '/recommendSetting', page: () => const RecommendSetting()), // 播放设置 CustomGetPage(name: '/playSetting', page: () => const PlaySetting()), // 外观设置 @@ -113,6 +121,8 @@ class Routes { // CustomGetPage(name: '/blackListPage', page: () => const BlackListPage()), CustomGetPage(name: '/colorSetting', page: () => const ColorSelectPage()), + // 首页tabbar + CustomGetPage(name: '/tabbarSetting', page: () => const TabbarSetPage()), CustomGetPage( name: '/fontSizeSetting', page: () => const FontSizeSelectPage()), // 屏幕帧率 @@ -149,25 +159,32 @@ class Routes { // 用户专栏 CustomGetPage( name: '/memberSeasons', page: () => const MemberSeasonsPage()), + // 日志 + CustomGetPage(name: '/logs', page: () => const LogsPage()), + // 搜索关注 + CustomGetPage(name: '/followSearch', page: () => const FollowSearchPage()), + // 订阅 + CustomGetPage(name: '/subscription', page: () => const SubPage()), + // 订阅详情 + CustomGetPage(name: '/subDetail', page: () => const SubDetailPage()), + // 播放器手势 + CustomGetPage( + name: '/playerGestureSet', page: () => const PlayGesturePage()), ]; } -class CustomGetPage extends GetPage { - bool? fullscreen = false; - +class CustomGetPage extends GetPage { CustomGetPage({ - name, - page, + required super.name, + required super.page, this.fullscreen, - transitionDuration, + super.transitionDuration, }) : super( - name: name, - page: page, curve: Curves.linear, - transition: iosTransition ? Transition.cupertino : Transition.native, + transition: Transition.native, showCupertinoParallax: false, popGesture: false, - transitionDuration: transitionDuration, fullscreenDialog: fullscreen != null && fullscreen, ); + bool? fullscreen = false; } diff --git a/lib/services/audio_handler.dart b/lib/services/audio_handler.dart index 61b32b96..bf98298b 100644 --- a/lib/services/audio_handler.dart +++ b/lib/services/audio_handler.dart @@ -26,6 +26,7 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { static final List _item = []; Box setting = GStrorage.setting; bool enableBackgroundPlay = false; + PlPlayerController player = PlPlayerController.getInstance(); VideoPlayerServiceHandler() { revalidateSetting(); @@ -38,12 +39,12 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { @override Future play() async { - PlPlayerController.getInstance().play(); + player.play(); } @override Future pause() async { - PlPlayerController.getInstance().pause(); + player.pause(); } @override @@ -51,7 +52,7 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { playbackState.add(playbackState.value.copyWith( updatePosition: position, )); - await PlPlayerController.getInstance().seekTo(position); + await player.seekTo(position); } Future setMediaItem(MediaItem newMediaItem) async { @@ -147,7 +148,9 @@ class VideoPlayerServiceHandler extends BaseAudioHandler with SeekHandler { processingState: AudioProcessingState.idle, playing: false, )); - _item.removeLast(); + if (_item.isNotEmpty) { + _item.removeLast(); + } if (_item.isNotEmpty) { setMediaItem(_item.last); } diff --git a/lib/services/audio_session.dart b/lib/services/audio_session.dart index 98707652..ea83a30a 100644 --- a/lib/services/audio_session.dart +++ b/lib/services/audio_session.dart @@ -20,11 +20,15 @@ class AudioSessionHandler { session.interruptionEventStream.listen((event) { final player = PlPlayerController.getInstance(); if (event.begin) { + if (!player.playerStatus.playing) return; switch (event.type) { case AudioInterruptionType.duck: player.setVolume(player.volume.value * 0.5); break; case AudioInterruptionType.pause: + player.pause(isInterrupt: true); + _playInterrupted = true; + break; case AudioInterruptionType.unknown: player.pause(isInterrupt: true); _playInterrupted = true; @@ -36,7 +40,7 @@ class AudioSessionHandler { player.setVolume(player.volume.value * 2); break; case AudioInterruptionType.pause: - if (_playInterrupted) PlPlayerController.getInstance().play(); + if (_playInterrupted) player.play(); break; case AudioInterruptionType.unknown: break; @@ -47,7 +51,10 @@ class AudioSessionHandler { // 耳机拔出暂停 session.becomingNoisyEventStream.listen((_) { - PlPlayerController.getInstance().pause(); + final player = PlPlayerController.getInstance(); + if (player.playerStatus.playing) { + player.pause(); + } }); } } diff --git a/lib/services/disable_battery_opt.dart b/lib/services/disable_battery_opt.dart new file mode 100644 index 00000000..ae018977 --- /dev/null +++ b/lib/services/disable_battery_opt.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:disable_battery_optimization/disable_battery_optimization.dart'; +import 'package:pilipala/utils/storage.dart'; + +void DisableBatteryOpt() async { + if (!Platform.isAndroid) { + return; + } + // 本地缓存中读取 是否禁用了电池优化 默认未禁用 + bool isDisableBatteryOptLocal = + GStrorage.localCache.get('isDisableBatteryOptLocal', defaultValue: false); + if (!isDisableBatteryOptLocal) { + final isBatteryOptimizationDisabled = + await DisableBatteryOptimization.isBatteryOptimizationDisabled; + if (isBatteryOptimizationDisabled == false) { + final hasDisabled = await DisableBatteryOptimization + .showDisableBatteryOptimizationSettings(); + // 设置为已禁用 + GStrorage.localCache.put('isDisableBatteryOptLocal', hasDisabled == true); + } + } + + bool isManufacturerBatteryOptimizationDisabled = GStrorage.localCache + .get('isManufacturerBatteryOptimizationDisabled', defaultValue: false); + if (!isManufacturerBatteryOptimizationDisabled) { + final isManBatteryOptimizationDisabled = await DisableBatteryOptimization + .isManufacturerBatteryOptimizationDisabled; + if (isManBatteryOptimizationDisabled == false) { + final hasDisabled = await DisableBatteryOptimization + .showDisableManufacturerBatteryOptimizationSettings( + "当前设备可能有额外的电池优化", + "按照步骤操作以禁用电池优化,以保证应用在后台正常运行", + ); + // 设置为已禁用 + GStrorage.localCache.put( + 'isManufacturerBatteryOptimizationDisabled', hasDisabled == true); + } + } +} diff --git a/lib/services/loggeer.dart b/lib/services/loggeer.dart new file mode 100644 index 00000000..5555432c --- /dev/null +++ b/lib/services/loggeer.dart @@ -0,0 +1,56 @@ +// final _loggerFactory = + +import 'dart:io'; + +import 'package:logger/logger.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +final _loggerFactory = PiliLogger(); + +PiliLogger getLogger() { + return _loggerFactory; +} + +class PiliLogger extends Logger { + PiliLogger() : super(); + + @override + void log(Level level, dynamic message, + {Object? error, StackTrace? stackTrace, DateTime? time}) async { + if (level == Level.error) { + String dir = (await getApplicationDocumentsDirectory()).path; + // 创建logo文件 + final String filename = p.join(dir, ".pili_logs"); + // 添加至文件末尾 + await File(filename).writeAsString( + "**${DateTime.now()}** \n $message \n $stackTrace", + mode: FileMode.writeOnlyAppend, + ); + } + super.log(level, "$message", error: error, stackTrace: stackTrace); + } +} + +Future getLogsPath() async { + String dir = (await getApplicationDocumentsDirectory()).path; + final String filename = p.join(dir, ".pili_logs"); + final file = File(filename); + if (!await file.exists()) { + await file.create(); + } + return file; +} + +Future clearLogs() async { + String dir = (await getApplicationDocumentsDirectory()).path; + final String filename = p.join(dir, ".pili_logs"); + final file = File(filename); + try { + await file.writeAsString(''); + } catch (e) { + print('Error clearing file: $e'); + return false; + } + return true; +} diff --git a/lib/services/shutdown_timer_service.dart b/lib/services/shutdown_timer_service.dart new file mode 100644 index 00000000..aa9c5ceb --- /dev/null +++ b/lib/services/shutdown_timer_service.dart @@ -0,0 +1,140 @@ +// 定时关闭服务 +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; + +import '../plugin/pl_player/controller.dart'; + +class ShutdownTimerService { + static final ShutdownTimerService _instance = + ShutdownTimerService._internal(); + Timer? _shutdownTimer; + Timer? _autoCloseDialogTimer; + //定时退出 + int scheduledExitInMinutes = -1; + bool exitApp = false; + bool waitForPlayingCompleted = false; + bool isWaiting = false; + + factory ShutdownTimerService() => _instance; + + ShutdownTimerService._internal(); + + void startShutdownTimer() { + cancelShutdownTimer(); // Cancel any previous timer + if (scheduledExitInMinutes == -1) { + //使用toast提示用户已取消 + SmartDialog.showToast("取消定时关闭"); + return; + } + SmartDialog.showToast("设置 $scheduledExitInMinutes 分钟后定时关闭"); + _shutdownTimer = Timer(Duration(minutes: scheduledExitInMinutes), + () => _shutdownDecider()); + } + + void _showTimeUpButPauseDialog() { + SmartDialog.show( + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('定时关闭'), + content: const Text('时间到啦!'), + actions: [ + TextButton( + child: const Text('确认'), + onPressed: () { + cancelShutdownTimer(); + SmartDialog.dismiss(); + }, + ), + ], + ); + }, + ); + } + + void _showShutdownDialog() { + SmartDialog.show( + builder: (BuildContext dialogContext) { + // Start the 10-second timer to auto close the dialog + _autoCloseDialogTimer?.cancel(); + _autoCloseDialogTimer = Timer(const Duration(seconds: 10), () { + SmartDialog.dismiss();// Close the dialog + _executeShutdown(); + }); + return AlertDialog( + title: const Text('定时关闭'), + content: const Text('将在10秒后执行,是否需要取消?'), + actions: [ + TextButton( + child: const Text('取消关闭'), + onPressed: () { + _autoCloseDialogTimer?.cancel(); // Cancel the auto-close timer + cancelShutdownTimer(); // Cancel the shutdown timer + SmartDialog.dismiss(); // Close the dialog + }, + ), + ], + ); + }, + ).then((_) { + // Cleanup when the dialog is dismissed + _autoCloseDialogTimer?.cancel(); + }); + } + + void _shutdownDecider() { + if (exitApp && !waitForPlayingCompleted) { + _showShutdownDialog(); + return; + } + PlPlayerController plPlayerController = PlPlayerController.getInstance(); + if (!exitApp && !waitForPlayingCompleted) { + if (!plPlayerController.playerStatus.playing) { + //仅提示用户 + _showTimeUpButPauseDialog(); + } else { + _showShutdownDialog(); + } + return; + } + //waitForPlayingCompleted + if (!plPlayerController.playerStatus.playing) { + _showShutdownDialog(); + return; + } + SmartDialog.showToast("定时关闭时间已到,等待当前视频播放完成"); + //监听播放完成 + //该方法依赖耦合实现,不够优雅 + isWaiting = true; + } + void handleWaitingFinished(){ + if(isWaiting){ + _showShutdownDialog(); + isWaiting = false; + } + } + void _executeShutdown() { + if (exitApp) { + //退出app + exit(0); + } else { + //暂停播放 + PlPlayerController plPlayerController = PlPlayerController.getInstance(); + if (plPlayerController.playerStatus.playing) { + plPlayerController.pause(); + waitForPlayingCompleted = true; + SmartDialog.showToast("已暂停播放"); + } else { + SmartDialog.showToast("当前未播放"); + } + } + } + + void cancelShutdownTimer() { + isWaiting = false; + _shutdownTimer?.cancel(); + } +} + +final shutdownTimerService = ShutdownTimerService(); diff --git a/lib/utils/app_scheme.dart b/lib/utils/app_scheme.dart index 5ac64a97..bb9d556f 100644 --- a/lib/utils/app_scheme.dart +++ b/lib/utils/app_scheme.dart @@ -2,30 +2,30 @@ import 'package:appscheme/appscheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -import 'package:pilipala/http/search.dart'; -import 'package:pilipala/models/common/search_type.dart'; - +import '../http/search.dart'; +import '../models/common/search_type.dart'; import 'id_utils.dart'; +import 'url_utils.dart'; import 'utils.dart'; class PiliSchame { - static AppScheme appScheme = AppSchemeImpl.getInstance() as AppScheme; - static void init() async { + static AppScheme appScheme = AppSchemeImpl.getInstance()!; + static Future init() async { /// - SchemeEntity? value = await appScheme.getInitScheme(); + final SchemeEntity? value = await appScheme.getInitScheme(); if (value != null) { _routePush(value); } /// 完整链接进入 b23.无效 - appScheme.getLatestScheme().then((value) { + appScheme.getLatestScheme().then((SchemeEntity? value) { if (value != null) { _fullPathPush(value); } }); /// 注册从外部打开的Scheme监听信息 # - appScheme.registerSchemeListener().listen((event) { + appScheme.registerSchemeListener().listen((SchemeEntity? event) { if (event != null) { _routePush(event); } @@ -34,52 +34,67 @@ class PiliSchame { /// 路由跳转 static void _routePush(value) async { - String scheme = value.scheme; - String host = value.host; - String path = value.path; + final String scheme = value.scheme; + final String host = value.host; + final String path = value.path; if (scheme == 'bilibili') { - // bilibili://root if (host == 'root') { - Navigator.popUntil(Get.context!, (route) => route.isFirst); - } - - // bilibili://space/{uid} - else if (host == 'space') { - var mid = path.split('/').last; - Get.toNamed( + Navigator.popUntil( + Get.context!, (Route route) => route.isFirst); + } else if (host == 'space') { + final String mid = path.split('/').last; + Get.toNamed( '/member?mid=$mid', - arguments: {'face': null}, + arguments: {'face': null}, ); - } - - // bilibili://video/{aid} - else if (host == 'video') { - var pathQuery = path.split('/').last; - int aid = int.parse(pathQuery); - _videoPush(aid, null); - } - - // bilibili://live/{roomid} - else if (host == 'live') { - var roomId = path.split('/').last; - Get.toNamed('/liveRoom?roomid=$roomId', - arguments: {'liveItem': null, 'heroTag': roomId.toString()}); - } - - // bilibili://bangumi/season/${ssid} - else if (host == 'bangumi') { - if (path.startsWith('/season')) { - var seasonId = path.split('/').last; - _bangumiPush(int.parse(seasonId)); + } else if (host == 'video') { + String pathQuery = path.split('/').last; + final numericRegex = RegExp(r'^[0-9]+$'); + if (numericRegex.hasMatch(pathQuery)) { + pathQuery = 'AV$pathQuery'; } + Map map = IdUtils.matchAvorBv(input: pathQuery); + if (map.containsKey('AV')) { + _videoPush(map['AV'], null); + } else if (map.containsKey('BV')) { + _videoPush(null, map['BV']); + } else { + SmartDialog.showToast('投稿匹配失败'); + } + } else if (host == 'live') { + final String roomId = path.split('/').last; + Get.toNamed('/liveRoom?roomid=$roomId', + arguments: {'liveItem': null, 'heroTag': roomId}); + } else if (host == 'bangumi') { + if (path.startsWith('/season')) { + final String seasonId = path.split('/').last; + _bangumiPush(int.parse(seasonId), null); + } + } else if (host == 'opus') { + if (path.startsWith('/detail')) { + var opusId = path.split('/').last; + Get.toNamed( + '/webview', + parameters: { + 'url': 'https://www.bilibili.com/opus/$opusId', + 'type': 'url', + 'pageTitle': '', + }, + ); + } + } else if (host == 'search') { + Get.toNamed('/searchResult', parameters: {'keyword': ''}); } } + if (scheme == 'https') { + _fullPathPush(value); + } } // 投稿跳转 - static void _videoPush(int? aidVal, String? bvidVal) async { - SmartDialog.showLoading(msg: '获取中...'); + static Future _videoPush(int? aidVal, String? bvidVal) async { + SmartDialog.showLoading(msg: '获取中...'); try { int? aid = aidVal; String? bvid = bvidVal; @@ -89,79 +104,122 @@ class PiliSchame { if (bvidVal == null) { bvid = IdUtils.av2bv(aidVal!); } - int cid = await SearchHttp.ab2c(bvid: bvidVal, aid: aidVal); - String heroTag = Utils.makeHeroTag(aid); - SmartDialog.dismiss().then( - (e) => Get.toNamed('/video?bvid=$bvid&cid=$cid', arguments: { - 'pic': null, - 'heroTag': heroTag, - }), + final int cid = await SearchHttp.ab2c(bvid: bvidVal, aid: aidVal); + final String heroTag = Utils.makeHeroTag(aid); + SmartDialog.dismiss().then( + // ignore: always_specify_types + (e) => Get.toNamed('/video?bvid=$bvid&cid=$cid', + arguments: { + 'pic': null, + 'heroTag': heroTag, + }), ); } catch (e) { - SmartDialog.showToast('video获取失败:${e.toString()}'); + SmartDialog.showToast('video获取失败: $e'); } } // 番剧跳转 - static void _bangumiPush(int seasonId) async { - SmartDialog.showLoading(msg: '获取中...'); + static Future _bangumiPush(int? seasonId, int? epId) async { + SmartDialog.showLoading(msg: '获取中...'); try { - var result = await SearchHttp.bangumiInfo(seasonId: seasonId, epId: null); + var result = await SearchHttp.bangumiInfo(seasonId: seasonId, epId: epId); if (result['status']) { var bangumiDetail = result['data']; - int cid = bangumiDetail.episodes!.first.cid; - String bvid = IdUtils.av2bv(bangumiDetail.episodes!.first.aid); - String heroTag = Utils.makeHeroTag(cid); + final int cid = bangumiDetail.episodes!.first.cid; + final String bvid = IdUtils.av2bv(bangumiDetail.episodes!.first.aid); + final String heroTag = Utils.makeHeroTag(cid); var epId = bangumiDetail.episodes!.first.id; SmartDialog.dismiss().then( (e) => Get.toNamed( '/video?bvid=$bvid&cid=$cid&epId=$epId', - arguments: { + arguments: { 'pic': bangumiDetail.cover, 'heroTag': heroTag, 'videoType': SearchType.media_bangumi, }, ), ); + } else { + SmartDialog.showToast(result['msg']); } } catch (e) { - SmartDialog.showToast('番剧获取失败:${e.toString()}'); + SmartDialog.showToast('番剧获取失败:$e'); } } - static void _fullPathPush(value) async { + static Future _fullPathPush(SchemeEntity value) async { // https://m.bilibili.com/bangumi/play/ss39708 // https | m.bilibili.com | /bangumi/play/ss39708 - String scheme = value.scheme!; - String host = value.host!; - String? path = value.path; - // Map query = value.query!; - if (host.startsWith('live.bilibili')) { + // final String scheme = value.scheme!; + final String host = value.host!; + final String? path = value.path; + Map? query = value.query; + RegExp regExp = RegExp(r'^(www\.)?m?\.(bilibili\.com)$'); + if (regExp.hasMatch(host)) { + print('bilibili.com'); + } else if (host.contains('live')) { int roomId = int.parse(path!.split('/').last); - // print('直播'); - Get.toNamed('/liveRoom?roomid=$roomId', - arguments: {'liveItem': null, 'heroTag': roomId.toString()}); - return; - } - if (host.startsWith('space.bilibili')) { - print('个人空间'); + Get.toNamed( + '/liveRoom?roomid=$roomId', + arguments: {'liveItem': null, 'heroTag': roomId.toString()}, + ); + } else if (host.contains('space')) { + var mid = path!.split('/').last; + Get.toNamed('/member?mid=$mid', arguments: {'face': ''}); return; + } else if (host == 'b23.tv') { + final String fullPath = 'https://$host$path'; + final String redirectUrl = await UrlUtils.parseRedirectUrl(fullPath); + final String pathSegment = Uri.parse(redirectUrl).path; + final String lastPathSegment = pathSegment.split('/').last; + final RegExp avRegex = RegExp(r'^[aA][vV]\d+', caseSensitive: false); + if (avRegex.hasMatch(lastPathSegment)) { + final Map map = + IdUtils.matchAvorBv(input: lastPathSegment); + if (map.containsKey('AV')) { + _videoPush(map['AV']! as int, null); + } else if (map.containsKey('BV')) { + _videoPush(null, map['BV'] as String); + } else { + SmartDialog.showToast('投稿匹配失败'); + } + } else if (lastPathSegment.startsWith('ep')) { + _handleEpisodePath(lastPathSegment, redirectUrl); + } else if (lastPathSegment.startsWith('ss')) { + _handleSeasonPath(lastPathSegment, redirectUrl); + } else if (lastPathSegment.startsWith('BV')) { + UrlUtils.matchUrlPush( + lastPathSegment, + '', + redirectUrl, + ); + } else { + Get.toNamed( + '/webview', + parameters: {'url': redirectUrl, 'type': 'url', 'pageTitle': ''}, + ); + } } + if (path != null) { - String area = path.split('/')[1]; + final String area = path.split('/').last; switch (area) { case 'bangumi': - // print('番剧'); - String seasonId = path.split('/').last; - _bangumiPush(matchNum(seasonId).first); + print('番剧'); + if (area.startsWith('ep')) { + _bangumiPush(null, matchNum(area).first); + } else if (area.startsWith('ss')) { + _bangumiPush(matchNum(area).first, null); + } break; case 'video': - // print('投稿'); - Map map = IdUtils.matchAvorBv(input: path); + print('投稿'); + final Map map = IdUtils.matchAvorBv(input: path); if (map.containsKey('AV')) { - _videoPush(map['AV'], null); + _videoPush(map['AV']! as int, null); } else if (map.containsKey('BV')) { - _videoPush(null, map['BV']); + _videoPush(null, map['BV'] as String); } else { SmartDialog.showToast('投稿匹配失败'); } @@ -171,15 +229,30 @@ class PiliSchame { break; case 'space': print('个人空间'); + Get.toNamed('/member?mid=$area', arguments: {'face': ''}); break; } } } static List matchNum(String str) { - RegExp regExp = RegExp(r'\d+'); - Iterable matches = regExp.allMatches(str); + final RegExp regExp = RegExp(r'\d+'); + final Iterable matches = regExp.allMatches(str); - return matches.map((match) => int.parse(match.group(0)!)).toList(); + return matches.map((Match match) => int.parse(match.group(0)!)).toList(); + } + + static void _handleEpisodePath(String lastPathSegment, String redirectUrl) { + final String seasonId = _extractIdFromPath(lastPathSegment); + _bangumiPush(null, matchNum(seasonId).first); + } + + static void _handleSeasonPath(String lastPathSegment, String redirectUrl) { + final String seasonId = _extractIdFromPath(lastPathSegment); + _bangumiPush(matchNum(seasonId).first, null); + } + + static String _extractIdFromPath(String lastPathSegment) { + return lastPathSegment.split('/').last; } } diff --git a/lib/utils/cache_manage.dart b/lib/utils/cache_manage.dart new file mode 100644 index 00000000..d6bbb816 --- /dev/null +++ b/lib/utils/cache_manage.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; + +class CacheManage { + CacheManage._internal(); + + static final CacheManage cacheManage = CacheManage._internal(); + + factory CacheManage() => cacheManage; + + // 获取缓存目录 + Future loadApplicationCache() async { + /// clear all of image in memory + // clearMemoryImageCache(); + /// get ImageCache + // var res = getMemoryImageCache(); + + // 缓存大小 + double cacheSize = 0; + // cached_network_image directory + Directory tempDirectory = await getTemporaryDirectory(); + // get_storage directory + Directory docDirectory = await getApplicationDocumentsDirectory(); + + // 获取缓存大小 + if (tempDirectory.existsSync()) { + double value = await getTotalSizeOfFilesInDir(tempDirectory); + cacheSize += value; + } + + /// 获取缓存大小 dioCache + if (docDirectory.existsSync()) { + double value = 0; + String dioCacheFileName = + '${docDirectory.path}${Platform.pathSeparator}DioCache.db'; + var dioCacheFile = File(dioCacheFileName); + if (dioCacheFile.existsSync()) { + value = await getTotalSizeOfFilesInDir(dioCacheFile); + } + cacheSize += value; + } + + return formatSize(cacheSize); + } + + // 循环计算文件的大小(递归) + Future getTotalSizeOfFilesInDir(final FileSystemEntity file) async { + if (file is File) { + int length = await file.length(); + return double.parse(length.toString()); + } + if (file is Directory) { + final List children = file.listSync(); + double total = 0; + for (final FileSystemEntity child in children) { + total += await getTotalSizeOfFilesInDir(child); + } + return total; + } + return 0; + } + + // 缓存大小格式转换 + String formatSize(double value) { + List unitArr = ['B', 'K', 'M', 'G']; + int index = 0; + while (value > 1024) { + index++; + value = value / 1024; + } + String size = value.toStringAsFixed(2); + return size + unitArr[index]; + } + + // 清除缓存 + Future clearCacheAll() async { + bool cleanStatus = await SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: const Text('该操作将清除图片及网络请求缓存数据,确认清除?'), + actions: [ + TextButton( + onPressed: (() => {SmartDialog.dismiss()}), + child: Text( + '取消', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () async { + SmartDialog.dismiss(); + SmartDialog.showLoading(msg: '正在清除...'); + try { + // 清除缓存 图片缓存 + await clearLibraryCache(); + SmartDialog.dismiss().then((res) { + SmartDialog.showToast('清除完成'); + }); + } catch (err) { + SmartDialog.dismiss(); + SmartDialog.showToast(err.toString()); + } + }, + child: const Text('确认'), + ) + ], + ); + }, + ).then((res) { + return true; + }); + return cleanStatus; + } + + /// 清除 Documents 目录下的 DioCache.db + Future clearApplicationCache() async { + Directory directory = await getApplicationDocumentsDirectory(); + if (directory.existsSync()) { + String dioCacheFileName = + '${directory.path}${Platform.pathSeparator}DioCache.db'; + var dioCacheFile = File(dioCacheFileName); + if (dioCacheFile.existsSync()) { + dioCacheFile.delete(); + } + } + } + + // 清除 Library/Caches 目录及文件缓存 + Future clearLibraryCache() async { + var appDocDir = await getTemporaryDirectory(); + if (appDocDir.existsSync()) { + await appDocDir.delete(recursive: true); + } + } + + /// 递归方式删除目录及文件 + Future deleteDirectory(FileSystemEntity file) async { + if (file is Directory) { + final List children = file.listSync(); + for (final FileSystemEntity child in children) { + await deleteDirectory(child); + } + } + await file.delete(); + } +} diff --git a/lib/utils/cookie.dart b/lib/utils/cookie.dart index 5d4d9cbd..31284c03 100644 --- a/lib/utils/cookie.dart +++ b/lib/utils/cookie.dart @@ -11,9 +11,9 @@ class SetCookie { cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; '); Request.dio.options.headers['cookie'] = cookieString; - cookies = await WebviewCookieManager().getCookies(HttpString.baseApiUrl); + cookies = await WebviewCookieManager().getCookies(HttpString.apiBaseUrl); await Request.cookieManager.cookieJar - .saveFromResponse(Uri.parse(HttpString.baseApiUrl), cookies); + .saveFromResponse(Uri.parse(HttpString.apiBaseUrl), cookies); cookies = await WebviewCookieManager().getCookies(HttpString.tUrl); await Request.cookieManager.cookieJar diff --git a/lib/utils/download.dart b/lib/utils/download.dart index ad008f6d..a9c56ec0 100644 --- a/lib/utils/download.dart +++ b/lib/utils/download.dart @@ -1,40 +1,94 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:saver_gallery/saver_gallery.dart'; class DownloadUtils { // 获取存储权限 - static requestStoragePer() async { - Map statuses = await [ - Permission.storage, - Permission.photos, - ].request(); - statuses[Permission.storage].toString(); + static Future requestStoragePer() async { + await Permission.storage.request(); + PermissionStatus status = await Permission.storage.status; + if (status == PermissionStatus.denied || + status == PermissionStatus.permanentlyDenied) { + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: const Text('存储权限未授权'), + actions: [ + TextButton( + onPressed: () async { + openAppSettings(); + }, + child: const Text('去授权'), + ) + ], + ); + }, + ); + return false; + } else { + return true; + } + } + + // 获取相册权限 + static Future requestPhotoPer() async { + await Permission.photos.request(); + PermissionStatus status = await Permission.photos.status; + if (status == PermissionStatus.denied || + status == PermissionStatus.permanentlyDenied) { + SmartDialog.show( + useSystem: true, + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('提示'), + content: const Text('相册权限未授权'), + actions: [ + TextButton( + onPressed: () async { + openAppSettings(); + }, + child: const Text('去授权'), + ) + ], + ); + }, + ); + return false; + } else { + return true; + } } static Future downloadImg(String imgUrl, {String imgType = 'cover'}) async { try { - await requestStoragePer(); + if (!await requestPhotoPer()) { + return false; + } SmartDialog.showLoading(msg: '保存中'); var response = await Dio() .get(imgUrl, options: Options(responseType: ResponseType.bytes)); + final String imgSuffix = imgUrl.split('.').last; String picName = - "plpl_${imgType}_${DateTime.now().toString().split('-').join()}"; + "plpl_${imgType}_${DateTime.now().toString().replaceAll(RegExp(r'[- :]'), '').split('.').first}"; final SaveResult result = await SaverGallery.saveImage( Uint8List.fromList(response.data), - quality: 60, - name: picName, + name: '$picName.$imgSuffix', // 保存到 PiliPala文件夹 androidRelativePath: "Pictures/PiliPala", androidExistNotSave: false, ); SmartDialog.dismiss(); if (result.isSuccess) { - await SmartDialog.showToast('「$picName」已保存 '); + await SmartDialog.showToast('「${'$picName.$imgSuffix'}」已保存 '); } return true; } catch (err) { diff --git a/lib/utils/em.dart b/lib/utils/em.dart index 733f5c35..2c5af8ba 100644 --- a/lib/utils/em.dart +++ b/lib/utils/em.dart @@ -19,15 +19,7 @@ class Em { return regCate(matchStr); }, onNonMatch: (String str) { if (str != '') { - str = str - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll(' ', " ") - .replaceAll('&', "&"); + str = decodeHtmlEntities(str); Map map = {'type': 'text', 'text': str}; res.add(map); } @@ -35,4 +27,17 @@ class Em { }); return res; } + + static String decodeHtmlEntities(String title) { + return title + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(' ', " ") + .replaceAll('&', "&") + .replaceAll(''', "'"); + } } diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart new file mode 100644 index 00000000..1b54c628 --- /dev/null +++ b/lib/utils/extension.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +extension ImageExtension on num { + int cacheSize(BuildContext context) { + return (this * MediaQuery.of(context).devicePixelRatio).round(); + } +} diff --git a/lib/utils/feed_back.dart b/lib/utils/feed_back.dart index cb3a693c..b0f9f035 100644 --- a/lib/utils/feed_back.dart +++ b/lib/utils/feed_back.dart @@ -1,11 +1,12 @@ import 'package:flutter/services.dart'; import 'package:hive/hive.dart'; -import 'package:pilipala/utils/storage.dart'; +import 'storage.dart'; -Box setting = GStrorage.setting; +Box setting = GStrorage.setting; void feedBack() { // 设置中是否开启 - bool enable = setting.get(SettingBoxKey.feedBackEnable, defaultValue: false); + final bool enable = + setting.get(SettingBoxKey.feedBackEnable, defaultValue: false) as bool; if (enable) { HapticFeedback.lightImpact(); } diff --git a/lib/utils/global_data.dart b/lib/utils/global_data.dart new file mode 100644 index 00000000..ef3daf21 --- /dev/null +++ b/lib/utils/global_data.dart @@ -0,0 +1,17 @@ +import '../models/common/index.dart'; + +class GlobalData { + int imgQuality = 10; + FullScreenGestureMode fullScreenGestureMode = + FullScreenGestureMode.values.last; + bool enablePlayerControlAnimation = true; + + // 私有构造函数 + GlobalData._(); + + // 单例实例 + static final GlobalData _instance = GlobalData._(); + + // 获取全局实例 + factory GlobalData() => _instance; +} diff --git a/lib/utils/id_utils.dart b/lib/utils/id_utils.dart index 8e2e6d70..7fefc268 100644 --- a/lib/utils/id_utils.dart +++ b/lib/utils/id_utils.dart @@ -1,73 +1,106 @@ -// ignore_for_file: constant_identifier_names +// ignore_for_file: constant_identifier_names, non_constant_identifier_names -import 'dart:math'; - -import 'package:flutter/material.dart'; +import 'dart:convert'; class IdUtils { - static const String TABLE = - 'fZodR9XQDSUm21yCkr6zBqiveYah8bt4xsWpHnJE7jL5VG3guMTKNPAwcF'; - static const List S = [11, 10, 3, 8, 4, 6]; // 位置编码表 - static const int XOR = 177451812; // 固定异或值 - static const int ADD = 8728348608; // 固定加法值 - static const List r = [ - 'B', - 'V', - '1', - '', - '', - '4', - '', - '1', - '', - '7', - '', - '' - ]; + static final XOR_CODE = BigInt.parse('23442827791579'); + static final MASK_CODE = BigInt.parse('2251799813685247'); + static final MAX_AID = BigInt.one << (BigInt.from(51)).toInt(); + static final BASE = BigInt.from(58); + + static const data = + 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'; /// av转bv - static String av2bv(int av) { - int x_ = (av ^ XOR) + ADD; - List newR = []; - newR.addAll(r); - for (int i = 0; i < S.length; i++) { - newR[S[i]] = - TABLE.characters.elementAt((x_ / pow(58, i).toInt() % 58).toInt()); + static String av2bv(int aid) { + List bytes = [ + 'B', + 'V', + '1', + '0', + '0', + '0', + '0', + '0', + '0', + '0', + '0', + '0' + ]; + int bvIndex = bytes.length - 1; + BigInt tmp = (MAX_AID | BigInt.from(aid)) ^ XOR_CODE; + while (tmp > BigInt.zero) { + bytes[bvIndex] = data[(tmp % BASE).toInt()]; + tmp = tmp ~/ BASE; + bvIndex -= 1; } - return newR.join(); + String tmpSwap = bytes[3]; + bytes[3] = bytes[9]; + bytes[9] = tmpSwap; + + tmpSwap = bytes[4]; + bytes[4] = bytes[7]; + bytes[7] = tmpSwap; + + return bytes.join(); } - /// bv转bv - static int bv2av(String bv) { - int r = 0; - for (int i = 0; i < S.length; i++) { - r += (TABLE.indexOf(bv.characters.elementAt(S[i])).toInt()) * - pow(58, i).toInt(); - } - return (r - ADD) ^ XOR; + /// bv转av + static int bv2av(String bvid) { + List bvidArr = bvid.split(''); + final tmpValue = bvidArr[3]; + bvidArr[3] = bvidArr[9]; + bvidArr[9] = tmpValue; + + final tmpValue2 = bvidArr[4]; + bvidArr[4] = bvidArr[7]; + bvidArr[7] = tmpValue2; + + bvidArr.removeRange(0, 3); + BigInt tmp = bvidArr.fold(BigInt.zero, + (pre, bvidChar) => pre * BASE + BigInt.from(data.indexOf(bvidChar))); + return ((tmp & MASK_CODE) ^ XOR_CODE).toInt(); } // 匹配 - static Map matchAvorBv({String? input}) { - Map result = {}; - if (input == null || input == '') { + static Map matchAvorBv({String? input}) { + final Map result = {}; + if (input == null || input.isEmpty) { return result; } - RegExp bvRegex = RegExp(r'BV[0-9A-Za-z]{10}', caseSensitive: false); - RegExp avRegex = RegExp(r'AV\d+', caseSensitive: false); + final RegExp bvRegex = + RegExp(r'[bB][vV][0-9A-Za-z]{10}', caseSensitive: false); + final RegExp avRegex = RegExp(r'[aA][vV]\d+', caseSensitive: false); - Iterable bvMatches = bvRegex.allMatches(input); - Iterable avMatches = avRegex.allMatches(input); + final Iterable bvMatches = bvRegex.allMatches(input); + final Iterable avMatches = avRegex.allMatches(input); - List bvs = bvMatches.map((match) => match.group(0)!).toList(); - List avs = avMatches.map((match) => match.group(0)!).toList(); + final List bvs = + bvMatches.map((Match match) => match.group(0)!).toList(); + final List avs = + avMatches.map((Match match) => match.group(0)!).toList(); if (bvs.isNotEmpty) { result['BV'] = bvs[0].substring(0, 2).toUpperCase() + bvs[0].substring(2); } if (avs.isNotEmpty) { - result['AV'] = avs[0].substring(2); + result['AV'] = int.parse(avs[0].substring(2)); } return result; } + + // eid生成 + static String? genAuroraEid(int uid) { + if (uid == 0) { + return null; + } + String uidString = uid.toString(); + List resultBytes = List.generate( + uidString.length, + (i) => uidString.codeUnitAt(i) ^ "ad1va46a7lza".codeUnitAt(i % 12), + ); + String auroraEid = base64Url.encode(resultBytes); + auroraEid = auroraEid.replaceAll(RegExp(r'=*$', multiLine: true), ''); + return auroraEid; + } } diff --git a/lib/utils/recommend_filter.dart b/lib/utils/recommend_filter.dart new file mode 100644 index 00000000..113e2261 --- /dev/null +++ b/lib/utils/recommend_filter.dart @@ -0,0 +1,52 @@ +import 'dart:math'; + +import 'storage.dart'; + +class RecommendFilter { + // static late int filterUnfollowedRatio; + static late int minDurationForRcmd; + static late int minLikeRatioForRecommend; + static late bool exemptFilterForFollowed; + static late bool applyFilterToRelatedVideos; + RecommendFilter() { + update(); + } + + static void update() { + var setting = GStrorage.setting; + // filterUnfollowedRatio = + // setting.get(SettingBoxKey.filterUnfollowedRatio, defaultValue: 0); + minDurationForRcmd = + setting.get(SettingBoxKey.minDurationForRcmd, defaultValue: 0); + minLikeRatioForRecommend = + setting.get(SettingBoxKey.minLikeRatioForRecommend, defaultValue: 0); + exemptFilterForFollowed = + setting.get(SettingBoxKey.exemptFilterForFollowed, defaultValue: true); + applyFilterToRelatedVideos = setting + .get(SettingBoxKey.applyFilterToRelatedVideos, defaultValue: true); + } + + static bool filter(dynamic videoItem, {bool relatedVideos = false}) { + if (relatedVideos && !applyFilterToRelatedVideos) { + return false; + } + //由于相关视频中没有已关注标签,只能视为非关注视频 + if (!relatedVideos && + videoItem.isFollowed == 1 && + exemptFilterForFollowed) { + return false; + } + if (videoItem.duration > 0 && videoItem.duration < minDurationForRcmd) { + return true; + } + if (videoItem.stat.view is int && + videoItem.stat.view > -1 && + videoItem.stat.like is int && + videoItem.stat.like > -1 && + videoItem.stat.like * 100 < + minLikeRatioForRecommend * videoItem.stat.view) { + return true; + } + return false; + } +} diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 44cb162a..a82972e0 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -1,42 +1,35 @@ -// import 'package:hive/hive.dart'; +import 'dart:io'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:pilipala/models/home/rcmd/result.dart'; import 'package:pilipala/models/model_owner.dart'; import 'package:pilipala/models/search/hot.dart'; import 'package:pilipala/models/user/info.dart'; +import '../models/common/gesture_mode.dart'; +import 'global_data.dart'; class GStrorage { - static late final Box recVideo; - static late final Box userInfo; - static late final Box historyword; - static late final Box localCache; - static late final Box setting; - static late final Box video; + static late final Box userInfo; + static late final Box historyword; + static late final Box localCache; + static late final Box setting; + static late final Box video; static Future init() async { - final dir = await getApplicationSupportDirectory(); - final path = dir.path; + final Directory dir = await getApplicationSupportDirectory(); + final String path = dir.path; await Hive.initFlutter('$path/hive'); regAdapter(); - // 首页推荐视频 - recVideo = await Hive.openBox( - 'recVideo', - compactionStrategy: (entries, deletedEntries) { - return deletedEntries > 12; - }, - ); // 登录用户信息 userInfo = await Hive.openBox( 'userInfo', - compactionStrategy: (entries, deletedEntries) { + compactionStrategy: (int entries, int deletedEntries) { return deletedEntries > 2; }, ); // 本地缓存 localCache = await Hive.openBox( 'localCache', - compactionStrategy: (entries, deletedEntries) { + compactionStrategy: (int entries, int deletedEntries) { return deletedEntries > 4; }, ); @@ -45,17 +38,22 @@ class GStrorage { // 搜索历史 historyword = await Hive.openBox( 'historyWord', - compactionStrategy: (entries, deletedEntries) { + compactionStrategy: (int entries, int deletedEntries) { return deletedEntries > 10; }, ); + // 视频设置 + video = await Hive.openBox('video'); + GlobalData().imgQuality = + setting.get(SettingBoxKey.defaultPicQa, defaultValue: 10); // 设置全局变量 + GlobalData().fullScreenGestureMode = FullScreenGestureMode.values[ + setting.get(SettingBoxKey.fullScreenGestureMode, + defaultValue: FullScreenGestureMode.values.last.index) as int]; + GlobalData().enablePlayerControlAnimation = setting + .get(SettingBoxKey.enablePlayerControlAnimation, defaultValue: true); } - static regAdapter() { - Hive.registerAdapter(RecVideoItemAppModelAdapter()); - Hive.registerAdapter(RcmdReasonAdapter()); - Hive.registerAdapter(RcmdStatAdapter()); - Hive.registerAdapter(RcmdOwnerAdapter()); + static void regAdapter() { Hive.registerAdapter(OwnerAdapter()); Hive.registerAdapter(UserInfoDataAdapter()); Hive.registerAdapter(LevelInfoAdapter()); @@ -63,16 +61,9 @@ class GStrorage { Hive.registerAdapter(HotSearchItemAdapter()); } - static Future lazyInit() async { - // 视频设置 - video = await Hive.openBox('video'); - } - static Future close() async { // user.compact(); // user.close(); - recVideo.compact(); - recVideo.close(); userInfo.compact(); userInfo.close(); historyword.compact(); @@ -88,99 +79,122 @@ class GStrorage { class SettingBoxKey { /// 播放器 - static const String btmProgressBehavior = 'btmProgressBehavior'; - static const String defaultVideoSpeed = 'defaultVideoSpeed'; - static const String autoUpgradeEnable = 'autoUpgradeEnable'; - static const String feedBackEnable = 'feedBackEnable'; - static const String defaultVideoQa = 'defaultVideoQa'; - static const String defaultAudioQa = 'defaultAudioQa'; - static const String autoPlayEnable = 'autoPlayEnable'; - static const String fullScreenMode = 'fullScreenMode'; - static const String defaultDecode = 'defaultDecode'; - static const String danmakuEnable = 'danmakuEnable'; - static const String defaultPicQa = 'defaultPicQa'; - static const String enableHA = 'enableHA'; - static const String enableOnlineTotal = 'enableOnlineTotal'; - static const String enableAutoBrightness = 'enableAutoBrightness'; - static const String enableAutoEnter = 'enableAutoEnter'; - static const String enableAutoExit = 'enableAutoExit'; - static const String p1080 = 'p1080'; - static const String enableCDN = 'enableCDN'; - static const String autoPiP = 'autoPiP'; - static const String enableAutoLongPressSpeed = 'enableAutoLongPressSpeed'; + static const String btmProgressBehavior = 'btmProgressBehavior', + defaultVideoSpeed = 'defaultVideoSpeed', + autoUpgradeEnable = 'autoUpgradeEnable', + feedBackEnable = 'feedBackEnable', + defaultVideoQa = 'defaultVideoQa', + defaultLiveQa = 'defaultLiveQa', + defaultAudioQa = 'defaultAudioQa', + autoPlayEnable = 'autoPlayEnable', + fullScreenMode = 'fullScreenMode', + defaultDecode = 'defaultDecode', + danmakuEnable = 'danmakuEnable', + defaultToastOp = 'defaultToastOp', + defaultPicQa = 'defaultPicQa', + enableHA = 'enableHA', + enableOnlineTotal = 'enableOnlineTotal', + enableAutoBrightness = 'enableAutoBrightness', + enableAutoEnter = 'enableAutoEnter', + enableAutoExit = 'enableAutoExit', + p1080 = 'p1080', + enableCDN = 'enableCDN', + autoPiP = 'autoPiP', + enableAutoLongPressSpeed = 'enableAutoLongPressSpeed', + enablePlayerControlAnimation = 'enablePlayerControlAnimation', - // youtube 双击快进快退 - static const String enableQuickDouble = 'enableQuickDouble'; - static const String enableShowDanmaku = 'enableShowDanmaku'; - static const String enableBackgroundPlay = 'enableBackgroundPlay'; + // youtube 双击快进快退 + enableQuickDouble = 'enableQuickDouble', + enableShowDanmaku = 'enableShowDanmaku', + enableBackgroundPlay = 'enableBackgroundPlay', + fullScreenGestureMode = 'fullScreenGestureMode', - /// 隐私 - static const String blackMidsList = 'blackMidsList'; + /// 隐私 + blackMidsList = 'blackMidsList', - /// 其他 - static const String autoUpdate = 'autoUpdate'; - static const String replySortType = 'replySortType'; - static const String defaultDynamicType = 'defaultDynamicType'; - static const String enableHotKey = 'enableHotKey'; - static const String enableQuickFav = 'enableQuickFav'; - static const String enableWordRe = 'enableWordRe'; - static const String enableSearchWord = 'enableSearchWord'; - static const String enableRcmdDynamic = 'enableRcmdDynamic'; - static const String enableSaveLastData = 'enableSaveLastData'; - static const String enableSystemProxy = 'enableSystemProxy'; - static const String enableAi = 'enableAi'; + /// 推荐 + enableRcmdDynamic = 'enableRcmdDynamic', + defaultRcmdType = 'defaultRcmdType', + enableSaveLastData = 'enableSaveLastData', + minDurationForRcmd = 'minDurationForRcmd', + minLikeRatioForRecommend = 'minLikeRatioForRecommend', + exemptFilterForFollowed = 'exemptFilterForFollowed', + //filterUnfollowedRatio = 'filterUnfollowedRatio', + applyFilterToRelatedVideos = 'applyFilterToRelatedVideos', + + /// 其他 + autoUpdate = 'autoUpdate', + replySortType = 'replySortType', + defaultDynamicType = 'defaultDynamicType', + enableHotKey = 'enableHotKey', + enableQuickFav = 'enableQuickFav', + enableWordRe = 'enableWordRe', + enableSearchWord = 'enableSearchWord', + enableSystemProxy = 'enableSystemProxy', + enableAi = 'enableAi', + defaultHomePage = 'defaultHomePage', + enableRelatedVideo = 'enableRelatedVideo'; /// 外观 - static const String themeMode = 'themeMode'; - static const String defaultTextScale = 'textScale'; - static const String dynamicColor = 'dynamicColor'; // bool - static const String customColor = 'customColor'; // 自定义主题色 - static const String iosTransition = 'iosTransition'; // ios路由 - static const String enableSingleRow = 'enableSingleRow'; // 首页单列 - static const String displayMode = 'displayMode'; - static const String customRows = 'customRows'; // 自定义列 - static const String enableMYBar = 'enableMYBar'; - static const String hideSearchBar = 'hideSearchBar'; // 收起顶栏 - static const String hideTabBar = 'hideTabBar'; // 收起底栏 + static const String themeMode = 'themeMode', + defaultTextScale = 'textScale', + dynamicColor = 'dynamicColor', // bool + customColor = 'customColor', // 自定义主题色 + enableSingleRow = 'enableSingleRow', // 首页单列 + displayMode = 'displayMode', + customRows = 'customRows', // 自定义列 + enableMYBar = 'enableMYBar', + hideSearchBar = 'hideSearchBar', // 收起顶栏 + hideTabBar = 'hideTabBar', // 收起底栏 + tabbarSort = 'tabbarSort', // 首页tabbar + dynamicBadgeMode = 'dynamicBadgeMode', + enableGradientBg = 'enableGradientBg'; } class LocalCacheKey { // 历史记录暂停状态 默认false 记录 - static const String historyPause = 'historyPause'; - // access_key - static const String accessKey = 'accessKey'; + static const String historyPause = 'historyPause', + // access_key + accessKey = 'accessKey', - // - static const String wbiKeys = 'wbiKeys'; - static const String timeStamp = 'timeStamp'; + // + wbiKeys = 'wbiKeys', + timeStamp = 'timeStamp', - // 弹幕相关设置 屏蔽类型 显示区域 透明度 字体大小 弹幕时间 - static const String danmakuBlockType = 'danmakuBlockType'; - static const String danmakuShowArea = 'danmakuShowArea'; - static const String danmakuOpacity = 'danmakuOpacity'; - static const String danmakuFontScale = 'danmakuFontScale'; - static const String danmakuDuration = 'danmakuDuration'; + // 弹幕相关设置 屏蔽类型 显示区域 透明度 字体大小 弹幕时间 描边粗细 + danmakuBlockType = 'danmakuBlockType', + danmakuShowArea = 'danmakuShowArea', + danmakuOpacity = 'danmakuOpacity', + danmakuFontScale = 'danmakuFontScale', + danmakuDuration = 'danmakuDuration', + strokeWidth = 'strokeWidth', - // 代理host port - static const String systemProxyHost = 'systemProxyHost'; - static const String systemProxyPort = 'systemProxyPort'; + // 代理host port + systemProxyHost = 'systemProxyHost', + systemProxyPort = 'systemProxyPort'; + + static const String isDisableBatteryOptLocal = 'isDisableBatteryOptLocal', + isManufacturerBatteryOptimizationDisabled = + 'isManufacturerBatteryOptimizationDisabled'; } class VideoBoxKey { // 视频比例 - static const String videoFit = 'videoFit'; - // 亮度 - static const String videoBrightness = 'videoBrightness'; - // 倍速 - static const String videoSpeed = 'videoSpeed'; - // 播放顺序 - static const String playRepeat = 'playRepeat'; - // 默认倍速 - static const String playSpeedDefault = 'playSpeedDefault'; - // 默认长按倍速 - static const String longPressSpeedDefault = 'longPressSpeedDefault'; - // 自定义倍速集合 - static const String customSpeedsList = 'customSpeedsList'; - // 画面填充比例 - static const String cacheVideoFit = 'cacheVideoFit'; + static const String videoFit = 'videoFit', + // 亮度 + videoBrightness = 'videoBrightness', + // 倍速 + videoSpeed = 'videoSpeed', + // 播放顺序 + playRepeat = 'playRepeat', + // 系统预设倍速 + playSpeedSystem = 'playSpeedSystem', + // 默认倍速 + playSpeedDefault = 'playSpeedDefault', + // 默认长按倍速 + longPressSpeedDefault = 'longPressSpeedDefault', + // 自定义倍速集合 + customSpeedsList = 'customSpeedsList', + // 画面填充比例 + cacheVideoFit = 'cacheVideoFit'; } diff --git a/lib/utils/subtitle.dart b/lib/utils/subtitle.dart new file mode 100644 index 00000000..452be542 --- /dev/null +++ b/lib/utils/subtitle.dart @@ -0,0 +1,32 @@ +class SubTitleUtils { + // 格式整理 + static String convertToWebVTT(List jsonData) { + String webVTTContent = 'WEBVTT FILE\n\n'; + + for (int i = 0; i < jsonData.length; i++) { + final item = jsonData[i]; + double from = item['from'] as double; + double to = item['to'] as double; + int sid = (item['sid'] ?? 0) as int; + String content = item['content'] as String; + + webVTTContent += '$sid\n'; + webVTTContent += '${formatTime(from)} --> ${formatTime(to)}\n'; + webVTTContent += '$content\n\n'; + } + + return webVTTContent; + } + + static String formatTime(num seconds) { + final String h = (seconds / 3600).floor().toString().padLeft(2, '0'); + final String m = (seconds % 3600 / 60).floor().toString().padLeft(2, '0'); + final String s = (seconds % 60).floor().toString().padLeft(2, '0'); + final String ms = + (seconds * 1000 % 1000).floor().toString().padLeft(3, '0'); + if (h == '00') { + return "$m:$s.$ms"; + } + return "$h:$m:$s.$ms"; + } +} diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart new file mode 100644 index 00000000..cf0ef9e2 --- /dev/null +++ b/lib/utils/url_utils.dart @@ -0,0 +1,65 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; + +import '../http/search.dart'; +import 'id_utils.dart'; +import 'utils.dart'; + +class UrlUtils { + // 302重定向路由截取 + static Future parseRedirectUrl(String url) async { + late String redirectUrl; + final dio = Dio(); + dio.options.followRedirects = false; + dio.options.validateStatus = (status) { + return status == 200 || status == 301 || status == 302; + }; + try { + final response = await dio.get(url); + if (response.statusCode == 302) { + redirectUrl = response.headers['location']?.first as String; + if (redirectUrl.endsWith('/')) { + redirectUrl = redirectUrl.substring(0, redirectUrl.length - 1); + } + } else { + if (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + return url; + } + return redirectUrl; + } catch (err) { + return url; + } + } + + // 匹配url路由跳转 + static matchUrlPush( + String pathSegment, + String title, + String redirectUrl, + ) async { + final Map matchRes = IdUtils.matchAvorBv(input: pathSegment); + if (matchRes.containsKey('BV')) { + final String bv = matchRes['BV']; + final int cid = await SearchHttp.ab2c(bvid: bv); + final String heroTag = Utils.makeHeroTag(bv); + await Get.toNamed( + '/video?bvid=$bv&cid=$cid', + arguments: { + 'pic': '', + 'heroTag': heroTag, + }, + ); + } else { + await Get.toNamed( + '/webview', + parameters: { + 'url': redirectUrl, + 'type': 'url', + 'pageTitle': title, + }, + ); + } + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 25488d59..cb7cbf25 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -9,29 +9,36 @@ import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; -import 'package:get/get_utils/get_utils.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:pilipala/http/index.dart'; -import 'package:pilipala/models/github/latest.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../http/index.dart'; +import '../models/github/latest.dart'; class Utils { + static final Random random = Random(); + static Future getCookiePath() async { - Directory tempDir = await getApplicationSupportDirectory(); - String tempPath = "${tempDir.path}/.plpl/"; - Directory dir = Directory(tempPath); - bool b = await dir.exists(); + final Directory tempDir = await getApplicationSupportDirectory(); + final String tempPath = "${tempDir.path}/.plpl/"; + final Directory dir = Directory(tempPath); + final bool b = await dir.exists(); if (!b) { dir.createSync(recursive: true); } return tempPath; } - static String numFormat(int number) { - String res = (number / 10000).toString(); + static String numFormat(dynamic number) { + if (number == null) { + return '0'; + } + if (number is String) { + return number; + } + final String res = (number / 10000).toString(); if (int.parse(res.split('.')[0]) >= 1) { - return '${(number / 10000).toPrecision(1)}万'; + return '${(number / 10000).toStringAsFixed(1)}万'; } else { return number.toString(); } @@ -43,23 +50,49 @@ class Utils { return time; } if (time < 3600) { - int minute = time ~/ 60; - double res = time / 60; + if (time == 0) { + return '00:00'; + } + final int minute = time ~/ 60; + final double res = time / 60; if (minute != res) { return '${minute < 10 ? '0$minute' : minute}:${(time - minute * 60) < 10 ? '0${(time - minute * 60)}' : (time - minute * 60)}'; } else { return '$minute:00'; } } else { - int hour = time ~/ 3600; - String hourStr = hour < 10 ? '0$hour' : hour.toString(); + final int hour = time ~/ 3600; + final String hourStr = hour < 10 ? '0$hour' : hour.toString(); var a = timeFormat(time - hour * 3600); return '$hourStr:$a'; } } + // 完全相对时间显示 + static String formatTimestampToRelativeTime(timeStamp) { + var difference = DateTime.now() + .difference(DateTime.fromMillisecondsSinceEpoch(timeStamp * 1000)); + + if (difference.inDays > 365) { + return '${difference.inDays ~/ 365}年前'; + } else if (difference.inDays > 30) { + return '${difference.inDays ~/ 30}个月前'; + } else if (difference.inDays > 0) { + return '${difference.inDays}天前'; + } else if (difference.inHours > 0) { + return '${difference.inHours}小时前'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}分钟前'; + } else { + return '刚刚'; + } + } + // 时间显示,刚刚,x分钟前 static String dateFormat(timeStamp, {formatType = 'list'}) { + if (timeStamp == 0 || timeStamp == null || timeStamp == '') { + return ''; + } // 当前时间 int time = (DateTime.now().millisecondsSinceEpoch / 1000).round(); // 对比 @@ -76,6 +109,7 @@ class Utils { toInt: false, formatType: formatType); } + print('distance: $distance'); if (distance <= 60) { return '刚刚'; } else if (distance <= 3600) { @@ -155,7 +189,7 @@ class Utils { } static String makeHeroTag(v) { - return v.toString() + Random().nextInt(9999).toString(); + return v.toString() + random.nextInt(9999).toString(); } static int duration(String duration) { @@ -174,14 +208,16 @@ class Utils { static int findClosestNumber(int target, List numbers) { int minDiff = 127; - late int closestNumber; + int closestNumber = 0; // 初始化为0,表示没有找到比目标值小的整数 try { for (int number in numbers) { - int diff = (number - target).abs(); + if (number < target) { + int diff = target - number; // 计算目标值与当前整数的差值 - if (diff < minDiff) { - minDiff = diff; - closestNumber = number; + if (diff < minDiff) { + minDiff = diff; + closestNumber = number; + } } } } catch (_) {} @@ -209,6 +245,10 @@ class Utils { SmartDialog.dismiss(); var currentInfo = await PackageInfo.fromPlatform(); var result = await Request().get(Api.latestApp, extra: {'ua': 'mob'}); + if (result.data == null || result.data.isEmpty) { + SmartDialog.showToast('获取远程版本失败,请检查网络'); + return false; + } LatestDataModel data = LatestDataModel.fromJson(result.data); bool isUpdate = Utils.needUpdate(currentInfo.version, data.tagName!); if (isUpdate) { @@ -276,16 +316,18 @@ class Utils { // [arm64-v8a] String abi = androidInfo.supportedAbis.first; late String downloadUrl; - for (var i in data.assets) { - if (i.downloadUrl.contains(abi)) { - downloadUrl = i.downloadUrl; + if (data.assets.isNotEmpty) { + for (var i in data.assets) { + if (i.downloadUrl.contains(abi)) { + downloadUrl = i.downloadUrl; + } } + // 应用外下载 + launchUrl( + Uri.parse(downloadUrl), + mode: LaunchMode.externalApplication, + ); } - // 应用外下载 - launchUrl( - Uri.parse(downloadUrl), - mode: LaunchMode.externalApplication, - ); } } @@ -313,4 +355,14 @@ class Utils { return md5String; } + + static List generateRandomBytes(int minLength, int maxLength) { + return List.generate(random.nextInt(maxLength - minLength + 1), + (_) => random.nextInt(0x60) + 0x20); + } + + static String base64EncodeRandomString(int minLength, int maxLength) { + List randomBytes = generateRandomBytes(minLength, maxLength); + return base64.encode(randomBytes); + } } diff --git a/lib/utils/video_utils.dart b/lib/utils/video_utils.dart index 88faba3c..a4bdd027 100644 --- a/lib/utils/video_utils.dart +++ b/lib/utils/video_utils.dart @@ -1,5 +1,7 @@ import 'package:pilipala/models/video/play/url.dart'; +import '../models/live/room_info.dart'; + class VideoUtils { static String getCdnUrl(dynamic item) { var backupUrl = ""; @@ -12,13 +14,20 @@ class VideoUtils { } else if (item is AudioItem) { backupUrl = item.backupUrl ?? ""; videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); + } else if (item is CodecItem) { + backupUrl = (item.urlInfo?.first.host)! + + item.baseUrl! + + item.urlInfo!.first.extra!; + videoUrl = backupUrl.contains("http") ? backupUrl : (item.baseUrl ?? ""); } else { return ""; } /// issues #70 - if (videoUrl.contains(".mcdn.bilivideo") || - videoUrl.contains("/upgcxcode/")) { + if (videoUrl.contains(".mcdn.bilivideo")) { + videoUrl = + 'https://proxy-tf-all-ws.bilivideo.com/?url=${Uri.encodeComponent(videoUrl)}'; + } else if (videoUrl.contains("/upgcxcode/")) { //CDN列表 var cdnList = { 'ali': 'upos-sz-mirrorali.bilivideo.com', diff --git a/lib/utils/wbi_sign.dart b/lib/utils/wbi_sign.dart index 84065964..4f831f16 100644 --- a/lib/utils/wbi_sign.dart +++ b/lib/utils/wbi_sign.dart @@ -2,16 +2,15 @@ // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md // import md5 from 'md5' // import axios from 'axios' -import 'package:hive/hive.dart'; -import 'package:pilipala/http/index.dart'; -import 'package:crypto/crypto.dart'; import 'dart:convert'; - -import 'package:pilipala/utils/storage.dart'; +import 'package:crypto/crypto.dart'; +import 'package:hive/hive.dart'; +import '../http/index.dart'; +import 'storage.dart'; class WbiSign { - static Box localCache = GStrorage.localCache; - List mixinKeyEncTab = [ + static Box localCache = GStrorage.localCache; + final List mixinKeyEncTab = [ 46, 47, 18, @@ -78,7 +77,7 @@ class WbiSign { 52 ]; // 对 imgKey 和 subKey 进行字符顺序打乱编码 - String getMixinKey(orig) { + String getMixinKey(String orig) { String temp = ''; for (int i = 0; i < mixinKeyEncTab.length; i++) { temp += orig.split('')[mixinKeyEncTab[i]]; @@ -87,43 +86,45 @@ class WbiSign { } // 为请求参数进行 wbi 签名 - Map encWbi(params, imgKey, subKey) { - String mixinKey = getMixinKey(imgKey + subKey); - DateTime now = DateTime.now(); - int currTime = (now.millisecondsSinceEpoch / 1000).round(); - RegExp chrFilter = RegExp(r"[!\'\(\)*]"); - List query = []; - Map newParams = Map.from(params)..addAll({"wts": currTime}); // 添加 wts 字段 + Map encWbi( + Map params, String imgKey, String subKey) { + final String mixinKey = getMixinKey(imgKey + subKey); + final DateTime now = DateTime.now(); + final int currTime = (now.millisecondsSinceEpoch / 1000).round(); + final RegExp chrFilter = RegExp(r"[!\'\(\)*]"); + final List query = []; + final Map newParams = Map.from(params) + ..addAll({"wts": currTime}); // 添加 wts 字段 // 按照 key 重排参数 - List keys = newParams.keys.toList()..sort(); - for (var i in keys) { + final List keys = newParams.keys.toList()..sort(); + for (String i in keys) { query.add( '${Uri.encodeComponent(i)}=${Uri.encodeComponent(newParams[i].toString().replaceAll(chrFilter, ''))}'); } - String queryStr = query.join('&'); - String wbiSign = + final String queryStr = query.join('&'); + final String wbiSign = md5.convert(utf8.encode(queryStr + mixinKey)).toString(); // 计算 w_rid return {'wts': currTime.toString(), 'w_rid': wbiSign}; } // 获取最新的 img_key 和 sub_key 可以从缓存中获取 static Future> getWbiKeys() async { - DateTime nowDate = DateTime.now(); + final DateTime nowDate = DateTime.now(); if (localCache.get(LocalCacheKey.wbiKeys) != null && DateTime.fromMillisecondsSinceEpoch( - localCache.get(LocalCacheKey.timeStamp)) + localCache.get(LocalCacheKey.timeStamp) as int) .day == nowDate.day) { - Map cacheWbiKeys = localCache.get('wbiKeys'); + final Map cacheWbiKeys = localCache.get('wbiKeys'); return Map.from(cacheWbiKeys); } var resp = await Request().get('https://api.bilibili.com/x/web-interface/nav'); var jsonContent = resp.data['data']; - String imgUrl = jsonContent['wbi_img']['img_url']; - String subUrl = jsonContent['wbi_img']['sub_url']; - Map wbiKeys = { + final String imgUrl = jsonContent['wbi_img']['img_url']; + final String subUrl = jsonContent['wbi_img']['sub_url']; + final Map wbiKeys = { 'imgKey': imgUrl .substring(imgUrl.lastIndexOf('/') + 1, imgUrl.length) .split('.')[0], @@ -136,10 +137,10 @@ class WbiSign { return wbiKeys; } - makSign(Map params) async { + Future> makSign(Map params) async { // params 为需要加密的请求参数 - Map wbiKeys = await getWbiKeys(); - Map query = params + final Map wbiKeys = await getWbiKeys(); + final Map query = params ..addAll(encWbi(params, wbiKeys['imgKey'], wbiKeys['subKey'])); return query; } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3e5f82f7..8af2f922 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -24,7 +24,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) - ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FlutterVolumeControllerPlugin.register(with: registry.registrar(forPlugin: "FlutterVolumeControllerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index c16788c0..84556c06 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: "direct main" description: name: animations - sha256: "708e4b68c23228c264b038fe7003a2f5d01ce85fc64d8cae090e86b27fcea6c5" + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.10" + version: "2.0.11" appscheme: dependency: "direct main" description: @@ -101,10 +101,10 @@ packages: dependency: "direct main" description: name: audio_video_progress_bar - sha256: "3384875247cdbea748bd9ae8330631cd06a6cabfcda4945d45c9b406da92bc66" + sha256: ccc7d7b83d2a16c52d4a7fb332faabd1baa053fb0e4c16815aefd3945ab33b81 url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.1" + version: "2.0.2" auto_orientation: dependency: "direct main" description: @@ -157,10 +157,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" + sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" url: "https://pub.flutter-io.cn" source: hosted - version: "2.4.7" + version: "2.4.8" build_runner_core: dependency: transitive description: @@ -209,6 +209,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + catcher_2: + dependency: "direct main" + description: + name: catcher_2 + sha256: "9cf33d2befd10058374e5fc6177577fdd938d73d9c06810de81cf91311a7ce98" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" characters: dependency: transitive description: @@ -261,18 +269,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" + sha256: e9feae83b1849f61bad9f6f33ee00646e3410d54ce0821e02f262f9901dad3c9 url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.2" + version: "6.0.1" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: b6a56efe1e6675be240de39107281d4034b64ac23438026355b4234042a35adb url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.4" + version: "2.0.0" convert: dependency: transitive description: @@ -365,10 +373,10 @@ packages: dependency: "direct main" description: name: dio - sha256: "797e1e341c3dd2f69f2dad42564a6feff3bfb87187d05abb93b9609e6f1645c3" + sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8" url: "https://pub.flutter-io.cn" source: hosted - version: "5.4.0" + version: "5.4.1" dio_cookie_manager: dependency: "direct main" description: @@ -385,6 +393,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.4.0" + disable_battery_optimization: + dependency: "direct main" + description: + name: disable_battery_optimization + sha256: "6b2ba802f984af141faf1b6b5fb956d5ef01f9cd555597c35b9cc335a03185ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" dismissible_page: dependency: "direct main" description: @@ -397,10 +413,10 @@ packages: dependency: "direct main" description: name: dynamic_color - sha256: "8b8bd1d798bd393e11eddeaa8ae95b12ff028bf7d5998fc5d003488cd5f4ce2f" + sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d url: "https://pub.flutter-io.cn" source: hosted - version: "1.6.8" + version: "1.7.0" easy_debounce: dependency: "direct main" description: @@ -417,6 +433,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.0.3" + expandable: + dependency: "direct main" + description: + name: expandable + sha256: "9604d612d4d1146dafa96c6d8eec9c2ff0994658d6d09fed720ab788c7f5afc2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.1" extended_image: dependency: "direct main" description: @@ -453,10 +477,10 @@ packages: dependency: "direct main" description: name: extended_nested_scroll_view - sha256: "444a6f883e6e07effc7639e69a309e1fb491b6c19b095e9281714a51ace2b384" + sha256: "835580d40c2c62b448bd14adecd316acba469ba61f1510ef559d17668a85e777" url: "https://pub.flutter-io.cn" source: hosted - version: "6.1.2" + version: "6.2.1" fake_async: dependency: transitive description: @@ -492,10 +516,11 @@ packages: floating: dependency: "direct main" description: - name: floating - sha256: d9d563089e34fbd714ffdcdd2df447ec41b40c9226dacae6b4f78847aef8b991 - url: "https://pub.flutter-io.cn" - source: hosted + path: "." + ref: main + resolved-ref: "8e89669eb9341f9980265306e24ef96fdbd3fd08" + url: "https://github.com/guozhigq/floating.git" + source: git version: "2.0.1" flutter: dependency: "direct main" @@ -547,6 +572,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_mailer: + dependency: transitive + description: + name: flutter_mailer + sha256: "4fffaa35e911ff5ec2e5a4ebbca62c372e99a154eb3bb2c0bf79f09adf6ecf4c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -567,10 +600,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c + sha256: "7b4ca6cf3304575fe9c8ec64813c8d02ee41d2afe60bcfe0678bcb5375d596a2" url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.9" + version: "2.0.10+1" flutter_test: dependency: "direct dev" description: flutter @@ -589,6 +622,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: transitive + description: + name: fluttertoast + sha256: dfdde255317af381bfc1c486ed968d5a43a2ded9c931e87cbecd88767d6a71c1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.2.4" font_awesome_flutter: dependency: "direct main" description: @@ -781,6 +822,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: "6bbb9d6f7056729537a4309bda2e74e18e5d9f14302489cc1e93f33b3fe32cac" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2+1" logging: dependency: transitive description: @@ -789,6 +838,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.2.0" + mailer: + dependency: transitive + description: + name: mailer + sha256: "57f6dd1496699999a7bfd0aa6be0645384f477f4823e16d4321c40a434346382" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.1" matcher: dependency: transitive description: @@ -893,6 +950,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.4" + nil: + dependency: "direct main" + description: + name: nil + sha256: ef05770c48942876d843bf6a4822d35e5da0ff893a61f1d5ad96d15c4a659136 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" nm: dependency: transitive description: @@ -906,8 +971,8 @@ packages: description: path: "." ref: master - resolved-ref: "419a35a776f9784f07999c8f1f75eb26fd9fe90a" - url: "https://github.com/xiaoyaocz/flutter_ns_danmaku.git" + resolved-ref: d1cb3f0190ca67ec4d7fd372dac96f4f17a81a1a + url: "https://github.com/guozhigq/flutter_ns_danmaku.git" source: git version: "0.0.5" octo_image: @@ -943,7 +1008,7 @@ packages: source: hosted version: "2.0.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" @@ -1010,50 +1075,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "860c6b871c94c78e202dc69546d4d8fd84bd59faeb36f8fb9888668a53ff4f78" + sha256: "74e962b7fad7ff75959161bb2c0ad8fe7f2568ee82621c9c2660b751146bfe44" url: "https://pub.flutter-io.cn" source: hosted - version: "11.1.0" + version: "11.3.0" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "2f1bec180ee2f5665c22faada971a8f024761f632e93ddc23310487df52dcfa6" + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.1" + version: "12.0.5" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "1a816084338ada8d574b1cb48390e6e8b19305d5120fe3a37c98825bacc78306" + sha256: "92861b0f0c2443dd8898398c2baa4f1ae925109b5909ae4a17d0108a6a788932" url: "https://pub.flutter-io.cn" source: hosted - version: "9.2.0" + version: "9.4.2" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "11b762a8c123dced6461933a88ea1edbbe036078c3f9f41b08886e678e7864df" + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.0+2" + version: "0.1.1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: d87349312f7eaf6ce0adaf668daf700ac5b06af84338bd8b8574dfbd93ffe1a1 + sha256: "23dfba8447c076ab5be3dee9ceb66aad345c4a648f0cac292c77b1eb0e800b78" url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.2" + version: "4.2.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1e8640c1e39121128da6b816d236e714d2cf17fac5a105dd6acdd3403a628004" + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "0.2.1" petitparser: dependency: transitive description: @@ -1206,6 +1271,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.3.8" + sentry: + dependency: transitive + description: + name: sentry + sha256: "5686ed515bb620dc52b4ae99a6586fe720d443591183cf1f620ec5d1f0eec100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.15.0" share_plus: dependency: "direct main" description: @@ -1471,26 +1544,26 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "0f0c746dd2d6254a0057218ff980fc7f5670fd0fcf5e4db38a490d31eed4ad43" + sha256: "32c3c684e02f9bc0afb0ae0aa653337a2fe022e8ab064bcd7ffda27a74e288e3" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.9+1" + version: "1.1.11+1" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "0edf6d630d1bfd5589114138ed8fada3234deacc37966bec033d3047c29248b7" + sha256: c86987475f162fadff579e7320c7ddda04cd2fdeffbe1129227a85d9ac9e03da url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.9+1" + version: "1.1.11+1" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: d24333727332d9bd20990f1483af4e09abdb9b1fc7c3db940b56ab5c42790c26 + sha256: "12faff3f73b1741a36ca7e31b292ddeb629af819ca9efe9953b70bd63fc8cd81" url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.9+1" + version: "1.1.11+1" vector_math: dependency: transitive description: @@ -1575,10 +1648,10 @@ packages: dependency: "direct main" description: name: webview_flutter - sha256: "42393b4492e629aa3a88618530a4a00de8bb46e50e7b3993fedbfdc5352f0dbf" + sha256: d81b68e88cc353e546afb93fb38958e3717282c5ac6e5d3be4a4aef9fc3c1413 url: "https://pub.flutter-io.cn" source: hosted - version: "4.4.2" + version: "4.5.0" webview_flutter_android: dependency: transitive description: @@ -1599,10 +1672,10 @@ packages: dependency: transitive description: name: webview_flutter_wkwebview - sha256: accdaaa49a2aca2dc3c3230907988954cdd23fed0a19525d6c9789d380f4dc76 + sha256: "4d062ad505390ecef1c4bfb6001cd857a51e00912cc9dfb66edb1886a9ebd80c" url: "https://pub.flutter-io.cn" source: hosted - version: "3.9.4" + version: "3.10.2" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e8572cc4..ba5976eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,10 +16,10 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.14 +version: 1.0.21+1021 environment: - sdk: ">=2.19.6 <3.0.0" + sdk: ">=3.0.0 <4.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -36,39 +36,39 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.5 # 动态取色 - dynamic_color: ^1.6.8 + dynamic_color: ^1.7.0 get: ^4.6.5 # 网络 - dio: ^5.3.3 + dio: ^5.4.1 cookie_jar: ^4.0.8 dio_cookie_manager: ^3.1.1 - connectivity_plus: ^4.0.1 + connectivity_plus: ^6.0.1 dio_http2_adapter: ^2.3.1+1 # 图片 cached_network_image: ^3.3.0 extended_image: ^8.2.0 saver_gallery: ^3.0.1 - + # 存储 path_provider: ^2.1.1 hive: ^2.2.3 hive_flutter: ^1.1.0 # 设备信息 - device_info_plus: ^9.0.2 + device_info_plus: ^9.0.0 # 权限 - permission_handler: ^11.0.1 + permission_handler: ^11.3.0 # 分享 share_plus: ^7.0.2 # cookie 管理 webview_cookie_manager: ^2.0.6 # 浏览器 - webview_flutter: ^4.2.2 + webview_flutter: ^4.5.0 # 解决sliver滑动不同步 - extended_nested_scroll_view: ^6.1.2 + extended_nested_scroll_view: ^6.2.1 # 上拉加载 loading_more_list: ^6.0.0 # 下拉刷新 @@ -85,29 +85,29 @@ dependencies: encrypt: ^5.0.3 # 视频播放器 - media_kit: ^1.1.10 # Primary package. - media_kit_video: ^1.2.4 # For video rendering. + media_kit: ^1.1.10 # Primary package. + media_kit_video: ^1.2.4 # For video rendering. media_kit_libs_video: ^1.0.4 # 媒体通知 audio_service: ^0.18.12 audio_session: ^0.1.16 - + # 音量、亮度、屏幕控制 flutter_volume_controller: ^1.3.1 screen_brightness: ^0.2.2+1 wakelock_plus: ^1.1.1 universal_platform: ^1.0.0+1 # 进度条 - audio_video_progress_bar: ^2.0.1 + audio_video_progress_bar: ^2.0.2 auto_orientation: ^2.3.1 protobuf: ^3.0.0 - animations: ^2.0.8 - + animations: ^2.0.11 + # 获取appx信息 package_info_plus: ^4.1.0 url_launcher: ^6.1.14 - flutter_svg: ^2.0.7 + flutter_svg: ^2.0.10+1 # 防抖节流 easy_debounce: ^2.0.3 # 高帧率 @@ -116,15 +116,18 @@ dependencies: appscheme: ^1.0.8 # 弹幕 ns_danmaku: - git: - url: https://github.com/xiaoyaocz/flutter_ns_danmaku.git + git: + url: https://github.com/guozhigq/flutter_ns_danmaku.git ref: master # 状态栏图标控制 status_bar_control: ^3.2.1 # 代理 system_proxy: ^0.1.0 # pip - floating: ^2.0.1 + floating: + git: + url: https://github.com/guozhigq/floating.git + ref: main # html解析 html: ^0.15.4 # html渲染 @@ -133,8 +136,15 @@ dependencies: gt3_flutter_plugin: ^0.0.8 uuid: ^3.0.7 scrollable_positioned_list: ^0.3.8 + nil: ^1.1.1 + catcher_2: ^1.2.3 + logger: ^2.0.2+1 + path: 1.8.3 + # 电池优化 + disable_battery_optimization: ^1.1.1 + # 展开/收起 + expandable: ^5.0.1 - dev_dependencies: flutter_test: sdk: flutter @@ -151,7 +161,7 @@ dev_dependencies: # url: https://github.com/nvi9/flutter_launcher_icons.git # ref: e045d40 hive_generator: ^2.0.0 - build_runner: ^2.3.3 + build_runner: ^2.4.8 flutter_launcher_icons: android: true @@ -182,6 +192,8 @@ flutter: - assets/images/ - assets/images/lv/ - assets/images/logo/ + - assets/images/live/ + - assets/images/video/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware @@ -203,7 +215,6 @@ flutter: # - family: HarmonyOS # fonts: # - asset: assets/fonts/HarmonyOS_Sans_SC_Regular.ttf - - + # For details regarding fonts from package dependencies, # see https://flutter.dev/custom-fonts/#from-packages