diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22f14eb..ede5f88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,11 +6,11 @@ permissions: on: push: tags: - - 'v*' # 当推送 v 开头的tag时触发,如 v1.0.0 + - "v*" # 当推送 v 开头的tag时触发,如 v1.0.0 workflow_dispatch: env: - FLUTTER_VERSION: '3.27.0' + FLUTTER_VERSION: "3.41.1" jobs: build-android: @@ -19,19 +19,19 @@ jobs: steps: - uses: actions/checkout@v3 with: - fetch-depth: 0 # 获取完整的 git 历史 + fetch-depth: 0 # 获取完整的 git 历史 - name: Setup Java uses: actions/setup-java@v3 with: - distribution: 'zulu' - java-version: '17' + distribution: "zulu" + java-version: "21" - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - channel: 'stable' + channel: "stable" - name: Get dependencies run: flutter pub get @@ -66,13 +66,13 @@ jobs: steps: - uses: actions/checkout@v3 with: - fetch-depth: 0 # 获取完整的 git 历史 + fetch-depth: 0 # 获取完整的 git 历史 - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.27.0' - channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: "stable" - name: Get dependencies run: flutter pub get @@ -93,14 +93,45 @@ jobs: name: ios-build path: build/ios/iphoneos/app-release.ipa + build-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # 获取完整的 git 历史 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: "stable" + + - name: Get dependencies + run: flutter pub get + + - name: Build Windows + run: flutter build windows --release + + - name: Package Windows build + shell: pwsh + run: | + Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/windows-release.zip" -Force + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: build/windows/x64/runner/Release/windows-release.zip + upload: runs-on: ubuntu-latest - needs: [ build-android, build-ios ] + needs: [build-android, build-ios, build-windows] steps: - uses: actions/checkout@v3 with: - fetch-depth: 0 # 获取完整的 git 历史 + fetch-depth: 0 # 获取完整的 git 历史 - name: Download artifacts uses: actions/download-artifact@v4 @@ -141,35 +172,38 @@ jobs: draft: false body: | ## 🚧 Pre-release Version - + ### 📋 Release Information **Version:** ${{ github.ref_name }} **Previous Version:** ${{ steps.previoustag.outputs.tag }} **Build Environment:** Flutter ${{ env.FLUTTER_VERSION }} - + ### 📝 Changelog ${{ steps.commits.outputs.commits }} - + ### 📦 Distribution | File | Description | Purpose | |------|-------------|----------| | `.apk` | Android Package | Direct installation for testing | | `.aab` | Android App Bundle | Google Play Store deployment | - + | `.ipa` | iOS Package | iOS sideload/testing distribution | + | `.zip` | Windows Package | Windows desktop distribution | + ### 🔍 Additional Notes - This is a pre-release build intended for testing purposes - Features and functionality may not be fully stable - Not recommended for production use - + ### 📱 Compatibility - Minimum Android SDK: 21 (Android 5.0) - Target Android SDK: 33 (Android 13) - + > **Note:** Please report any issues or bugs through the GitHub issue tracker. files: | dist/flutter-apk/app-release.apk dist/bundle/release/app-release.aab dist/app-release.ipa + dist/windows-release.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index bd805d8..d651c53 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ app.*.map.json # 添加以下内容 **/android/key.properties **/android/app/upload-keystore.jks + +# mise +mise.local.toml diff --git a/README.md b/README.md index 27a899e..fd3dbf2 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,45 @@ # Yuro -[English](README_en.md) +[English](README_en.md) | [日本語](README_ja.md) -一个使用 Flutter 构建的 ASMR.ONE 客户端。 +Yuro 是一个使用 Flutter 构建的 ASMR.ONE 第三方客户端,聚焦于稳定播放、下载管理与多语言体验。 ## 项目概述 -Yuro 旨在通过精美的动画和现代化的用户界面,提供流畅愉悦的 ASMR 聆听体验。 +Yuro 致力于提供流畅、轻量且现代化的 ASMR 使用体验,在播放、缓存和文件处理上做了针对性优化。 -## 特性 +## 近期更新(主要为 `8882982` 之后) -- 稳定的后台播放,再也不用担心杀后台了 -- 精美的动画效果 -- 流畅的播放体验 -- 简洁的UI设计 -- 全方位的智能缓存机制 - - 图片智能缓存:优化封面加载速度,告别重复加载 - - 字幕本地缓存:实现快速字幕匹配与加载 - - 音频文件缓存:减少重复下载,节省流量开销 -- 为服务器减轻压力 - - 智能的缓存策略确保资源高效利用 - - 懒加载机制避免无效请求 - - 合理的缓存清理机制平衡本地存储 +- 新增多语言本地化(中文 / English / 日本語)与应用内语言切换 +- 增强作品详情本地化:标题、标签等内容可按语言显示 +- 实现文件下载能力:文件选择下载、下载目录设置、下载进度与历史记录 +- 新增文件预览(音频 / 图片 / 文本)及图片浏览导航 +- 支持作品评分、退出登录确认、在浏览器打开 DLsite +- 强化播放列表登录态处理,未登录时提供引导 +- 构建与发布流程增强:新增 Windows 构建支持,CI 使用 Flutter 3.41.1 + +## 核心特性 + +- 稳定后台播放与 Mini Player +- 智能缓存机制(封面、字幕、音频) +- 搜索、收藏夹与播放列表管理 +- 推荐内容与相关推荐浏览 +- 作品标记(想听 / 在听 / 听过等)与评分 +- 下载任务管理与进度跟踪 +- 多语言界面(中文、English、日本語) + +## 支持平台 + +- Android +- iOS(14.0+) +- Windows +- GitHub Actions 提供 Android / iOS / Windows 构建产物 + +## 开发要求 + +- Flutter 3.41.1(stable) +- Dart >= 3.2.3 < 4.0.0 +- CI Java 21 ## 开发准则 @@ -32,11 +50,13 @@ Yuro 旨在通过精美的动画和现代化的用户界面,提供流畅愉悦
 lib/
-├── core/                 # 核心功能
-├── data/                # 数据层
-├── domain/              # 领域层
-├── presentation/        # 表现层
-└── common/             # 通用功能
+├── core/                 # 音频、缓存、下载、国际化、依赖注入等核心能力
+├── data/                 # API、数据模型与仓库实现
+├── presentation/         # ViewModel、布局与展示逻辑
+├── screens/              # 页面级 Screen
+├── widgets/              # 可复用 UI 组件
+├── common/               # 常量、扩展与工具方法
+└── l10n/                 # 本地化资源(ARB)
 
## 开始使用 @@ -56,19 +76,10 @@ flutter pub get flutter run ``` -## 功能特性 - -- 现代化UI设计 -- 流畅的动画效果 -- ASMR 播放控制 -- 播放列表管理 -- 搜索功能 -- 收藏功能 - ## 贡献指南 在提交贡献之前,请阅读我们的[开发准则](docs/guidelines_zh.md)。 ## 许可证 -本项目采用 Creative Commons 非商业性使用-相同方式共享许可证 (CC BY-NC-SA) - 查看 [LICENSE](LICENSE) 文件了解详细信息。该许可证允许他人修改和分享您的作品,但禁止商业用途,要求保留署名,并要求对修改后的作品以相同的许可证发布。 +本项目采用 Creative Commons 非商业性使用-相同方式共享许可证 (CC BY-NC-SA)。详情请参阅 [LICENSE](LICENSE)。 diff --git a/README_en.md b/README_en.md index bb1a452..d26830d 100644 --- a/README_en.md +++ b/README_en.md @@ -1,12 +1,45 @@ -# ASMR One App +# Yuro -[中文说明](README.md) +[中文说明](README.md) | [日本語](README_ja.md) -A beautiful and modern ASMR player application built with Flutter. +Yuro is a Flutter-based third-party ASMR.ONE client focused on stable playback, download management, and multilingual usability. ## Project Overview -ASMR One App is designed to provide a smooth and enjoyable ASMR listening experience with beautiful animations and a modern user interface. +Yuro aims to provide a smooth, lightweight, and modern ASMR experience with practical optimizations around playback, caching, and file handling. + +## Recent Updates (mostly after `8882982`) + +- Added multilingual localization (Chinese / English / Japanese) and in-app language switching +- Improved localized metadata in detail views (titles, tags, and related labels) +- Added file downloads with file selection, configurable download directory, progress panel, and history +- Added file preview support (audio / image / text) with image navigation +- Added work rating, logout confirmation, and "open DLsite in browser" +- Improved playlist login-state handling with user guidance when authentication is required +- Enhanced build and release pipeline with Windows build support and Flutter 3.41.1 CI + +## Core Features + +- Stable background playback and Mini Player +- Smart caching for covers, subtitles, and audio files +- Search, favorites, and playlist management +- Recommendation views and related works browsing +- Work mark status (want/listening/listened/etc.) and rating +- Download task management with progress tracking +- Multilingual UI (Chinese, English, Japanese) + +## Supported Platforms + +- Android +- iOS (14.0+) +- Windows +- GitHub Actions builds Android / iOS / Windows artifacts + +## Development Requirements + +- Flutter 3.41.1 (stable) +- Dart >= 3.2.3 < 4.0.0 +- CI Java 21 ## Development Guidelines @@ -17,11 +50,13 @@ We maintain a comprehensive set of development guidelines to ensure code quality
 lib/
-├── core/                 # Core functionality
-├── data/                # Data layer
-├── domain/              # Domain layer
-├── presentation/        # Presentation layer
-└── common/             # Common functionality
+├── core/                 # Audio, cache, download, locale, DI, and other core modules
+├── data/                 # API layer, models, and repository implementations
+├── presentation/         # ViewModels, layouts, and presentation logic
+├── screens/              # Route-level screens
+├── widgets/              # Reusable UI components
+├── common/               # Shared constants, extensions, and utilities
+└── l10n/                 # Localization resources (ARB)
 
## Getting Started @@ -41,19 +76,10 @@ flutter pub get flutter run ``` -## Features - -- Modern UI design -- Smooth animations -- ASMR playback control -- Playlist management -- Search functionality -- Favorites collection - ## Contributing Please read our [Development Guidelines](docs/guidelines_en.md) before making a contribution. ## License -This project is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike License (CC BY-NC-SA) - see the [LICENSE](LICENSE) file for details. This license allows others to remix, tweak, and build upon your work non-commercially, as long as they credit you and license their new creations under the identical terms. \ No newline at end of file +This project is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike License (CC BY-NC-SA). See [LICENSE](LICENSE) for details. diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..a42cdba --- /dev/null +++ b/README_ja.md @@ -0,0 +1,85 @@ +# Yuro + +[中文说明](README.md) | [English](README_en.md) + +Yuro は Flutter で構築された ASMR.ONE のサードパーティクライアントで、安定した再生・ダウンロード管理・多言語対応に注力しています。 + +## プロジェクト概要 + +Yuro は、再生・キャッシュ・ファイル処理を最適化し、軽快でモダンな ASMR 体験を提供することを目的としています。 + +## 最近の更新(主に `8882982` 以降) + +- 多言語ローカライズ(中文 / English / 日本語)とアプリ内言語切り替えを追加 +- 詳細画面のローカライズを強化(作品タイトル、タグなど) +- ファイルダウンロード機能を追加(対象選択、保存先設定、進捗表示、履歴管理) +- ファイルプレビュー(音声 / 画像 / テキスト)と画像ナビゲーションに対応 +- 作品評価、ログアウト確認、DLsite をブラウザで開く機能を追加 +- プレイリストのログイン状態ハンドリングを改善し、未認証時の導線を追加 +- ビルド/リリース基盤を強化し、Windows ビルド対応と Flutter 3.41.1 CI を導入 + +## 主な機能 + +- 安定したバックグラウンド再生と Mini Player +- カバー画像・字幕・音声に対するスマートキャッシュ +- 検索、お気に入り、プレイリスト管理 +- レコメンド表示と関連作品の閲覧 +- 作品のマーク状態管理(聴きたい/聴いている/聴いた など)と評価 +- ダウンロードタスク管理と進捗トラッキング +- 多言語 UI(中文、English、日本語) + +## 対応プラットフォーム + +- Android +- iOS(14.0+) +- Windows +- GitHub Actions で Android / iOS / Windows 向け成果物をビルド + +## 開発要件 + +- Flutter 3.41.1(stable) +- Dart >= 3.2.3 < 4.0.0 +- CI Java 21 + +## 開発ガイドライン + +コード品質と一貫性を保つため、開発ガイドラインを整備しています: +- [Development Guidelines](docs/guidelines_en.md) + +## プロジェクト構成 + +
+lib/
+├── core/                 # 音声・キャッシュ・ダウンロード・ロケール・DI などのコア機能
+├── data/                 # API 層、データモデル、Repository 実装
+├── presentation/         # ViewModel、レイアウト、表示ロジック
+├── screens/              # 画面単位の Screen
+├── widgets/              # 再利用可能な UI コンポーネント
+├── common/               # 共通定数・拡張・ユーティリティ
+└── l10n/                 # ローカライズリソース(ARB)
+
+ +## はじめに + +1. リポジトリをクローン +```bash +git clone [repository-url] +``` + +2. 依存関係をインストール +```bash +flutter pub get +``` + +3. アプリを実行 +```bash +flutter run +``` + +## コントリビュート + +コントリビュート前に [Development Guidelines](docs/guidelines_en.md) を確認してください。 + +## ライセンス + +本プロジェクトは Creative Commons Attribution-NonCommercial-ShareAlike License (CC BY-NC-SA) のもとで公開されています。詳細は [LICENSE](LICENSE) を参照してください。 diff --git a/android/app/build.gradle b/android/app/build.gradle index d2a7e5b..e449cd0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -17,12 +17,12 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 + jvmTarget = JavaVersion.VERSION_17.toString() } defaultConfig { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 17e651e..c0b6bf3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 14.0 diff --git a/ios/Podfile b/ios/Podfile index d97f17e..ef7020e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# Define a global platform for your project +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 58ef42a..4f037a1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -473,7 +473,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -602,7 +602,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -653,7 +653,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 318a3f4..558b465 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 0c4bddc..e5ee72b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index f4c893b..8fc0f84 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 525b4f1..3415067 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index c051f63..aeac6a9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..bcf09d3 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_zh.arb +output-localization-file: app_localizations.dart diff --git a/lib/common/extensions/mark_status_localizations.dart b/lib/common/extensions/mark_status_localizations.dart new file mode 100644 index 0000000..c9627cb --- /dev/null +++ b/lib/common/extensions/mark_status_localizations.dart @@ -0,0 +1,19 @@ +import 'package:asmrapp/data/models/mark_status.dart'; +import 'package:asmrapp/l10n/app_localizations.dart'; + +extension MarkStatusLocalizations on MarkStatus { + String localizedLabel(AppLocalizations l10n) { + switch (this) { + case MarkStatus.wantToListen: + return l10n.markStatusWantToListen; + case MarkStatus.listening: + return l10n.markStatusListening; + case MarkStatus.listened: + return l10n.markStatusListened; + case MarkStatus.relistening: + return l10n.markStatusRelistening; + case MarkStatus.onHold: + return l10n.markStatusOnHold; + } + } +} diff --git a/lib/common/utils/file_preview_utils.dart b/lib/common/utils/file_preview_utils.dart new file mode 100644 index 0000000..443fc8c --- /dev/null +++ b/lib/common/utils/file_preview_utils.dart @@ -0,0 +1,67 @@ +import 'package:asmrapp/data/models/files/child.dart'; + +class FilePreviewUtils { + static const Set _audioExtensions = { + '.mp3', + '.wav', + '.flac', + '.m4a', + '.aac', + '.ogg', + '.opus', + }; + + static const Set _imageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.webp', + '.gif', + '.bmp', + }; + + static const Set _textExtensions = { + '.txt', + '.md', + '.json', + '.xml', + '.csv', + '.log', + '.yaml', + '.yml', + '.lrc', + '.vtt', + '.srt', + '.ass', + '.ssa', + }; + + static bool isAudio(Child file) { + if (file.type?.toLowerCase() == 'audio') return true; + return _audioExtensions.contains(_extension(file.title)); + } + + static bool isImage(Child file) { + if (file.type?.toLowerCase() == 'image') return true; + return _imageExtensions.contains(_extension(file.title)); + } + + static bool isText(Child file) { + final type = file.type?.toLowerCase(); + if (type == 'text' || type == 'subtitle' || type == 'lyrics') return true; + return _textExtensions.contains(_extension(file.title)); + } + + static bool canPreview(Child file) { + return isImage(file) || isText(file); + } + + static String? _extension(String? fileName) { + if (fileName == null || fileName.isEmpty) return null; + + final dotIndex = fileName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == fileName.length - 1) return null; + + return fileName.substring(dotIndex).toLowerCase(); + } +} diff --git a/lib/common/utils/playlist_localizations.dart b/lib/common/utils/playlist_localizations.dart new file mode 100644 index 0000000..95c9bfc --- /dev/null +++ b/lib/common/utils/playlist_localizations.dart @@ -0,0 +1,12 @@ +import 'package:asmrapp/l10n/app_localizations.dart'; + +String localizedPlaylistName(String? name, AppLocalizations l10n) { + switch (name) { + case '__SYS_PLAYLIST_MARKED': + return l10n.playlistSystemMarked; + case '__SYS_PLAYLIST_LIKED': + return l10n.playlistSystemLiked; + default: + return name ?? ''; + } +} diff --git a/lib/common/utils/work_localizations.dart b/lib/common/utils/work_localizations.dart new file mode 100644 index 0000000..765c095 --- /dev/null +++ b/lib/common/utils/work_localizations.dart @@ -0,0 +1,110 @@ +import 'package:asmrapp/data/models/works/tag.dart'; +import 'package:asmrapp/data/models/works/work.dart'; +import 'package:flutter/widgets.dart'; + +enum _PreferredLanguage { + chinese, + japanese, + english, + other, +} + +_PreferredLanguage _resolvePreferredLanguage(Locale locale) { + switch (locale.languageCode.toLowerCase()) { + case 'zh': + return _PreferredLanguage.chinese; + case 'ja': + return _PreferredLanguage.japanese; + case 'en': + return _PreferredLanguage.english; + default: + return _PreferredLanguage.other; + } +} + +String _firstNonEmpty(Iterable values) { + for (final value in values) { + final normalized = value?.trim(); + if (normalized != null && normalized.isNotEmpty) { + return normalized; + } + } + return ''; +} + +List _preferredEditionLangCodes(Locale locale) { + switch (_resolvePreferredLanguage(locale)) { + case _PreferredLanguage.chinese: + return const ['CHI_HANS', 'CHI_HANT']; + case _PreferredLanguage.japanese: + return const ['JPN']; + case _PreferredLanguage.english: + return const ['ENG']; + case _PreferredLanguage.other: + return const []; + } +} + +extension TagLocalizationX on Tag { + String localizedName(Locale locale) { + switch (_resolvePreferredLanguage(locale)) { + case _PreferredLanguage.chinese: + return _firstNonEmpty([ + i18n?.zhCn?.name, + i18n?.jaJp?.name, + i18n?.enUs?.name, + name, + ]); + case _PreferredLanguage.japanese: + return _firstNonEmpty([ + i18n?.jaJp?.name, + i18n?.zhCn?.name, + i18n?.enUs?.name, + name, + ]); + case _PreferredLanguage.english: + return _firstNonEmpty([ + i18n?.enUs?.name, + i18n?.jaJp?.name, + i18n?.zhCn?.name, + name, + ]); + case _PreferredLanguage.other: + return _firstNonEmpty([ + i18n?.enUs?.name, + i18n?.jaJp?.name, + i18n?.zhCn?.name, + name, + ]); + } + } +} + +extension WorkLocalizationX on Work { + String localizedTitle(Locale locale) { + final preferredTitles = []; + final preferredLangCodes = _preferredEditionLangCodes(locale); + final editions = otherLanguageEditionsInDb ?? const []; + + for (final langCode in preferredLangCodes) { + for (final edition in editions) { + if (edition.lang == langCode) { + preferredTitles.add(edition.title); + } + } + } + + return _firstNonEmpty([ + ...preferredTitles, + title, + sourceId, + ]); + } + + String localizedCircleName() { + return _firstNonEmpty([ + circle?.name, + name, + ]); + } +} diff --git a/lib/core/audio/audio_player_handler.dart b/lib/core/audio/audio_player_handler.dart index ab973e3..09f7bee 100644 --- a/lib/core/audio/audio_player_handler.dart +++ b/lib/core/audio/audio_player_handler.dart @@ -9,7 +9,7 @@ class AudioPlayerHandler extends BaseAudioHandler { AudioPlayerHandler(this._player, this._eventHub) { AppLogger.debug('AudioPlayerHandler 初始化'); - + // 改为监听 EventHub _eventHub.playbackState.listen((event) { final state = PlaybackState( diff --git a/lib/core/audio/audio_player_service.dart b/lib/core/audio/audio_player_service.dart index 33deb0e..6867643 100644 --- a/lib/core/audio/audio_player_service.dart +++ b/lib/core/audio/audio_player_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:asmrapp/utils/logger.dart'; import 'package:just_audio/just_audio.dart'; import 'package:audio_session/audio_session.dart'; @@ -14,23 +15,23 @@ import './events/playback_event_hub.dart'; class AudioPlayerService implements IAudioPlayerService { late final AudioPlayer _player; - late final AudioNotificationService _notificationService; - late final ConcatenatingAudioSource _playlist; + AudioNotificationService? _notificationService; late final PlaybackStateManager _stateManager; late final PlaybackController _playbackController; + late final Future _initialization; final PlaybackEventHub _eventHub; final IPlaybackStateRepository _stateRepository; AudioPlayerService._internal({ required PlaybackEventHub eventHub, required IPlaybackStateRepository stateRepository, - }) : _eventHub = eventHub, - _stateRepository = stateRepository { - _init(); + }) : _eventHub = eventHub, + _stateRepository = stateRepository { + _initialization = _init(); } static AudioPlayerService? _instance; - + factory AudioPlayerService({ required PlaybackEventHub eventHub, required IPlaybackStateRepository stateRepository, @@ -42,14 +43,23 @@ class AudioPlayerService implements IAudioPlayerService { return _instance!; } + bool get _supportsAudioSession => + Platform.isAndroid || Platform.isIOS || Platform.isMacOS; + + bool get _supportsNotificationService => + Platform.isAndroid || Platform.isIOS || Platform.isMacOS; + + Future _ensureInitialized() => _initialization; + Future _init() async { try { _player = AudioPlayer(); - _notificationService = AudioNotificationService( - _player, - _eventHub, - ); - _playlist = ConcatenatingAudioSource(children: []); + if (_supportsNotificationService) { + _notificationService = AudioNotificationService( + _player, + _eventHub, + ); + } _stateManager = PlaybackStateManager( player: _player, @@ -60,15 +70,29 @@ class AudioPlayerService implements IAudioPlayerService { _playbackController = PlaybackController( player: _player, stateManager: _stateManager, - playlist: _playlist, ); - final session = await AudioSession.instance; - await session.configure(const AudioSessionConfiguration.music()); - await _notificationService.init(); + if (_supportsAudioSession) { + try { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + } catch (e, stack) { + AppLogger.warning('音频会话初始化失败,将继续无会话模式运行'); + AppLogger.error('音频会话初始化失败', e, stack); + } + } + + if (_notificationService != null) { + try { + await _notificationService!.init(); + } catch (e, stack) { + AppLogger.warning('通知服务初始化失败,将继续无通知模式运行'); + AppLogger.error('通知服务初始化失败', e, stack); + } + } _stateManager.initStateListeners(); - await restorePlaybackState(); + await _restorePlaybackStateInternal(); } catch (e, stack) { AudioErrorHandler.handleError( AudioErrorType.init, @@ -86,29 +110,46 @@ class AudioPlayerService implements IAudioPlayerService { // 基础播放控制 @override - Future pause() => _playbackController.pause(); + Future pause() async { + await _ensureInitialized(); + await _playbackController.pause(); + } @override - Future resume() => _playbackController.play(); + Future resume() async { + await _ensureInitialized(); + await _playbackController.play(); + } @override Future stop() async { + await _ensureInitialized(); await _playbackController.stop(); _stateManager.clearState(); } @override - Future seek(Duration position) => _playbackController.seek(position); + Future seek(Duration position) async { + await _ensureInitialized(); + await _playbackController.seek(position); + } @override - Future previous() => _playbackController.previous(); + Future previous() async { + await _ensureInitialized(); + await _playbackController.previous(); + } @override - Future next() => _playbackController.next(); + Future next() async { + await _ensureInitialized(); + await _playbackController.next(); + } // 上下文管理 @override Future playWithContext(PlaybackContext context) async { + await _ensureInitialized(); await _playbackController.setPlaybackContext(context); // 添加自动播放 await resume(); @@ -123,21 +164,30 @@ class AudioPlayerService implements IAudioPlayerService { // 状态持久化 @override - Future savePlaybackState() => _stateManager.saveState(); + Future savePlaybackState() async { + await _ensureInitialized(); + await _stateManager.saveState(); + } @override Future restorePlaybackState() async { + await _ensureInitialized(); + await _restorePlaybackStateInternal(); + } + + Future _restorePlaybackStateInternal() async { try { AppLogger.debug('开始恢复播放状态'); final state = await _stateManager.loadState(); - + if (state == null) { AppLogger.debug('没有可恢复的播放状态'); return; } AppLogger.debug('已加载保存的状态: workId=${state.work.id}'); - AppLogger.debug('播放列表信息: 长度=${state.playlist.length}, 索引=${state.currentIndex}'); + AppLogger.debug( + '播放列表信息: 长度=${state.playlist.length}, 索引=${state.currentIndex}'); if (state.playlist.isEmpty) { AppLogger.debug('保存的播放列表为空,跳过恢复'); @@ -173,7 +223,8 @@ class AudioPlayerService implements IAudioPlayerService { @override Future dispose() async { - _player.dispose(); - _notificationService.dispose(); + await _ensureInitialized(); + await _player.dispose(); + await _notificationService?.dispose(); } } diff --git a/lib/core/audio/cache/audio_cache_manager.dart b/lib/core/audio/cache/audio_cache_manager.dart index 9ccea7b..79617e7 100644 --- a/lib/core/audio/cache/audio_cache_manager.dart +++ b/lib/core/audio/cache/audio_cache_manager.dart @@ -1,9 +1,10 @@ +import 'dart:convert'; import 'dart:io'; -import 'package:path_provider/path_provider.dart'; + +import 'package:asmrapp/utils/logger.dart'; import 'package:crypto/crypto.dart'; -import 'dart:convert'; import 'package:just_audio/just_audio.dart'; -import 'package:asmrapp/utils/logger.dart'; +import 'package:path_provider/path_provider.dart'; /// 音频缓存管理器 /// 负责管理音频文件的缓存,对外隐藏具体的缓存实现 @@ -18,10 +19,10 @@ class AudioCacheManager { final cacheFile = await _getCacheFile(url); final fileName = _generateFileName(url); AppLogger.debug('准备创建音频源 - URL: $url, 缓存文件名: $fileName'); - + // 检查缓存文件是否存在且有效 final isValid = await _isCacheValid(cacheFile, fileName); - + if (isValid) { AppLogger.debug('[$fileName] 使用已有缓存文件'); return _createCachingSource(url, cacheFile); @@ -29,7 +30,6 @@ class AudioCacheManager { AppLogger.debug('[$fileName] 创建新的缓存源'); return _createCachingSource(url, cacheFile); - } catch (e) { AppLogger.error('创建缓存音频源失败,使用非缓存源', e); return ProgressiveAudioSource(Uri.parse(url)); @@ -41,7 +41,7 @@ class AudioCacheManager { try { final cacheDir = await _getCacheDir(); final files = await cacheDir.list().toList(); - + // 按修改时间排序 files.sort((a, b) { return a.statSync().modified.compareTo(b.statSync().modified); @@ -51,7 +51,7 @@ class AudioCacheManager { for (var file in files) { if (file is File) { final stat = await file.stat(); - + // 检查是否过期 if (DateTime.now().difference(stat.modified) > _cacheExpiration) { await file.delete(); @@ -59,7 +59,7 @@ class AudioCacheManager { } totalSize += stat.size; - + // 如果总大小超过限制,删除最旧的文件 if (totalSize > _maxCacheSize) { await file.delete(); @@ -76,7 +76,7 @@ class AudioCacheManager { try { final cacheDir = await _getCacheDir(); final files = await cacheDir.list().toList(); - + var totalSize = 0; for (var file in files) { if (file is File) { @@ -94,10 +94,8 @@ class AudioCacheManager { /// 创建缓存音频源 static AudioSource _createCachingSource(String url, File cacheFile) { - return LockCachingAudioSource( - Uri.parse(url), - cacheFile: cacheFile - ); + // ignore: experimental_member_use + return LockCachingAudioSource(Uri.parse(url), cacheFile: cacheFile); } /// 检查缓存是否有效 @@ -112,9 +110,9 @@ class AudioCacheManager { final stat = await cacheFile.stat(); final size = stat.size; final age = DateTime.now().difference(stat.modified); - + AppLogger.debug('[$fileName] 缓存验证: 大小=${size}bytes, 年龄=$age'); - + // 移除单个文件大小检查,只保留过期检查 if (age > _cacheExpiration) { AppLogger.debug('[$fileName] 缓存无效: 文件过期 ($age > $_cacheExpiration)'); @@ -153,4 +151,4 @@ class AudioCacheManager { } return audioCacheDir; } -} \ No newline at end of file +} diff --git a/lib/core/audio/controllers/playback_controller.dart b/lib/core/audio/controllers/playback_controller.dart index 4f772c4..d75986e 100644 --- a/lib/core/audio/controllers/playback_controller.dart +++ b/lib/core/audio/controllers/playback_controller.dart @@ -7,26 +7,23 @@ import '../utils/audio_error_handler.dart'; import 'package:asmrapp/data/models/files/child.dart'; import 'package:asmrapp/data/models/works/work.dart'; - class PlaybackController { final AudioPlayer _player; final PlaybackStateManager _stateManager; - final ConcatenatingAudioSource _playlist; PlaybackController({ required AudioPlayer player, required PlaybackStateManager stateManager, - required ConcatenatingAudioSource playlist, - }) : _player = player, - _stateManager = stateManager, - _playlist = playlist; + }) : _player = player, + _stateManager = stateManager; // 基础播放控制 Future play() => _player.play(); Future pause() => _player.pause(); Future stop() => _player.stop(); - Future seek(Duration position, {int? index}) => _player.seek(position, index: index); - + Future seek(Duration position, {int? index}) => + _player.seek(position, index: index); + // 播放列表控制 Future next() async { try { @@ -66,9 +63,7 @@ class PlaybackController { AppLogger.debug('获取到上一个文件: ${previousFile?.title}'); if (previousFile != null) { _updateTrackAndContext( - previousFile, - _stateManager.currentContext!.work - ); + previousFile, _stateManager.currentContext!.work); AppLogger.debug('执行切换到上一曲'); await _player.seekToPrevious(); } @@ -87,11 +82,14 @@ class PlaybackController { } // 播放上下文设置 - Future setPlaybackContext(PlaybackContext context, {Duration? initialPosition}) async { + Future setPlaybackContext(PlaybackContext context, + {Duration? initialPosition}) async { try { - AppLogger.debug('准备设置播放上下文: workId=${context.work.id}, file=${context.currentFile.title}'); - AppLogger.debug('播放列表状态: 长度=${context.playlist.length}, 当前索引=${context.currentIndex}'); - + AppLogger.debug( + '准备设置播放上下文: workId=${context.work.id}, file=${context.currentFile.title}'); + AppLogger.debug( + '播放列表状态: 长度=${context.playlist.length}, 当前索引=${context.currentIndex}'); + // 验证上下文 try { context.validate(); @@ -99,25 +97,24 @@ class PlaybackController { AppLogger.error('播放上下文验证失败', e); rethrow; } - + // 1. 先停止当前播放 AppLogger.debug('停止当前播放'); await _player.stop(); - + // 2. 等待播放器就绪 AppLogger.debug('暂停播放器'); await _player.pause(); - + // 3. 更新上下文 AppLogger.debug('更新播放上下文'); _stateManager.updateContext(context); - + // 4. 设置新的播放源 AppLogger.debug('设置播放源: 初始位置=${initialPosition?.inMilliseconds}ms'); try { await PlaylistBuilder.setPlaylistSource( player: _player, - playlist: _playlist, files: context.playlist, initialIndex: context.currentIndex, initialPosition: initialPosition ?? Duration.zero, @@ -131,11 +128,11 @@ class PlaybackController { // 删掉,会导致播放器索引回到0 // AppLogger.debug('等待播放器加载'); // await _player.load(); - + // 6. 更新轨道信息 AppLogger.debug('更新轨道信息'); _updateTrackAndContext(context.currentFile, context.work); - + AppLogger.debug('播放上下文设置完成'); } catch (e, stack) { AppLogger.error('设置播放上下文失败', e, stack); @@ -154,4 +151,4 @@ class PlaybackController { AppLogger.debug('更新轨道和上下文: file=${file.title}'); _stateManager.updateTrackAndContext(file, work); } -} \ No newline at end of file +} diff --git a/lib/core/audio/events/playback_event.dart b/lib/core/audio/events/playback_event.dart index cc48b0a..d470f61 100644 --- a/lib/core/audio/events/playback_event.dart +++ b/lib/core/audio/events/playback_event.dart @@ -57,4 +57,4 @@ class InitialStateEvent extends PlaybackEvent { final AudioTrackInfo? track; final PlaybackContext? context; InitialStateEvent(this.track, this.context); -} \ No newline at end of file +} diff --git a/lib/core/audio/events/playback_event_hub.dart b/lib/core/audio/events/playback_event_hub.dart index 90fc9a1..e9fe26a 100644 --- a/lib/core/audio/events/playback_event_hub.dart +++ b/lib/core/audio/events/playback_event_hub.dart @@ -6,33 +6,32 @@ class PlaybackEventHub { final _eventSubject = PublishSubject(); // 分类后的特定事件流 - late final Stream playbackState = _eventSubject - .whereType() - .distinct(); - - late final Stream trackChange = _eventSubject - .whereType(); - - late final Stream contextChange = _eventSubject - .whereType(); - + late final Stream playbackState = + _eventSubject.whereType().distinct(); + + late final Stream trackChange = + _eventSubject.whereType(); + + late final Stream contextChange = + _eventSubject.whereType(); + late final Stream playbackProgress = _eventSubject .whereType() .distinct((prev, next) => prev.position == next.position); - - late final Stream errors = _eventSubject - .whereType(); + + late final Stream errors = + _eventSubject.whereType(); // 添加新的事件流 - late final Stream initialState = _eventSubject - .whereType(); - - late final Stream requestInitialState = _eventSubject - .whereType(); + late final Stream initialState = + _eventSubject.whereType(); + + late final Stream requestInitialState = + _eventSubject.whereType(); // 发送事件 void emit(PlaybackEvent event) => _eventSubject.add(event); // 资源释放 void dispose() => _eventSubject.close(); -} \ No newline at end of file +} diff --git a/lib/core/audio/i_audio_player_service.dart b/lib/core/audio/i_audio_player_service.dart index 1c6685d..0766c84 100644 --- a/lib/core/audio/i_audio_player_service.dart +++ b/lib/core/audio/i_audio_player_service.dart @@ -13,7 +13,7 @@ abstract class IAudioPlayerService { // 上下文管理 Future playWithContext(PlaybackContext context); - + // 状态访问 AudioTrackInfo? get currentTrack; PlaybackContext? get currentContext; diff --git a/lib/core/audio/models/file_path.dart b/lib/core/audio/models/file_path.dart index 9dbbf77..2536355 100644 --- a/lib/core/audio/models/file_path.dart +++ b/lib/core/audio/models/file_path.dart @@ -12,7 +12,7 @@ class FilePath { static String? getPath(Child targetFile, Files root) { AppLogger.debug('开始查找文件路径: ${targetFile.title}'); final segments = _findPathSegments(root.children, targetFile); - + if (segments == null) { AppLogger.debug('未找到文件路径'); return null; @@ -24,23 +24,23 @@ class FilePath { } /// 递归查找文件路径段 - static List? _findPathSegments(List? children, Child targetFile, [List currentPath = const []]) { + static List? _findPathSegments( + List? children, Child targetFile, + [List currentPath = const []]) { if (children == null) return null; for (final child in children) { - if (child.title == targetFile.title && - child.mediaDownloadUrl == targetFile.mediaDownloadUrl && + if (child.title == targetFile.title && + child.mediaDownloadUrl == targetFile.mediaDownloadUrl && child.type == targetFile.type && - child.size == targetFile.size) { // size 作为额外验证 + child.size == targetFile.size) { + // size 作为额外验证 return [...currentPath, child.title!]; } if (child.type == 'folder' && child.children != null) { final result = _findPathSegments( - child.children, - targetFile, - [...currentPath, child.title!] - ); + child.children, targetFile, [...currentPath, child.title!]); if (result != null) return result; } } @@ -52,7 +52,7 @@ class FilePath { /// 返回与目标文件在同一目录下的所有文件 static List getSiblings(Child targetFile, Files root) { AppLogger.debug('开始获取同级文件: ${targetFile.title}'); - + // 获取目标文件的路径 final path = getPath(targetFile, root); if (path == null) { @@ -62,7 +62,8 @@ class FilePath { // 获取父目录路径 final lastSeparator = path.lastIndexOf(separator); - final parentPath = lastSeparator > 0 ? path.substring(0, lastSeparator) : separator; + final parentPath = + lastSeparator > 0 ? path.substring(0, lastSeparator) : separator; AppLogger.debug('父目录路径: $parentPath'); // 查找父目录内容 @@ -93,18 +94,17 @@ class FilePath { if (path == separator) return children; // 分割路径 - final segments = path.split(separator) - ..removeWhere((s) => s.isEmpty); - + final segments = path.split(separator)..removeWhere((s) => s.isEmpty); + List? current = children; - + // 逐级查找目录 for (final segment in segments) { final nextDir = current?.firstWhere( (child) => child.title == segment && child.type == 'folder', orElse: () => Child(), ); - + if (nextDir?.title == null) return null; current = nextDir?.children; } @@ -121,7 +121,7 @@ class FilePath { if (children == null) return null; List? audioFolderPath; - + void findPath(Child folder, List currentPath) { if (audioFolderPath != null) return; @@ -144,7 +144,8 @@ class FilePath { // 如果当前目录没有音频文件,递归检查子目录 for (final child in folder.children!) { if (child.type == 'folder') { - List newPath = List.from(currentPath)..add(child.title ?? ''); + List newPath = List.from(currentPath) + ..add(child.title ?? ''); findPath(child, newPath); } } @@ -168,4 +169,4 @@ class FilePath { if (path == null || folderName == null) return false; return path.contains(folderName); } -} \ No newline at end of file +} diff --git a/lib/core/audio/models/play_mode.dart b/lib/core/audio/models/play_mode.dart index e549b85..8dcc92d 100644 --- a/lib/core/audio/models/play_mode.dart +++ b/lib/core/audio/models/play_mode.dart @@ -1,5 +1,5 @@ enum PlayMode { - single, // 单曲循环 - loop, // 列表循环 - sequence, // 顺序播放 -} \ No newline at end of file + single, // 单曲循环 + loop, // 列表循环 + sequence, // 顺序播放 +} diff --git a/lib/core/audio/models/playback_context.dart b/lib/core/audio/models/playback_context.dart index 4508b12..0e643c8 100644 --- a/lib/core/audio/models/playback_context.dart +++ b/lib/core/audio/models/playback_context.dart @@ -1,10 +1,10 @@ +import 'package:asmrapp/core/audio/models/file_path.dart'; +import 'package:asmrapp/core/audio/models/play_mode.dart'; import 'package:asmrapp/core/audio/utils/audio_error_handler.dart'; -import 'package:asmrapp/data/models/works/work.dart'; -import 'package:asmrapp/data/models/files/files.dart'; import 'package:asmrapp/data/models/files/child.dart'; +import 'package:asmrapp/data/models/files/files.dart'; +import 'package:asmrapp/data/models/works/work.dart'; import 'package:asmrapp/utils/logger.dart'; -import 'package:asmrapp/core/audio/models/play_mode.dart'; -import 'package:asmrapp/core/audio/models/file_path.dart'; class PlaybackContext { final Work work; @@ -21,7 +21,7 @@ class PlaybackContext { '无效的播放列表状态:播放列表为空', ); } - + if (currentIndex < 0 || currentIndex >= playlist.length) { throw AudioError( AudioErrorType.state, @@ -55,8 +55,9 @@ class PlaybackContext { PlayMode playMode = PlayMode.sequence, }) { final playlist = _getPlaylistFromSameDirectory(currentFile, files); - final currentIndex = playlist.indexWhere((file) => file.title == currentFile.title); - + final currentIndex = + playlist.indexWhere((file) => file.title == currentFile.title); + return PlaybackContext._( work: work, files: files, @@ -68,7 +69,8 @@ class PlaybackContext { } // 获取同级文件列表 - static List _getPlaylistFromSameDirectory(Child currentFile, Files files) { + static List _getPlaylistFromSameDirectory( + Child currentFile, Files files) { // AppLogger.debug('开始获取播放列表...'); // AppLogger.debug('当前文件: ${currentFile.title}'); // AppLogger.debug('当前文件类型: ${currentFile.type}'); @@ -76,7 +78,7 @@ class PlaybackContext { // 获取当前文件的扩展名 final extension = currentFile.title?.split('.').last.toLowerCase(); // AppLogger.debug('当前文件扩展名: $extension'); - + if (extension != 'mp3' && extension != 'wav') { AppLogger.debug('不支持的文件类型: $extension'); return []; @@ -84,17 +86,18 @@ class PlaybackContext { // 使用 FilePath 获取同级文件 final siblings = FilePath.getSiblings(currentFile, files); - + // 过滤出相同扩展名的文件 - final playlist = siblings.where((file) => - file.title?.toLowerCase().endsWith('.$extension') ?? false - ).toList(); - + final playlist = siblings + .where((file) => + file.title?.toLowerCase().endsWith('.$extension') ?? false) + .toList(); + // AppLogger.debug('找到 ${playlist.length} 个可播放文件:'); // for (var file in playlist) { // AppLogger.debug('- [${file.type}] ${file.title} (URL: ${file.mediaDownloadUrl != null ? '有' : '无'})'); // } - + return playlist; } @@ -107,10 +110,10 @@ class PlaybackContext { // 获取下一曲(考虑播放模式) Child? getNextFile() { if (playlist.isEmpty) return null; - + switch (playMode) { case PlayMode.single: - return currentFile; // 单曲循环返回当前文件 + return currentFile; // 单曲循环返回当前文件 case PlayMode.loop: // 列表循环:最后一首返回第一首,否则返回下一首 return hasNext ? playlist[currentIndex + 1] : playlist[0]; @@ -123,13 +126,15 @@ class PlaybackContext { // 获取上一曲 Child? getPreviousFile() { if (playlist.isEmpty) return null; - + switch (playMode) { case PlayMode.single: return currentFile; case PlayMode.loop: // 列表循环:第一首返回最后一首,否则返回上一首 - return hasPrevious ? playlist[currentIndex - 1] : playlist[playlist.length - 1]; + return hasPrevious + ? playlist[currentIndex - 1] + : playlist[playlist.length - 1]; case PlayMode.sequence: // 顺序播放:有上一首则返回,否则返回null return hasPrevious ? playlist[currentIndex - 1] : null; @@ -166,15 +171,9 @@ class PlaybackContext { // 便捷方法:获取可播放文件列表 List getPlayableFiles() { if (files.children == null) return []; - return files.children!.where((file) => - file.mediaDownloadUrl != null && - file.type?.toLowerCase() != 'vtt' - ).toList(); - } - - // 工具方法:获取文件名(不含扩展名) - String? _getBaseName(String? filename) { - if (filename == null) return null; - return filename.replaceAll(RegExp(r'\.[^.]+$'), ''); + return files.children! + .where((file) => + file.mediaDownloadUrl != null && file.type?.toLowerCase() != 'vtt') + .toList(); } -} \ No newline at end of file +} diff --git a/lib/core/audio/models/subtitle.dart b/lib/core/audio/models/subtitle.dart index 3d6388e..47dd5e6 100644 --- a/lib/core/audio/models/subtitle.dart +++ b/lib/core/audio/models/subtitle.dart @@ -1,9 +1,9 @@ import 'dart:math' as math; enum SubtitleState { - current, // 当前播放的字幕 - waiting, // 即将播放的字幕 - passed // 已经播放过的字幕 + current, // 当前播放的字幕 + waiting, // 即将播放的字幕 + passed // 已经播放过的字幕 } class Subtitle { @@ -41,15 +41,17 @@ class SubtitleList { final List subtitles; int _currentIndex = -1; - SubtitleList(List subtitles) - : subtitles = subtitles.asMap().entries.map( - (entry) => Subtitle( - start: entry.value.start, - end: entry.value.end, - text: entry.value.text, - index: entry.key, - ) - ).toList(); + SubtitleList(List subtitles) + : subtitles = subtitles + .asMap() + .entries + .map((entry) => Subtitle( + start: entry.value.start, + end: entry.value.end, + text: entry.value.text, + index: entry.key, + )) + .toList(); SubtitleWithState? getCurrentSubtitle(Duration position) { if (subtitles.isEmpty) return null; @@ -73,7 +75,7 @@ class SubtitleList { return SubtitleWithState(subtitle, SubtitleState.current); } // 如果已经超过了当前字幕,但还没到下一个字幕 - if (position > subtitle.end && + if (position > subtitle.end && (i == subtitles.length - 1 || position < subtitles[i + 1].start)) { return SubtitleWithState(subtitle, SubtitleState.passed); } @@ -92,18 +94,20 @@ class SubtitleList { (Subtitle?, Subtitle?, Subtitle?) getCurrentContext() { if (_currentIndex == -1) return (null, null, null); - + final previous = _currentIndex > 0 ? subtitles[_currentIndex - 1] : null; final current = subtitles[_currentIndex]; - final next = _currentIndex < subtitles.length - 1 ? subtitles[_currentIndex + 1] : null; - + final next = _currentIndex < subtitles.length - 1 + ? subtitles[_currentIndex + 1] + : null; + return (previous, current, next); } static SubtitleList parse(String vttContent) { final lines = vttContent.split('\n'); final subtitles = []; - + int i = 0; while (i < lines.length && !lines[i].contains('-->')) { i++; @@ -111,13 +115,13 @@ class SubtitleList { while (i < lines.length) { final line = lines[i].trim(); - + if (line.contains('-->')) { final times = line.split('-->'); if (times.length == 2) { final start = _parseTimestamp(times[0].trim()); final end = _parseTimestamp(times[1].trim()); - + i++; String text = ''; while (i < lines.length && lines[i].trim().isNotEmpty) { @@ -125,7 +129,7 @@ class SubtitleList { text += lines[i].trim(); i++; } - + if (start != null && end != null && text.isNotEmpty) { subtitles.add(Subtitle( start: start, @@ -151,7 +155,8 @@ class SubtitleList { hours: int.parse(parts[0]), minutes: int.parse(parts[1]), seconds: int.parse(seconds[0]), - milliseconds: seconds.length > 1 ? int.parse(seconds[1].padRight(3, '0')) : 0, + milliseconds: + seconds.length > 1 ? int.parse(seconds[1].padRight(3, '0')) : 0, ); } } catch (e) { @@ -166,4 +171,4 @@ class SubtitleWithState { final SubtitleState state; SubtitleWithState(this.subtitle, this.state); -} \ No newline at end of file +} diff --git a/lib/core/audio/state/playback_state_manager.dart b/lib/core/audio/state/playback_state_manager.dart index 5f9cfd8..6765815 100644 --- a/lib/core/audio/state/playback_state_manager.dart +++ b/lib/core/audio/state/playback_state_manager.dart @@ -1,24 +1,26 @@ import 'dart:async'; + +import 'package:asmrapp/data/models/files/child.dart'; +import 'package:asmrapp/data/models/playback/playback_state.dart'; +import 'package:asmrapp/data/models/works/work.dart'; import 'package:just_audio/just_audio.dart'; + +import '../events/playback_event.dart'; +import '../events/playback_event_hub.dart'; import '../models/audio_track_info.dart'; import '../models/playback_context.dart'; +import '../storage/i_playback_state_repository.dart'; import '../utils/audio_error_handler.dart'; import '../utils/track_info_creator.dart'; -import 'package:asmrapp/data/models/playback/playback_state.dart'; -import '../storage/i_playback_state_repository.dart'; -import '../events/playback_event.dart'; -import '../events/playback_event_hub.dart'; -import 'package:asmrapp/data/models/files/child.dart'; -import 'package:asmrapp/data/models/works/work.dart'; - class PlaybackStateManager { final AudioPlayer _player; final PlaybackEventHub _eventHub; final IPlaybackStateRepository _stateRepository; - + AudioTrackInfo? _currentTrack; PlaybackContext? _currentContext; + bool _listenersInitialized = false; final List _subscriptions = []; @@ -26,40 +28,51 @@ class PlaybackStateManager { required AudioPlayer player, required PlaybackEventHub eventHub, required IPlaybackStateRepository stateRepository, - }) : _player = player, - _eventHub = eventHub, - _stateRepository = stateRepository; + }) : _player = player, + _eventHub = eventHub, + _stateRepository = stateRepository; // 初始化状态监听 void initStateListeners() { + if (_listenersInitialized) { + return; + } + _listenersInitialized = true; + // 监听播放器索引变化 - _player.currentIndexStream.listen((index) { - if (index != null && _currentContext != null) { - final newFile = _currentContext!.playlist[index]; - updateTrackAndContext(newFile, _currentContext!.work); - } - }); + _subscriptions.add( + _player.currentIndexStream.listen((index) { + if (index != null && _currentContext != null) { + final newFile = _currentContext!.playlist[index]; + updateTrackAndContext(newFile, _currentContext!.work); + } + }), + ); // 直接监听 AudioPlayer 的原始流 - _player.playerStateStream.listen((state) async { - final position = _player.position; - final duration = _player.duration; - - // 转换并发送到 EventHub - _eventHub.emit(PlaybackStateEvent(state, position, duration)); - - if (state.processingState == ProcessingState.completed) { - _onPlaybackCompleted(); - } - saveState(); - }); - - _player.positionStream.listen((position) { - _eventHub.emit(PlaybackProgressEvent( - position, - _player.bufferedPosition - )); - }); + _subscriptions.add( + _player.playerStateStream.listen((state) async { + final position = _player.position; + final duration = _player.duration; + + // 转换并发送到 EventHub + _eventHub.emit(PlaybackStateEvent(state, position, duration)); + + if (state.processingState == ProcessingState.completed) { + _onPlaybackCompleted(); + } + saveState(); + }), + ); + + _subscriptions.add( + _player.positionStream.listen((position) { + _eventHub + .emit(PlaybackProgressEvent(position, _player.bufferedPosition)); + }), + ); + + _setupEventListeners(); } // 状态更新方法 @@ -72,7 +85,8 @@ class PlaybackStateManager { void updateTrackInfo(AudioTrackInfo track) { _currentTrack = track; - _eventHub.emit(TrackChangeEvent(track, _currentContext!.currentFile, _currentContext!.work)); + _eventHub.emit(TrackChangeEvent( + track, _currentContext!.currentFile, _currentContext!.work)); } void updateTrackAndContext(Child file, Work work) { @@ -80,7 +94,7 @@ class PlaybackStateManager { final newContext = _currentContext!.copyWithFile(file); updateContext(newContext); } - + final trackInfo = TrackInfoCreator.createFromFile(file, work); updateTrackInfo(trackInfo); } @@ -115,7 +129,7 @@ class PlaybackStateManager { position: (_player.position).inMilliseconds, timestamp: DateTime.now().toIso8601String(), ); - + await _stateRepository.saveState(state); } catch (e, stack) { AudioErrorHandler.handleError( @@ -145,10 +159,7 @@ class PlaybackStateManager { // 处理初始状态请求 _subscriptions.add( _eventHub.requestInitialState.listen((_) { - _eventHub.emit(InitialStateEvent( - _currentTrack, - _currentContext - )); + _eventHub.emit(InitialStateEvent(_currentTrack, _currentContext)); }), ); } @@ -158,5 +169,6 @@ class PlaybackStateManager { subscription.cancel(); } _subscriptions.clear(); + _listenersInitialized = false; } -} \ No newline at end of file +} diff --git a/lib/core/audio/storage/i_playback_state_repository.dart b/lib/core/audio/storage/i_playback_state_repository.dart index 3c56acc..6a910bf 100644 --- a/lib/core/audio/storage/i_playback_state_repository.dart +++ b/lib/core/audio/storage/i_playback_state_repository.dart @@ -3,4 +3,4 @@ import 'package:asmrapp/data/models/playback/playback_state.dart'; abstract class IPlaybackStateRepository { Future saveState(PlaybackState state); Future loadState(); -} \ No newline at end of file +} diff --git a/lib/core/audio/storage/playback_state_repository.dart b/lib/core/audio/storage/playback_state_repository.dart index 1ac1604..aa10091 100644 --- a/lib/core/audio/storage/playback_state_repository.dart +++ b/lib/core/audio/storage/playback_state_repository.dart @@ -41,4 +41,4 @@ class PlaybackStateRepository implements IPlaybackStateRepository { return null; } } -} \ No newline at end of file +} diff --git a/lib/core/audio/utils/audio_error_handler.dart b/lib/core/audio/utils/audio_error_handler.dart index e832cdf..3ca6e33 100644 --- a/lib/core/audio/utils/audio_error_handler.dart +++ b/lib/core/audio/utils/audio_error_handler.dart @@ -1,11 +1,11 @@ import 'package:asmrapp/utils/logger.dart'; enum AudioErrorType { - playback, // 播放错误 - playlist, // 播放列表错误 - state, // 状态错误 - context, // 上下文错误 - init, // 初始化错误 + playback, // 播放错误 + playlist, // 播放列表错误 + state, // 状态错误 + context, // 上下文错误 + init, // 初始化错误 } class AudioError implements Exception { @@ -16,7 +16,8 @@ class AudioError implements Exception { AudioError(this.type, this.message, [this.originalError]); @override - String toString() => '$message${originalError != null ? ': $originalError' : ''}'; + String toString() => + '$message${originalError != null ? ': $originalError' : ''}'; } class AudioErrorHandler { @@ -29,7 +30,7 @@ class AudioErrorHandler { final message = _getErrorMessage(type, operation); AppLogger.error(message, error, stack); } - + static Never throwError( AudioErrorType type, String operation, @@ -53,4 +54,4 @@ class AudioErrorHandler { return '初始化失败: $operation'; } } -} \ No newline at end of file +} diff --git a/lib/core/audio/utils/playlist_builder.dart b/lib/core/audio/utils/playlist_builder.dart index be281d2..36890f8 100644 --- a/lib/core/audio/utils/playlist_builder.dart +++ b/lib/core/audio/utils/playlist_builder.dart @@ -4,35 +4,22 @@ import 'package:asmrapp/core/audio/cache/audio_cache_manager.dart'; class PlaylistBuilder { static Future> buildAudioSources(List files) async { - return await Future.wait( - files.map((file) async { - return AudioCacheManager.createAudioSource(file.mediaDownloadUrl!); - }) - ); - } - - static Future updatePlaylist( - ConcatenatingAudioSource playlist, - List sources, - ) async { - await playlist.clear(); - await playlist.addAll(sources); + return await Future.wait(files.map((file) async { + return AudioCacheManager.createAudioSource(file.mediaDownloadUrl!); + })); } static Future setPlaylistSource({ required AudioPlayer player, - required ConcatenatingAudioSource playlist, required List files, required int initialIndex, required Duration initialPosition, }) async { final sources = await buildAudioSources(files); - await updatePlaylist(playlist, sources); - - await player.setAudioSource( - playlist, + await player.setAudioSources( + sources, initialIndex: initialIndex, initialPosition: initialPosition, ); } -} \ No newline at end of file +} diff --git a/lib/core/audio/utils/track_info_creator.dart b/lib/core/audio/utils/track_info_creator.dart index 4bd728a..ce74b8b 100644 --- a/lib/core/audio/utils/track_info_creator.dart +++ b/lib/core/audio/utils/track_info_creator.dart @@ -16,7 +16,7 @@ class TrackInfoCreator { url: url, ); } - + static AudioTrackInfo createFromFile(Child file, Work work) { return createTrackInfo( title: file.title ?? '', @@ -25,4 +25,4 @@ class TrackInfoCreator { url: file.mediaDownloadUrl!, ); } -} \ No newline at end of file +} diff --git a/lib/core/cache/recommendation_cache_manager.dart b/lib/core/cache/recommendation_cache_manager.dart index 4237de6..0bc0313 100644 --- a/lib/core/cache/recommendation_cache_manager.dart +++ b/lib/core/cache/recommendation_cache_manager.dart @@ -1,16 +1,16 @@ -import 'dart:collection'; import 'package:asmrapp/data/services/api_service.dart'; import 'package:asmrapp/utils/logger.dart'; class RecommendationCacheManager { // 单例模式 - static final RecommendationCacheManager _instance = RecommendationCacheManager._internal(); + static final RecommendationCacheManager _instance = + RecommendationCacheManager._internal(); factory RecommendationCacheManager() => _instance; RecommendationCacheManager._internal(); // 使用 LinkedHashMap 便于按访问顺序管理缓存 - final _cache = LinkedHashMap(); - + final _cache = {}; + // 缓存配置 static const int _maxCacheSize = 1000; // 最大缓存条目数 static const Duration _cacheDuration = Duration(hours: 24); // 缓存有效期 @@ -43,7 +43,7 @@ class RecommendationCacheManager { /// 存储缓存数据 void set(String itemId, int page, int subtitle, WorksResponse data) { final key = _generateKey(itemId, page, subtitle); - + // 检查缓存大小,如果达到上限则移除最早的条目 if (_cache.length >= _maxCacheSize) { _cache.remove(_cache.keys.first); @@ -73,6 +73,7 @@ class _CacheItem { _CacheItem(this.data) : timestamp = DateTime.now(); - bool get isExpired => - DateTime.now().difference(timestamp) > RecommendationCacheManager._cacheDuration; -} \ No newline at end of file + bool get isExpired => + DateTime.now().difference(timestamp) > + RecommendationCacheManager._cacheDuration; +} diff --git a/lib/core/di/service_locator.dart b/lib/core/di/service_locator.dart index 741f6c1..8942b8e 100644 --- a/lib/core/di/service_locator.dart +++ b/lib/core/di/service_locator.dart @@ -16,10 +16,13 @@ import '../../core/audio/storage/i_playback_state_repository.dart'; import '../../core/audio/storage/playback_state_repository.dart'; import '../audio/events/playback_event_hub.dart'; import '../../core/theme/theme_controller.dart'; +import '../locale/locale_controller.dart'; import '../../core/platform/i_lyric_overlay_controller.dart'; import '../../core/platform/lyric_overlay_controller.dart'; import '../../core/platform/lyric_overlay_manager.dart'; import '../../core/platform/wakelock_controller.dart'; +import '../download/download_directory_controller.dart'; +import '../download/download_progress_manager.dart'; final getIt = GetIt.instance; @@ -91,22 +94,35 @@ Future setupServiceLocator() async { getIt.registerLazySingleton( () => ThemeController(prefs), ); + getIt.registerLazySingleton( + () => LocaleController(prefs), + ); // 注册 WakeLockController getIt.registerLazySingleton(() => WakeLockController(prefs)); + + // 注册下载设置与进度 + getIt.registerLazySingleton( + () => DownloadDirectoryController(prefs), + ); + getIt.registerLazySingleton( + () => DownloadProgressManager(), + ); } Future setupSubtitleServices() async { getIt.registerLazySingleton(() => SubtitleLoader()); if (Platform.isAndroid) { - getIt.registerLazySingleton(() => LyricOverlayController()); + getIt.registerLazySingleton( + () => LyricOverlayController()); } else { - getIt.registerLazySingleton(() => DummyLyricOverlayController()); + getIt.registerLazySingleton( + () => DummyLyricOverlayController()); } getIt.registerLazySingleton(() => LyricOverlayManager( - controller: getIt(), - subtitleService: getIt(), - )); + controller: getIt(), + subtitleService: getIt(), + )); // 初始化悬浮窗管理器 await getIt().initialize(); diff --git a/lib/core/download/download_directory_controller.dart b/lib/core/download/download_directory_controller.dart new file mode 100644 index 0000000..f583df1 --- /dev/null +++ b/lib/core/download/download_directory_controller.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:asmrapp/utils/logger.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DownloadDirectoryController extends ChangeNotifier { + static const String _customDirectoryKey = 'download_custom_directory'; + + final SharedPreferences _prefs; + + DownloadDirectoryController(this._prefs) { + _customDirectoryPath = _prefs.getString(_customDirectoryKey); + } + + String? _customDirectoryPath; + String? _lastError; + + String? get customDirectoryPath => _customDirectoryPath; + bool get hasCustomDirectory => + _customDirectoryPath != null && _customDirectoryPath!.isNotEmpty; + String? get lastError => _lastError; + + Future ensureWritePermissionIfNeeded() async { + if (!Platform.isAndroid) { + return true; + } + + try { + final storageStatus = await Permission.storage.status; + if (storageStatus.isGranted) { + return true; + } + + final storageResult = await Permission.storage.request(); + if (storageResult.isGranted) { + return true; + } + + final manageStatus = await Permission.manageExternalStorage.status; + if (manageStatus.isGranted) { + return true; + } + final manageResult = await Permission.manageExternalStorage.request(); + return manageResult.isGranted; + } catch (e) { + AppLogger.error('请求存储权限失败', e); + return false; + } + } + + Future setCustomDirectoryPath(String path) async { + final trimmed = path.trim(); + if (trimmed.isEmpty) { + throw Exception('目录路径为空'); + } + + final directory = Directory(trimmed); + await _ensureDirectoryExists(directory); + + var writable = await _checkWritable(directory); + if (!writable) { + final granted = await ensureWritePermissionIfNeeded(); + if (granted) { + if (!await directory.exists()) { + await directory.create(recursive: true); + } + writable = await _checkWritable(directory); + } + } + if (!writable) { + throw Exception('目录不可写或权限不足'); + } + + _customDirectoryPath = directory.path; + _lastError = null; + await _prefs.setString(_customDirectoryKey, directory.path); + notifyListeners(); + } + + Future clearCustomDirectoryPath() async { + _customDirectoryPath = null; + _lastError = null; + await _prefs.remove(_customDirectoryKey); + notifyListeners(); + } + + Future resolveDownloadRootDirectory() async { + if (hasCustomDirectory) { + final custom = Directory(_customDirectoryPath!); + await _ensureDirectoryExists(custom); + + var writable = await _checkWritable(custom); + if (!writable) { + final granted = await ensureWritePermissionIfNeeded(); + if (granted) { + if (!await custom.exists()) { + await custom.create(recursive: true); + } + writable = await _checkWritable(custom); + } + } + + if (!writable) { + _lastError = '自定义下载目录不可写'; + notifyListeners(); + throw Exception(_lastError); + } + + _lastError = null; + return custom; + } + + final candidateBaseDirectories = []; + try { + candidateBaseDirectories.add(await getDownloadsDirectory()); + } catch (_) { + candidateBaseDirectories.add(null); + } + candidateBaseDirectories.add(await getApplicationDocumentsDirectory()); + + for (final baseDirectory in candidateBaseDirectories) { + if (baseDirectory == null) continue; + + try { + final rootDirectory = Directory( + '${baseDirectory.path}${Platform.pathSeparator}asmr_downloads', + ); + await _ensureDirectoryExists(rootDirectory); + var writable = await _checkWritable(rootDirectory); + if (!writable) { + final granted = await ensureWritePermissionIfNeeded(); + if (granted) { + if (!await rootDirectory.exists()) { + await rootDirectory.create(recursive: true); + } + writable = await _checkWritable(rootDirectory); + } + } + if (writable) { + _lastError = null; + return rootDirectory; + } + } catch (e) { + AppLogger.error('创建默认下载目录失败: ${baseDirectory.path}', e); + } + } + + _lastError = '无法创建下载目录'; + notifyListeners(); + throw Exception(_lastError); + } + + Future _checkWritable(Directory directory) async { + final probeFile = File( + '${directory.path}${Platform.pathSeparator}.asmr_write_probe', + ); + try { + await probeFile.writeAsString( + DateTime.now().microsecondsSinceEpoch.toString(), + flush: true, + ); + if (await probeFile.exists()) { + await probeFile.delete(); + } + return true; + } catch (e) { + AppLogger.error('目录写入检查失败: ${directory.path}', e); + return false; + } + } + + Future _ensureDirectoryExists(Directory directory) async { + if (await directory.exists()) { + return; + } + + try { + await directory.create(recursive: true); + return; + } catch (_) { + final granted = await ensureWritePermissionIfNeeded(); + if (!granted) { + rethrow; + } + if (!await directory.exists()) { + await directory.create(recursive: true); + } + } + } +} diff --git a/lib/core/download/download_progress_manager.dart b/lib/core/download/download_progress_manager.dart new file mode 100644 index 0000000..ef72c4f --- /dev/null +++ b/lib/core/download/download_progress_manager.dart @@ -0,0 +1,115 @@ +import 'dart:collection'; + +import 'package:asmrapp/core/download/download_task.dart'; +import 'package:flutter/foundation.dart'; + +class DownloadProgressManager extends ChangeNotifier { + static const int _maxHistory = 200; + + final List _tasks = []; + int _sequence = 0; + + UnmodifiableListView get tasks => UnmodifiableListView(_tasks); + + List get activeTasks => + _tasks.where((task) => task.isActive).toList(growable: false); + + List get finishedTasks => + _tasks.where((task) => task.isFinished).toList(growable: false); + + bool get hasActiveTasks => _tasks.any((task) => task.isActive); + + String createTask({ + required int? workId, + required String workTitle, + required String fileName, + required String savePath, + }) { + _sequence++; + final id = '${DateTime.now().microsecondsSinceEpoch}_$_sequence'; + _tasks.insert( + 0, + DownloadTask( + id: id, + workId: workId, + workTitle: workTitle, + fileName: fileName, + savePath: savePath, + receivedBytes: 0, + totalBytes: 0, + status: DownloadTaskStatus.queued, + createdAt: DateTime.now(), + ), + ); + _trimHistory(); + notifyListeners(); + return id; + } + + void markStarted(String taskId) { + _updateTask( + taskId, + (task) => task.copyWith( + status: DownloadTaskStatus.running, + startedAt: DateTime.now(), + clearError: true, + ), + ); + } + + void updateProgress(String taskId, int receivedBytes, int totalBytes) { + _updateTask( + taskId, + (task) => task.copyWith( + status: DownloadTaskStatus.running, + receivedBytes: receivedBytes < 0 ? 0 : receivedBytes, + totalBytes: totalBytes < 0 ? 0 : totalBytes, + ), + ); + } + + void markCompleted(String taskId) { + _updateTask( + taskId, + (task) => task.copyWith( + status: DownloadTaskStatus.completed, + receivedBytes: + task.totalBytes > 0 ? task.totalBytes : task.receivedBytes, + finishedAt: DateTime.now(), + ), + ); + } + + void markFailed(String taskId, Object error) { + _updateTask( + taskId, + (task) => task.copyWith( + status: DownloadTaskStatus.failed, + finishedAt: DateTime.now(), + errorMessage: error.toString(), + ), + ); + } + + void clearFinished() { + _tasks.removeWhere((task) => task.isFinished); + notifyListeners(); + } + + void _updateTask( + String taskId, DownloadTask Function(DownloadTask task) map) { + final index = _tasks.indexWhere((task) => task.id == taskId); + if (index < 0) { + return; + } + _tasks[index] = map(_tasks[index]); + notifyListeners(); + } + + void _trimHistory() { + if (_tasks.length <= _maxHistory) { + return; + } + _tasks.removeRange(_maxHistory, _tasks.length); + } +} diff --git a/lib/core/download/download_request_item.dart b/lib/core/download/download_request_item.dart new file mode 100644 index 0000000..8b2891a --- /dev/null +++ b/lib/core/download/download_request_item.dart @@ -0,0 +1,11 @@ +import 'package:asmrapp/data/models/files/child.dart'; + +class DownloadRequestItem { + final Child file; + final List relativeDirectories; + + const DownloadRequestItem({ + required this.file, + required this.relativeDirectories, + }); +} diff --git a/lib/core/download/download_task.dart b/lib/core/download/download_task.dart new file mode 100644 index 0000000..50b83dc --- /dev/null +++ b/lib/core/download/download_task.dart @@ -0,0 +1,83 @@ +enum DownloadTaskStatus { + queued, + running, + completed, + failed, +} + +class DownloadTask { + final String id; + final int? workId; + final String workTitle; + final String fileName; + final String savePath; + final int receivedBytes; + final int totalBytes; + final DownloadTaskStatus status; + final DateTime createdAt; + final DateTime? startedAt; + final DateTime? finishedAt; + final String? errorMessage; + + const DownloadTask({ + required this.id, + required this.workId, + required this.workTitle, + required this.fileName, + required this.savePath, + required this.receivedBytes, + required this.totalBytes, + required this.status, + required this.createdAt, + this.startedAt, + this.finishedAt, + this.errorMessage, + }); + + double get progress { + if (status == DownloadTaskStatus.completed) { + return 1; + } + if (totalBytes <= 0) { + return 0; + } + final value = receivedBytes / totalBytes; + if (value.isNaN || value.isInfinite) { + return 0; + } + return value.clamp(0, 1); + } + + bool get isActive => + status == DownloadTaskStatus.queued || + status == DownloadTaskStatus.running; + + bool get isFinished => + status == DownloadTaskStatus.completed || + status == DownloadTaskStatus.failed; + + DownloadTask copyWith({ + int? receivedBytes, + int? totalBytes, + DownloadTaskStatus? status, + DateTime? startedAt, + DateTime? finishedAt, + String? errorMessage, + bool clearError = false, + }) { + return DownloadTask( + id: id, + workId: workId, + workTitle: workTitle, + fileName: fileName, + savePath: savePath, + receivedBytes: receivedBytes ?? this.receivedBytes, + totalBytes: totalBytes ?? this.totalBytes, + status: status ?? this.status, + createdAt: createdAt, + startedAt: startedAt ?? this.startedAt, + finishedAt: finishedAt ?? this.finishedAt, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + ); + } +} diff --git a/lib/core/locale/locale_controller.dart b/lib/core/locale/locale_controller.dart new file mode 100644 index 0000000..5a9ca4b --- /dev/null +++ b/lib/core/locale/locale_controller.dart @@ -0,0 +1,45 @@ +import 'package:asmrapp/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class LocaleController extends ChangeNotifier { + static const String _localeKey = 'app_locale_code'; + + final SharedPreferences _prefs; + + LocaleController(this._prefs) { + final savedLanguageCode = _prefs.getString(_localeKey); + if (savedLanguageCode == null || savedLanguageCode.trim().isEmpty) { + return; + } + + final isSupported = AppLocalizations.supportedLocales.any( + (locale) => locale.languageCode == savedLanguageCode, + ); + if (isSupported) { + _locale = Locale(savedLanguageCode); + } + } + + Locale? _locale; + + Locale? get locale => _locale; + + Future setLocale(Locale? locale) async { + final currentLanguageCode = _locale?.languageCode; + final nextLanguageCode = locale?.languageCode; + if (currentLanguageCode == nextLanguageCode) { + return; + } + + _locale = locale; + notifyListeners(); + + if (locale == null) { + await _prefs.remove(_localeKey); + return; + } + + await _prefs.setString(_localeKey, locale.languageCode); + } +} diff --git a/lib/core/platform/dummy_lyric_overlay_controller.dart b/lib/core/platform/dummy_lyric_overlay_controller.dart index c8cf002..2e0fd10 100644 --- a/lib/core/platform/dummy_lyric_overlay_controller.dart +++ b/lib/core/platform/dummy_lyric_overlay_controller.dart @@ -5,23 +5,16 @@ class DummyLyricOverlayController implements ILyricOverlayController { static const _tag = 'LyricOverlay'; @override - Future initialize() async { - } + Future initialize() async {} @override - Future show() async { - - } + Future show() async {} @override - Future hide() async { - - } + Future hide() async {} @override - Future updateLyric(String? text) async { - - } + Future updateLyric(String? text) async {} @override Future checkPermission() async { @@ -35,12 +28,10 @@ class DummyLyricOverlayController implements ILyricOverlayController { } @override - Future dispose() async { - - } + Future dispose() async {} @override Future isShowing() async { return false; } -} \ No newline at end of file +} diff --git a/lib/core/platform/i_lyric_overlay_controller.dart b/lib/core/platform/i_lyric_overlay_controller.dart index e79d860..b2b8c3d 100644 --- a/lib/core/platform/i_lyric_overlay_controller.dart +++ b/lib/core/platform/i_lyric_overlay_controller.dart @@ -1,25 +1,25 @@ abstract class ILyricOverlayController { /// 初始化悬浮窗 Future initialize(); - + /// 显示悬浮窗 Future show(); - + /// 隐藏悬浮窗 Future hide(); - + /// 更新歌词内容 Future updateLyric(String? text); - + /// 检查悬浮窗权限 Future checkPermission(); - + /// 请求悬浮窗权限 Future requestPermission(); - + /// 释放资源 Future dispose(); - + /// 获取悬浮窗当前显示状态 Future isShowing(); -} \ No newline at end of file +} diff --git a/lib/core/platform/lyric_overlay_controller.dart b/lib/core/platform/lyric_overlay_controller.dart index c8f2fb7..8ae456e 100644 --- a/lib/core/platform/lyric_overlay_controller.dart +++ b/lib/core/platform/lyric_overlay_controller.dart @@ -6,7 +6,7 @@ import 'i_lyric_overlay_controller.dart'; class LyricOverlayController implements ILyricOverlayController { static const _tag = 'LyricOverlay'; static const _channel = MethodChannel('one.asmr.yuro/lyric_overlay'); - + @override Future initialize() async { try { @@ -18,47 +18,47 @@ class LyricOverlayController implements ILyricOverlayController { // 因为这个错误不应该影响应用的主要功能 } } - + @override Future show() async { AppLogger.debug('[$_tag] 显示悬浮窗'); await _channel.invokeMethod('show'); } - + @override Future hide() async { AppLogger.debug('[$_tag] 隐藏悬浮窗'); await _channel.invokeMethod('hide'); } - + @override Future updateLyric(String? text) async { AppLogger.debug('[$_tag] 更新歌词: ${text ?? '<空>'}'); await _channel.invokeMethod('updateLyric', {'text': text}); } - + @override Future checkPermission() async { AppLogger.debug('[$_tag] 检查权限'); return await Permission.systemAlertWindow.isGranted; } - + @override Future requestPermission() async { AppLogger.debug('[$_tag] 请求权限'); final status = await Permission.systemAlertWindow.request(); return status.isGranted; } - + @override Future dispose() async { AppLogger.debug('[$_tag] 释放资源'); await _channel.invokeMethod('dispose'); } - + @override Future isShowing() async { final result = await _channel.invokeMethod('isShowing') ?? false; return result; } -} \ No newline at end of file +} diff --git a/lib/core/platform/lyric_overlay_manager.dart b/lib/core/platform/lyric_overlay_manager.dart index e91bdff..4e78498 100644 --- a/lib/core/platform/lyric_overlay_manager.dart +++ b/lib/core/platform/lyric_overlay_manager.dart @@ -9,12 +9,12 @@ class LyricOverlayManager { final ISubtitleService _subtitleService; StreamSubscription? _subscription; bool _isShowing = false; - + LyricOverlayManager({ required ILyricOverlayController controller, required ISubtitleService subtitleService, - }) : _controller = controller, - _subtitleService = subtitleService; + }) : _controller = controller, + _subtitleService = subtitleService; Future initialize() async { await _controller.initialize(); @@ -23,19 +23,19 @@ class LyricOverlayManager { _controller.updateLyric(subtitle?.text); } }); - + _isShowing = await _controller.isShowing(); - + if (_isShowing) { await show(); } } - + Future dispose() async { await _subscription?.cancel(); await _controller.dispose(); } - + Future checkPermission() async { return await _controller.checkPermission(); } @@ -81,22 +81,23 @@ class LyricOverlayManager { Future _showPermissionDialog(BuildContext context) async { return await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('开启悬浮歌词'), - content: const Text('需要悬浮窗权限来显示歌词,是否授予权限?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('取消'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: const Text('确定'), + context: context, + builder: (context) => AlertDialog( + title: const Text('开启悬浮歌词'), + content: const Text('需要悬浮窗权限来显示歌词,是否授予权限?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确定'), + ), + ], ), - ], - ), - ) ?? false; + ) ?? + false; } /// 切换显示/隐藏状态 @@ -107,10 +108,10 @@ class LyricOverlayManager { await showWithPermissionCheck(context); } } - + // 其他控制方法... Future syncState() async { _isShowing = await _controller.isShowing(); } -} \ No newline at end of file +} diff --git a/lib/core/platform/wakelock_controller.dart b/lib/core/platform/wakelock_controller.dart index 1f7fdeb..e263fef 100644 --- a/lib/core/platform/wakelock_controller.dart +++ b/lib/core/platform/wakelock_controller.dart @@ -1,8 +1,7 @@ +import 'package:asmrapp/utils/logger.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; - import 'package:wakelock_plus/wakelock_plus.dart'; -import 'package:asmrapp/utils/logger.dart'; class WakeLockController extends ChangeNotifier { static const _tag = 'WakeLock'; @@ -46,6 +45,7 @@ class WakeLockController extends ChangeNotifier { } } + @override Future dispose() async { try { await WakelockPlus.disable(); @@ -54,4 +54,4 @@ class WakeLockController extends ChangeNotifier { } super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/cache/subtitle_cache_manager.dart b/lib/core/subtitle/cache/subtitle_cache_manager.dart index 1742c4d..951d569 100644 --- a/lib/core/subtitle/cache/subtitle_cache_manager.dart +++ b/lib/core/subtitle/cache/subtitle_cache_manager.dart @@ -6,7 +6,7 @@ import 'package:asmrapp/utils/logger.dart'; class SubtitleCacheManager { static const String key = 'subtitleCache'; - + static final CacheManager instance = CacheManager( Config( key, @@ -62,4 +62,4 @@ class SubtitleCacheManager { return 0; } } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/i_subtitle_service.dart b/lib/core/subtitle/i_subtitle_service.dart index 96ee76f..a97b40e 100644 --- a/lib/core/subtitle/i_subtitle_service.dart +++ b/lib/core/subtitle/i_subtitle_service.dart @@ -3,28 +3,28 @@ import 'package:asmrapp/core/audio/models/subtitle.dart'; abstract class ISubtitleService { // 字幕加载 Future loadSubtitle(String url); - + // 字幕状态流 Stream get subtitleStream; - + // 当前字幕流 Stream get currentSubtitleStream; - + // 当前字幕 Subtitle? get currentSubtitle; - + // 更新播放位置 void updatePosition(Duration position); - + // 资源释放 void dispose(); - + // 添加这一行 - SubtitleList? get subtitleList; // 获取当前字幕列表 - + SubtitleList? get subtitleList; // 获取当前字幕列表 + // 添加清除字幕的方法 void clearSubtitle(); - + Stream get currentSubtitleWithStateStream; SubtitleWithState? get currentSubtitleWithState; -} \ No newline at end of file +} diff --git a/lib/core/subtitle/managers/subtitle_state_manager.dart b/lib/core/subtitle/managers/subtitle_state_manager.dart index be450ae..20cf2b7 100644 --- a/lib/core/subtitle/managers/subtitle_state_manager.dart +++ b/lib/core/subtitle/managers/subtitle_state_manager.dart @@ -9,11 +9,13 @@ class SubtitleStateManager { final _subtitleController = StreamController.broadcast(); final _currentSubtitleController = StreamController.broadcast(); - final _currentSubtitleWithStateController = StreamController.broadcast(); + final _currentSubtitleWithStateController = + StreamController.broadcast(); Stream get subtitleStream => _subtitleController.stream; - Stream get currentSubtitleStream => _currentSubtitleController.stream; - Stream get currentSubtitleWithStateStream => + Stream get currentSubtitleStream => + _currentSubtitleController.stream; + Stream get currentSubtitleWithStateStream => _currentSubtitleWithStateController.stream; Subtitle? get currentSubtitle => _currentSubtitle; @@ -28,10 +30,12 @@ class SubtitleStateManager { void updatePosition(Duration position) { if (_subtitleList != null) { final newSubtitleWithState = _subtitleList!.getCurrentSubtitle(position); - if (newSubtitleWithState?.subtitle != _currentSubtitleWithState?.subtitle) { + if (newSubtitleWithState?.subtitle != + _currentSubtitleWithState?.subtitle) { _currentSubtitleWithState = newSubtitleWithState; _currentSubtitle = newSubtitleWithState?.subtitle; - AppLogger.debug('字幕更新: ${_currentSubtitle?.text ?? '无字幕'} (${newSubtitleWithState?.state})'); + AppLogger.debug( + '字幕更新: ${_currentSubtitle?.text ?? '无字幕'} (${newSubtitleWithState?.state})'); _currentSubtitleWithStateController.add(newSubtitleWithState); _currentSubtitleController.add(_currentSubtitle); } @@ -53,4 +57,4 @@ class SubtitleStateManager { _currentSubtitleController.close(); _currentSubtitleWithStateController.close(); } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/parsers/lrc_parser.dart b/lib/core/subtitle/parsers/lrc_parser.dart index fade29f..a4ebad5 100644 --- a/lib/core/subtitle/parsers/lrc_parser.dart +++ b/lib/core/subtitle/parsers/lrc_parser.dart @@ -5,38 +5,38 @@ import 'package:asmrapp/utils/logger.dart'; class LrcParser extends BaseSubtitleParser { static final _timeTagRegex = RegExp(r'\[(\d{2}):(\d{2})\.(\d{2})\]'); static final _idTagRegex = RegExp(r'^\[(ar|ti|al|by|offset):(.+)\]$'); - + @override bool canParse(String content) { final lines = content.trim().split('\n'); return lines.any((line) => _timeTagRegex.hasMatch(line)); } - + @override SubtitleList doParse(String content) { final lines = content.split('\n'); final subtitles = []; final metadata = {}; - + for (final line in lines) { final trimmedLine = line.trim(); if (trimmedLine.isEmpty) continue; - + // 检查是否是ID标签 final idMatch = _idTagRegex.firstMatch(trimmedLine); if (idMatch != null) { metadata[idMatch.group(1)!] = idMatch.group(2)!; continue; } - + // 解析时间标签和歌词 final timeMatches = _timeTagRegex.allMatches(trimmedLine); if (timeMatches.isEmpty) continue; - + // 获取歌词内容 (移除所有时间标签) final text = trimmedLine.replaceAll(_timeTagRegex, '').trim(); if (text.isEmpty) continue; - + // 一行可能有多个时间标签 for (final match in timeMatches) { try { @@ -45,7 +45,7 @@ class LrcParser extends BaseSubtitleParser { seconds: match.group(2)!, milliseconds: match.group(3)!, ); - + subtitles.add(Subtitle( start: timestamp, end: timestamp + const Duration(seconds: 5), // 默认持续5秒 @@ -58,10 +58,10 @@ class LrcParser extends BaseSubtitleParser { } } } - + // 按时间排序 subtitles.sort((a, b) => a.start.compareTo(b.start)); - + // 设置正确的结束时间 for (int i = 0; i < subtitles.length - 1; i++) { subtitles[i] = Subtitle( @@ -71,11 +71,11 @@ class LrcParser extends BaseSubtitleParser { index: i, ); } - + AppLogger.debug('LRC解析完成: ${subtitles.length}条字幕, ${metadata.length}个元数据'); return SubtitleList(subtitles); } - + Duration _parseTimestamp({ required String minutes, required String seconds, @@ -87,4 +87,4 @@ class LrcParser extends BaseSubtitleParser { milliseconds: int.parse(milliseconds) * 10, ); } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/parsers/subtitle_parser.dart b/lib/core/subtitle/parsers/subtitle_parser.dart index fac2e85..2b9ea02 100644 --- a/lib/core/subtitle/parsers/subtitle_parser.dart +++ b/lib/core/subtitle/parsers/subtitle_parser.dart @@ -4,7 +4,7 @@ import 'package:asmrapp/core/audio/models/subtitle.dart'; abstract class SubtitleParser { /// 解析字幕内容 SubtitleList parse(String content); - + /// 检查内容格式是否匹配 bool canParse(String content); } @@ -14,11 +14,11 @@ abstract class BaseSubtitleParser implements SubtitleParser { @override SubtitleList parse(String content) { if (!canParse(content)) { - throw FormatException('不支持的字幕格式'); + throw const FormatException('不支持的字幕格式'); } return doParse(content); } - + /// 具体的解析实现 SubtitleList doParse(String content); -} \ No newline at end of file +} diff --git a/lib/core/subtitle/parsers/subtitle_parser_factory.dart b/lib/core/subtitle/parsers/subtitle_parser_factory.dart index 0041d5f..83c9939 100644 --- a/lib/core/subtitle/parsers/subtitle_parser_factory.dart +++ b/lib/core/subtitle/parsers/subtitle_parser_factory.dart @@ -8,7 +8,7 @@ class SubtitleParserFactory { VttParser(), LrcParser(), ]; - + static SubtitleParser? getParser(String content) { try { return _parsers.firstWhere((parser) => parser.canParse(content)); @@ -17,4 +17,4 @@ class SubtitleParserFactory { return null; } } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/parsers/vtt_parser.dart b/lib/core/subtitle/parsers/vtt_parser.dart index 13a88a8..3204d75 100644 --- a/lib/core/subtitle/parsers/vtt_parser.dart +++ b/lib/core/subtitle/parsers/vtt_parser.dart @@ -3,23 +3,23 @@ import 'package:asmrapp/core/subtitle/parsers/subtitle_parser.dart'; class VttParser extends BaseSubtitleParser { static final _vttHeaderRegex = RegExp(r'^WEBVTT'); - + @override bool canParse(String content) { return content.trim().startsWith(_vttHeaderRegex); } - + @override SubtitleList doParse(String content) { final lines = content.split('\n'); final subtitles = []; int index = 0; - + // 跳过WEBVTT头部 while (index < lines.length && !lines[index].contains('-->')) { index++; } - + while (index < lines.length) { final timeLine = lines[index]; if (timeLine.contains('-->')) { @@ -27,15 +27,15 @@ class VttParser extends BaseSubtitleParser { if (times.length == 2) { final start = _parseTimeString(times[0].trim()); final end = _parseTimeString(times[1].trim()); - + // 收集字幕文本 index++; String text = ''; while (index < lines.length && lines[index].trim().isNotEmpty) { - text += lines[index].trim() + '\n'; + text += '${lines[index].trim()}\n'; index++; } - + if (text.isNotEmpty) { subtitles.add(Subtitle( start: start, @@ -48,20 +48,21 @@ class VttParser extends BaseSubtitleParser { } index++; } - + return SubtitleList(subtitles); } - + Duration _parseTimeString(String timeString) { final parts = timeString.split(':'); - if (parts.length != 3) throw FormatException('Invalid time format'); - + if (parts.length != 3) throw const FormatException('Invalid time format'); + final seconds = parts[2].split('.'); return Duration( hours: int.parse(parts[0]), minutes: int.parse(parts[1]), seconds: int.parse(seconds[0]), - milliseconds: seconds.length > 1 ? int.parse(seconds[1].padRight(3, '0')) : 0, + milliseconds: + seconds.length > 1 ? int.parse(seconds[1].padRight(3, '0')) : 0, ); } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/subtitle_loader.dart b/lib/core/subtitle/subtitle_loader.dart index a8f2a21..0b8036a 100644 --- a/lib/core/subtitle/subtitle_loader.dart +++ b/lib/core/subtitle/subtitle_loader.dart @@ -14,27 +14,27 @@ class SubtitleLoader { // 查找字幕文件 Child? findSubtitleFile(Child audioFile, Files files) { if (files.children == null || audioFile.title == null) { - AppLogger.debug('无法查找字幕文件: ${files.children == null ? '文件列表为空' : '当前文件名为空'}'); + AppLogger.debug( + '无法查找字幕文件: ${files.children == null ? '文件列表为空' : '当前文件名为空'}'); return null; } AppLogger.debug('开始查找字幕文件...'); - + // 使用 FilePath 获取同级文件 final siblings = FilePath.getSiblings(audioFile, files); - + // 使用 SubtitleMatcher 查找匹配的字幕文件 - final subtitleFile = SubtitleMatcher.findMatchingSubtitle( - audioFile.title!, - siblings - ); - + final subtitleFile = + SubtitleMatcher.findMatchingSubtitle(audioFile.title!, siblings); + if (subtitleFile != null) { - AppLogger.debug('找到字幕文件: ${subtitleFile.title}, URL: ${subtitleFile.mediaDownloadUrl}'); + AppLogger.debug( + '找到字幕文件: ${subtitleFile.title}, URL: ${subtitleFile.mediaDownloadUrl}'); } else { AppLogger.debug('在当前目录中未找到字幕文件'); } - + return subtitleFile; } @@ -52,13 +52,13 @@ class SubtitleLoader { AppLogger.debug('从网络加载字幕: $url'); final response = await _dio.get(url); AppLogger.debug('字幕文件下载状态: ${response.statusCode}'); - + if (response.statusCode == 200) { final content = response.data as String; - + // 保存到缓存 await SubtitleCacheManager.cacheContent(url, content); - + return _parseSubtitleContent(content); } else { throw Exception('字幕下载失败: ${response.statusCode}'); @@ -71,16 +71,17 @@ class SubtitleLoader { // 新增: 解析字幕内容的私有方法 SubtitleList? _parseSubtitleContent(String content) { - AppLogger.debug('字幕文件内容预览: ${content.substring(0, content.length > 100 ? 100 : content.length)}...'); - + AppLogger.debug( + '字幕文件内容预览: ${content.substring(0, content.length > 100 ? 100 : content.length)}...'); + final parser = SubtitleParserFactory.getParser(content); if (parser == null) { throw Exception('不支持的字幕格式'); } - + final subtitleList = parser.parse(content); AppLogger.debug('字幕解析完成,字幕数量: ${subtitleList.subtitles.length}'); - + return subtitleList; } -} \ No newline at end of file +} diff --git a/lib/core/subtitle/subtitle_service.dart b/lib/core/subtitle/subtitle_service.dart index 87091b3..17460cd 100644 --- a/lib/core/subtitle/subtitle_service.dart +++ b/lib/core/subtitle/subtitle_service.dart @@ -6,20 +6,20 @@ import 'package:get_it/get_it.dart'; import 'package:asmrapp/core/subtitle/subtitle_loader.dart'; import 'package:asmrapp/core/subtitle/managers/subtitle_state_manager.dart'; - class SubtitleService implements ISubtitleService { final _subtitleLoader = GetIt.I(); final _stateManager = SubtitleStateManager(); - + @override Stream get subtitleStream => _stateManager.subtitleStream; - + @override - Stream get currentSubtitleStream => _stateManager.currentSubtitleStream; - + Stream get currentSubtitleStream => + _stateManager.currentSubtitleStream; + @override Subtitle? get currentSubtitle => _stateManager.currentSubtitle; - + @override Future loadSubtitle(String url) async { try { @@ -32,7 +32,7 @@ class SubtitleService implements ISubtitleService { rethrow; } } - + @override void updatePosition(Duration position) { _stateManager.updatePosition(position); @@ -52,10 +52,10 @@ class SubtitleService implements ISubtitleService { } @override - Stream get currentSubtitleWithStateStream => + Stream get currentSubtitleWithStateStream => _stateManager.currentSubtitleWithStateStream; - + @override - SubtitleWithState? get currentSubtitleWithState => + SubtitleWithState? get currentSubtitleWithState => _stateManager.currentSubtitleWithState; -} \ No newline at end of file +} diff --git a/lib/core/subtitle/utils/subtitle_matcher.dart b/lib/core/subtitle/utils/subtitle_matcher.dart index 23ad8f0..a75e568 100644 --- a/lib/core/subtitle/utils/subtitle_matcher.dart +++ b/lib/core/subtitle/utils/subtitle_matcher.dart @@ -3,55 +3,55 @@ import 'package:asmrapp/data/models/files/child.dart'; class SubtitleMatcher { // 支持的字幕格式 static const supportedFormats = ['.vtt', '.lrc']; - + // 检查文件是否为字幕文件 static bool isSubtitleFile(String? fileName) { if (fileName == null) return false; - return supportedFormats.any((format) => - fileName.toLowerCase().endsWith(format)); + return supportedFormats + .any((format) => fileName.toLowerCase().endsWith(format)); } - + // 获取音频文件的可能的字幕文件名列表 static List getPossibleSubtitleNames(String audioFileName) { final names = []; final baseName = _getBaseName(audioFileName); - + // 生成可能的字幕文件名 for (final format in supportedFormats) { // 1. 直接替换扩展名: aaa.mp3 -> aaa.vtt names.add('$baseName$format'); - + // 2. 保留原扩展名: aaa.mp3 -> aaa.mp3.vtt names.add('$audioFileName$format'); } - + return names; } - + // 查找匹配的字幕文件 - static Child? findMatchingSubtitle(String audioFileName, List siblings) { + static Child? findMatchingSubtitle( + String audioFileName, List siblings) { final possibleNames = getPossibleSubtitleNames(audioFileName); - + // 遍历所有可能的字幕文件名 for (final subtitleName in possibleNames) { try { final subtitleFile = siblings.firstWhere( - (file) => file.title?.toLowerCase() == subtitleName.toLowerCase() - ); + (file) => file.title?.toLowerCase() == subtitleName.toLowerCase()); return subtitleFile; } catch (_) { // 继续查找下一个可能的文件名 continue; } } - + return null; } - + // 获取不带扩展名的文件名 static String _getBaseName(String fileName) { final lastDot = fileName.lastIndexOf('.'); if (lastDot == -1) return fileName; return fileName.substring(0, lastDot); } -} \ No newline at end of file +} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 448a2d2..0e849d9 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -10,17 +10,12 @@ class AppColors { // 基础色调 primary: Color(0xFF6750A4), onPrimary: Colors.white, - + // 表面颜色 surface: Colors.white, - surfaceVariant: Color(0xFFF4F4F4), onSurface: Colors.black87, surfaceContainerHighest: Color(0xFFE6E6E6), - - // 背景颜色 - background: Colors.white, - onBackground: Colors.black87, - + // 错误状态颜色 error: Color(0xFFB3261E), errorContainer: Color(0xFFF9DEDC), @@ -32,20 +27,15 @@ class AppColors { // 基础色调 primary: Color(0xFFD0BCFF), onPrimary: Color(0xFF381E72), - + // 表面颜色 surface: Color(0xFF1C1B1F), - surfaceVariant: Color(0xFF2B2930), onSurface: Colors.white, surfaceContainerHighest: Color(0xFF2B2B2B), - - // 背景颜色 - background: Color(0xFF1C1B1F), - onBackground: Colors.white, - + // 错误状态颜色 error: Color(0xFFF2B8B5), errorContainer: Color(0xFF8C1D18), onError: Color(0xFF601410), ); -} \ No newline at end of file +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 023ee93..66b4def 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; + import 'app_colors.dart'; /// 应用主题配置 @@ -8,45 +9,45 @@ class AppTheme { // 亮色主题 static ThemeData get light => ThemeData( - useMaterial3: true, - brightness: Brightness.light, - colorScheme: AppColors.lightColorScheme, - - // Card主题 - cardTheme: const CardTheme( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - ), - ), - - // AppBar主题 - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - scrolledUnderElevation: 0, - ), - ); + useMaterial3: true, + brightness: Brightness.light, + colorScheme: AppColors.lightColorScheme, + + // Card主题 + cardTheme: const CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + + // AppBar主题 + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + ), + ); // 暗色主题 static ThemeData get dark => ThemeData( - useMaterial3: true, - brightness: Brightness.dark, - colorScheme: AppColors.darkColorScheme, - - // Card主题 - cardTheme: const CardTheme( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), - ), - ), - - // AppBar主题 - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - scrolledUnderElevation: 0, - ), - ); -} \ No newline at end of file + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: AppColors.darkColorScheme, + + // Card主题 + cardTheme: const CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + + // AppBar主题 + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + scrolledUnderElevation: 0, + ), + ); +} diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index a83877f..e6bfcac 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -17,25 +17,25 @@ class ThemeController extends ChangeNotifier { } ThemeMode _themeMode = ThemeMode.system; - + ThemeMode get themeMode => _themeMode; // 切换主题模式 Future setThemeMode(ThemeMode mode) async { if (_themeMode == mode) return; - + _themeMode = mode; notifyListeners(); - + // 保存到持久化存储 await _prefs.setString(_themeKey, mode.toString()); } // 切换到下一个主题模式 Future toggleThemeMode() async { - final modes = ThemeMode.values; + const modes = ThemeMode.values; final currentIndex = modes.indexOf(_themeMode); final nextIndex = (currentIndex + 1) % modes.length; await setThemeMode(modes[nextIndex]); } -} \ No newline at end of file +} diff --git a/lib/data/models/mark_status.dart b/lib/data/models/mark_status.dart index 0ef7ea0..c18cc5c 100644 --- a/lib/data/models/mark_status.dart +++ b/lib/data/models/mark_status.dart @@ -7,4 +7,4 @@ enum MarkStatus { final String label; const MarkStatus(this.label); -} \ No newline at end of file +} diff --git a/lib/data/models/playback/playback_state.dart b/lib/data/models/playback/playback_state.dart index 2c399c2..1f15c73 100644 --- a/lib/data/models/playback/playback_state.dart +++ b/lib/data/models/playback/playback_state.dart @@ -16,10 +16,10 @@ class PlaybackState with _$PlaybackState { required List playlist, required int currentIndex, required PlayMode playMode, - required int position, // 使用毫秒存储 - required String timestamp, // ISO8601 格式 + required int position, // 使用毫秒存储 + required String timestamp, // ISO8601 格式 }) = _PlaybackState; - factory PlaybackState.fromJson(Map json) => + factory PlaybackState.fromJson(Map json) => _$PlaybackStateFromJson(json); -} \ No newline at end of file +} diff --git a/lib/data/repositories/auth_repository.dart b/lib/data/repositories/auth_repository.dart index 3267ff0..e3d4d57 100644 --- a/lib/data/repositories/auth_repository.dart +++ b/lib/data/repositories/auth_repository.dart @@ -43,4 +43,4 @@ class AuthRepository { rethrow; } } -} \ No newline at end of file +} diff --git a/lib/data/services/api_service.dart b/lib/data/services/api_service.dart index 92639aa..0b7d590 100644 --- a/lib/data/services/api_service.dart +++ b/lib/data/services/api_service.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:asmrapp/core/cache/recommendation_cache_manager.dart'; import 'package:asmrapp/data/models/mark_status.dart'; import 'package:asmrapp/data/models/playlists_with_exist_statu/playlists_with_exist_statu.dart'; @@ -10,7 +12,6 @@ import 'package:asmrapp/data/services/interceptors/auth_interceptor.dart'; import 'package:asmrapp/data/models/playlists_with_exist_statu/playlist.dart'; import 'package:asmrapp/data/models/my_lists/my_playlists/my_playlists.dart'; - class WorksResponse { final List works; final Pagination pagination; @@ -33,11 +34,11 @@ class ApiService { Future getWorkFiles(String workId, {CancelToken? cancelToken}) async { try { final response = await _dio.get( - '/tracks/$workId', + '/tracks/$workId', queryParameters: { 'v': '1', }, - cancelToken: cancelToken, // 添加 cancelToken 支持 + cancelToken: cancelToken, // 添加 cancelToken 支持 ); if (response.statusCode == 200) { @@ -60,6 +61,39 @@ class ApiService { } } + /// 获取文本文件内容 + Future getTextFileContent( + String url, { + CancelToken? cancelToken, + }) async { + try { + final response = await _dio.get( + url, + options: Options(responseType: ResponseType.bytes), + cancelToken: cancelToken, + ); + + if (response.statusCode != 200) { + throw Exception('获取文本文件失败: ${response.statusCode}'); + } + + final data = response.data; + if (data == null) return ''; + if (data is String) return data; + if (data is List) { + return utf8.decode(data, allowMalformed: true); + } + + return data.toString(); + } on DioException catch (e) { + AppLogger.error('文本文件请求失败', e, e.stackTrace); + throw Exception('网络请求失败: ${e.message}'); + } catch (e, stackTrace) { + AppLogger.error('文本文件解析失败', e, stackTrace); + throw Exception('文本文件解析失败: $e'); + } + } + /// 获取作品列表 Future getWorks({ int page = 1, @@ -260,7 +294,8 @@ class ApiService { }) async { try { // 先尝试从缓存获取 - final cachedData = _recommendationCache.get(itemId, page, hasSubtitle ? 1 : 0); + final cachedData = + _recommendationCache.get(itemId, page, hasSubtitle ? 1 : 0); if (cachedData != null) { return cachedData; } @@ -288,7 +323,8 @@ class ApiService { ); // 存入缓存 - _recommendationCache.set(itemId, page, hasSubtitle ? 1 : 0, worksResponse); + _recommendationCache.set( + itemId, page, hasSubtitle ? 1 : 0, worksResponse); return worksResponse; } @@ -396,6 +432,26 @@ class ApiService { } } + /// 更新作品评分 + Future updateWorkRating(String workId, int rating) async { + try { + final response = await _dio.put( + '/review', + data: { + 'work_id': int.parse(workId), + 'rating': rating, + }, + ); + + if (response.statusCode != 200) { + throw Exception('评分失败: ${response.statusCode}'); + } + } catch (e) { + AppLogger.error('更新评分失败', e); + rethrow; + } + } + /// 将 MarkStatus 枚举转换为 API 参数 String convertMarkStatusToApi(MarkStatus status) { switch (status) { @@ -415,11 +471,13 @@ class ApiService { /// 获取默认标记目标收藏夹 Future getDefaultMarkTargetPlaylist() async { try { - final response = await _dio.get('/playlist/get-default-mark-target-playlist'); + final response = + await _dio.get('/playlist/get-default-mark-target-playlist'); if (response.statusCode == 200) { final playlist = Playlist.fromJson(response.data); - AppLogger.info('获取默认标记目标收藏夹成功: id=${playlist.id}, name=${playlist.name}'); + AppLogger.info( + '获取默认标记目标收藏夹成功: id=${playlist.id}, name=${playlist.name}'); return playlist; } diff --git a/lib/data/services/auth_service.dart b/lib/data/services/auth_service.dart index a1f784c..516c6b9 100644 --- a/lib/data/services/auth_service.dart +++ b/lib/data/services/auth_service.dart @@ -5,15 +5,16 @@ import '../../utils/logger.dart'; class AuthService { final Dio _dio; - AuthService() - : _dio = Dio(BaseOptions( - baseUrl: 'https://api.asmr.one/api', - )); + AuthService() + : _dio = Dio(BaseOptions( + baseUrl: 'https://api.asmr.one/api', + )); Future login(String name, String password) async { try { AppLogger.info('开始登录请求: name=$name'); - final response = await _dio.post('/auth/me', + final response = await _dio.post( + '/auth/me', data: { 'name': name, 'password': password, @@ -25,7 +26,8 @@ class AuthService { if (response.statusCode == 200) { final authResp = AuthResp.fromJson(response.data); - AppLogger.info('登录成功: username=${authResp.user?.name}, group=${authResp.user?.group}'); + AppLogger.info( + '登录成功: username=${authResp.user?.name}, group=${authResp.user?.group}'); return authResp; } @@ -39,4 +41,4 @@ class AuthService { throw Exception('登录失败: $e'); } } -} \ No newline at end of file +} diff --git a/lib/data/services/interceptors/auth_interceptor.dart b/lib/data/services/interceptors/auth_interceptor.dart index fc7cc93..4caf46d 100644 --- a/lib/data/services/interceptors/auth_interceptor.dart +++ b/lib/data/services/interceptors/auth_interceptor.dart @@ -6,21 +6,21 @@ import 'package:asmrapp/utils/logger.dart'; class AuthInterceptor extends Interceptor { @override Future onRequest( - RequestOptions options, + RequestOptions options, RequestInterceptorHandler handler, ) async { try { final authRepository = GetIt.I(); final authData = await authRepository.getAuthData(); - + if (authData?.token != null) { options.headers['Authorization'] = 'Bearer ${authData!.token}'; } - + handler.next(options); } catch (e) { AppLogger.error('AuthInterceptor: 处理请求失败', e); - handler.next(options); // 即使出错也继续请求 + handler.next(options); // 即使出错也继续请求 } } -} \ No newline at end of file +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..10a5613 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,267 @@ +{ + "@@locale": "en", + "appName": "asmr.one", + "retry": "Retry", + "cancel": "Cancel", + "confirm": "Confirm", + "logoutAction": "Log out", + "logoutConfirmTitle": "Log out", + "logoutConfirmMessage": "Are you sure you want to log out?", + "login": "Log in", + "favorites": "Favorites", + "settings": "Settings", + "languageTitle": "Language", + "languageDescription": "Choose the app display language.", + "languageSystem": "Follow System", + "languageEnglish": "English", + "languageJapanese": "Japanese", + "languageChinese": "Chinese", + "cacheManager": "Cache Manager", + "screenAlwaysOn": "Keep Screen On", + "themeSystem": "Follow System", + "themeLight": "Light Mode", + "themeDark": "Dark Mode", + "navigationFavorites": "Favorites", + "navigationHome": "Home", + "navigationDownloadProgress": "Downloads", + "navigationForYou": "For You", + "navigationPopularWorks": "Popular Works", + "navigationRecommend": "Recommendations", + "homeTabWorks": "Works", + "homeTabDownloads": "Progress", + "titleWithCount": "{title} ({count})", + "@titleWithCount": { + "placeholders": { + "title": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "search": "Search", + "searchHint": "Search...", + "searchPromptInitial": "Enter keywords to search", + "searchNoResults": "No results found", + "subtitle": "Subtitles", + "subtitleAvailable": "Subtitles available", + "orderFieldCollectionTime": "Collection Time", + "orderFieldReleaseDate": "Release Date", + "orderFieldSales": "Sales", + "orderFieldPrice": "Price", + "orderFieldRating": "Rating", + "orderFieldReviewCount": "Review Count", + "orderFieldId": "RJ Number", + "orderFieldMyRating": "My Rating", + "orderFieldAllAges": "All Ages", + "orderFieldRandom": "Random", + "orderLabel": "Sort By", + "orderDirectionDesc": "Descending", + "orderDirectionAsc": "Ascending", + "searchOrderNewest": "Newest Collected", + "searchOrderOldest": "Oldest Collected", + "searchOrderReleaseDesc": "Release Date (Newest)", + "searchOrderReleaseAsc": "Release Date (Oldest)", + "searchOrderSalesDesc": "Sales (High to Low)", + "searchOrderSalesAsc": "Sales (Low to High)", + "searchOrderPriceDesc": "Price (High to Low)", + "searchOrderPriceAsc": "Price (Low to High)", + "searchOrderRatingDesc": "Rating (High to Low)", + "searchOrderReviewCountDesc": "Review Count (High to Low)", + "searchOrderIdDesc": "RJ Number (High to Low)", + "searchOrderIdAsc": "RJ Number (Low to High)", + "searchOrderRandom": "Random Order", + "favoritesTitle": "Favorites", + "pleaseLogin": "Please log in first", + "emptyContent": "No content", + "emptyWorks": "No works", + "similarWorksTitle": "Similar Works", + "similarWorksSeeAll": "See All", + "playlistAddToFavorites": "Add to Favorites", + "playlistEmpty": "No playlists", + "playlistAddSuccess": "Added: {name}", + "@playlistAddSuccess": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "playlistRemoveSuccess": "Removed: {name}", + "@playlistRemoveSuccess": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "playlistSystemMarked": "My Marks", + "playlistSystemLiked": "Liked", + "playlistWorksCount": "{count} works", + "@playlistWorksCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "workActionFavorite": "Favorite", + "workActionMark": "Mark", + "workActionRate": "Rate", + "workActionDownload": "Download", + "workActionChecking": "Checking", + "workActionRecommend": "Recommendations", + "workActionNoRecommendation": "No recommendations", + "downloadDialogTitle": "Select files to download", + "downloadDialogNoFiles": "No downloadable files", + "downloadSelectedCount": "Selected: {count}", + "@downloadSelectedCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadSelectAll": "Select All", + "downloadClearSelection": "Clear Selection", + "downloadNoFilesSelected": "Please select files to download", + "downloadSuccess": "Downloaded {count} files: {path}", + "@downloadSuccess": { + "placeholders": { + "count": { + "type": "int" + }, + "path": { + "type": "String" + } + } + }, + "downloadPartial": "Downloaded {successCount} / Failed {failedCount}", + "@downloadPartial": { + "placeholders": { + "successCount": { + "type": "int" + }, + "failedCount": { + "type": "int" + } + } + }, + "downloadAllFailed": "Download failed ({failedCount})", + "@downloadAllFailed": { + "placeholders": { + "failedCount": { + "type": "int" + } + } + }, + "downloadDirectoryTitle": "Download Folder", + "downloadDirectoryDescription": "You can choose where downloads are saved. If not set, the default folder is used.", + "downloadDirectoryDefaultValue": "Not set (use default location)", + "downloadDirectoryPermissionHint": "Storage permission will be requested if needed.", + "downloadDirectoryPick": "Choose Folder", + "downloadDirectoryReset": "Reset to Default", + "downloadDirectoryUpdated": "Save location updated: {path}", + "@downloadDirectoryUpdated": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "downloadDirectoryResetSuccess": "Save location reset to default", + "downloadProgressEmpty": "No active downloads", + "downloadProgressClearFinished": "Clear Completed", + "downloadProgressActiveSection": "Active", + "downloadProgressHistorySection": "History", + "downloadStatusQueued": "Queued", + "downloadStatusRunning": "Downloading", + "downloadStatusCompleted": "Completed", + "downloadStatusFailed": "Failed", + "openDlsiteInBrowser": "Open DLsite in Browser", + "markStatusTitle": "Mark Status", + "markStatusWantToListen": "Want to Listen", + "markStatusListening": "Listening", + "markStatusListened": "Listened", + "markStatusRelistening": "Relistening", + "markStatusOnHold": "On Hold", + "markUpdated": "Status changed to {status}", + "@markUpdated": { + "placeholders": { + "status": { + "type": "String" + } + } + }, + "markFailed": "Failed to update mark: {error}", + "@markFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "workFilesTitle": "Files", + "playUnsupportedFileType": "Unsupported format: {type}", + "@playUnsupportedFileType": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "playUrlMissing": "Cannot play: URL is missing", + "playFilesNotLoaded": "File list not loaded", + "playFailed": "Playback failed: {error}", + "@playFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "operationFailed": "Operation failed: {error}", + "@operationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cacheManagerTitle": "Cache Manager", + "cacheAudio": "Audio Cache", + "cacheSubtitle": "Subtitle Cache", + "cacheTotal": "Total Cache Size", + "cacheClear": "Clear", + "cacheClearAll": "Clear All", + "cacheInfoTitle": "About Cache", + "cacheDescription": "Cache keeps recent audio and subtitles to speed up playback. Expired and oversized data is cleaned up automatically.", + "cacheLoadFailed": "Failed to load: {error}", + "@cacheLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cacheClearFailed": "Failed to clear: {error}", + "@cacheClearFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subtitleTag": "Subtitles", + "noPlaying": "Nothing playing", + "screenOnDisable": "Disable Keep Screen On", + "screenOnEnable": "Enable Keep Screen On", + "unknownWorkTitle": "Unknown Work", + "unknownArtist": "Unknown Artist", + "lyricsEmpty": "No lyrics", + "loginTitle": "Log in", + "loginUsernameLabel": "Username", + "loginPasswordLabel": "Password", + "loginAction": "Log in" +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb new file mode 100644 index 0000000..baef9ae --- /dev/null +++ b/lib/l10n/app_ja.arb @@ -0,0 +1,267 @@ +{ + "@@locale": "ja", + "appName": "asmr.one", + "retry": "再試行", + "cancel": "キャンセル", + "confirm": "確認", + "logoutAction": "ログアウト", + "logoutConfirmTitle": "ログアウト", + "logoutConfirmMessage": "本当にログアウトしますか?", + "login": "ログイン", + "favorites": "お気に入り", + "settings": "設定", + "languageTitle": "言語", + "languageDescription": "アプリの表示言語を選択できます。", + "languageSystem": "システムと同じ", + "languageEnglish": "English", + "languageJapanese": "日本語", + "languageChinese": "中文", + "cacheManager": "キャッシュ管理", + "screenAlwaysOn": "画面常時オン", + "themeSystem": "システムと同じ", + "themeLight": "ライトモード", + "themeDark": "ダークモード", + "navigationFavorites": "お気に入り", + "navigationHome": "ホーム", + "navigationDownloadProgress": "ダウンロード進捗", + "navigationForYou": "あなた向け", + "navigationPopularWorks": "人気作品", + "navigationRecommend": "おすすめ", + "homeTabWorks": "作品", + "homeTabDownloads": "進捗", + "titleWithCount": "{title} ({count})", + "@titleWithCount": { + "placeholders": { + "title": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "search": "検索", + "searchHint": "検索...", + "searchPromptInitial": "キーワードを入力して検索", + "searchNoResults": "該当する結果がありません", + "subtitle": "字幕", + "subtitleAvailable": "字幕あり", + "orderFieldCollectionTime": "収録日時", + "orderFieldReleaseDate": "発売日", + "orderFieldSales": "売上", + "orderFieldPrice": "価格", + "orderFieldRating": "評価", + "orderFieldReviewCount": "レビュー数", + "orderFieldId": "RJ番号", + "orderFieldMyRating": "自分の評価", + "orderFieldAllAges": "全年齢", + "orderFieldRandom": "ランダム", + "orderLabel": "並び替え", + "orderDirectionDesc": "降順", + "orderDirectionAsc": "昇順", + "searchOrderNewest": "最新収録", + "searchOrderOldest": "最古の収録", + "searchOrderReleaseDesc": "発売日(新しい順)", + "searchOrderReleaseAsc": "発売日(古い順)", + "searchOrderSalesDesc": "売上(高い順)", + "searchOrderSalesAsc": "売上(低い順)", + "searchOrderPriceDesc": "価格(高い順)", + "searchOrderPriceAsc": "価格(低い順)", + "searchOrderRatingDesc": "評価(高い順)", + "searchOrderReviewCountDesc": "レビュー数(多い順)", + "searchOrderIdDesc": "RJ番号(大きい順)", + "searchOrderIdAsc": "RJ番号(小さい順)", + "searchOrderRandom": "ランダム順", + "favoritesTitle": "お気に入り", + "pleaseLogin": "先にログインしてください", + "emptyContent": "内容がありません", + "emptyWorks": "作品がありません", + "similarWorksTitle": "関連作品", + "similarWorksSeeAll": "すべて見る", + "playlistAddToFavorites": "お気に入りに追加", + "playlistEmpty": "リストがありません", + "playlistAddSuccess": "追加しました: {name}", + "@playlistAddSuccess": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "playlistRemoveSuccess": "削除しました: {name}", + "@playlistRemoveSuccess": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "playlistSystemMarked": "自分のマーク", + "playlistSystemLiked": "いいねした", + "playlistWorksCount": "{count} 件の作品", + "@playlistWorksCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "workActionFavorite": "お気に入り", + "workActionMark": "マーク", + "workActionRate": "評価", + "workActionDownload": "ダウンロード", + "workActionChecking": "確認中", + "workActionRecommend": "おすすめ", + "workActionNoRecommendation": "おすすめはありません", + "downloadDialogTitle": "ダウンロードするファイルを選択", + "downloadDialogNoFiles": "ダウンロード可能なファイルがありません", + "downloadSelectedCount": "選択中: {count}件", + "@downloadSelectedCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadSelectAll": "すべて選択", + "downloadClearSelection": "選択解除", + "downloadNoFilesSelected": "ダウンロードするファイルを選択してください", + "downloadSuccess": "{count}件のダウンロードが完了しました: {path}", + "@downloadSuccess": { + "placeholders": { + "count": { + "type": "int" + }, + "path": { + "type": "String" + } + } + }, + "downloadPartial": "ダウンロード完了 {successCount}件 / 失敗 {failedCount}件", + "@downloadPartial": { + "placeholders": { + "successCount": { + "type": "int" + }, + "failedCount": { + "type": "int" + } + } + }, + "downloadAllFailed": "ダウンロードに失敗しました({failedCount}件)", + "@downloadAllFailed": { + "placeholders": { + "failedCount": { + "type": "int" + } + } + }, + "downloadDirectoryTitle": "ダウンロードフォルダ", + "downloadDirectoryDescription": "保存先フォルダを指定できます。未指定時は既定のダウンロード先を使います。", + "downloadDirectoryDefaultValue": "未設定(既定の保存先)", + "downloadDirectoryPermissionHint": "必要な場合はストレージ権限を要求します。", + "downloadDirectoryPick": "フォルダを選択", + "downloadDirectoryReset": "既定に戻す", + "downloadDirectoryUpdated": "保存先を更新しました: {path}", + "@downloadDirectoryUpdated": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "downloadDirectoryResetSuccess": "保存先を既定に戻しました", + "downloadProgressEmpty": "進行中のダウンロードはありません", + "downloadProgressClearFinished": "完了履歴をクリア", + "downloadProgressActiveSection": "進行中", + "downloadProgressHistorySection": "履歴", + "downloadStatusQueued": "待機中", + "downloadStatusRunning": "ダウンロード中", + "downloadStatusCompleted": "完了", + "downloadStatusFailed": "失敗", + "openDlsiteInBrowser": "DLsiteをブラウザで開く", + "markStatusTitle": "マーク状態", + "markStatusWantToListen": "聴きたい", + "markStatusListening": "聴いている", + "markStatusListened": "聴いた", + "markStatusRelistening": "聴き直し", + "markStatusOnHold": "保留", + "markUpdated": "状態を{status}に変更", + "@markUpdated": { + "placeholders": { + "status": { + "type": "String" + } + } + }, + "markFailed": "マーク失敗: {error}", + "@markFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "workFilesTitle": "ファイル一覧", + "playUnsupportedFileType": "未対応形式: {type}", + "@playUnsupportedFileType": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "playUrlMissing": "再生できません: URLがありません", + "playFilesNotLoaded": "ファイル一覧未読み込み", + "playFailed": "再生失敗: {error}", + "@playFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "operationFailed": "操作失敗: {error}", + "@operationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cacheManagerTitle": "キャッシュ管理", + "cacheAudio": "音声キャッシュ", + "cacheSubtitle": "字幕キャッシュ", + "cacheTotal": "キャッシュ総量", + "cacheClear": "削除", + "cacheClearAll": "全て削除", + "cacheInfoTitle": "キャッシュについて", + "cacheDescription": "キャッシュは直近の音声と字幕を保存し、次回再生を速くします。期限切れや容量超過は自動で整理されます。", + "cacheLoadFailed": "読み込み失敗: {error}", + "@cacheLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cacheClearFailed": "削除失敗: {error}", + "@cacheClearFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subtitleTag": "字幕", + "noPlaying": "再生中なし", + "screenOnDisable": "画面常時オンをオフ", + "screenOnEnable": "画面常時オンをオン", + "unknownWorkTitle": "不明な作品", + "unknownArtist": "不明な出演者", + "lyricsEmpty": "歌詞なし", + "loginTitle": "ログイン", + "loginUsernameLabel": "ユーザー名", + "loginPasswordLabel": "パスワード", + "loginAction": "ログイン" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..d874225 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,995 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ja.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ja'), + Locale('zh') + ]; + + /// No description provided for @appName. + /// + /// In zh, this message translates to: + /// **'asmr.one'** + String get appName; + + /// No description provided for @retry. + /// + /// In zh, this message translates to: + /// **'重试'** + String get retry; + + /// No description provided for @cancel. + /// + /// In zh, this message translates to: + /// **'取消'** + String get cancel; + + /// No description provided for @confirm. + /// + /// In zh, this message translates to: + /// **'确认'** + String get confirm; + + /// No description provided for @logoutAction. + /// + /// In zh, this message translates to: + /// **'退出登录'** + String get logoutAction; + + /// No description provided for @logoutConfirmTitle. + /// + /// In zh, this message translates to: + /// **'退出登录'** + String get logoutConfirmTitle; + + /// No description provided for @logoutConfirmMessage. + /// + /// In zh, this message translates to: + /// **'确定要退出登录吗?'** + String get logoutConfirmMessage; + + /// No description provided for @login. + /// + /// In zh, this message translates to: + /// **'登录'** + String get login; + + /// No description provided for @favorites. + /// + /// In zh, this message translates to: + /// **'我的收藏'** + String get favorites; + + /// No description provided for @settings. + /// + /// In zh, this message translates to: + /// **'设置'** + String get settings; + + /// No description provided for @languageTitle. + /// + /// In zh, this message translates to: + /// **'语言'** + String get languageTitle; + + /// No description provided for @languageDescription. + /// + /// In zh, this message translates to: + /// **'可以选择应用显示语言。'** + String get languageDescription; + + /// No description provided for @languageSystem. + /// + /// In zh, this message translates to: + /// **'跟随系统'** + String get languageSystem; + + /// No description provided for @languageEnglish. + /// + /// In zh, this message translates to: + /// **'English'** + String get languageEnglish; + + /// No description provided for @languageJapanese. + /// + /// In zh, this message translates to: + /// **'日本語'** + String get languageJapanese; + + /// No description provided for @languageChinese. + /// + /// In zh, this message translates to: + /// **'中文'** + String get languageChinese; + + /// No description provided for @cacheManager. + /// + /// In zh, this message translates to: + /// **'缓存管理'** + String get cacheManager; + + /// No description provided for @screenAlwaysOn. + /// + /// In zh, this message translates to: + /// **'屏幕常亮'** + String get screenAlwaysOn; + + /// No description provided for @themeSystem. + /// + /// In zh, this message translates to: + /// **'跟随系统主题'** + String get themeSystem; + + /// No description provided for @themeLight. + /// + /// In zh, this message translates to: + /// **'浅色模式'** + String get themeLight; + + /// No description provided for @themeDark. + /// + /// In zh, this message translates to: + /// **'深色模式'** + String get themeDark; + + /// No description provided for @navigationFavorites. + /// + /// In zh, this message translates to: + /// **'收藏'** + String get navigationFavorites; + + /// No description provided for @navigationHome. + /// + /// In zh, this message translates to: + /// **'主页'** + String get navigationHome; + + /// No description provided for @navigationDownloadProgress. + /// + /// In zh, this message translates to: + /// **'下载进度'** + String get navigationDownloadProgress; + + /// No description provided for @navigationForYou. + /// + /// In zh, this message translates to: + /// **'为你推荐'** + String get navigationForYou; + + /// No description provided for @navigationPopularWorks. + /// + /// In zh, this message translates to: + /// **'热门作品'** + String get navigationPopularWorks; + + /// No description provided for @navigationRecommend. + /// + /// In zh, this message translates to: + /// **'推荐'** + String get navigationRecommend; + + /// No description provided for @homeTabWorks. + /// + /// In zh, this message translates to: + /// **'作品'** + String get homeTabWorks; + + /// No description provided for @homeTabDownloads. + /// + /// In zh, this message translates to: + /// **'进度'** + String get homeTabDownloads; + + /// No description provided for @titleWithCount. + /// + /// In zh, this message translates to: + /// **'{title} ({count})'** + String titleWithCount(String title, int count); + + /// No description provided for @search. + /// + /// In zh, this message translates to: + /// **'搜索'** + String get search; + + /// No description provided for @searchHint. + /// + /// In zh, this message translates to: + /// **'搜索...'** + String get searchHint; + + /// No description provided for @searchPromptInitial. + /// + /// In zh, this message translates to: + /// **'输入关键词开始搜索'** + String get searchPromptInitial; + + /// No description provided for @searchNoResults. + /// + /// In zh, this message translates to: + /// **'没有找到相关结果'** + String get searchNoResults; + + /// No description provided for @subtitle. + /// + /// In zh, this message translates to: + /// **'字幕'** + String get subtitle; + + /// No description provided for @subtitleAvailable. + /// + /// In zh, this message translates to: + /// **'有字幕'** + String get subtitleAvailable; + + /// No description provided for @orderFieldCollectionTime. + /// + /// In zh, this message translates to: + /// **'收录时间'** + String get orderFieldCollectionTime; + + /// No description provided for @orderFieldReleaseDate. + /// + /// In zh, this message translates to: + /// **'发售日期'** + String get orderFieldReleaseDate; + + /// No description provided for @orderFieldSales. + /// + /// In zh, this message translates to: + /// **'销量'** + String get orderFieldSales; + + /// No description provided for @orderFieldPrice. + /// + /// In zh, this message translates to: + /// **'价格'** + String get orderFieldPrice; + + /// No description provided for @orderFieldRating. + /// + /// In zh, this message translates to: + /// **'评价'** + String get orderFieldRating; + + /// No description provided for @orderFieldReviewCount. + /// + /// In zh, this message translates to: + /// **'评论数量'** + String get orderFieldReviewCount; + + /// No description provided for @orderFieldId. + /// + /// In zh, this message translates to: + /// **'RJ号'** + String get orderFieldId; + + /// No description provided for @orderFieldMyRating. + /// + /// In zh, this message translates to: + /// **'我的评价'** + String get orderFieldMyRating; + + /// No description provided for @orderFieldAllAges. + /// + /// In zh, this message translates to: + /// **'全年龄'** + String get orderFieldAllAges; + + /// No description provided for @orderFieldRandom. + /// + /// In zh, this message translates to: + /// **'随机'** + String get orderFieldRandom; + + /// No description provided for @orderLabel. + /// + /// In zh, this message translates to: + /// **'排序'** + String get orderLabel; + + /// No description provided for @orderDirectionDesc. + /// + /// In zh, this message translates to: + /// **'降序'** + String get orderDirectionDesc; + + /// No description provided for @orderDirectionAsc. + /// + /// In zh, this message translates to: + /// **'升序'** + String get orderDirectionAsc; + + /// No description provided for @searchOrderNewest. + /// + /// In zh, this message translates to: + /// **'最新收录'** + String get searchOrderNewest; + + /// No description provided for @searchOrderOldest. + /// + /// In zh, this message translates to: + /// **'最早收录'** + String get searchOrderOldest; + + /// No description provided for @searchOrderReleaseDesc. + /// + /// In zh, this message translates to: + /// **'发售日期倒序'** + String get searchOrderReleaseDesc; + + /// No description provided for @searchOrderReleaseAsc. + /// + /// In zh, this message translates to: + /// **'发售日期顺序'** + String get searchOrderReleaseAsc; + + /// No description provided for @searchOrderSalesDesc. + /// + /// In zh, this message translates to: + /// **'销量倒序'** + String get searchOrderSalesDesc; + + /// No description provided for @searchOrderSalesAsc. + /// + /// In zh, this message translates to: + /// **'销量顺序'** + String get searchOrderSalesAsc; + + /// No description provided for @searchOrderPriceDesc. + /// + /// In zh, this message translates to: + /// **'价格倒序'** + String get searchOrderPriceDesc; + + /// No description provided for @searchOrderPriceAsc. + /// + /// In zh, this message translates to: + /// **'价格顺序'** + String get searchOrderPriceAsc; + + /// No description provided for @searchOrderRatingDesc. + /// + /// In zh, this message translates to: + /// **'评价倒序'** + String get searchOrderRatingDesc; + + /// No description provided for @searchOrderReviewCountDesc. + /// + /// In zh, this message translates to: + /// **'评论数量倒序'** + String get searchOrderReviewCountDesc; + + /// No description provided for @searchOrderIdDesc. + /// + /// In zh, this message translates to: + /// **'RJ号倒序'** + String get searchOrderIdDesc; + + /// No description provided for @searchOrderIdAsc. + /// + /// In zh, this message translates to: + /// **'RJ号顺序'** + String get searchOrderIdAsc; + + /// No description provided for @searchOrderRandom. + /// + /// In zh, this message translates to: + /// **'随机排序'** + String get searchOrderRandom; + + /// No description provided for @favoritesTitle. + /// + /// In zh, this message translates to: + /// **'我的收藏'** + String get favoritesTitle; + + /// No description provided for @pleaseLogin. + /// + /// In zh, this message translates to: + /// **'请先登录'** + String get pleaseLogin; + + /// No description provided for @emptyContent. + /// + /// In zh, this message translates to: + /// **'暂无内容'** + String get emptyContent; + + /// No description provided for @emptyWorks. + /// + /// In zh, this message translates to: + /// **'暂无作品'** + String get emptyWorks; + + /// No description provided for @similarWorksTitle. + /// + /// In zh, this message translates to: + /// **'相关推荐'** + String get similarWorksTitle; + + /// No description provided for @similarWorksSeeAll. + /// + /// In zh, this message translates to: + /// **'查看全部'** + String get similarWorksSeeAll; + + /// No description provided for @playlistAddToFavorites. + /// + /// In zh, this message translates to: + /// **'添加到收藏夹'** + String get playlistAddToFavorites; + + /// No description provided for @playlistEmpty. + /// + /// In zh, this message translates to: + /// **'暂无收藏夹'** + String get playlistEmpty; + + /// No description provided for @playlistAddSuccess. + /// + /// In zh, this message translates to: + /// **'添加成功: {name}'** + String playlistAddSuccess(String name); + + /// No description provided for @playlistRemoveSuccess. + /// + /// In zh, this message translates to: + /// **'移除成功: {name}'** + String playlistRemoveSuccess(String name); + + /// No description provided for @playlistSystemMarked. + /// + /// In zh, this message translates to: + /// **'我标记的'** + String get playlistSystemMarked; + + /// No description provided for @playlistSystemLiked. + /// + /// In zh, this message translates to: + /// **'我喜欢的'** + String get playlistSystemLiked; + + /// No description provided for @playlistWorksCount. + /// + /// In zh, this message translates to: + /// **'{count} 个作品'** + String playlistWorksCount(int count); + + /// No description provided for @workActionFavorite. + /// + /// In zh, this message translates to: + /// **'收藏'** + String get workActionFavorite; + + /// No description provided for @workActionMark. + /// + /// In zh, this message translates to: + /// **'标记'** + String get workActionMark; + + /// No description provided for @workActionRate. + /// + /// In zh, this message translates to: + /// **'评分'** + String get workActionRate; + + /// No description provided for @workActionDownload. + /// + /// In zh, this message translates to: + /// **'下载'** + String get workActionDownload; + + /// No description provided for @workActionChecking. + /// + /// In zh, this message translates to: + /// **'检查中'** + String get workActionChecking; + + /// No description provided for @workActionRecommend. + /// + /// In zh, this message translates to: + /// **'相关推荐'** + String get workActionRecommend; + + /// No description provided for @workActionNoRecommendation. + /// + /// In zh, this message translates to: + /// **'暂无推荐'** + String get workActionNoRecommendation; + + /// No description provided for @downloadDialogTitle. + /// + /// In zh, this message translates to: + /// **'选择要下载的文件'** + String get downloadDialogTitle; + + /// No description provided for @downloadDialogNoFiles. + /// + /// In zh, this message translates to: + /// **'没有可下载的文件'** + String get downloadDialogNoFiles; + + /// No description provided for @downloadSelectedCount. + /// + /// In zh, this message translates to: + /// **'已选择 {count} 个'** + String downloadSelectedCount(int count); + + /// No description provided for @downloadSelectAll. + /// + /// In zh, this message translates to: + /// **'全选'** + String get downloadSelectAll; + + /// No description provided for @downloadClearSelection. + /// + /// In zh, this message translates to: + /// **'清空选择'** + String get downloadClearSelection; + + /// No description provided for @downloadNoFilesSelected. + /// + /// In zh, this message translates to: + /// **'请选择要下载的文件'** + String get downloadNoFilesSelected; + + /// No description provided for @downloadSuccess. + /// + /// In zh, this message translates to: + /// **'已下载 {count} 个文件: {path}'** + String downloadSuccess(int count, String path); + + /// No description provided for @downloadPartial. + /// + /// In zh, this message translates to: + /// **'下载完成 {successCount} 个,失败 {failedCount} 个'** + String downloadPartial(int successCount, int failedCount); + + /// No description provided for @downloadAllFailed. + /// + /// In zh, this message translates to: + /// **'下载失败({failedCount} 个)'** + String downloadAllFailed(int failedCount); + + /// No description provided for @downloadDirectoryTitle. + /// + /// In zh, this message translates to: + /// **'下载文件夹'** + String get downloadDirectoryTitle; + + /// No description provided for @downloadDirectoryDescription. + /// + /// In zh, this message translates to: + /// **'可指定下载保存位置。未设置时使用默认下载目录。'** + String get downloadDirectoryDescription; + + /// No description provided for @downloadDirectoryDefaultValue. + /// + /// In zh, this message translates to: + /// **'未设置(使用默认目录)'** + String get downloadDirectoryDefaultValue; + + /// No description provided for @downloadDirectoryPermissionHint. + /// + /// In zh, this message translates to: + /// **'必要时会请求存储权限。'** + String get downloadDirectoryPermissionHint; + + /// No description provided for @downloadDirectoryPick. + /// + /// In zh, this message translates to: + /// **'选择文件夹'** + String get downloadDirectoryPick; + + /// No description provided for @downloadDirectoryReset. + /// + /// In zh, this message translates to: + /// **'恢复默认'** + String get downloadDirectoryReset; + + /// No description provided for @downloadDirectoryUpdated. + /// + /// In zh, this message translates to: + /// **'下载目录已更新:{path}'** + String downloadDirectoryUpdated(String path); + + /// No description provided for @downloadDirectoryResetSuccess. + /// + /// In zh, this message translates to: + /// **'已恢复默认下载目录'** + String get downloadDirectoryResetSuccess; + + /// No description provided for @downloadProgressEmpty. + /// + /// In zh, this message translates to: + /// **'当前没有下载任务'** + String get downloadProgressEmpty; + + /// No description provided for @downloadProgressClearFinished. + /// + /// In zh, this message translates to: + /// **'清空已完成'** + String get downloadProgressClearFinished; + + /// No description provided for @downloadProgressActiveSection. + /// + /// In zh, this message translates to: + /// **'进行中'** + String get downloadProgressActiveSection; + + /// No description provided for @downloadProgressHistorySection. + /// + /// In zh, this message translates to: + /// **'历史记录'** + String get downloadProgressHistorySection; + + /// No description provided for @downloadStatusQueued. + /// + /// In zh, this message translates to: + /// **'等待中'** + String get downloadStatusQueued; + + /// No description provided for @downloadStatusRunning. + /// + /// In zh, this message translates to: + /// **'下载中'** + String get downloadStatusRunning; + + /// No description provided for @downloadStatusCompleted. + /// + /// In zh, this message translates to: + /// **'已完成'** + String get downloadStatusCompleted; + + /// No description provided for @downloadStatusFailed. + /// + /// In zh, this message translates to: + /// **'失败'** + String get downloadStatusFailed; + + /// No description provided for @openDlsiteInBrowser. + /// + /// In zh, this message translates to: + /// **'在浏览器中打开DLsite'** + String get openDlsiteInBrowser; + + /// No description provided for @markStatusTitle. + /// + /// In zh, this message translates to: + /// **'标记状态'** + String get markStatusTitle; + + /// No description provided for @markStatusWantToListen. + /// + /// In zh, this message translates to: + /// **'想听'** + String get markStatusWantToListen; + + /// No description provided for @markStatusListening. + /// + /// In zh, this message translates to: + /// **'在听'** + String get markStatusListening; + + /// No description provided for @markStatusListened. + /// + /// In zh, this message translates to: + /// **'听过'** + String get markStatusListened; + + /// No description provided for @markStatusRelistening. + /// + /// In zh, this message translates to: + /// **'重听'** + String get markStatusRelistening; + + /// No description provided for @markStatusOnHold. + /// + /// In zh, this message translates to: + /// **'搁置'** + String get markStatusOnHold; + + /// No description provided for @markUpdated. + /// + /// In zh, this message translates to: + /// **'已标记为{status}'** + String markUpdated(String status); + + /// No description provided for @markFailed. + /// + /// In zh, this message translates to: + /// **'标记失败: {error}'** + String markFailed(String error); + + /// No description provided for @workFilesTitle. + /// + /// In zh, this message translates to: + /// **'文件列表'** + String get workFilesTitle; + + /// No description provided for @playUnsupportedFileType. + /// + /// In zh, this message translates to: + /// **'不支持的文件类型: {type}'** + String playUnsupportedFileType(String type); + + /// No description provided for @playUrlMissing. + /// + /// In zh, this message translates to: + /// **'无法播放:文件URL不存在'** + String get playUrlMissing; + + /// No description provided for @playFilesNotLoaded. + /// + /// In zh, this message translates to: + /// **'文件列表未加载'** + String get playFilesNotLoaded; + + /// No description provided for @playFailed. + /// + /// In zh, this message translates to: + /// **'播放失败: {error}'** + String playFailed(String error); + + /// No description provided for @operationFailed. + /// + /// In zh, this message translates to: + /// **'操作失败: {error}'** + String operationFailed(String error); + + /// No description provided for @cacheManagerTitle. + /// + /// In zh, this message translates to: + /// **'缓存管理'** + String get cacheManagerTitle; + + /// No description provided for @cacheAudio. + /// + /// In zh, this message translates to: + /// **'音频缓存'** + String get cacheAudio; + + /// No description provided for @cacheSubtitle. + /// + /// In zh, this message translates to: + /// **'字幕缓存'** + String get cacheSubtitle; + + /// No description provided for @cacheTotal. + /// + /// In zh, this message translates to: + /// **'总缓存大小'** + String get cacheTotal; + + /// No description provided for @cacheClear. + /// + /// In zh, this message translates to: + /// **'清理'** + String get cacheClear; + + /// No description provided for @cacheClearAll. + /// + /// In zh, this message translates to: + /// **'清理全部'** + String get cacheClearAll; + + /// No description provided for @cacheInfoTitle. + /// + /// In zh, this message translates to: + /// **'缓存说明'** + String get cacheInfoTitle; + + /// No description provided for @cacheDescription. + /// + /// In zh, this message translates to: + /// **'缓存用于存储最近播放的音频文件和字幕文件,以提高再次播放时的加载速度。系统会自动清理过期和超量的缓存。'** + String get cacheDescription; + + /// No description provided for @cacheLoadFailed. + /// + /// In zh, this message translates to: + /// **'加载失败: {error}'** + String cacheLoadFailed(String error); + + /// No description provided for @cacheClearFailed. + /// + /// In zh, this message translates to: + /// **'清理失败: {error}'** + String cacheClearFailed(String error); + + /// No description provided for @subtitleTag. + /// + /// In zh, this message translates to: + /// **'字幕'** + String get subtitleTag; + + /// No description provided for @noPlaying. + /// + /// In zh, this message translates to: + /// **'未在播放'** + String get noPlaying; + + /// No description provided for @screenOnDisable. + /// + /// In zh, this message translates to: + /// **'关闭屏幕常亮'** + String get screenOnDisable; + + /// No description provided for @screenOnEnable. + /// + /// In zh, this message translates to: + /// **'开启屏幕常亮'** + String get screenOnEnable; + + /// No description provided for @unknownWorkTitle. + /// + /// In zh, this message translates to: + /// **'未知作品'** + String get unknownWorkTitle; + + /// No description provided for @unknownArtist. + /// + /// In zh, this message translates to: + /// **'未知演员'** + String get unknownArtist; + + /// No description provided for @lyricsEmpty. + /// + /// In zh, this message translates to: + /// **'无歌词'** + String get lyricsEmpty; + + /// No description provided for @loginTitle. + /// + /// In zh, this message translates to: + /// **'登录'** + String get loginTitle; + + /// No description provided for @loginUsernameLabel. + /// + /// In zh, this message translates to: + /// **'用户名'** + String get loginUsernameLabel; + + /// No description provided for @loginPasswordLabel. + /// + /// In zh, this message translates to: + /// **'密码'** + String get loginPasswordLabel; + + /// No description provided for @loginAction. + /// + /// In zh, this message translates to: + /// **'登录'** + String get loginAction; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ja', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ja': + return AppLocalizationsJa(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..6f84546 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,474 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appName => 'asmr.one'; + + @override + String get retry => 'Retry'; + + @override + String get cancel => 'Cancel'; + + @override + String get confirm => 'Confirm'; + + @override + String get logoutAction => 'Log out'; + + @override + String get logoutConfirmTitle => 'Log out'; + + @override + String get logoutConfirmMessage => 'Are you sure you want to log out?'; + + @override + String get login => 'Log in'; + + @override + String get favorites => 'Favorites'; + + @override + String get settings => 'Settings'; + + @override + String get languageTitle => 'Language'; + + @override + String get languageDescription => 'Choose the app display language.'; + + @override + String get languageSystem => 'Follow System'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageJapanese => 'Japanese'; + + @override + String get languageChinese => 'Chinese'; + + @override + String get cacheManager => 'Cache Manager'; + + @override + String get screenAlwaysOn => 'Keep Screen On'; + + @override + String get themeSystem => 'Follow System'; + + @override + String get themeLight => 'Light Mode'; + + @override + String get themeDark => 'Dark Mode'; + + @override + String get navigationFavorites => 'Favorites'; + + @override + String get navigationHome => 'Home'; + + @override + String get navigationDownloadProgress => 'Downloads'; + + @override + String get navigationForYou => 'For You'; + + @override + String get navigationPopularWorks => 'Popular Works'; + + @override + String get navigationRecommend => 'Recommendations'; + + @override + String get homeTabWorks => 'Works'; + + @override + String get homeTabDownloads => 'Progress'; + + @override + String titleWithCount(String title, int count) { + return '$title ($count)'; + } + + @override + String get search => 'Search'; + + @override + String get searchHint => 'Search...'; + + @override + String get searchPromptInitial => 'Enter keywords to search'; + + @override + String get searchNoResults => 'No results found'; + + @override + String get subtitle => 'Subtitles'; + + @override + String get subtitleAvailable => 'Subtitles available'; + + @override + String get orderFieldCollectionTime => 'Collection Time'; + + @override + String get orderFieldReleaseDate => 'Release Date'; + + @override + String get orderFieldSales => 'Sales'; + + @override + String get orderFieldPrice => 'Price'; + + @override + String get orderFieldRating => 'Rating'; + + @override + String get orderFieldReviewCount => 'Review Count'; + + @override + String get orderFieldId => 'RJ Number'; + + @override + String get orderFieldMyRating => 'My Rating'; + + @override + String get orderFieldAllAges => 'All Ages'; + + @override + String get orderFieldRandom => 'Random'; + + @override + String get orderLabel => 'Sort By'; + + @override + String get orderDirectionDesc => 'Descending'; + + @override + String get orderDirectionAsc => 'Ascending'; + + @override + String get searchOrderNewest => 'Newest Collected'; + + @override + String get searchOrderOldest => 'Oldest Collected'; + + @override + String get searchOrderReleaseDesc => 'Release Date (Newest)'; + + @override + String get searchOrderReleaseAsc => 'Release Date (Oldest)'; + + @override + String get searchOrderSalesDesc => 'Sales (High to Low)'; + + @override + String get searchOrderSalesAsc => 'Sales (Low to High)'; + + @override + String get searchOrderPriceDesc => 'Price (High to Low)'; + + @override + String get searchOrderPriceAsc => 'Price (Low to High)'; + + @override + String get searchOrderRatingDesc => 'Rating (High to Low)'; + + @override + String get searchOrderReviewCountDesc => 'Review Count (High to Low)'; + + @override + String get searchOrderIdDesc => 'RJ Number (High to Low)'; + + @override + String get searchOrderIdAsc => 'RJ Number (Low to High)'; + + @override + String get searchOrderRandom => 'Random Order'; + + @override + String get favoritesTitle => 'Favorites'; + + @override + String get pleaseLogin => 'Please log in first'; + + @override + String get emptyContent => 'No content'; + + @override + String get emptyWorks => 'No works'; + + @override + String get similarWorksTitle => 'Similar Works'; + + @override + String get similarWorksSeeAll => 'See All'; + + @override + String get playlistAddToFavorites => 'Add to Favorites'; + + @override + String get playlistEmpty => 'No playlists'; + + @override + String playlistAddSuccess(String name) { + return 'Added: $name'; + } + + @override + String playlistRemoveSuccess(String name) { + return 'Removed: $name'; + } + + @override + String get playlistSystemMarked => 'My Marks'; + + @override + String get playlistSystemLiked => 'Liked'; + + @override + String playlistWorksCount(int count) { + return '$count works'; + } + + @override + String get workActionFavorite => 'Favorite'; + + @override + String get workActionMark => 'Mark'; + + @override + String get workActionRate => 'Rate'; + + @override + String get workActionDownload => 'Download'; + + @override + String get workActionChecking => 'Checking'; + + @override + String get workActionRecommend => 'Recommendations'; + + @override + String get workActionNoRecommendation => 'No recommendations'; + + @override + String get downloadDialogTitle => 'Select files to download'; + + @override + String get downloadDialogNoFiles => 'No downloadable files'; + + @override + String downloadSelectedCount(int count) { + return 'Selected: $count'; + } + + @override + String get downloadSelectAll => 'Select All'; + + @override + String get downloadClearSelection => 'Clear Selection'; + + @override + String get downloadNoFilesSelected => 'Please select files to download'; + + @override + String downloadSuccess(int count, String path) { + return 'Downloaded $count files: $path'; + } + + @override + String downloadPartial(int successCount, int failedCount) { + return 'Downloaded $successCount / Failed $failedCount'; + } + + @override + String downloadAllFailed(int failedCount) { + return 'Download failed ($failedCount)'; + } + + @override + String get downloadDirectoryTitle => 'Download Folder'; + + @override + String get downloadDirectoryDescription => + 'You can choose where downloads are saved. If not set, the default folder is used.'; + + @override + String get downloadDirectoryDefaultValue => 'Not set (use default location)'; + + @override + String get downloadDirectoryPermissionHint => + 'Storage permission will be requested if needed.'; + + @override + String get downloadDirectoryPick => 'Choose Folder'; + + @override + String get downloadDirectoryReset => 'Reset to Default'; + + @override + String downloadDirectoryUpdated(String path) { + return 'Save location updated: $path'; + } + + @override + String get downloadDirectoryResetSuccess => 'Save location reset to default'; + + @override + String get downloadProgressEmpty => 'No active downloads'; + + @override + String get downloadProgressClearFinished => 'Clear Completed'; + + @override + String get downloadProgressActiveSection => 'Active'; + + @override + String get downloadProgressHistorySection => 'History'; + + @override + String get downloadStatusQueued => 'Queued'; + + @override + String get downloadStatusRunning => 'Downloading'; + + @override + String get downloadStatusCompleted => 'Completed'; + + @override + String get downloadStatusFailed => 'Failed'; + + @override + String get openDlsiteInBrowser => 'Open DLsite in Browser'; + + @override + String get markStatusTitle => 'Mark Status'; + + @override + String get markStatusWantToListen => 'Want to Listen'; + + @override + String get markStatusListening => 'Listening'; + + @override + String get markStatusListened => 'Listened'; + + @override + String get markStatusRelistening => 'Relistening'; + + @override + String get markStatusOnHold => 'On Hold'; + + @override + String markUpdated(String status) { + return 'Status changed to $status'; + } + + @override + String markFailed(String error) { + return 'Failed to update mark: $error'; + } + + @override + String get workFilesTitle => 'Files'; + + @override + String playUnsupportedFileType(String type) { + return 'Unsupported format: $type'; + } + + @override + String get playUrlMissing => 'Cannot play: URL is missing'; + + @override + String get playFilesNotLoaded => 'File list not loaded'; + + @override + String playFailed(String error) { + return 'Playback failed: $error'; + } + + @override + String operationFailed(String error) { + return 'Operation failed: $error'; + } + + @override + String get cacheManagerTitle => 'Cache Manager'; + + @override + String get cacheAudio => 'Audio Cache'; + + @override + String get cacheSubtitle => 'Subtitle Cache'; + + @override + String get cacheTotal => 'Total Cache Size'; + + @override + String get cacheClear => 'Clear'; + + @override + String get cacheClearAll => 'Clear All'; + + @override + String get cacheInfoTitle => 'About Cache'; + + @override + String get cacheDescription => + 'Cache keeps recent audio and subtitles to speed up playback. Expired and oversized data is cleaned up automatically.'; + + @override + String cacheLoadFailed(String error) { + return 'Failed to load: $error'; + } + + @override + String cacheClearFailed(String error) { + return 'Failed to clear: $error'; + } + + @override + String get subtitleTag => 'Subtitles'; + + @override + String get noPlaying => 'Nothing playing'; + + @override + String get screenOnDisable => 'Disable Keep Screen On'; + + @override + String get screenOnEnable => 'Enable Keep Screen On'; + + @override + String get unknownWorkTitle => 'Unknown Work'; + + @override + String get unknownArtist => 'Unknown Artist'; + + @override + String get lyricsEmpty => 'No lyrics'; + + @override + String get loginTitle => 'Log in'; + + @override + String get loginUsernameLabel => 'Username'; + + @override + String get loginPasswordLabel => 'Password'; + + @override + String get loginAction => 'Log in'; +} diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart new file mode 100644 index 0000000..4f39146 --- /dev/null +++ b/lib/l10n/app_localizations_ja.dart @@ -0,0 +1,473 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Japanese (`ja`). +class AppLocalizationsJa extends AppLocalizations { + AppLocalizationsJa([String locale = 'ja']) : super(locale); + + @override + String get appName => 'asmr.one'; + + @override + String get retry => '再試行'; + + @override + String get cancel => 'キャンセル'; + + @override + String get confirm => '確認'; + + @override + String get logoutAction => 'ログアウト'; + + @override + String get logoutConfirmTitle => 'ログアウト'; + + @override + String get logoutConfirmMessage => '本当にログアウトしますか?'; + + @override + String get login => 'ログイン'; + + @override + String get favorites => 'お気に入り'; + + @override + String get settings => '設定'; + + @override + String get languageTitle => '言語'; + + @override + String get languageDescription => 'アプリの表示言語を選択できます。'; + + @override + String get languageSystem => 'システムと同じ'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageJapanese => '日本語'; + + @override + String get languageChinese => '中文'; + + @override + String get cacheManager => 'キャッシュ管理'; + + @override + String get screenAlwaysOn => '画面常時オン'; + + @override + String get themeSystem => 'システムと同じ'; + + @override + String get themeLight => 'ライトモード'; + + @override + String get themeDark => 'ダークモード'; + + @override + String get navigationFavorites => 'お気に入り'; + + @override + String get navigationHome => 'ホーム'; + + @override + String get navigationDownloadProgress => 'ダウンロード進捗'; + + @override + String get navigationForYou => 'あなた向け'; + + @override + String get navigationPopularWorks => '人気作品'; + + @override + String get navigationRecommend => 'おすすめ'; + + @override + String get homeTabWorks => '作品'; + + @override + String get homeTabDownloads => '進捗'; + + @override + String titleWithCount(String title, int count) { + return '$title ($count)'; + } + + @override + String get search => '検索'; + + @override + String get searchHint => '検索...'; + + @override + String get searchPromptInitial => 'キーワードを入力して検索'; + + @override + String get searchNoResults => '該当する結果がありません'; + + @override + String get subtitle => '字幕'; + + @override + String get subtitleAvailable => '字幕あり'; + + @override + String get orderFieldCollectionTime => '収録日時'; + + @override + String get orderFieldReleaseDate => '発売日'; + + @override + String get orderFieldSales => '売上'; + + @override + String get orderFieldPrice => '価格'; + + @override + String get orderFieldRating => '評価'; + + @override + String get orderFieldReviewCount => 'レビュー数'; + + @override + String get orderFieldId => 'RJ番号'; + + @override + String get orderFieldMyRating => '自分の評価'; + + @override + String get orderFieldAllAges => '全年齢'; + + @override + String get orderFieldRandom => 'ランダム'; + + @override + String get orderLabel => '並び替え'; + + @override + String get orderDirectionDesc => '降順'; + + @override + String get orderDirectionAsc => '昇順'; + + @override + String get searchOrderNewest => '最新収録'; + + @override + String get searchOrderOldest => '最古の収録'; + + @override + String get searchOrderReleaseDesc => '発売日(新しい順)'; + + @override + String get searchOrderReleaseAsc => '発売日(古い順)'; + + @override + String get searchOrderSalesDesc => '売上(高い順)'; + + @override + String get searchOrderSalesAsc => '売上(低い順)'; + + @override + String get searchOrderPriceDesc => '価格(高い順)'; + + @override + String get searchOrderPriceAsc => '価格(低い順)'; + + @override + String get searchOrderRatingDesc => '評価(高い順)'; + + @override + String get searchOrderReviewCountDesc => 'レビュー数(多い順)'; + + @override + String get searchOrderIdDesc => 'RJ番号(大きい順)'; + + @override + String get searchOrderIdAsc => 'RJ番号(小さい順)'; + + @override + String get searchOrderRandom => 'ランダム順'; + + @override + String get favoritesTitle => 'お気に入り'; + + @override + String get pleaseLogin => '先にログインしてください'; + + @override + String get emptyContent => '内容がありません'; + + @override + String get emptyWorks => '作品がありません'; + + @override + String get similarWorksTitle => '関連作品'; + + @override + String get similarWorksSeeAll => 'すべて見る'; + + @override + String get playlistAddToFavorites => 'お気に入りに追加'; + + @override + String get playlistEmpty => 'リストがありません'; + + @override + String playlistAddSuccess(String name) { + return '追加しました: $name'; + } + + @override + String playlistRemoveSuccess(String name) { + return '削除しました: $name'; + } + + @override + String get playlistSystemMarked => '自分のマーク'; + + @override + String get playlistSystemLiked => 'いいねした'; + + @override + String playlistWorksCount(int count) { + return '$count 件の作品'; + } + + @override + String get workActionFavorite => 'お気に入り'; + + @override + String get workActionMark => 'マーク'; + + @override + String get workActionRate => '評価'; + + @override + String get workActionDownload => 'ダウンロード'; + + @override + String get workActionChecking => '確認中'; + + @override + String get workActionRecommend => 'おすすめ'; + + @override + String get workActionNoRecommendation => 'おすすめはありません'; + + @override + String get downloadDialogTitle => 'ダウンロードするファイルを選択'; + + @override + String get downloadDialogNoFiles => 'ダウンロード可能なファイルがありません'; + + @override + String downloadSelectedCount(int count) { + return '選択中: $count件'; + } + + @override + String get downloadSelectAll => 'すべて選択'; + + @override + String get downloadClearSelection => '選択解除'; + + @override + String get downloadNoFilesSelected => 'ダウンロードするファイルを選択してください'; + + @override + String downloadSuccess(int count, String path) { + return '$count件のダウンロードが完了しました: $path'; + } + + @override + String downloadPartial(int successCount, int failedCount) { + return 'ダウンロード完了 $successCount件 / 失敗 $failedCount件'; + } + + @override + String downloadAllFailed(int failedCount) { + return 'ダウンロードに失敗しました($failedCount件)'; + } + + @override + String get downloadDirectoryTitle => 'ダウンロードフォルダ'; + + @override + String get downloadDirectoryDescription => + '保存先フォルダを指定できます。未指定時は既定のダウンロード先を使います。'; + + @override + String get downloadDirectoryDefaultValue => '未設定(既定の保存先)'; + + @override + String get downloadDirectoryPermissionHint => '必要な場合はストレージ権限を要求します。'; + + @override + String get downloadDirectoryPick => 'フォルダを選択'; + + @override + String get downloadDirectoryReset => '既定に戻す'; + + @override + String downloadDirectoryUpdated(String path) { + return '保存先を更新しました: $path'; + } + + @override + String get downloadDirectoryResetSuccess => '保存先を既定に戻しました'; + + @override + String get downloadProgressEmpty => '進行中のダウンロードはありません'; + + @override + String get downloadProgressClearFinished => '完了履歴をクリア'; + + @override + String get downloadProgressActiveSection => '進行中'; + + @override + String get downloadProgressHistorySection => '履歴'; + + @override + String get downloadStatusQueued => '待機中'; + + @override + String get downloadStatusRunning => 'ダウンロード中'; + + @override + String get downloadStatusCompleted => '完了'; + + @override + String get downloadStatusFailed => '失敗'; + + @override + String get openDlsiteInBrowser => 'DLsiteをブラウザで開く'; + + @override + String get markStatusTitle => 'マーク状態'; + + @override + String get markStatusWantToListen => '聴きたい'; + + @override + String get markStatusListening => '聴いている'; + + @override + String get markStatusListened => '聴いた'; + + @override + String get markStatusRelistening => '聴き直し'; + + @override + String get markStatusOnHold => '保留'; + + @override + String markUpdated(String status) { + return '状態を$statusに変更'; + } + + @override + String markFailed(String error) { + return 'マーク失敗: $error'; + } + + @override + String get workFilesTitle => 'ファイル一覧'; + + @override + String playUnsupportedFileType(String type) { + return '未対応形式: $type'; + } + + @override + String get playUrlMissing => '再生できません: URLがありません'; + + @override + String get playFilesNotLoaded => 'ファイル一覧未読み込み'; + + @override + String playFailed(String error) { + return '再生失敗: $error'; + } + + @override + String operationFailed(String error) { + return '操作失敗: $error'; + } + + @override + String get cacheManagerTitle => 'キャッシュ管理'; + + @override + String get cacheAudio => '音声キャッシュ'; + + @override + String get cacheSubtitle => '字幕キャッシュ'; + + @override + String get cacheTotal => 'キャッシュ総量'; + + @override + String get cacheClear => '削除'; + + @override + String get cacheClearAll => '全て削除'; + + @override + String get cacheInfoTitle => 'キャッシュについて'; + + @override + String get cacheDescription => + 'キャッシュは直近の音声と字幕を保存し、次回再生を速くします。期限切れや容量超過は自動で整理されます。'; + + @override + String cacheLoadFailed(String error) { + return '読み込み失敗: $error'; + } + + @override + String cacheClearFailed(String error) { + return '削除失敗: $error'; + } + + @override + String get subtitleTag => '字幕'; + + @override + String get noPlaying => '再生中なし'; + + @override + String get screenOnDisable => '画面常時オンをオフ'; + + @override + String get screenOnEnable => '画面常時オンをオン'; + + @override + String get unknownWorkTitle => '不明な作品'; + + @override + String get unknownArtist => '不明な出演者'; + + @override + String get lyricsEmpty => '歌詞なし'; + + @override + String get loginTitle => 'ログイン'; + + @override + String get loginUsernameLabel => 'ユーザー名'; + + @override + String get loginPasswordLabel => 'パスワード'; + + @override + String get loginAction => 'ログイン'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..feb522a --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,472 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appName => 'asmr.one'; + + @override + String get retry => '重试'; + + @override + String get cancel => '取消'; + + @override + String get confirm => '确认'; + + @override + String get logoutAction => '退出登录'; + + @override + String get logoutConfirmTitle => '退出登录'; + + @override + String get logoutConfirmMessage => '确定要退出登录吗?'; + + @override + String get login => '登录'; + + @override + String get favorites => '我的收藏'; + + @override + String get settings => '设置'; + + @override + String get languageTitle => '语言'; + + @override + String get languageDescription => '可以选择应用显示语言。'; + + @override + String get languageSystem => '跟随系统'; + + @override + String get languageEnglish => 'English'; + + @override + String get languageJapanese => '日本語'; + + @override + String get languageChinese => '中文'; + + @override + String get cacheManager => '缓存管理'; + + @override + String get screenAlwaysOn => '屏幕常亮'; + + @override + String get themeSystem => '跟随系统主题'; + + @override + String get themeLight => '浅色模式'; + + @override + String get themeDark => '深色模式'; + + @override + String get navigationFavorites => '收藏'; + + @override + String get navigationHome => '主页'; + + @override + String get navigationDownloadProgress => '下载进度'; + + @override + String get navigationForYou => '为你推荐'; + + @override + String get navigationPopularWorks => '热门作品'; + + @override + String get navigationRecommend => '推荐'; + + @override + String get homeTabWorks => '作品'; + + @override + String get homeTabDownloads => '进度'; + + @override + String titleWithCount(String title, int count) { + return '$title ($count)'; + } + + @override + String get search => '搜索'; + + @override + String get searchHint => '搜索...'; + + @override + String get searchPromptInitial => '输入关键词开始搜索'; + + @override + String get searchNoResults => '没有找到相关结果'; + + @override + String get subtitle => '字幕'; + + @override + String get subtitleAvailable => '有字幕'; + + @override + String get orderFieldCollectionTime => '收录时间'; + + @override + String get orderFieldReleaseDate => '发售日期'; + + @override + String get orderFieldSales => '销量'; + + @override + String get orderFieldPrice => '价格'; + + @override + String get orderFieldRating => '评价'; + + @override + String get orderFieldReviewCount => '评论数量'; + + @override + String get orderFieldId => 'RJ号'; + + @override + String get orderFieldMyRating => '我的评价'; + + @override + String get orderFieldAllAges => '全年龄'; + + @override + String get orderFieldRandom => '随机'; + + @override + String get orderLabel => '排序'; + + @override + String get orderDirectionDesc => '降序'; + + @override + String get orderDirectionAsc => '升序'; + + @override + String get searchOrderNewest => '最新收录'; + + @override + String get searchOrderOldest => '最早收录'; + + @override + String get searchOrderReleaseDesc => '发售日期倒序'; + + @override + String get searchOrderReleaseAsc => '发售日期顺序'; + + @override + String get searchOrderSalesDesc => '销量倒序'; + + @override + String get searchOrderSalesAsc => '销量顺序'; + + @override + String get searchOrderPriceDesc => '价格倒序'; + + @override + String get searchOrderPriceAsc => '价格顺序'; + + @override + String get searchOrderRatingDesc => '评价倒序'; + + @override + String get searchOrderReviewCountDesc => '评论数量倒序'; + + @override + String get searchOrderIdDesc => 'RJ号倒序'; + + @override + String get searchOrderIdAsc => 'RJ号顺序'; + + @override + String get searchOrderRandom => '随机排序'; + + @override + String get favoritesTitle => '我的收藏'; + + @override + String get pleaseLogin => '请先登录'; + + @override + String get emptyContent => '暂无内容'; + + @override + String get emptyWorks => '暂无作品'; + + @override + String get similarWorksTitle => '相关推荐'; + + @override + String get similarWorksSeeAll => '查看全部'; + + @override + String get playlistAddToFavorites => '添加到收藏夹'; + + @override + String get playlistEmpty => '暂无收藏夹'; + + @override + String playlistAddSuccess(String name) { + return '添加成功: $name'; + } + + @override + String playlistRemoveSuccess(String name) { + return '移除成功: $name'; + } + + @override + String get playlistSystemMarked => '我标记的'; + + @override + String get playlistSystemLiked => '我喜欢的'; + + @override + String playlistWorksCount(int count) { + return '$count 个作品'; + } + + @override + String get workActionFavorite => '收藏'; + + @override + String get workActionMark => '标记'; + + @override + String get workActionRate => '评分'; + + @override + String get workActionDownload => '下载'; + + @override + String get workActionChecking => '检查中'; + + @override + String get workActionRecommend => '相关推荐'; + + @override + String get workActionNoRecommendation => '暂无推荐'; + + @override + String get downloadDialogTitle => '选择要下载的文件'; + + @override + String get downloadDialogNoFiles => '没有可下载的文件'; + + @override + String downloadSelectedCount(int count) { + return '已选择 $count 个'; + } + + @override + String get downloadSelectAll => '全选'; + + @override + String get downloadClearSelection => '清空选择'; + + @override + String get downloadNoFilesSelected => '请选择要下载的文件'; + + @override + String downloadSuccess(int count, String path) { + return '已下载 $count 个文件: $path'; + } + + @override + String downloadPartial(int successCount, int failedCount) { + return '下载完成 $successCount 个,失败 $failedCount 个'; + } + + @override + String downloadAllFailed(int failedCount) { + return '下载失败($failedCount 个)'; + } + + @override + String get downloadDirectoryTitle => '下载文件夹'; + + @override + String get downloadDirectoryDescription => '可指定下载保存位置。未设置时使用默认下载目录。'; + + @override + String get downloadDirectoryDefaultValue => '未设置(使用默认目录)'; + + @override + String get downloadDirectoryPermissionHint => '必要时会请求存储权限。'; + + @override + String get downloadDirectoryPick => '选择文件夹'; + + @override + String get downloadDirectoryReset => '恢复默认'; + + @override + String downloadDirectoryUpdated(String path) { + return '下载目录已更新:$path'; + } + + @override + String get downloadDirectoryResetSuccess => '已恢复默认下载目录'; + + @override + String get downloadProgressEmpty => '当前没有下载任务'; + + @override + String get downloadProgressClearFinished => '清空已完成'; + + @override + String get downloadProgressActiveSection => '进行中'; + + @override + String get downloadProgressHistorySection => '历史记录'; + + @override + String get downloadStatusQueued => '等待中'; + + @override + String get downloadStatusRunning => '下载中'; + + @override + String get downloadStatusCompleted => '已完成'; + + @override + String get downloadStatusFailed => '失败'; + + @override + String get openDlsiteInBrowser => '在浏览器中打开DLsite'; + + @override + String get markStatusTitle => '标记状态'; + + @override + String get markStatusWantToListen => '想听'; + + @override + String get markStatusListening => '在听'; + + @override + String get markStatusListened => '听过'; + + @override + String get markStatusRelistening => '重听'; + + @override + String get markStatusOnHold => '搁置'; + + @override + String markUpdated(String status) { + return '已标记为$status'; + } + + @override + String markFailed(String error) { + return '标记失败: $error'; + } + + @override + String get workFilesTitle => '文件列表'; + + @override + String playUnsupportedFileType(String type) { + return '不支持的文件类型: $type'; + } + + @override + String get playUrlMissing => '无法播放:文件URL不存在'; + + @override + String get playFilesNotLoaded => '文件列表未加载'; + + @override + String playFailed(String error) { + return '播放失败: $error'; + } + + @override + String operationFailed(String error) { + return '操作失败: $error'; + } + + @override + String get cacheManagerTitle => '缓存管理'; + + @override + String get cacheAudio => '音频缓存'; + + @override + String get cacheSubtitle => '字幕缓存'; + + @override + String get cacheTotal => '总缓存大小'; + + @override + String get cacheClear => '清理'; + + @override + String get cacheClearAll => '清理全部'; + + @override + String get cacheInfoTitle => '缓存说明'; + + @override + String get cacheDescription => + '缓存用于存储最近播放的音频文件和字幕文件,以提高再次播放时的加载速度。系统会自动清理过期和超量的缓存。'; + + @override + String cacheLoadFailed(String error) { + return '加载失败: $error'; + } + + @override + String cacheClearFailed(String error) { + return '清理失败: $error'; + } + + @override + String get subtitleTag => '字幕'; + + @override + String get noPlaying => '未在播放'; + + @override + String get screenOnDisable => '关闭屏幕常亮'; + + @override + String get screenOnEnable => '开启屏幕常亮'; + + @override + String get unknownWorkTitle => '未知作品'; + + @override + String get unknownArtist => '未知演员'; + + @override + String get lyricsEmpty => '无歌词'; + + @override + String get loginTitle => '登录'; + + @override + String get loginUsernameLabel => '用户名'; + + @override + String get loginPasswordLabel => '密码'; + + @override + String get loginAction => '登录'; +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb new file mode 100644 index 0000000..5399732 --- /dev/null +++ b/lib/l10n/app_zh.arb @@ -0,0 +1,267 @@ +{ + "@@locale": "zh", + "appName": "asmr.one", + "retry": "重试", + "cancel": "取消", + "confirm": "确认", + "logoutAction": "退出登录", + "logoutConfirmTitle": "退出登录", + "logoutConfirmMessage": "确定要退出登录吗?", + "login": "登录", + "favorites": "我的收藏", + "settings": "设置", + "languageTitle": "语言", + "languageDescription": "可以选择应用显示语言。", + "languageSystem": "跟随系统", + "languageEnglish": "English", + "languageJapanese": "日本語", + "languageChinese": "中文", + "cacheManager": "缓存管理", + "screenAlwaysOn": "屏幕常亮", + "themeSystem": "跟随系统主题", + "themeLight": "浅色模式", + "themeDark": "深色模式", + "navigationFavorites": "收藏", + "navigationHome": "主页", + "navigationDownloadProgress": "下载进度", + "navigationForYou": "为你推荐", + "navigationPopularWorks": "热门作品", + "navigationRecommend": "推荐", + "homeTabWorks": "作品", + "homeTabDownloads": "进度", + "titleWithCount": "{title} ({count})", + "@titleWithCount": { + "placeholders": { + "title": { + "type": "String" + }, + "count": { + "type": "int" + } + } + }, + "search": "搜索", + "searchHint": "搜索...", + "searchPromptInitial": "输入关键词开始搜索", + "searchNoResults": "没有找到相关结果", + "subtitle": "字幕", + "subtitleAvailable": "有字幕", + "orderFieldCollectionTime": "收录时间", + "orderFieldReleaseDate": "发售日期", + "orderFieldSales": "销量", + "orderFieldPrice": "价格", + "orderFieldRating": "评价", + "orderFieldReviewCount": "评论数量", + "orderFieldId": "RJ号", + "orderFieldMyRating": "我的评价", + "orderFieldAllAges": "全年龄", + "orderFieldRandom": "随机", + "orderLabel": "排序", + "orderDirectionDesc": "降序", + "orderDirectionAsc": "升序", + "searchOrderNewest": "最新收录", + "searchOrderOldest": "最早收录", + "searchOrderReleaseDesc": "发售日期倒序", + "searchOrderReleaseAsc": "发售日期顺序", + "searchOrderSalesDesc": "销量倒序", + "searchOrderSalesAsc": "销量顺序", + "searchOrderPriceDesc": "价格倒序", + "searchOrderPriceAsc": "价格顺序", + "searchOrderRatingDesc": "评价倒序", + "searchOrderReviewCountDesc": "评论数量倒序", + "searchOrderIdDesc": "RJ号倒序", + "searchOrderIdAsc": "RJ号顺序", + "searchOrderRandom": "随机排序", + "favoritesTitle": "我的收藏", + "pleaseLogin": "请先登录", + "emptyContent": "暂无内容", + "emptyWorks": "暂无作品", + "similarWorksTitle": "相关推荐", + "similarWorksSeeAll": "查看全部", + "playlistAddToFavorites": "添加到收藏夹", + "playlistEmpty": "暂无收藏夹", + "playlistAddSuccess": "添加成功: {name}", + "@playlistAddSuccess": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "playlistRemoveSuccess": "移除成功: {name}", + "@playlistRemoveSuccess": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "playlistSystemMarked": "我标记的", + "playlistSystemLiked": "我喜欢的", + "playlistWorksCount": "{count} 个作品", + "@playlistWorksCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "workActionFavorite": "收藏", + "workActionMark": "标记", + "workActionRate": "评分", + "workActionDownload": "下载", + "workActionChecking": "检查中", + "workActionRecommend": "相关推荐", + "workActionNoRecommendation": "暂无推荐", + "downloadDialogTitle": "选择要下载的文件", + "downloadDialogNoFiles": "没有可下载的文件", + "downloadSelectedCount": "已选择 {count} 个", + "@downloadSelectedCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "downloadSelectAll": "全选", + "downloadClearSelection": "清空选择", + "downloadNoFilesSelected": "请选择要下载的文件", + "downloadSuccess": "已下载 {count} 个文件: {path}", + "@downloadSuccess": { + "placeholders": { + "count": { + "type": "int" + }, + "path": { + "type": "String" + } + } + }, + "downloadPartial": "下载完成 {successCount} 个,失败 {failedCount} 个", + "@downloadPartial": { + "placeholders": { + "successCount": { + "type": "int" + }, + "failedCount": { + "type": "int" + } + } + }, + "downloadAllFailed": "下载失败({failedCount} 个)", + "@downloadAllFailed": { + "placeholders": { + "failedCount": { + "type": "int" + } + } + }, + "downloadDirectoryTitle": "下载文件夹", + "downloadDirectoryDescription": "可指定下载保存位置。未设置时使用默认下载目录。", + "downloadDirectoryDefaultValue": "未设置(使用默认目录)", + "downloadDirectoryPermissionHint": "必要时会请求存储权限。", + "downloadDirectoryPick": "选择文件夹", + "downloadDirectoryReset": "恢复默认", + "downloadDirectoryUpdated": "下载目录已更新:{path}", + "@downloadDirectoryUpdated": { + "placeholders": { + "path": { + "type": "String" + } + } + }, + "downloadDirectoryResetSuccess": "已恢复默认下载目录", + "downloadProgressEmpty": "当前没有下载任务", + "downloadProgressClearFinished": "清空已完成", + "downloadProgressActiveSection": "进行中", + "downloadProgressHistorySection": "历史记录", + "downloadStatusQueued": "等待中", + "downloadStatusRunning": "下载中", + "downloadStatusCompleted": "已完成", + "downloadStatusFailed": "失败", + "openDlsiteInBrowser": "在浏览器中打开DLsite", + "markStatusTitle": "标记状态", + "markStatusWantToListen": "想听", + "markStatusListening": "在听", + "markStatusListened": "听过", + "markStatusRelistening": "重听", + "markStatusOnHold": "搁置", + "markUpdated": "已标记为{status}", + "@markUpdated": { + "placeholders": { + "status": { + "type": "String" + } + } + }, + "markFailed": "标记失败: {error}", + "@markFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "workFilesTitle": "文件列表", + "playUnsupportedFileType": "不支持的文件类型: {type}", + "@playUnsupportedFileType": { + "placeholders": { + "type": { + "type": "String" + } + } + }, + "playUrlMissing": "无法播放:文件URL不存在", + "playFilesNotLoaded": "文件列表未加载", + "playFailed": "播放失败: {error}", + "@playFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "operationFailed": "操作失败: {error}", + "@operationFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cacheManagerTitle": "缓存管理", + "cacheAudio": "音频缓存", + "cacheSubtitle": "字幕缓存", + "cacheTotal": "总缓存大小", + "cacheClear": "清理", + "cacheClearAll": "清理全部", + "cacheInfoTitle": "缓存说明", + "cacheDescription": "缓存用于存储最近播放的音频文件和字幕文件,以提高再次播放时的加载速度。系统会自动清理过期和超量的缓存。", + "cacheLoadFailed": "加载失败: {error}", + "@cacheLoadFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "cacheClearFailed": "清理失败: {error}", + "@cacheClearFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "subtitleTag": "字幕", + "noPlaying": "未在播放", + "screenOnDisable": "关闭屏幕常亮", + "screenOnEnable": "开启屏幕常亮", + "unknownWorkTitle": "未知作品", + "unknownArtist": "未知演员", + "lyricsEmpty": "无歌词", + "loginTitle": "登录", + "loginUsernameLabel": "用户名", + "loginPasswordLabel": "密码", + "loginAction": "登录" +} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart new file mode 100644 index 0000000..df574ff --- /dev/null +++ b/lib/l10n/l10n.dart @@ -0,0 +1,6 @@ +import 'package:flutter/widgets.dart'; +import 'package:asmrapp/l10n/app_localizations.dart'; + +extension L10nX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this)!; +} diff --git a/lib/main.dart b/lib/main.dart index e9b9afb..c4ec458 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,27 @@ -import 'package:flutter/material.dart'; -import 'package:asmrapp/common/constants/strings.dart'; +import 'package:asmrapp/core/download/download_directory_controller.dart'; +import 'package:asmrapp/core/download/download_progress_manager.dart'; +import 'package:asmrapp/core/locale/locale_controller.dart'; +import 'package:asmrapp/core/theme/app_theme.dart'; +import 'package:asmrapp/core/theme/theme_controller.dart'; +import 'package:asmrapp/l10n/app_localizations.dart'; +import 'package:asmrapp/l10n/l10n.dart'; import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; -import 'core/di/service_locator.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:just_audio_media_kit/just_audio_media_kit.dart'; import 'package:provider/provider.dart'; + +import 'core/di/service_locator.dart'; import 'screens/main_screen.dart'; -import 'package:asmrapp/core/theme/app_theme.dart'; -import 'package:asmrapp/core/theme/theme_controller.dart'; import 'screens/search_screen.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + JustAudioMediaKit.ensureInitialized( + android: false, + iOS: false, + macOS: false, + ); // 初始化服务定位器 await setupServiceLocator(); @@ -30,11 +42,28 @@ class MyApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => getIt(), ), + ChangeNotifierProvider( + create: (_) => getIt(), + ), + ChangeNotifierProvider( + create: (_) => getIt(), + ), + ChangeNotifierProvider( + create: (_) => getIt(), + ), ], - child: Consumer( - builder: (context, themeController, child) { + child: Consumer2( + builder: (context, themeController, localeController, child) { return MaterialApp( - title: Strings.appName, + onGenerateTitle: (context) => context.l10n.appName, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: localeController.locale, theme: AppTheme.light, darkTheme: AppTheme.dark, themeMode: themeController.themeMode, diff --git a/lib/presentation/models/filter_state.dart b/lib/presentation/models/filter_state.dart index facbec0..33e986f 100644 --- a/lib/presentation/models/filter_state.dart +++ b/lib/presentation/models/filter_state.dart @@ -9,7 +9,8 @@ class FilterState { bool get showSortDirection => orderField != 'random'; - String get sortValue => orderField == 'random' ? 'desc' : (isDescending ? 'desc' : 'asc'); + String get sortValue => + orderField == 'random' ? 'desc' : (isDescending ? 'desc' : 'asc'); FilterState copyWith({ String? orderField, @@ -23,13 +24,13 @@ class FilterState { // 用于持久化 Map toJson() => { - 'orderField': orderField, - 'isDescending': isDescending, - }; + 'orderField': orderField, + 'isDescending': isDescending, + }; // 从持久化恢复 factory FilterState.fromJson(Map json) => FilterState( - orderField: json['orderField'] ?? 'create_date', - isDescending: json['isDescending'] ?? true, - ); -} \ No newline at end of file + orderField: json['orderField'] ?? 'create_date', + isDescending: json['isDescending'] ?? true, + ); +} diff --git a/lib/presentation/viewmodels/auth_viewmodel.dart b/lib/presentation/viewmodels/auth_viewmodel.dart index 85b1242..588469e 100644 --- a/lib/presentation/viewmodels/auth_viewmodel.dart +++ b/lib/presentation/viewmodels/auth_viewmodel.dart @@ -37,10 +37,10 @@ class AuthViewModel extends ChangeNotifier { try { AppLogger.info('AuthViewModel: 开始登录流程'); _authData = await _authService.login(name, password); - + // 保存认证数据 await _authRepository.saveAuthData(_authData!); - + AppLogger.info(''' 登录成功,完整数据: - token: ${_authData?.token} @@ -50,7 +50,6 @@ class AuthViewModel extends ChangeNotifier { - email: ${_authData?.user?.email} - recommenderUuid: ${_authData?.user?.recommenderUuid} '''); - } catch (e) { AppLogger.error('AuthViewModel: 登录失败', e); _error = e.toString(); @@ -69,7 +68,7 @@ class AuthViewModel extends ChangeNotifier { - group: ${_authData?.user?.group} - token: ${_authData?.token} '''); - + await _authRepository.clearAuthData(); _authData = null; notifyListeners(); @@ -91,4 +90,4 @@ class AuthViewModel extends ChangeNotifier { } notifyListeners(); } -} \ No newline at end of file +} diff --git a/lib/presentation/viewmodels/base/paginated_works_viewmodel.dart b/lib/presentation/viewmodels/base/paginated_works_viewmodel.dart index 80f615f..d9ae5b2 100644 --- a/lib/presentation/viewmodels/base/paginated_works_viewmodel.dart +++ b/lib/presentation/viewmodels/base/paginated_works_viewmodel.dart @@ -30,9 +30,10 @@ abstract class PaginatedWorksViewModel extends ChangeNotifier { bool get isLoading => _isLoading; String? get error => _error; int get currentPage => _currentPage; - int? get totalPages => _pagination?.totalCount != null && _pagination?.pageSize != null - ? (_pagination!.totalCount! / _pagination!.pageSize!).ceil() - : null; + int? get totalPages => + _pagination?.totalCount != null && _pagination?.pageSize != null + ? (_pagination!.totalCount! / _pagination!.pageSize!).ceil() + : null; // 获取页面名称,用于日志 String get pageName; @@ -82,4 +83,4 @@ abstract class PaginatedWorksViewModel extends ChangeNotifier { // 添加 pagination getter Pagination? get pagination => _pagination; -} \ No newline at end of file +} diff --git a/lib/presentation/viewmodels/detail_viewmodel.dart b/lib/presentation/viewmodels/detail_viewmodel.dart index 71d0629..02fd86a 100644 --- a/lib/presentation/viewmodels/detail_viewmodel.dart +++ b/lib/presentation/viewmodels/detail_viewmodel.dart @@ -1,7 +1,12 @@ +import 'dart:io'; + +import 'package:asmrapp/core/download/download_request_item.dart'; import 'package:asmrapp/data/models/playlists_with_exist_statu/pagination.dart'; import 'package:asmrapp/data/models/playlists_with_exist_statu/playlist.dart'; +import 'package:asmrapp/data/repositories/auth_repository.dart'; import 'package:get_it/get_it.dart'; import 'package:flutter/material.dart'; +import 'package:asmrapp/common/utils/file_preview_utils.dart'; import 'package:asmrapp/data/models/files/files.dart'; import 'package:asmrapp/data/models/files/child.dart'; import 'package:asmrapp/data/models/works/work.dart'; @@ -12,20 +17,80 @@ import 'package:asmrapp/core/audio/models/playback_context.dart'; import 'package:asmrapp/widgets/detail/playlist_selection_dialog.dart'; import 'package:asmrapp/data/models/mark_status.dart'; import 'package:asmrapp/widgets/detail/mark_selection_dialog.dart'; +import 'package:asmrapp/core/download/download_directory_controller.dart'; +import 'package:asmrapp/core/download/download_progress_manager.dart'; +import 'package:background_downloader/background_downloader.dart'; import 'package:dio/dio.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/common/extensions/mark_status_localizations.dart'; + +enum PlaybackError { + unsupportedType, + missingUrl, + filesNotLoaded, + failed, +} + +enum FilePreviewError { + unsupportedType, + missingUrl, + failed, +} + +class PlaybackException implements Exception { + final PlaybackError error; + final String? detail; + final Object? originalError; + + const PlaybackException( + this.error, { + this.detail, + this.originalError, + }); +} + +class FilePreviewException implements Exception { + final FilePreviewError error; + final String? detail; + final Object? originalError; + + const FilePreviewException( + this.error, { + this.detail, + this.originalError, + }); +} + +class DownloadBatchResult { + final int successCount; + final int failedCount; + final String saveDirectoryPath; + + const DownloadBatchResult({ + required this.successCount, + required this.failedCount, + required this.saveDirectoryPath, + }); +} class DetailViewModel extends ChangeNotifier { late final ApiService _apiService; late final IAudioPlayerService _audioService; + late final DownloadDirectoryController _downloadDirectoryController; + late final DownloadProgressManager _downloadProgressManager; + late final AuthRepository _authRepository; + late final FileDownloader _fileDownloader; final Work work; Files? _files; bool _isLoading = false; String? _error; bool _disposed = false; - + + List _recommendedWorks = []; bool _hasRecommendations = false; - bool _checkingRecommendations = false; + bool _loadingRecommendations = false; + String? _recommendationsError; // 收藏夹相关状态 bool _loadingPlaylists = false; @@ -41,6 +106,12 @@ class DetailViewModel extends ChangeNotifier { bool _loadingMark = false; bool get loadingMark => _loadingMark; + int? _currentRating; + int? get currentRating => _currentRating; + bool _loadingRating = false; + bool get loadingRating => _loadingRating; + bool _downloadingFiles = false; + bool get downloadingFiles => _downloadingFiles; // 添加取消标记 final _cancelToken = CancelToken(); @@ -50,26 +121,37 @@ class DetailViewModel extends ChangeNotifier { }) { _audioService = GetIt.I(); _apiService = GetIt.I(); - _checkRecommendations(); + _downloadDirectoryController = GetIt.I(); + _downloadProgressManager = GetIt.I(); + _authRepository = GetIt.I(); + _fileDownloader = FileDownloader(); + _currentRating = _normalizeRating(work.userRating); + loadRecommendationsPreview(); } Files? get files => _files; bool get isLoading => _isLoading; String? get error => _error; + List get recommendedWorks => _recommendedWorks; bool get hasRecommendations => _hasRecommendations; - bool get checkingRecommendations => _checkingRecommendations; + bool get loadingRecommendations => _loadingRecommendations; + String? get recommendationsError => _recommendationsError; // 收藏夹相关 getters bool get loadingPlaylists => _loadingPlaylists; String? get playlistsError => _playlistsError; List? get playlists => _playlists; - int? get playlistsTotalPages => - _playlistsPagination?.totalCount != null && _playlistsPagination?.pageSize != null - ? (_playlistsPagination!.totalCount! / _playlistsPagination!.pageSize!).ceil() - : null; + int? get playlistsTotalPages => _playlistsPagination?.totalCount != null && + _playlistsPagination?.pageSize != null + ? (_playlistsPagination!.totalCount! / _playlistsPagination!.pageSize!) + .ceil() + : null; - Future _checkRecommendations() async { - _checkingRecommendations = true; + Future loadRecommendationsPreview() async { + if (_loadingRecommendations) return; + + _loadingRecommendations = true; + _recommendationsError = null; notifyListeners(); try { @@ -77,13 +159,19 @@ class DetailViewModel extends ChangeNotifier { itemId: work.id.toString(), page: 1, ); - _hasRecommendations = (response.pagination.totalCount ?? 0) > 0; + _recommendedWorks = response.works + .where((recommendedWork) => recommendedWork.id != work.id) + .toList(); + _hasRecommendations = (response.pagination.totalCount ?? 0) > 0 || + _recommendedWorks.isNotEmpty; } catch (e) { AppLogger.error('检查相关推荐失败', e); + _recommendedWorks = []; _hasRecommendations = false; + _recommendationsError = e.toString(); } finally { if (!_disposed) { - _checkingRecommendations = false; + _loadingRecommendations = false; notifyListeners(); } } @@ -117,16 +205,19 @@ class DetailViewModel extends ChangeNotifier { } Future playFile(Child file, BuildContext context) async { - if (file.type?.toLowerCase() != 'audio') { - throw Exception('不支持的文件类型: ${file.type}'); + if (!FilePreviewUtils.isAudio(file)) { + throw PlaybackException( + PlaybackError.unsupportedType, + detail: file.type ?? file.title, + ); } if (file.mediaDownloadUrl == null) { - throw Exception('无法播放:文件URL不存在'); + throw const PlaybackException(PlaybackError.missingUrl); } if (_files == null) { - throw Exception('文件列表未加载'); + throw const PlaybackException(PlaybackError.filesNotLoaded); } try { @@ -141,7 +232,41 @@ class DetailViewModel extends ChangeNotifier { if (!_disposed) { AppLogger.error('播放失败', e); } - rethrow; + throw PlaybackException( + PlaybackError.failed, + detail: e.toString(), + originalError: e, + ); + } + } + + bool canPreviewFile(Child file) { + return FilePreviewUtils.canPreview(file); + } + + Future loadTextPreview(Child file) async { + if (!FilePreviewUtils.isText(file)) { + throw FilePreviewException( + FilePreviewError.unsupportedType, + detail: file.type ?? file.title, + ); + } + + if (file.mediaDownloadUrl == null || file.mediaDownloadUrl!.isEmpty) { + throw const FilePreviewException(FilePreviewError.missingUrl); + } + + try { + return await _apiService.getTextFileContent( + file.mediaDownloadUrl!, + cancelToken: _cancelToken, + ); + } catch (e) { + throw FilePreviewException( + FilePreviewError.failed, + detail: e.toString(), + originalError: e, + ); } } @@ -158,7 +283,7 @@ class DetailViewModel extends ChangeNotifier { workId: work.id.toString(), page: page, ); - + _playlists = response.playlists; _playlistsPagination = response.pagination; AppLogger.info('收藏夹列表加载成功: ${_playlists?.length ?? 0}个收藏夹'); @@ -174,14 +299,14 @@ class DetailViewModel extends ChangeNotifier { Future showPlaylistsDialog(BuildContext context) async { _loadingFavorite = true; notifyListeners(); - + try { await loadPlaylists(); _loadingFavorite = false; notifyListeners(); - + if (!context.mounted) return; - + await showDialog( context: context, builder: (context) => PlaylistSelectionDialog( @@ -195,7 +320,11 @@ class DetailViewModel extends ChangeNotifier { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('操作失败: $e')), + SnackBar( + content: Text( + context.l10n.operationFailed(e.toString()), + ), + ), ); } } @@ -222,7 +351,7 @@ class DetailViewModel extends ChangeNotifier { workId: work.id.toString(), ); } - + // 更新本地收藏夹状态 final index = _playlists?.indexWhere((p) => p.id == playlist.id); if (index != null && index != -1) { @@ -230,7 +359,7 @@ class DetailViewModel extends ChangeNotifier { ..[index] = playlist.copyWith(exist: !(playlist.exist ?? false)); notifyListeners(); } - + final action = (playlist.exist ?? false) ? '移除' : '添加'; AppLogger.info('$action收藏成功: ${playlist.name}'); } catch (e) { @@ -248,7 +377,7 @@ class DetailViewModel extends ChangeNotifier { work.id.toString(), _apiService.convertMarkStatusToApi(status), ); - + _currentMarkStatus = status; AppLogger.info('更新标记状态成功: ${status.label}'); } catch (e) { @@ -260,6 +389,233 @@ class DetailViewModel extends ChangeNotifier { } } + Future updateRating(int rating) async { + _loadingRating = true; + notifyListeners(); + + try { + await _apiService.updateWorkRating(work.id.toString(), rating); + _currentRating = rating; + AppLogger.info('更新评分成功: $rating'); + } catch (e) { + AppLogger.error('更新评分失败', e); + rethrow; + } finally { + _loadingRating = false; + notifyListeners(); + } + } + + Future downloadFiles( + List files, + ) async { + _downloadingFiles = true; + notifyListeners(); + + var successCount = 0; + var failedCount = 0; + var saveDirectoryPath = ''; + + try { + await _fileDownloader.ready; + final rootDirectory = + await _downloadDirectoryController.resolveDownloadRootDirectory(); + final saveDirectory = await _resolveWorkDirectory(rootDirectory); + saveDirectoryPath = saveDirectory.path; + final downloadHeaders = await _buildDownloadHeaders(); + + for (final requestItem in files) { + final file = requestItem.file; + final downloadUrl = file.mediaDownloadUrl; + if (downloadUrl == null || downloadUrl.isEmpty) { + failedCount++; + continue; + } + + final targetDirectory = await _resolveTargetDirectory( + saveDirectory, + requestItem.relativeDirectories, + ); + final safeFileName = _sanitizeFileName(file.title); + final savePath = + await _createUniqueSavePath(targetDirectory, safeFileName); + final taskId = _downloadProgressManager.createTask( + workId: work.id, + workTitle: _resolveWorkTitle(), + fileName: safeFileName, + savePath: savePath, + ); + + try { + _downloadProgressManager.markStarted(taskId); + final expectedBytes = (file.size ?? 0) > 0 ? file.size! : 0; + final statusUpdate = await _fileDownloader.download( + DownloadTask( + taskId: taskId, + url: downloadUrl, + filename: _fileNameFromPath(savePath), + directory: targetDirectory.path, + baseDirectory: BaseDirectory.root, + headers: downloadHeaders, + updates: Updates.statusAndProgress, + ), + onProgress: (progress) { + if (expectedBytes > 0) { + final receivedBytes = + (expectedBytes * progress.clamp(0.0, 1.0)).round(); + _downloadProgressManager.updateProgress( + taskId, + receivedBytes, + expectedBytes, + ); + return; + } + _downloadProgressManager.updateProgress(taskId, 0, 0); + }, + ); + + if (statusUpdate.status == TaskStatus.complete) { + _downloadProgressManager.markCompleted(taskId); + successCount++; + continue; + } + + final errorMessage = statusUpdate.exception?.description ?? + '下载状态异常: ${statusUpdate.status.name}'; + _downloadProgressManager.markFailed(taskId, errorMessage); + failedCount++; + AppLogger.error('下载文件失败: ${file.title}', errorMessage); + } catch (e) { + _downloadProgressManager.markFailed(taskId, e); + failedCount++; + AppLogger.error('下载文件失败: ${file.title}', e); + } + } + } finally { + _downloadingFiles = false; + if (!_disposed) { + notifyListeners(); + } + } + + return DownloadBatchResult( + successCount: successCount, + failedCount: failedCount, + saveDirectoryPath: saveDirectoryPath, + ); + } + + Future> _buildDownloadHeaders() async { + final authData = await _authRepository.getAuthData(); + final token = authData?.token?.trim(); + if (token == null || token.isEmpty) { + return const {}; + } + return {'Authorization': 'Bearer $token'}; + } + + Future _resolveWorkDirectory(Directory rootDirectory) async { + final workFolderName = _sanitizeFileName( + (work.sourceId?.trim().isNotEmpty ?? false) + ? work.sourceId! + : 'work_${work.id ?? 'unknown'}', + ); + + final workDirectory = Directory( + '${rootDirectory.path}${Platform.pathSeparator}$workFolderName', + ); + if (!await workDirectory.exists()) { + await workDirectory.create(recursive: true); + } + return workDirectory; + } + + Future _resolveTargetDirectory( + Directory workDirectory, + List relativeDirectories, + ) async { + if (relativeDirectories.isEmpty) { + return workDirectory; + } + + final safeSegments = relativeDirectories + .map((segment) => _sanitizePathSegment(segment, fallback: 'folder')) + .where((segment) => segment.isNotEmpty) + .toList(growable: false); + if (safeSegments.isEmpty) { + return workDirectory; + } + + var targetPath = workDirectory.path; + for (final segment in safeSegments) { + targetPath = '$targetPath${Platform.pathSeparator}$segment'; + } + final targetDirectory = Directory(targetPath); + if (!await targetDirectory.exists()) { + await targetDirectory.create(recursive: true); + } + return targetDirectory; + } + + Future _createUniqueSavePath( + Directory directory, + String fileName, + ) async { + final dotIndex = fileName.lastIndexOf('.'); + final baseName = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName; + final extension = dotIndex > 0 ? fileName.substring(dotIndex) : ''; + + var candidateName = fileName; + var counter = 1; + while (await File( + '${directory.path}${Platform.pathSeparator}$candidateName', + ).exists()) { + candidateName = '$baseName ($counter)$extension'; + counter++; + } + + return '${directory.path}${Platform.pathSeparator}$candidateName'; + } + + String _sanitizeFileName(String? original) { + return _sanitizePathSegment(original, fallback: 'file'); + } + + String _fileNameFromPath(String fullPath) { + final segments = fullPath.split(RegExp(r'[\\/]')); + if (segments.isEmpty || segments.last.isEmpty) { + return 'file'; + } + return segments.last; + } + + String _sanitizePathSegment( + String? original, { + required String fallback, + }) { + final normalized = (original ?? '').trim(); + if (normalized.isEmpty) { + return fallback; + } + final sanitized = normalized.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + if (sanitized == '.' || sanitized == '..') { + return fallback; + } + return sanitized; + } + + String _resolveWorkTitle() { + final sourceId = work.sourceId?.trim(); + if (sourceId != null && sourceId.isNotEmpty) { + return sourceId; + } + final title = work.title?.trim(); + if (title != null && title.isNotEmpty) { + return title; + } + return 'work_${work.id ?? 'unknown'}'; + } + void showMarkDialog(BuildContext context) { showDialog( context: context, @@ -272,7 +628,10 @@ class DetailViewModel extends ChangeNotifier { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('已标记为${status.label}'), + content: Text( + context.l10n + .markUpdated(status.localizedLabel(context.l10n)), + ), duration: const Duration(seconds: 2), behavior: SnackBarBehavior.floating, ), @@ -281,7 +640,9 @@ class DetailViewModel extends ChangeNotifier { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('标记失败: $e')), + SnackBar( + content: Text(context.l10n.markFailed(e.toString())), + ), ); } } @@ -290,6 +651,73 @@ class DetailViewModel extends ChangeNotifier { ); } + Future showRatingDialog(BuildContext context) async { + var selectedRating = _currentRating ?? 0; + final rating = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (builderContext, setState) => AlertDialog( + title: Text(builderContext.l10n.workActionRate), + content: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(5, (index) { + final value = index + 1; + return IconButton( + onPressed: _loadingRating + ? null + : () => setState(() { + selectedRating = value; + }), + icon: Icon( + value <= selectedRating ? Icons.star : Icons.star_border, + color: Colors.amber, + ), + ); + }), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(builderContext.l10n.cancel), + ), + TextButton( + onPressed: selectedRating == 0 || _loadingRating + ? null + : () => Navigator.of(dialogContext).pop(selectedRating), + child: Text(builderContext.l10n.confirm), + ), + ], + ), + ), + ); + + if (rating == null) return; + + try { + await updateRating(rating); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${context.l10n.workActionRate}: $rating/5')), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.operationFailed(e.toString()))), + ); + } + } + + int? _normalizeRating(dynamic rating) { + final parsed = switch (rating) { + int value => value, + double value => value.round(), + String value => int.tryParse(value) ?? double.tryParse(value)?.round(), + _ => null, + }; + if (parsed == null) return null; + return parsed.clamp(1, 5); + } + @override void dispose() { // 取消所有正在进行的请求 diff --git a/lib/presentation/viewmodels/favorites_viewmodel.dart b/lib/presentation/viewmodels/favorites_viewmodel.dart index cd0ed6a..ad7f550 100644 --- a/lib/presentation/viewmodels/favorites_viewmodel.dart +++ b/lib/presentation/viewmodels/favorites_viewmodel.dart @@ -52,4 +52,4 @@ class FavoritesViewModel extends ChangeNotifier { Future loadFavorites({bool refresh = false}) async { await loadPage(1); } -} \ No newline at end of file +} diff --git a/lib/presentation/viewmodels/home_viewmodel.dart b/lib/presentation/viewmodels/home_viewmodel.dart index 8486e5f..a1b1bee 100644 --- a/lib/presentation/viewmodels/home_viewmodel.dart +++ b/lib/presentation/viewmodels/home_viewmodel.dart @@ -9,11 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart'; class HomeViewModel extends PaginatedWorksViewModel { static const String _filterStateKey = 'home_filter_state'; static const String _subtitleFilterKey = 'subtitle_filter'; - + bool _filterPanelExpanded = false; bool _hasSubtitle = false; FilterState _filterState = const FilterState(); - + bool get filterPanelExpanded => _filterPanelExpanded; bool get hasSubtitle => _hasSubtitle; FilterState get filterState => _filterState; diff --git a/lib/presentation/viewmodels/player_viewmodel.dart b/lib/presentation/viewmodels/player_viewmodel.dart index b7e0644..7c2c4e0 100644 --- a/lib/presentation/viewmodels/player_viewmodel.dart +++ b/lib/presentation/viewmodels/player_viewmodel.dart @@ -29,9 +29,9 @@ class PlayerViewModel extends ChangeNotifier { required IAudioPlayerService audioService, required PlaybackEventHub eventHub, required ISubtitleService subtitleService, - }) : _audioService = audioService, - _eventHub = eventHub, - _subtitleService = subtitleService { + }) : _audioService = audioService, + _eventHub = eventHub, + _subtitleService = subtitleService { _initStreams(); _requestInitialState(); } @@ -174,10 +174,8 @@ class PlayerViewModel extends ChangeNotifier { // 修改字幕加载方法,返回 Future 以便等待加载完成 Future _loadSubtitleIfAvailable(PlaybackContext context) async { - final subtitleFile = _subtitleLoader.findSubtitleFile( - context.currentFile, - context.files - ); + final subtitleFile = + _subtitleLoader.findSubtitleFile(context.currentFile, context.files); if (subtitleFile?.mediaDownloadUrl != null) { await _subtitleService.loadSubtitle(subtitleFile!.mediaDownloadUrl!); } else { @@ -192,7 +190,7 @@ class PlayerViewModel extends ChangeNotifier { Future seekToNextLyric() async { final currentSubtitle = _subtitleService.currentSubtitleWithState; final subtitleList = _subtitleService.subtitleList; - + if (currentSubtitle != null && subtitleList != null) { final nextSubtitle = currentSubtitle.subtitle.getNext(subtitleList); if (nextSubtitle != null) { @@ -204,9 +202,10 @@ class PlayerViewModel extends ChangeNotifier { Future seekToPreviousLyric() async { final currentSubtitle = _subtitleService.currentSubtitleWithState; final subtitleList = _subtitleService.subtitleList; - + if (currentSubtitle != null && subtitleList != null) { - final previousSubtitle = currentSubtitle.subtitle.getPrevious(subtitleList); + final previousSubtitle = + currentSubtitle.subtitle.getPrevious(subtitleList); if (previousSubtitle != null) { await seek(previousSubtitle.start); } diff --git a/lib/presentation/viewmodels/playlist_works_viewmodel.dart b/lib/presentation/viewmodels/playlist_works_viewmodel.dart index 2b48f59..e604add 100644 --- a/lib/presentation/viewmodels/playlist_works_viewmodel.dart +++ b/lib/presentation/viewmodels/playlist_works_viewmodel.dart @@ -9,7 +9,7 @@ import 'package:get_it/get_it.dart'; class PlaylistWorksViewModel extends ChangeNotifier { final ApiService _apiService = GetIt.I(); final Playlist playlist; - + List _works = []; bool _isLoading = false; String? _error; @@ -22,9 +22,10 @@ class PlaylistWorksViewModel extends ChangeNotifier { bool get isLoading => _isLoading; String? get error => _error; int get currentPage => _currentPage; - int? get totalPages => _pagination?.totalCount != null && _pagination?.pageSize != null - ? (_pagination!.totalCount! / _pagination!.pageSize!).ceil() - : null; + int? get totalPages => + _pagination?.totalCount != null && _pagination?.pageSize != null + ? (_pagination!.totalCount! / _pagination!.pageSize!).ceil() + : null; Future loadWorks({int page = 1}) async { if (_isLoading) return; @@ -39,7 +40,7 @@ class PlaylistWorksViewModel extends ChangeNotifier { playlistId: playlist.id!, page: page, ); - + _works = response.works; _pagination = response.pagination; _currentPage = page; diff --git a/lib/presentation/viewmodels/playlists_viewmodel.dart b/lib/presentation/viewmodels/playlists_viewmodel.dart index f3eb659..1d0d38a 100644 --- a/lib/presentation/viewmodels/playlists_viewmodel.dart +++ b/lib/presentation/viewmodels/playlists_viewmodel.dart @@ -1,18 +1,20 @@ -import 'package:asmrapp/data/models/works/work.dart'; -import 'package:flutter/foundation.dart'; -import 'package:asmrapp/data/models/my_lists/my_playlists/my_playlists.dart'; -import 'package:asmrapp/data/models/my_lists/my_playlists/playlist.dart'; import 'package:asmrapp/data/models/my_lists/my_playlists/pagination.dart'; +import 'package:asmrapp/data/models/my_lists/my_playlists/playlist.dart'; +import 'package:asmrapp/data/models/works/work.dart'; import 'package:asmrapp/data/services/api_service.dart'; +import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; import 'package:asmrapp/utils/logger.dart'; +import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; class PlaylistsViewModel extends ChangeNotifier { final ApiService _apiService = GetIt.I(); - + final AuthViewModel _authViewModel; + List? _playlists; bool _isLoading = false; String? _error; + bool _loginRequired = false; Pagination? _pagination; int _currentPage = 1; @@ -28,20 +30,23 @@ class PlaylistsViewModel extends ChangeNotifier { List get playlists => _playlists ?? []; bool get isLoading => _isLoading; String? get error => _error; + bool get loginRequired => _loginRequired; int get currentPage => _currentPage; - int? get totalPages => _pagination?.totalCount != null && _pagination?.pageSize != null - ? (_pagination!.totalCount! / _pagination!.pageSize!).ceil() - : null; + int? get totalPages => + _pagination?.totalCount != null && _pagination?.pageSize != null + ? (_pagination!.totalCount! / _pagination!.pageSize!).ceil() + : null; Playlist? get selectedPlaylist => _selectedPlaylist; List get playlistWorks => _playlistWorks; bool get loadingWorks => _loadingWorks; String? get worksError => _worksError; int get worksCurrentPage => _worksCurrentPage; - int? get worksTotalPages => _worksPagination?.totalCount != null && _worksPagination?.pageSize != null - ? (_worksPagination!.totalCount! / _worksPagination!.pageSize!).ceil() - : null; + int? get worksTotalPages => + _worksPagination?.totalCount != null && _worksPagination?.pageSize != null + ? (_worksPagination!.totalCount! / _worksPagination!.pageSize!).ceil() + : null; - PlaylistsViewModel() { + PlaylistsViewModel(this._authViewModel) { loadPlaylists(); } @@ -50,8 +55,19 @@ class PlaylistsViewModel extends ChangeNotifier { if (_isLoading) return; if (page < 1 || (totalPages != null && page > totalPages!)) return; + if (!_authViewModel.isLoggedIn) { + _playlists = []; + _pagination = null; + _currentPage = 1; + _loginRequired = true; + _error = null; + notifyListeners(); + return; + } + _isLoading = true; _error = null; + _loginRequired = false; notifyListeners(); try { @@ -62,6 +78,7 @@ class PlaylistsViewModel extends ChangeNotifier { AppLogger.info('第$page页播放列表加载成功: ${_playlists?.length ?? 0}个播放列表'); } catch (e) { AppLogger.error('加载播放列表失败', e); + _loginRequired = false; _error = e.toString(); } finally { _isLoading = false; @@ -82,7 +99,7 @@ class PlaylistsViewModel extends ChangeNotifier { _worksPagination = null; _worksCurrentPage = 1; notifyListeners(); - + await loadPlaylistWorks(); } @@ -99,7 +116,9 @@ class PlaylistsViewModel extends ChangeNotifier { /// 加载播放列表作品 Future loadPlaylistWorks({int page = 1}) async { if (_loadingWorks || _selectedPlaylist == null) return; - if (page < 1 || (worksTotalPages != null && page > worksTotalPages!)) return; + if (page < 1 || (worksTotalPages != null && page > worksTotalPages!)) { + return; + } _loadingWorks = true; _worksError = null; @@ -110,7 +129,7 @@ class PlaylistsViewModel extends ChangeNotifier { playlistId: _selectedPlaylist!.id!, page: page, ); - + _playlistWorks = response.works; _worksPagination = response.pagination as Pagination?; _worksCurrentPage = page; @@ -127,21 +146,9 @@ class PlaylistsViewModel extends ChangeNotifier { /// 刷新播放列表作品 Future refreshWorks() => loadPlaylistWorks(page: 1); - /// 获取播放列表显示名称 - String getDisplayName(String? name) { - switch (name) { - case '__SYS_PLAYLIST_MARKED': - return '我标记的'; - case '__SYS_PLAYLIST_LIKED': - return '我喜欢的'; - default: - return name ?? ''; - } - } - @override void dispose() { AppLogger.info('销毁 PlaylistsViewModel'); super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/presentation/viewmodels/popular_viewmodel.dart b/lib/presentation/viewmodels/popular_viewmodel.dart index 08786c0..5b6ecc9 100644 --- a/lib/presentation/viewmodels/popular_viewmodel.dart +++ b/lib/presentation/viewmodels/popular_viewmodel.dart @@ -15,7 +15,7 @@ class PopularViewModel extends PaginatedWorksViewModel { @override Future onInit() async { - await _loadSubtitleFilter(); // 使用 onInit 钩子加载状态 + await _loadSubtitleFilter(); // 使用 onInit 钩子加载状态 } Future _loadSubtitleFilter() async { @@ -81,12 +81,12 @@ class PopularViewModel extends PaginatedWorksViewModel { } // 保持原有的便捷方法 - Future loadPopular({bool refresh = false}) => - refresh ? this.refresh() : loadPage(1); + Future loadPopular({bool refresh = false}) => + refresh ? this.refresh() : loadPage(1); @override void dispose() { _saveFilterState(); super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/presentation/viewmodels/recommend_viewmodel.dart b/lib/presentation/viewmodels/recommend_viewmodel.dart index 9fc15d9..f470018 100644 --- a/lib/presentation/viewmodels/recommend_viewmodel.dart +++ b/lib/presentation/viewmodels/recommend_viewmodel.dart @@ -8,7 +8,8 @@ import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; class RecommendViewModel extends ChangeNotifier { - static const _subtitleFilterKey = 'subtitle_filter'; // 与 PopularViewModel 使用相同的 key 实现全局共享 + static const _subtitleFilterKey = + 'subtitle_filter'; // 与 PopularViewModel 使用相同的 key 实现全局共享 final ApiService _apiService; final AuthViewModel _authViewModel; List _works = []; @@ -18,8 +19,10 @@ class RecommendViewModel extends ChangeNotifier { int _currentPage = 1; bool _hasSubtitle = false; bool _filterPanelExpanded = false; + bool _loginRequired = false; - RecommendViewModel(this._authViewModel) : _apiService = GetIt.I() { + RecommendViewModel(this._authViewModel) + : _apiService = GetIt.I() { _loadFilterState(); } @@ -57,6 +60,7 @@ class RecommendViewModel extends ChangeNotifier { : null; bool get hasSubtitle => _hasSubtitle; bool get filterPanelExpanded => _filterPanelExpanded; + bool get loginRequired => _loginRequired; Pagination? get pagination => _pagination; @@ -84,17 +88,19 @@ class RecommendViewModel extends ChangeNotifier { Future loadPage(int page) async { if (_isLoading) return; if (page < 1 || (totalPages != null && page > totalPages!)) return; - + // 检查是否已登录 final uuid = _authViewModel.recommenderUuid; if (uuid == null) { - _error = '请先登录'; + _loginRequired = true; + _error = null; notifyListeners(); return; } _isLoading = true; _error = null; + _loginRequired = false; notifyListeners(); try { @@ -126,4 +132,4 @@ class RecommendViewModel extends ChangeNotifier { _saveFilterState(); // 在销毁时保存状态 super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/presentation/viewmodels/settings/cache_manager_viewmodel.dart b/lib/presentation/viewmodels/settings/cache_manager_viewmodel.dart index 4cb0428..459a9cf 100644 --- a/lib/presentation/viewmodels/settings/cache_manager_viewmodel.dart +++ b/lib/presentation/viewmodels/settings/cache_manager_viewmodel.dart @@ -7,13 +7,16 @@ class CacheManagerViewModel extends ChangeNotifier { bool _isLoading = false; int _audioCacheSize = 0; int _subtitleCacheSize = 0; - String? _error; + String? _errorDetail; + CacheOperation? _lastFailedOperation; bool get isLoading => _isLoading; int get audioCacheSize => _audioCacheSize; int get subtitleCacheSize => _subtitleCacheSize; int get totalCacheSize => _audioCacheSize + _subtitleCacheSize; - String? get error => _error; + String? get error => _errorDetail; + String? get errorDetail => _errorDetail; + CacheOperation? get lastFailedOperation => _lastFailedOperation; // 格式化缓存大小显示 String _formatSize(int size) { @@ -30,17 +33,20 @@ class CacheManagerViewModel extends ChangeNotifier { Future loadCacheSize() async { try { _isLoading = true; + _errorDetail = null; + _lastFailedOperation = null; notifyListeners(); - + // 获取音频缓存大小 _audioCacheSize = await AudioCacheManager.getCacheSize(); // 获取字幕缓存大小 _subtitleCacheSize = await SubtitleCacheManager.getSize(); - - _error = null; + + _errorDetail = null; } catch (e) { AppLogger.error('加载缓存大小失败', e); - _error = '加载失败: $e'; + _errorDetail = e.toString(); + _lastFailedOperation = CacheOperation.load; } finally { _isLoading = false; notifyListeners(); @@ -51,14 +57,17 @@ class CacheManagerViewModel extends ChangeNotifier { Future clearAudioCache() async { try { _isLoading = true; + _errorDetail = null; + _lastFailedOperation = null; notifyListeners(); - + await AudioCacheManager.cleanCache(); await loadCacheSize(); - _error = null; + _errorDetail = null; } catch (e) { AppLogger.error('清理音频缓存失败', e); - _error = '清理失败: $e'; + _errorDetail = e.toString(); + _lastFailedOperation = CacheOperation.clearAudio; } finally { _isLoading = false; notifyListeners(); @@ -69,14 +78,17 @@ class CacheManagerViewModel extends ChangeNotifier { Future clearSubtitleCache() async { try { _isLoading = true; + _errorDetail = null; + _lastFailedOperation = null; notifyListeners(); - + await SubtitleCacheManager.clearCache(); await loadCacheSize(); - _error = null; + _errorDetail = null; } catch (e) { AppLogger.error('清理字幕缓存失败', e); - _error = '清理失败: $e'; + _errorDetail = e.toString(); + _lastFailedOperation = CacheOperation.clearSubtitle; } finally { _isLoading = false; notifyListeners(); @@ -87,21 +99,31 @@ class CacheManagerViewModel extends ChangeNotifier { Future clearAllCache() async { try { _isLoading = true; + _errorDetail = null; + _lastFailedOperation = null; notifyListeners(); - + await Future.wait([ AudioCacheManager.cleanCache(), SubtitleCacheManager.clearCache(), ]); - + await loadCacheSize(); - _error = null; + _errorDetail = null; } catch (e) { AppLogger.error('清理缓存失败', e); - _error = '清理失败: $e'; + _errorDetail = e.toString(); + _lastFailedOperation = CacheOperation.clearAll; } finally { _isLoading = false; notifyListeners(); } } -} \ No newline at end of file +} + +enum CacheOperation { + load, + clearAudio, + clearSubtitle, + clearAll, +} diff --git a/lib/presentation/viewmodels/similar_works_viewmodel.dart b/lib/presentation/viewmodels/similar_works_viewmodel.dart index e56d58f..ed2e00d 100644 --- a/lib/presentation/viewmodels/similar_works_viewmodel.dart +++ b/lib/presentation/viewmodels/similar_works_viewmodel.dart @@ -7,7 +7,8 @@ import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SimilarWorksViewModel extends ChangeNotifier { - static const _subtitleFilterKey = 'subtitle_filter'; // 与其他 ViewModel 使用相同的 key + static const _subtitleFilterKey = + 'subtitle_filter'; // 与其他 ViewModel 使用相同的 key final ApiService _apiService; final Work work; List _works = []; @@ -115,4 +116,4 @@ class SimilarWorksViewModel extends ChangeNotifier { _saveFilterState(); super.dispose(); } -} \ No newline at end of file +} diff --git a/lib/presentation/widgets/auth/login_dialog.dart b/lib/presentation/widgets/auth/login_dialog.dart index 230cd61..085137f 100644 --- a/lib/presentation/widgets/auth/login_dialog.dart +++ b/lib/presentation/widgets/auth/login_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; import 'package:asmrapp/utils/logger.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class LoginDialog extends StatefulWidget { const LoginDialog({super.key}); @@ -25,10 +26,10 @@ class _LoginDialogState extends State { Future _handleLogin() async { final name = _nameController.text.trim(); AppLogger.info('LoginDialog: 尝试登录: name=$name'); - + final authVM = context.read(); await authVM.login(name, _passwordController.text); - + if (mounted) { if (authVM.error == null) { AppLogger.info('LoginDialog: 登录成功,关闭对话框'); @@ -42,15 +43,15 @@ class _LoginDialogState extends State { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('登录'), + title: Text(context.l10n.loginTitle), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _nameController, - decoration: const InputDecoration( - labelText: '用户名', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: context.l10n.loginUsernameLabel, + border: const OutlineInputBorder(), ), textInputAction: TextInputAction.next, ), @@ -58,7 +59,7 @@ class _LoginDialogState extends State { TextField( controller: _passwordController, decoration: InputDecoration( - labelText: '密码', + labelText: context.l10n.loginPasswordLabel, border: const OutlineInputBorder(), suffixIcon: IconButton( icon: Icon( @@ -94,7 +95,7 @@ class _LoginDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('取消'), + child: Text(context.l10n.cancel), ), Consumer( builder: (context, authVM, _) { @@ -108,11 +109,11 @@ class _LoginDialogState extends State { strokeWidth: 2, ), ) - : const Text('登录'), + : Text(context.l10n.loginAction), ); }, ), ], ); } -} \ No newline at end of file +} diff --git a/lib/screens/contents/home_content.dart b/lib/screens/contents/home_content.dart index c760295..97ac65f 100644 --- a/lib/screens/contents/home_content.dart +++ b/lib/screens/contents/home_content.dart @@ -1,9 +1,9 @@ +import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; +import 'package:asmrapp/presentation/viewmodels/home_viewmodel.dart'; import 'package:asmrapp/widgets/filter/filter_panel.dart'; +import 'package:asmrapp/widgets/work_grid/enhanced_work_grid_view.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:asmrapp/presentation/viewmodels/home_viewmodel.dart'; -import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; -import 'package:asmrapp/widgets/work_grid/enhanced_work_grid_view.dart'; class HomeContent extends StatefulWidget { const HomeContent({super.key}); @@ -73,13 +73,16 @@ class _HomeContentState extends State child: AnimatedSlide( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, - offset: Offset(0, viewModel.filterPanelExpanded ? 0 : -1), + offset: Offset( + 0, + viewModel.filterPanelExpanded ? 0 : -1, + ), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, spreadRadius: 1, offset: const Offset(0, 1), diff --git a/lib/screens/contents/playlists/playlist_works_view.dart b/lib/screens/contents/playlists/playlist_works_view.dart index 59fcb58..790f275 100644 --- a/lib/screens/contents/playlists/playlist_works_view.dart +++ b/lib/screens/contents/playlists/playlist_works_view.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:asmrapp/data/models/my_lists/my_playlists/playlist.dart'; import 'package:asmrapp/presentation/viewmodels/playlist_works_viewmodel.dart'; -import 'package:asmrapp/presentation/viewmodels/playlists_viewmodel.dart'; import 'package:asmrapp/widgets/work_grid/enhanced_work_grid_view.dart'; import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/common/utils/playlist_localizations.dart'; class PlaylistWorksView extends StatelessWidget { final Playlist playlist; @@ -19,8 +20,6 @@ class PlaylistWorksView extends StatelessWidget { @override Widget build(BuildContext context) { - final playlistsViewModel = context.read(); - return ChangeNotifierProvider( create: (_) => PlaylistWorksViewModel(playlist)..loadWorks(), child: Consumer( @@ -39,7 +38,7 @@ class PlaylistWorksView extends StatelessWidget { ), Expanded( child: Text( - playlistsViewModel.getDisplayName(playlist.name), + localizedPlaylistName(playlist.name, context.l10n), style: Theme.of(context).textTheme.titleMedium, overflow: TextOverflow.ellipsis, ), @@ -59,7 +58,7 @@ class PlaylistWorksView extends StatelessWidget { totalPages: viewModel.totalPages, onPageChanged: (page) => viewModel.loadWorks(page: page), layoutStrategy: _layoutStrategy, - emptyMessage: '暂无作品', + emptyMessage: context.l10n.emptyWorks, ), ), ], @@ -68,4 +67,4 @@ class PlaylistWorksView extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/contents/playlists/playlists_list_view.dart b/lib/screens/contents/playlists/playlists_list_view.dart index afe586f..f91d8f8 100644 --- a/lib/screens/contents/playlists/playlists_list_view.dart +++ b/lib/screens/contents/playlists/playlists_list_view.dart @@ -3,6 +3,8 @@ import 'package:provider/provider.dart'; import 'package:asmrapp/presentation/viewmodels/playlists_viewmodel.dart'; import 'package:asmrapp/data/models/my_lists/my_playlists/playlist.dart'; import 'package:asmrapp/widgets/pagination_controls.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/common/utils/playlist_localizations.dart'; class PlaylistsListView extends StatelessWidget { final Function(Playlist) onPlaylistSelected; @@ -16,21 +18,27 @@ class PlaylistsListView extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, viewModel, child) { + final errorMessage = viewModel.loginRequired + ? context.l10n.pleaseLogin + : viewModel.error; + if (viewModel.isLoading && viewModel.playlists.isEmpty) { return const Center(child: CircularProgressIndicator()); } - if (viewModel.error != null && viewModel.playlists.isEmpty) { + if (errorMessage != null && viewModel.playlists.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(viewModel.error!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: viewModel.refresh, - child: const Text('重试'), - ), + Text(errorMessage), + if (!viewModel.loginRequired) ...[ + const SizedBox(height: 16), + ElevatedButton( + onPressed: viewModel.refresh, + child: Text(context.l10n.retry), + ), + ], ], ), ); @@ -47,8 +55,13 @@ class PlaylistsListView extends StatelessWidget { final playlist = viewModel.playlists[index]; return ListTile( leading: const Icon(Icons.playlist_play), - title: Text(viewModel.getDisplayName(playlist.name)), - subtitle: Text('${playlist.worksCount ?? 0} 个作品'), + title: Text( + localizedPlaylistName(playlist.name, context.l10n), + ), + subtitle: Text( + context.l10n + .playlistWorksCount(playlist.worksCount ?? 0), + ), onTap: () => onPlaylistSelected(playlist), ); }, @@ -67,4 +80,4 @@ class PlaylistsListView extends StatelessWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/contents/playlists_content.dart b/lib/screens/contents/playlists_content.dart index 05ec9ae..6335be7 100644 --- a/lib/screens/contents/playlists_content.dart +++ b/lib/screens/contents/playlists_content.dart @@ -1,9 +1,7 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import 'package:asmrapp/data/models/my_lists/my_playlists/playlist.dart'; -import 'package:asmrapp/screens/contents/playlists/playlists_list_view.dart'; import 'package:asmrapp/screens/contents/playlists/playlist_works_view.dart'; -import 'package:asmrapp/presentation/viewmodels/playlists_viewmodel.dart'; +import 'package:asmrapp/screens/contents/playlists/playlists_list_view.dart'; +import 'package:flutter/material.dart'; class PlaylistsContent extends StatefulWidget { const PlaylistsContent({super.key}); @@ -12,7 +10,8 @@ class PlaylistsContent extends StatefulWidget { State createState() => _PlaylistsContentState(); } -class _PlaylistsContentState extends State with AutomaticKeepAliveClientMixin { +class _PlaylistsContentState extends State + with AutomaticKeepAliveClientMixin { Playlist? _selectedPlaylist; @override @@ -30,18 +29,10 @@ class _PlaylistsContentState extends State with AutomaticKeepA }); } - Future _onWillPop() async { - if (_selectedPlaylist != null) { - _handleBack(); - return false; - } - return true; - } - @override Widget build(BuildContext context) { super.build(context); - + return PopScope( canPop: _selectedPlaylist == null, onPopInvokedWithResult: (didPop, result) { @@ -59,4 +50,4 @@ class _PlaylistsContentState extends State with AutomaticKeepA ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/contents/popular_content.dart b/lib/screens/contents/popular_content.dart index 4365f77..b1bb746 100644 --- a/lib/screens/contents/popular_content.dart +++ b/lib/screens/contents/popular_content.dart @@ -12,7 +12,8 @@ class PopularContent extends StatefulWidget { State createState() => _PopularContentState(); } -class _PopularContentState extends State with AutomaticKeepAliveClientMixin { +class _PopularContentState extends State + with AutomaticKeepAliveClientMixin { final _layoutStrategy = const WorkLayoutStrategy(); final _scrollController = ScrollController(); @@ -32,7 +33,8 @@ class _PopularContentState extends State with AutomaticKeepAlive } void _onScroll() { - if (_scrollController.position.pixels != _scrollController.position.minScrollExtent) { + if (_scrollController.position.pixels != + _scrollController.position.minScrollExtent) { final viewModel = context.read(); if (viewModel.filterPanelExpanded) { viewModel.closeFilterPanel(); @@ -80,4 +82,4 @@ class _PopularContentState extends State with AutomaticKeepAlive }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/contents/recommend_content.dart b/lib/screens/contents/recommend_content.dart index ed5f1b9..307acfc 100644 --- a/lib/screens/contents/recommend_content.dart +++ b/lib/screens/contents/recommend_content.dart @@ -1,10 +1,10 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:asmrapp/presentation/viewmodels/recommend_viewmodel.dart'; import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; -import 'package:asmrapp/widgets/work_grid/enhanced_work_grid_view.dart'; -import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; +import 'package:asmrapp/presentation/viewmodels/recommend_viewmodel.dart'; import 'package:asmrapp/widgets/filter/filter_with_keyword.dart'; +import 'package:asmrapp/widgets/work_grid/enhanced_work_grid_view.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class RecommendContent extends StatefulWidget { const RecommendContent({super.key}); @@ -13,7 +13,8 @@ class RecommendContent extends StatefulWidget { State createState() => _RecommendContentState(); } -class _RecommendContentState extends State with AutomaticKeepAliveClientMixin { +class _RecommendContentState extends State + with AutomaticKeepAliveClientMixin { final _layoutStrategy = const WorkLayoutStrategy(); final _scrollController = ScrollController(); @@ -21,7 +22,8 @@ class _RecommendContentState extends State with AutomaticKeepA bool get wantKeepAlive => true; void _onScroll() { - if (_scrollController.position.pixels != _scrollController.position.minScrollExtent) { + if (_scrollController.position.pixels != + _scrollController.position.minScrollExtent) { final viewModel = context.read(); if (viewModel.filterPanelExpanded) { viewModel.closeFilterPanel(); @@ -56,7 +58,9 @@ class _RecommendContentState extends State with AutomaticKeepA EnhancedWorkGridView( works: viewModel.works, isLoading: viewModel.isLoading, - error: viewModel.error, + error: viewModel.loginRequired + ? context.l10n.pleaseLogin + : viewModel.error, currentPage: viewModel.currentPage, totalPages: viewModel.totalPages, onPageChanged: (page) => viewModel.loadPage(page), @@ -85,4 +89,4 @@ class _RecommendContentState extends State with AutomaticKeepA }, ); } -} \ No newline at end of file +} diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index ede94ef..334b365 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -1,18 +1,28 @@ -import 'package:asmrapp/widgets/mini_player/mini_player.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:asmrapp/common/utils/work_localizations.dart'; +import 'package:asmrapp/common/utils/file_preview_utils.dart'; +import 'package:asmrapp/core/download/download_request_item.dart'; +import 'package:asmrapp/data/models/files/child.dart'; import 'package:asmrapp/data/models/works/work.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/presentation/viewmodels/detail_viewmodel.dart'; +import 'package:asmrapp/screens/similar_works_screen.dart'; +import 'package:asmrapp/widgets/detail/file_preview_dialog.dart'; +import 'package:asmrapp/widgets/detail/download_file_selection_dialog.dart'; +import 'package:asmrapp/widgets/detail/related_works_section.dart'; +import 'package:asmrapp/widgets/detail/work_action_buttons.dart'; import 'package:asmrapp/widgets/detail/work_cover.dart'; -import 'package:asmrapp/widgets/detail/work_info.dart'; import 'package:asmrapp/widgets/detail/work_files_list.dart'; import 'package:asmrapp/widgets/detail/work_files_skeleton.dart'; -import 'package:asmrapp/presentation/viewmodels/detail_viewmodel.dart'; -import 'package:asmrapp/widgets/detail/work_action_buttons.dart'; -import 'package:asmrapp/screens/similar_works_screen.dart'; +import 'package:asmrapp/widgets/detail/work_info.dart'; +import 'package:asmrapp/widgets/mini_player/mini_player.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; class DetailScreen extends StatelessWidget { final Work work; final bool fromPlayer; + static final RegExp _rjCodePattern = RegExp(r'(RJ\d+)', caseSensitive: false); const DetailScreen({ super.key, @@ -22,16 +32,27 @@ class DetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final rjCode = _extractRjCode(); + final localizedTitle = work.localizedTitle(Localizations.localeOf(context)); + final appBarTitle = _buildAppBarTitle(rjCode, localizedTitle); + return ChangeNotifierProvider( create: (_) => DetailViewModel( work: work, )..loadFiles(), child: Scaffold( appBar: AppBar( - title: Text(work.sourceId ?? ''), + title: Text(appBarTitle), + actions: [ + if (rjCode != null) + IconButton( + tooltip: context.l10n.openDlsiteInBrowser, + icon: const Icon(Icons.open_in_new), + onPressed: () => _openDlsiteInBrowser(context, rjCode), + ), + ], ), body: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: MiniPlayer.height), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -45,33 +66,17 @@ class DetailScreen extends StatelessWidget { WorkInfo(work: work), Consumer( builder: (context, viewModel, _) => WorkActionButtons( - hasRecommendations: viewModel.hasRecommendations, - checkingRecommendations: viewModel.checkingRecommendations, - onRecommendationsTap: () { - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - SimilarWorksScreen(work: work), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(1.0, 0.0); - const end = Offset.zero; - const curve = Curves.easeInOut; - var tween = Tween(begin: begin, end: end).chain( - CurveTween(curve: curve), - ); - return SlideTransition( - position: animation.drive(tween), - child: child, - ); - }, - ), - ); - }, onFavoriteTap: () => viewModel.showPlaylistsDialog(context), loadingFavorite: viewModel.loadingFavorite, onMarkTap: () => viewModel.showMarkDialog(context), currentMarkStatus: viewModel.currentMarkStatus, loadingMark: viewModel.loadingMark, + onRateTap: () => viewModel.showRatingDialog(context), + loadingRate: viewModel.loadingRating, + onDownloadTap: viewModel.files == null + ? null + : () => _showDownloadDialog(context, viewModel), + loadingDownload: viewModel.downloadingFiles, ), ), Consumer( @@ -93,28 +98,301 @@ class DetailScreen extends StatelessWidget { if (viewModel.files != null) { return WorkFilesList( files: viewModel.files!, - onFileTap: (file) async { - try { - await viewModel.playFile(file, context); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('播放失败: $e')), - ); - } - } - }, + onFileTap: (file) => + _handleFileTap(context, viewModel, file), ); } return const SizedBox.shrink(); }, ), + Consumer( + builder: (context, viewModel, _) => RelatedWorksSection( + works: viewModel.recommendedWorks, + isLoading: viewModel.loadingRecommendations, + error: viewModel.recommendationsError, + hasRecommendations: viewModel.hasRecommendations, + onSeeAll: () => _openSimilarWorksScreen(context), + onRetry: viewModel.loadRecommendationsPreview, + onWorkTap: (relatedWork) => + _openWorkDetail(context, relatedWork), + ), + ), ], ), ), - bottomSheet: const MiniPlayer(), + bottomNavigationBar: const MiniPlayer(), ), ); } + + String _buildAppBarTitle(String? rjCode, String title) { + final normalizedTitle = title.trim(); + if (rjCode == null || rjCode.isEmpty) { + return normalizedTitle; + } + if (normalizedTitle.isEmpty) { + return rjCode; + } + if (normalizedTitle.toUpperCase() == rjCode.toUpperCase()) { + return rjCode; + } + return '$rjCode - $normalizedTitle'; + } + + Future _handleFileTap( + BuildContext context, + DetailViewModel viewModel, + Child file, + ) async { + if (FilePreviewUtils.isAudio(file)) { + try { + await viewModel.playFile(file, context); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _playbackErrorMessage(context, e), + ), + ), + ); + } + } + return; + } + + if (viewModel.canPreviewFile(file)) { + final imageFiles = _resolveImageFilesForPreview(viewModel, file); + final initialImageIndex = imageFiles == null + ? null + : _findImageIndexForPreview(imageFiles, file); + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (dialogContext) => FilePreviewDialog( + file: file, + loadTextPreview: viewModel.loadTextPreview, + imageFiles: imageFiles, + initialImageIndex: initialImageIndex, + ), + ); + return; + } + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.playUnsupportedFileType(file.type ?? file.title ?? ''), + ), + ), + ); + } + + Future _showDownloadDialog( + BuildContext context, + DetailViewModel viewModel, + ) async { + final files = viewModel.files?.children; + if (files == null || files.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.playFilesNotLoaded)), + ); + return; + } + + final selectedFiles = await showDialog>( + context: context, + builder: (dialogContext) => DownloadFileSelectionDialog( + rootFiles: files, + ), + ); + + if (selectedFiles == null) return; + if (selectedFiles.isEmpty) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.downloadNoFilesSelected)), + ); + return; + } + + late final DownloadBatchResult result; + try { + result = await viewModel.downloadFiles(selectedFiles); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.operationFailed(e.toString()))), + ); + return; + } + + if (!context.mounted) return; + + if (result.successCount > 0 && result.failedCount == 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.downloadSuccess( + result.successCount, + result.saveDirectoryPath, + ), + ), + ), + ); + return; + } + + if (result.successCount > 0 && result.failedCount > 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.downloadPartial( + result.successCount, + result.failedCount, + ), + ), + ), + ); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.downloadAllFailed(result.failedCount)), + ), + ); + } + + String _playbackErrorMessage(BuildContext context, Object error) { + if (error is PlaybackException) { + switch (error.error) { + case PlaybackError.unsupportedType: + return context.l10n.playUnsupportedFileType(error.detail ?? ''); + case PlaybackError.missingUrl: + return context.l10n.playUrlMissing; + case PlaybackError.filesNotLoaded: + return context.l10n.playFilesNotLoaded; + case PlaybackError.failed: + return context.l10n.playFailed(error.detail ?? ''); + } + } + return context.l10n.playFailed(error.toString()); + } + + List? _resolveImageFilesForPreview( + DetailViewModel viewModel, + Child file, + ) { + if (!FilePreviewUtils.isImage(file)) { + return null; + } + + final rootChildren = viewModel.files?.children; + if (rootChildren == null || rootChildren.isEmpty) { + return [file]; + } + + final imageFiles = _collectImageFilesFromTree(rootChildren); + if (imageFiles.isEmpty) { + return [file]; + } + + return imageFiles; + } + + List _collectImageFilesFromTree(List nodes) { + final imageFiles = []; + + for (final node in nodes) { + if (node.type == 'folder') { + final children = node.children; + if (children != null && children.isNotEmpty) { + imageFiles.addAll(_collectImageFilesFromTree(children)); + } + continue; + } + + if (FilePreviewUtils.isImage(node)) { + imageFiles.add(node); + } + } + + return imageFiles; + } + + int _findImageIndexForPreview(List imageFiles, Child targetFile) { + final identityIndex = + imageFiles.indexWhere((imageFile) => identical(imageFile, targetFile)); + if (identityIndex >= 0) { + return identityIndex; + } + + return imageFiles.indexOf(targetFile); + } + + void _openWorkDetail(BuildContext context, Work targetWork) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DetailScreen(work: targetWork), + ), + ); + } + + void _openSimilarWorksScreen(BuildContext context) { + Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + SimilarWorksScreen(work: work), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + const begin = Offset(1.0, 0.0); + const end = Offset.zero; + const curve = Curves.easeInOut; + final tween = + Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + ), + ); + } + + String? _extractRjCode() { + final candidates = [ + work.sourceId, + work.originalWorkno, + work.translationInfo?.originalWorkno, + ]; + + for (final candidate in candidates) { + if (candidate == null || candidate.trim().isEmpty) continue; + final match = _rjCodePattern.firstMatch(candidate); + final rjCode = match?.group(1); + if (rjCode != null && rjCode.isNotEmpty) { + return rjCode.toUpperCase(); + } + } + + return null; + } + + Future _openDlsiteInBrowser(BuildContext context, String rjCode) async { + final url = 'https://www.dlsite.com/maniax/work/=/product_id/$rjCode.html'; + final opened = await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + + if (!opened && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.operationFailed(url))), + ); + } + } } diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index 0d98662..83bfd1a 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -5,6 +5,7 @@ import 'package:asmrapp/presentation/viewmodels/favorites_viewmodel.dart'; import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; import 'package:asmrapp/widgets/pagination_controls.dart'; import 'package:asmrapp/widgets/work_grid_view.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class FavoritesScreen extends StatefulWidget { const FavoritesScreen({super.key}); @@ -48,7 +49,7 @@ class _FavoritesScreenState extends State { value: _viewModel, child: Scaffold( appBar: AppBar( - title: const Text('我的收藏'), + title: Text(context.l10n.favoritesTitle), ), drawer: const DrawerMenu(), body: Consumer( @@ -80,4 +81,4 @@ class _FavoritesScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index ed2078b..f351fc7 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,17 +1,19 @@ -import 'package:asmrapp/screens/contents/playlists_content.dart'; -import 'package:flutter/material.dart'; -import 'package:asmrapp/widgets/mini_player/mini_player.dart'; -import 'package:asmrapp/widgets/drawer_menu.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; +import 'package:asmrapp/presentation/viewmodels/home_viewmodel.dart'; +import 'package:asmrapp/presentation/viewmodels/playlists_viewmodel.dart'; +import 'package:asmrapp/presentation/viewmodels/popular_viewmodel.dart'; +import 'package:asmrapp/presentation/viewmodels/recommend_viewmodel.dart'; import 'package:asmrapp/screens/contents/home_content.dart'; -import 'package:asmrapp/screens/contents/recommend_content.dart'; +import 'package:asmrapp/screens/contents/playlists_content.dart'; import 'package:asmrapp/screens/contents/popular_content.dart'; +import 'package:asmrapp/screens/contents/recommend_content.dart'; import 'package:asmrapp/screens/search_screen.dart'; +import 'package:asmrapp/widgets/drawer_menu.dart'; +import 'package:asmrapp/widgets/download/download_progress_panel.dart'; +import 'package:asmrapp/widgets/mini_player/mini_player.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:asmrapp/presentation/viewmodels/home_viewmodel.dart'; -import 'package:asmrapp/presentation/viewmodels/popular_viewmodel.dart'; -import 'package:asmrapp/presentation/viewmodels/recommend_viewmodel.dart'; -import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; -import 'package:asmrapp/presentation/viewmodels/playlists_viewmodel.dart'; /// MainScreen 是应用的主界面,负责管理底部导航栏和对应的内容页面。 /// 它采用了集中式的状态管理架构,所有子页面的 ViewModel 都在这里初始化和提供。 @@ -38,8 +40,6 @@ class _MainScreenState extends State { late final RecommendViewModel _recommendViewModel; late final PlaylistsViewModel _playlistsViewModel; - final _titles = const ['收藏', '主页', '为你推荐', '热门作品']; - // 页面内容列表 // 注意:这些页面不应该创建自己的 ViewModel 实例 // 而是应该通过 Provider.of 或 context.read 获取 MainScreen 提供的实例 @@ -48,6 +48,7 @@ class _MainScreenState extends State { HomeContent(), RecommendContent(), PopularContent(), + DownloadProgressPanel(), ]; @override @@ -55,12 +56,11 @@ class _MainScreenState extends State { super.initState(); // 初始化所有 ViewModel // 注意初始化顺序,如果有依赖关系需要先初始化依赖项 + final authViewModel = Provider.of(context, listen: false); _homeViewModel = HomeViewModel(); _popularViewModel = PopularViewModel(); - _recommendViewModel = RecommendViewModel( - Provider.of(context, listen: false), - ); - _playlistsViewModel = PlaylistsViewModel(); + _recommendViewModel = RecommendViewModel(authViewModel); + _playlistsViewModel = PlaylistsViewModel(authViewModel); } void _onPageChanged(int index) { @@ -101,36 +101,49 @@ class _MainScreenState extends State { ], child: Builder( builder: (context) { + final homeViewModel = context.watch(); // 根据当前页面获取对应的总数 final totalCount = _currentIndex == 1 - ? context.watch().pagination?.totalCount + ? homeViewModel.pagination?.totalCount : _currentIndex == 2 ? context.watch().pagination?.totalCount : _currentIndex == 3 ? context.watch().pagination?.totalCount : null; + final titles = [ + context.l10n.navigationFavorites, + context.l10n.navigationHome, + context.l10n.navigationForYou, + context.l10n.navigationPopularWorks, + context.l10n.navigationDownloadProgress, + ]; + final baseTitle = titles[_currentIndex]; + final showFilterButton = + _currentIndex == 1 || _currentIndex == 2 || _currentIndex == 3; + // 构建标题文本 final title = totalCount != null - ? '${_titles[_currentIndex]} (${totalCount})' - : _titles[_currentIndex]; + ? context.l10n.titleWithCount(baseTitle, totalCount) + : baseTitle; return Scaffold( appBar: AppBar( title: Text(title), actions: [ - IconButton( - icon: const Icon(Icons.filter_list), - onPressed: () { - if (_currentIndex == 1) { - context.read().toggleFilterPanel(); - } else if (_currentIndex == 2) { - context.read().toggleFilterPanel(); - } else if (_currentIndex == 3) { - context.read().toggleFilterPanel(); - } - }, - ), + if (showFilterButton) + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () { + if (_currentIndex == 1) { + context.read().toggleFilterPanel(); + } else if (_currentIndex == 2) { + context.read().toggleFilterPanel(); + } else if (_currentIndex == 3) { + context.read().toggleFilterPanel(); + } + }, + ), IconButton( icon: const Icon(Icons.search), onPressed: () { @@ -154,7 +167,7 @@ class _MainScreenState extends State { bottomNavigationBar: Column( mainAxisSize: MainAxisSize.min, children: [ - const MiniPlayer(), + const MiniPlayer(respectSafeArea: false), NavigationBar( height: 60, labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, @@ -162,26 +175,31 @@ class _MainScreenState extends State { elevation: 0, selectedIndex: _currentIndex, onDestinationSelected: _onTabTapped, - destinations: const [ + destinations: [ NavigationDestination( - icon: Icon(Icons.favorite_outline), - selectedIcon: Icon(Icons.favorite), - label: '收藏', + icon: const Icon(Icons.favorite_outline), + selectedIcon: const Icon(Icons.favorite), + label: context.l10n.navigationFavorites, ), NavigationDestination( - icon: Icon(Icons.home_outlined), - selectedIcon: Icon(Icons.home), - label: '主页', + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: context.l10n.navigationHome, ), NavigationDestination( - icon: Icon(Icons.recommend_outlined), - selectedIcon: Icon(Icons.recommend), - label: '推荐', + icon: const Icon(Icons.recommend_outlined), + selectedIcon: const Icon(Icons.recommend), + label: context.l10n.navigationRecommend, ), NavigationDestination( - icon: Icon(Icons.trending_up_outlined), - selectedIcon: Icon(Icons.trending_up), - label: '热门', + icon: const Icon(Icons.trending_up_outlined), + selectedIcon: const Icon(Icons.trending_up), + label: context.l10n.navigationPopularWorks, + ), + NavigationDestination( + icon: const Icon(Icons.download_outlined), + selectedIcon: const Icon(Icons.download), + label: context.l10n.navigationDownloadProgress, ), ], ), @@ -192,4 +210,4 @@ class _MainScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 472c412..630fa57 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -1,14 +1,15 @@ import 'package:asmrapp/core/platform/lyric_overlay_manager.dart'; -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; +import 'package:asmrapp/core/platform/wakelock_controller.dart'; import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart'; -import 'package:asmrapp/widgets/player/player_controls.dart'; -import 'package:asmrapp/widgets/player/player_progress.dart'; -import 'package:asmrapp/widgets/player/player_cover.dart'; import 'package:asmrapp/screens/detail_screen.dart'; import 'package:asmrapp/widgets/lyrics/components/player_lyric_view.dart'; +import 'package:asmrapp/widgets/player/player_controls.dart'; +import 'package:asmrapp/widgets/player/player_cover.dart'; +import 'package:asmrapp/widgets/player/player_progress.dart'; import 'package:asmrapp/widgets/player/player_work_info.dart'; -import 'package:asmrapp/core/platform/wakelock_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class PlayerScreen extends StatefulWidget { const PlayerScreen({super.key}); @@ -35,7 +36,7 @@ class _PlayerScreenState extends State { switchOutCurve: Curves.easeInQuart, transitionBuilder: (Widget child, Animation animation) { final isLyrics = (child as dynamic).key == const ValueKey('lyrics'); - + return FadeTransition( opacity: animation, child: SlideTransition( @@ -102,8 +103,12 @@ class _PlayerScreenState extends State { child: Material( color: Colors.transparent, child: Text( - _viewModel.currentTrackInfo?.title ?? '未在播放', - style: Theme.of(context).textTheme.titleLarge?.copyWith( + _viewModel.currentTrackInfo?.title ?? + context.l10n.noPlaying, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, @@ -114,11 +119,14 @@ class _PlayerScreenState extends State { if (_viewModel.currentTrackInfo?.artist != null) Text( _viewModel.currentTrackInfo!.artist, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.7), + .withValues(alpha: 0.7), ), textAlign: TextAlign.center, ), @@ -175,11 +183,13 @@ class _PlayerScreenState extends State { builder: (context, _) { return IconButton( icon: Icon( - wakeLockController.enabled - ? Icons.lightbulb - : Icons.lightbulb_outline, + wakeLockController.enabled + ? Icons.lightbulb + : Icons.lightbulb_outline, ), - tooltip: wakeLockController.enabled ? '关闭屏幕常亮' : '开启屏幕常亮', + tooltip: wakeLockController.enabled + ? context.l10n.screenOnDisable + : context.l10n.screenOnEnable, onPressed: () => wakeLockController.toggle(), ); }, @@ -206,8 +216,8 @@ class _PlayerScreenState extends State { ), Container( padding: const EdgeInsets.fromLTRB(12, 0, 12, 32), - child: Column( - children: const [ + child: const Column( + children: [ PlayerProgress(), SizedBox(height: 8), SizedBox(height: 8), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index eb3c1b8..6c5c628 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,10 +1,11 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:asmrapp/presentation/viewmodels/search_viewmodel.dart'; -import 'package:asmrapp/widgets/work_grid_view.dart'; +import 'package:asmrapp/l10n/l10n.dart'; import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; +import 'package:asmrapp/presentation/viewmodels/search_viewmodel.dart'; import 'package:asmrapp/utils/logger.dart'; import 'package:asmrapp/widgets/pagination_controls.dart'; +import 'package:asmrapp/widgets/work_grid_view.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class SearchScreen extends StatelessWidget { final String? initialKeyword; @@ -44,7 +45,7 @@ class _SearchScreenContentState extends State { void initState() { super.initState(); _searchController = TextEditingController(text: widget.initialKeyword); - + // 如果有初始关键词,自动执行搜索 if (widget.initialKeyword?.isNotEmpty == true) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -80,26 +81,33 @@ class _SearchScreenContentState extends State { } } - String _getOrderText(String order, String sort) { + String _getOrderText(BuildContext context, String order, String sort) { + final l10n = context.l10n; switch (order) { case 'create_date': - return sort == 'desc' ? '最新收录' : '最早收录'; + return sort == 'desc' ? l10n.searchOrderNewest : l10n.searchOrderOldest; case 'release': - return sort == 'desc' ? '发售日期倒序' : '发售日期顺序'; + return sort == 'desc' + ? l10n.searchOrderReleaseDesc + : l10n.searchOrderReleaseAsc; case 'dl_count': - return sort == 'desc' ? '销量倒序' : '销量顺序'; + return sort == 'desc' + ? l10n.searchOrderSalesDesc + : l10n.searchOrderSalesAsc; case 'price': - return sort == 'desc' ? '价格倒序' : '价格顺序'; + return sort == 'desc' + ? l10n.searchOrderPriceDesc + : l10n.searchOrderPriceAsc; case 'rate_average_2dp': - return '评价倒序'; + return l10n.searchOrderRatingDesc; case 'review_count': - return '评论数量倒序'; + return l10n.searchOrderReviewCountDesc; case 'id': - return sort == 'desc' ? 'RJ号倒序' : 'RJ号顺序'; + return sort == 'desc' ? l10n.searchOrderIdDesc : l10n.searchOrderIdAsc; case 'random': - return '随机排序'; + return l10n.searchOrderRandom; default: - return '排序'; + return l10n.orderLabel; } } @@ -121,12 +129,12 @@ class _SearchScreenContentState extends State { child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: '搜索...', + hintText: context.l10n.searchHint, filled: true, fillColor: Theme.of(context) .colorScheme .surfaceContainerHighest - .withOpacity(0.5), + .withValues(alpha: 0.5), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, @@ -158,7 +166,7 @@ class _SearchScreenContentState extends State { // 字幕选项 Consumer( builder: (context, viewModel, _) => FilterChip( - label: const Text('字幕'), + label: Text(context.l10n.subtitle), selected: viewModel.hasSubtitle, onSelected: (_) => viewModel.toggleSubtitle(), showCheckmark: true, @@ -170,56 +178,57 @@ class _SearchScreenContentState extends State { builder: (context, viewModel, _) => PopupMenuButton<(String, String)>( child: Chip( - label: Text( - _getOrderText(viewModel.order, viewModel.sort)), + label: Text(_getOrderText( + context, viewModel.order, viewModel.sort)), deleteIcon: const Icon(Icons.arrow_drop_down, size: 18), onDeleted: null, ), itemBuilder: (context) => [ - const PopupMenuItem( + PopupMenuItem( value: ('create_date', 'desc'), - child: Text('最新收录'), + child: Text(context.l10n.searchOrderNewest), ), - const PopupMenuItem( + PopupMenuItem( value: ('release', 'desc'), - child: Text('发售日期倒序'), + child: Text(context.l10n.searchOrderReleaseDesc), ), - const PopupMenuItem( + PopupMenuItem( value: ('release', 'asc'), - child: Text('发售日期顺序'), + child: Text(context.l10n.searchOrderReleaseAsc), ), - const PopupMenuItem( + PopupMenuItem( value: ('dl_count', 'desc'), - child: Text('销量倒序'), + child: Text(context.l10n.searchOrderSalesDesc), ), - const PopupMenuItem( + PopupMenuItem( value: ('price', 'asc'), - child: Text('价格顺序'), + child: Text(context.l10n.searchOrderPriceAsc), ), - const PopupMenuItem( + PopupMenuItem( value: ('price', 'desc'), - child: Text('价格倒序'), + child: Text(context.l10n.searchOrderPriceDesc), ), - const PopupMenuItem( + PopupMenuItem( value: ('rate_average_2dp', 'desc'), - child: Text('评价倒序'), + child: Text(context.l10n.searchOrderRatingDesc), ), - const PopupMenuItem( + PopupMenuItem( value: ('review_count', 'desc'), - child: Text('评论数量倒序'), + child: + Text(context.l10n.searchOrderReviewCountDesc), ), - const PopupMenuItem( + PopupMenuItem( value: ('id', 'desc'), - child: Text('RJ号倒序'), + child: Text(context.l10n.searchOrderIdDesc), ), - const PopupMenuItem( + PopupMenuItem( value: ('id', 'asc'), - child: Text('RJ号顺序'), + child: Text(context.l10n.searchOrderIdAsc), ), - const PopupMenuItem( + PopupMenuItem( value: ('random', 'desc'), - child: Text('随机排序'), + child: Text(context.l10n.searchOrderRandom), ), ], onSelected: (value) => @@ -237,12 +246,12 @@ class _SearchScreenContentState extends State { builder: (context, viewModel, child) { Widget? emptyWidget; if (viewModel.works.isEmpty && viewModel.keyword.isEmpty) { - emptyWidget = const Center( - child: Text('输入关键词开始搜索'), + emptyWidget = Center( + child: Text(context.l10n.searchPromptInitial), ); } else if (viewModel.works.isEmpty) { - emptyWidget = const Center( - child: Text('没有找到相关结果'), + emptyWidget = Center( + child: Text(context.l10n.searchNoResults), ); } diff --git a/lib/screens/settings/cache_manager_screen.dart b/lib/screens/settings/cache_manager_screen.dart index 7054a73..5081ba8 100644 --- a/lib/screens/settings/cache_manager_screen.dart +++ b/lib/screens/settings/cache_manager_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:asmrapp/presentation/viewmodels/settings/cache_manager_viewmodel.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class CacheManagerScreen extends StatelessWidget { const CacheManagerScreen({super.key}); @@ -11,7 +12,7 @@ class CacheManagerScreen extends StatelessWidget { create: (_) => CacheManagerViewModel()..loadCacheSize(), child: Scaffold( appBar: AppBar( - title: const Text('缓存管理'), + title: Text(context.l10n.cacheManagerTitle), ), body: Consumer( builder: (context, viewModel, _) { @@ -19,10 +20,15 @@ class CacheManagerScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } - if (viewModel.error != null) { + if (viewModel.errorDetail != null) { + final message = switch (viewModel.lastFailedOperation) { + CacheOperation.load => + context.l10n.cacheLoadFailed(viewModel.errorDetail!), + _ => context.l10n.cacheClearFailed(viewModel.errorDetail!), + }; return Center( child: Text( - viewModel.error!, + message, style: TextStyle(color: Theme.of(context).colorScheme.error), ), ); @@ -32,50 +38,47 @@ class CacheManagerScreen extends StatelessWidget { children: [ // 音频缓存 ListTile( - title: const Text('音频缓存'), + title: Text(context.l10n.cacheAudio), subtitle: Text(viewModel.audioCacheSizeFormatted), trailing: TextButton( - onPressed: viewModel.isLoading - ? null - : () => viewModel.clearAudioCache(), - child: const Text('清理'), + onPressed: viewModel.isLoading + ? null + : () => viewModel.clearAudioCache(), + child: Text(context.l10n.cacheClear), ), ), const Divider(), - + // 字幕缓存 ListTile( - title: const Text('字幕缓存'), + title: Text(context.l10n.cacheSubtitle), subtitle: Text(viewModel.subtitleCacheSizeFormatted), trailing: TextButton( - onPressed: viewModel.isLoading - ? null - : () => viewModel.clearSubtitleCache(), - child: const Text('清理'), + onPressed: viewModel.isLoading + ? null + : () => viewModel.clearSubtitleCache(), + child: Text(context.l10n.cacheClear), ), ), const Divider(), - + // 总缓存大小 ListTile( - title: const Text('总缓存大小'), + title: Text(context.l10n.cacheTotal), subtitle: Text(viewModel.totalCacheSizeFormatted), trailing: TextButton( - onPressed: viewModel.isLoading - ? null - : () => viewModel.clearAllCache(), - child: const Text('清理全部'), + onPressed: viewModel.isLoading + ? null + : () => viewModel.clearAllCache(), + child: Text(context.l10n.cacheClearAll), ), ), const Divider(), - + // 缓存说明 - const ListTile( - title: Text('缓存说明'), - subtitle: Text( - '缓存用于存储最近播放的音频文件和字幕文件,以提高再次播放时的加载速度。' - '系统会自动清理过期和超量的缓存。' - ), + ListTile( + title: Text(context.l10n.cacheInfoTitle), + subtitle: Text(context.l10n.cacheDescription), ), ], ); @@ -84,4 +87,4 @@ class CacheManagerScreen extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart new file mode 100644 index 0000000..f212ffb --- /dev/null +++ b/lib/screens/settings/settings_screen.dart @@ -0,0 +1,241 @@ +import 'package:asmrapp/core/download/download_directory_controller.dart'; +import 'package:asmrapp/core/locale/locale_controller.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/screens/settings/cache_manager_screen.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _updatingDirectory = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.settings), + ), + body: Consumer( + builder: (context, downloadController, _) { + final path = downloadController.customDirectoryPath; + final localeController = context.watch(); + final selectedLanguageCode = + localeController.locale?.languageCode ?? 'system'; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.downloadDirectoryTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + l10n.downloadDirectoryDescription, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + path?.isNotEmpty == true + ? path! + : l10n.downloadDirectoryDefaultValue, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 8), + Text( + l10n.downloadDirectoryPermissionHint, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: _updatingDirectory + ? null + : () => _pickDownloadDirectory( + context, + downloadController, + ), + icon: const Icon(Icons.folder_open), + label: Text(l10n.downloadDirectoryPick), + ), + OutlinedButton.icon( + onPressed: _updatingDirectory || + !downloadController.hasCustomDirectory + ? null + : () => _resetDownloadDirectory( + context, + downloadController, + ), + icon: const Icon(Icons.undo), + label: Text(l10n.downloadDirectoryReset), + ), + ], + ), + if (downloadController.lastError != null) ...[ + const SizedBox(height: 12), + Text( + downloadController.lastError!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.languageTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + l10n.languageDescription, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedLanguageCode, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'system', + child: Text(l10n.languageSystem), + ), + DropdownMenuItem( + value: 'en', + child: Text(l10n.languageEnglish), + ), + DropdownMenuItem( + value: 'ja', + child: Text(l10n.languageJapanese), + ), + DropdownMenuItem( + value: 'zh', + child: Text(l10n.languageChinese), + ), + ], + onChanged: (value) async { + if (value == null) { + return; + } + final nextLocale = + value == 'system' ? null : Locale(value); + await localeController.setLocale(nextLocale); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: ListTile( + leading: const Icon(Icons.storage), + title: Text(l10n.cacheManager), + subtitle: Text(l10n.cacheDescription), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CacheManagerScreen(), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ); + } + + Future _pickDownloadDirectory( + BuildContext context, + DownloadDirectoryController controller, + ) async { + final l10n = context.l10n; + setState(() => _updatingDirectory = true); + try { + final selectedPath = await getDirectoryPath( + confirmButtonText: l10n.confirm, + ); + if (selectedPath == null || selectedPath.trim().isEmpty) { + return; + } + + await controller.setCustomDirectoryPath(selectedPath); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.downloadDirectoryUpdated(selectedPath))), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.operationFailed(e.toString()))), + ); + } finally { + if (mounted) { + setState(() => _updatingDirectory = false); + } + } + } + + Future _resetDownloadDirectory( + BuildContext context, + DownloadDirectoryController controller, + ) async { + final l10n = context.l10n; + setState(() => _updatingDirectory = true); + try { + await controller.clearCustomDirectoryPath(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.downloadDirectoryResetSuccess)), + ); + } finally { + if (mounted) { + setState(() => _updatingDirectory = false); + } + } + } +} diff --git a/lib/screens/similar_works_screen.dart b/lib/screens/similar_works_screen.dart index 91e8fa5..acd9006 100644 --- a/lib/screens/similar_works_screen.dart +++ b/lib/screens/similar_works_screen.dart @@ -6,6 +6,7 @@ import 'package:asmrapp/presentation/viewmodels/similar_works_viewmodel.dart'; import 'package:asmrapp/widgets/work_grid_view.dart'; import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; import 'package:asmrapp/widgets/pagination_controls.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class SimilarWorksScreen extends StatefulWidget { final Work work; @@ -39,7 +40,8 @@ class _SimilarWorksScreenState extends State { } void _onScroll() { - if (_scrollController.position.pixels != _scrollController.position.minScrollExtent) { + if (_scrollController.position.pixels != + _scrollController.position.minScrollExtent) { if (_viewModel.filterPanelExpanded) { _viewModel.closeFilterPanel(); } @@ -62,7 +64,7 @@ class _SimilarWorksScreenState extends State { value: _viewModel, child: Scaffold( appBar: AppBar( - title: const Text('相关推荐'), + title: Text(context.l10n.similarWorksTitle), actions: [ Consumer( builder: (context, viewModel, _) => IconButton( @@ -111,7 +113,8 @@ class _SimilarWorksScreenState extends State { offset: Offset(0, viewModel.filterPanelExpanded ? 0 : -1), child: FilterWithKeyword( hasSubtitle: viewModel.hasSubtitle, - onSubtitleChanged: (_) => viewModel.toggleSubtitleFilter(), + onSubtitleChanged: (_) => + viewModel.toggleSubtitleFilter(), ), ), ), @@ -122,4 +125,4 @@ class _SimilarWorksScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 3e04275..4f1e609 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -8,7 +8,7 @@ class AppLogger { lineLength: 120, colors: true, printEmojis: true, - printTime: true, + dateTimeFormat: DateTimeFormat.dateAndTime, ), ); diff --git a/lib/widgets/common/tag_chip.dart b/lib/widgets/common/tag_chip.dart index 22c619b..db5221b 100644 --- a/lib/widgets/common/tag_chip.dart +++ b/lib/widgets/common/tag_chip.dart @@ -22,13 +22,15 @@ class TagChip extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHighest, + color: backgroundColor ?? + Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( text, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: textColor ?? Theme.of(context).colorScheme.onSurfaceVariant, + color: + textColor ?? Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 13, ), ), diff --git a/lib/widgets/detail/download_file_selection_dialog.dart b/lib/widgets/detail/download_file_selection_dialog.dart new file mode 100644 index 0000000..2f79121 --- /dev/null +++ b/lib/widgets/detail/download_file_selection_dialog.dart @@ -0,0 +1,418 @@ +import 'package:asmrapp/common/utils/file_preview_utils.dart'; +import 'package:asmrapp/core/download/download_request_item.dart'; +import 'package:asmrapp/data/models/files/child.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/utils/file_size_formatter.dart'; +import 'package:flutter/material.dart'; + +class DownloadFileSelectionDialog extends StatefulWidget { + final List rootFiles; + + const DownloadFileSelectionDialog({ + super.key, + required this.rootFiles, + }); + + @override + State createState() => + _DownloadFileSelectionDialogState(); +} + +class _DownloadFileSelectionDialogState + extends State { + final Set _selectedPaths = {}; + late final Map _downloadableFiles; + late final Map> _folderDescendantFiles; + + @override + void initState() { + super.initState(); + _downloadableFiles = _collectDownloadableFiles(widget.rootFiles); + _folderDescendantFiles = _collectFolderDescendantFiles(widget.rootFiles); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final hasDownloadableFiles = _downloadableFiles.isNotEmpty; + + return AlertDialog( + title: Text(l10n.downloadDialogTitle), + content: SizedBox( + width: double.maxFinite, + height: 460, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + l10n.downloadSelectedCount(_selectedPaths.length), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (hasDownloadableFiles) + TextButton( + onPressed: + _selectedPaths.length == _downloadableFiles.length + ? null + : _selectAllFiles, + child: Text(l10n.downloadSelectAll), + ), + if (_selectedPaths.isNotEmpty) + TextButton( + onPressed: () { + setState(_selectedPaths.clear); + }, + child: Text(l10n.downloadClearSelection), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: hasDownloadableFiles + ? Scrollbar( + child: ListView( + children: _buildTreeNodes( + widget.rootFiles, + parentPath: '', + indentation: 0, + ), + ), + ) + : Center( + child: Text( + l10n.downloadDialogNoFiles, + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton.icon( + onPressed: _selectedPaths.isEmpty + ? null + : () { + final selectedFiles = _selectedPaths + .map((path) => _downloadableFiles[path]) + .whereType() + .toList(growable: false); + Navigator.of(context).pop(selectedFiles); + }, + icon: const Icon(Icons.download), + label: Text(l10n.workActionDownload), + ), + ], + ); + } + + List _buildTreeNodes( + List nodes, { + required String parentPath, + required double indentation, + }) { + return [ + for (var i = 0; i < nodes.length; i++) + _buildTreeNode( + nodes[i], + parentPath: parentPath, + indentation: indentation, + index: i, + ), + ]; + } + + Widget _buildTreeNode( + Child node, { + required String parentPath, + required double indentation, + required int index, + }) { + final currentPath = _buildNodePath( + parentPath: parentPath, + node: node, + index: index, + ); + + if (_isFolder(node)) { + if (!_hasDownloadableDescendant(node)) { + return const SizedBox.shrink(); + } + + final children = node.children ?? const []; + final descendantFiles = + _folderDescendantFiles[currentPath] ?? const {}; + if (descendantFiles.isEmpty) { + return const SizedBox.shrink(); + } + final folderSelectionValue = _folderSelectionValue(currentPath); + + return Padding( + padding: EdgeInsets.only(left: indentation), + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.only(left: 8, right: 8), + childrenPadding: const EdgeInsets.only(bottom: 4), + leading: const Icon(Icons.folder_outlined), + title: Row( + children: [ + Expanded( + child: Text( + _displayName(node), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Checkbox( + value: folderSelectionValue, + tristate: true, + onChanged: (_) => _toggleFolderSelection( + currentPath, + folderSelectionValue != true, + ), + ), + ], + ), + children: _buildTreeNodes( + children, + parentPath: currentPath, + indentation: indentation + 16, + ), + ), + ), + ); + } + + if (!_isDownloadable(node)) { + return const SizedBox.shrink(); + } + + final selected = _selectedPaths.contains(currentPath); + return Padding( + padding: EdgeInsets.only(left: indentation), + child: CheckboxListTile( + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: selected, + onChanged: (checked) { + setState(() { + if (checked ?? false) { + _selectedPaths.add(currentPath); + } else { + _selectedPaths.remove(currentPath); + } + }); + }, + secondary: Icon(_iconForFile(node)), + title: Text( + _displayName(node), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(FileSizeFormatter.format(node.size)), + ), + ); + } + + Map _collectDownloadableFiles( + List nodes, { + String parentPath = '', + List parentDirectories = const [], + }) { + final result = {}; + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + final currentPath = _buildNodePath( + parentPath: parentPath, + node: node, + index: i, + ); + + if (_isFolder(node)) { + final children = node.children ?? const []; + result.addAll( + _collectDownloadableFiles( + children, + parentPath: currentPath, + parentDirectories: [ + ...parentDirectories, + _buildStorageSegment(node, index: i), + ], + ), + ); + continue; + } + + if (_isDownloadable(node)) { + result[currentPath] = DownloadRequestItem( + file: node, + relativeDirectories: List.from(parentDirectories), + ); + } + } + return result; + } + + Map> _collectFolderDescendantFiles( + List nodes, { + String parentPath = '', + }) { + final result = >{}; + + void visitNode(Child node, String currentParentPath, int index) { + final currentPath = _buildNodePath( + parentPath: currentParentPath, + node: node, + index: index, + ); + + if (!_isFolder(node)) { + return; + } + + final descendants = {}; + final children = node.children ?? const []; + + for (var i = 0; i < children.length; i++) { + final child = children[i]; + final childPath = _buildNodePath( + parentPath: currentPath, + node: child, + index: i, + ); + + if (_isFolder(child)) { + visitNode(child, currentPath, i); + descendants.addAll(result[childPath] ?? const {}); + continue; + } + + if (_isDownloadable(child)) { + descendants.add(childPath); + } + } + + result[currentPath] = descendants; + } + + for (var i = 0; i < nodes.length; i++) { + visitNode(nodes[i], parentPath, i); + } + + return result; + } + + bool _isFolder(Child node) => node.type?.toLowerCase() == 'folder'; + + bool _isDownloadable(Child node) { + final url = node.mediaDownloadUrl; + return !_isFolder(node) && url != null && url.isNotEmpty; + } + + bool _hasDownloadableDescendant(Child folder) { + final children = folder.children ?? const []; + for (final child in children) { + if (_isDownloadable(child)) { + return true; + } + if (_isFolder(child) && _hasDownloadableDescendant(child)) { + return true; + } + } + return false; + } + + String _buildNodePath({ + required String parentPath, + required Child node, + required int index, + }) { + final segment = _buildStorageSegment(node, index: index); + final indexedSegment = '${index}_$segment'; + return parentPath.isEmpty ? indexedSegment : '$parentPath/$indexedSegment'; + } + + String _buildStorageSegment(Child node, {required int index}) { + final title = node.title?.trim(); + return (title != null && title.isNotEmpty) + ? title + : (node.hash?.isNotEmpty ?? false) + ? node.hash! + : 'item_$index'; + } + + bool? _folderSelectionValue(String folderPath) { + final descendants = _folderDescendantFiles[folderPath]; + if (descendants == null || descendants.isEmpty) { + return false; + } + + var selectedCount = 0; + for (final path in descendants) { + if (_selectedPaths.contains(path)) { + selectedCount++; + } + } + + if (selectedCount == 0) { + return false; + } + if (selectedCount == descendants.length) { + return true; + } + return null; + } + + void _toggleFolderSelection(String folderPath, bool shouldSelectAll) { + final descendants = _folderDescendantFiles[folderPath]; + if (descendants == null || descendants.isEmpty) { + return; + } + + setState(() { + if (shouldSelectAll) { + _selectedPaths.addAll(descendants); + } else { + _selectedPaths.removeAll(descendants); + } + }); + } + + void _selectAllFiles() { + setState(() { + _selectedPaths + ..clear() + ..addAll(_downloadableFiles.keys); + }); + } + + String _displayName(Child node) { + final title = node.title?.trim(); + if (title != null && title.isNotEmpty) { + return title; + } + return context.l10n.unknownWorkTitle; + } + + IconData _iconForFile(Child file) { + if (FilePreviewUtils.isAudio(file)) { + return Icons.audio_file_outlined; + } + if (FilePreviewUtils.isImage(file)) { + return Icons.image_outlined; + } + if (FilePreviewUtils.isText(file)) { + return Icons.text_snippet_outlined; + } + return Icons.insert_drive_file_outlined; + } +} diff --git a/lib/widgets/detail/file_preview_dialog.dart b/lib/widgets/detail/file_preview_dialog.dart new file mode 100644 index 0000000..ea177df --- /dev/null +++ b/lib/widgets/detail/file_preview_dialog.dart @@ -0,0 +1,331 @@ +import 'package:asmrapp/common/utils/file_preview_utils.dart'; +import 'package:asmrapp/data/models/files/child.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:flutter/material.dart'; + +class FilePreviewDialog extends StatefulWidget { + final Child file; + final Future Function(Child file) loadTextPreview; + final List? imageFiles; + final int? initialImageIndex; + + const FilePreviewDialog({ + super.key, + required this.file, + required this.loadTextPreview, + this.imageFiles, + this.initialImageIndex, + }); + + @override + State createState() => _FilePreviewDialogState(); +} + +class _FilePreviewDialogState extends State { + late final bool _imageMode; + late final List _imageFiles; + late int _currentImageIndex; + + @override + void initState() { + super.initState(); + _imageMode = FilePreviewUtils.isImage(widget.file); + _imageFiles = _resolveImageFiles(); + _currentImageIndex = _resolveInitialImageIndex(); + } + + List _resolveImageFiles() { + if (!_imageMode) { + return const []; + } + + final candidates = widget.imageFiles; + if (candidates == null || candidates.isEmpty) { + return [widget.file]; + } + + final imageCandidates = + candidates.where(FilePreviewUtils.isImage).toList(growable: false); + if (imageCandidates.isEmpty) { + return [widget.file]; + } + + return imageCandidates; + } + + int _resolveInitialImageIndex() { + if (!_imageMode) return 0; + + final explicitIndex = widget.initialImageIndex; + if (explicitIndex != null && + explicitIndex >= 0 && + explicitIndex < _imageFiles.length) { + return explicitIndex; + } + + final fileIndex = _imageFiles.indexOf(widget.file); + if (fileIndex >= 0) { + return fileIndex; + } + + return 0; + } + + Child get _currentFile { + if (_imageMode) { + return _imageFiles[_currentImageIndex]; + } + return widget.file; + } + + bool get _hasPreviousImage => _currentImageIndex > 0; + + bool get _hasNextImage => _currentImageIndex < _imageFiles.length - 1; + + void _showPreviousImage() { + if (!_hasPreviousImage) return; + setState(() { + _currentImageIndex -= 1; + }); + } + + void _showNextImage() { + if (!_hasNextImage) return; + setState(() { + _currentImageIndex += 1; + }); + } + + String _dialogTitle() { + final title = _currentFile.title ?? ''; + if (!_imageMode || _imageFiles.length <= 1) { + return title; + } + + final indexText = '[$_currentImageIndex]'; + if (title.isEmpty) { + return indexText; + } + return '$title $indexText'; + } + + @override + Widget build(BuildContext context) { + final canNavigateImages = _imageMode && _imageFiles.length > 1; + final materialLocalizations = MaterialLocalizations.of(context); + + return Dialog.fullscreen( + child: Scaffold( + appBar: AppBar( + title: Text(_dialogTitle()), + actions: canNavigateImages + ? [ + IconButton( + tooltip: materialLocalizations.previousPageTooltip, + onPressed: _hasPreviousImage ? _showPreviousImage : null, + icon: const Icon(Icons.navigate_before), + ), + IconButton( + tooltip: materialLocalizations.nextPageTooltip, + onPressed: _hasNextImage ? _showNextImage : null, + icon: const Icon(Icons.navigate_next), + ), + ] + : null, + ), + body: SafeArea( + child: _imageMode + ? _ImagePreview( + file: _currentFile, + onPrevious: _hasPreviousImage ? _showPreviousImage : null, + onNext: _hasNextImage ? _showNextImage : null, + currentImageIndex: _currentImageIndex, + maxImageIndex: _imageFiles.length - 1, + canNavigateImages: canNavigateImages, + ) + : _TextPreview( + file: widget.file, + loadTextPreview: widget.loadTextPreview, + ), + ), + ), + ); + } +} + +class _ImagePreview extends StatelessWidget { + final Child file; + final VoidCallback? onPrevious; + final VoidCallback? onNext; + final int currentImageIndex; + final int maxImageIndex; + final bool canNavigateImages; + + const _ImagePreview({ + required this.file, + required this.onPrevious, + required this.onNext, + required this.currentImageIndex, + required this.maxImageIndex, + required this.canNavigateImages, + }); + + @override + Widget build(BuildContext context) { + final url = file.mediaDownloadUrl; + if (url == null || url.isEmpty) { + return _PreviewMessage(message: context.l10n.playUrlMissing); + } + + final colorScheme = Theme.of(context).colorScheme; + final materialLocalizations = MaterialLocalizations.of(context); + + return Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + key: ValueKey(url), + minScale: 1, + maxScale: 4, + child: Center( + child: Image.network( + url, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + return _PreviewMessage( + message: context.l10n.operationFailed(error.toString()), + ); + }, + ), + ), + ), + ), + if (canNavigateImages) + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + ), + child: IconButton( + tooltip: materialLocalizations.previousPageTooltip, + onPressed: onPrevious, + icon: const Icon(Icons.navigate_before), + ), + ), + const SizedBox(width: 12), + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + child: Text( + '$currentImageIndex / $maxImageIndex', + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 12), + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(999), + ), + child: IconButton( + tooltip: materialLocalizations.nextPageTooltip, + onPressed: onNext, + icon: const Icon(Icons.navigate_next), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _TextPreview extends StatelessWidget { + final Child file; + final Future Function(Child file) loadTextPreview; + + const _TextPreview({ + required this.file, + required this.loadTextPreview, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: loadTextPreview(file), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return _PreviewMessage( + message: context.l10n.operationFailed(snapshot.error.toString()), + ); + } + + final text = snapshot.data ?? ''; + if (text.trim().isEmpty) { + return _PreviewMessage(message: context.l10n.emptyContent); + } + + return Scrollbar( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: SelectableText( + text, + style: const TextStyle( + height: 1.5, + fontFamily: 'monospace', + ), + ), + ), + ); + }, + ); + } +} + +class _PreviewMessage extends StatelessWidget { + final String message; + + const _PreviewMessage({required this.message}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + ); + } +} diff --git a/lib/widgets/detail/mark_selection_dialog.dart b/lib/widgets/detail/mark_selection_dialog.dart index 4bed11b..efae424 100644 --- a/lib/widgets/detail/mark_selection_dialog.dart +++ b/lib/widgets/detail/mark_selection_dialog.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:asmrapp/common/extensions/mark_status_localizations.dart'; import 'package:asmrapp/data/models/mark_status.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:flutter/material.dart'; class MarkSelectionDialog extends StatelessWidget { final MarkStatus? currentStatus; @@ -16,63 +18,69 @@ class MarkSelectionDialog extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - + return AlertDialog( backgroundColor: isDark ? const Color(0xFF2C2C2C) : Colors.white, title: Text( - '标记状态', + context.l10n.markStatusTitle, style: TextStyle( color: isDark ? Colors.white70 : Colors.black87, ), ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: MarkStatus.values.map((status) { - final isSelected = status == currentStatus; - return ListTile( - enabled: !loading, - leading: Radio( - value: status, - groupValue: currentStatus, - onChanged: loading ? null : (MarkStatus? value) { - if (value != null) { - onMarkSelected(value); - Navigator.of(context).pop(); - } - }, - fillColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.disabled)) { - return isDark ? Colors.white24 : Colors.black26; - } - if (states.contains(MaterialState.selected)) { - return isDark ? Colors.white70 : Colors.black87; - } - return isDark ? Colors.white38 : Colors.black45; - }), - ), - title: Text( - status.label, - style: TextStyle( - color: loading - ? (isDark ? Colors.white38 : Colors.black38) - : (isSelected - ? (isDark ? Colors.white : Colors.black87) - : (isDark ? Colors.white70 : Colors.black54)), + content: RadioGroup( + groupValue: currentStatus, + onChanged: (MarkStatus? value) { + if (loading || value == null) { + return; + } + onMarkSelected(value); + Navigator.of(context).pop(); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: MarkStatus.values.map((status) { + final isSelected = status == currentStatus; + return ListTile( + enabled: !loading, + leading: Radio( + value: status, + enabled: !loading, + fillColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return isDark ? Colors.white24 : Colors.black26; + } + if (states.contains(WidgetState.selected)) { + return isDark ? Colors.white70 : Colors.black87; + } + return isDark ? Colors.white38 : Colors.black45; + }), ), - ), - onTap: loading ? null : () { - onMarkSelected(status); - Navigator.of(context).pop(); - }, - hoverColor: isDark - ? Colors.white.withOpacity(0.05) - : Colors.black.withOpacity(0.05), - ); - }).toList(), + title: Text( + status.localizedLabel(context.l10n), + style: TextStyle( + color: loading + ? (isDark ? Colors.white38 : Colors.black38) + : (isSelected + ? (isDark ? Colors.white : Colors.black87) + : (isDark ? Colors.white70 : Colors.black54)), + ), + ), + onTap: loading + ? null + : () { + onMarkSelected(status); + Navigator.of(context).pop(); + }, + hoverColor: isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05), + ); + }).toList(), + ), ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/playlist_selection_dialog.dart b/lib/widgets/detail/playlist_selection_dialog.dart index e674011..6c7a155 100644 --- a/lib/widgets/detail/playlist_selection_dialog.dart +++ b/lib/widgets/detail/playlist_selection_dialog.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/playlists_with_exist_statu/playlist.dart'; +import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/common/utils/playlist_localizations.dart'; class PlaylistSelectionDialog extends StatefulWidget { final List? playlists; @@ -18,7 +20,8 @@ class PlaylistSelectionDialog extends StatefulWidget { }); @override - State createState() => _PlaylistSelectionDialogState(); + State createState() => + _PlaylistSelectionDialogState(); } class _PlaylistSelectionDialogState extends State { @@ -40,7 +43,7 @@ class _PlaylistSelectionDialogState extends State { void _updateItemStates() { if (widget.playlists == null) return; - + final newStates = {}; for (final playlist in widget.playlists!) { newStates[playlist.id!] = _PlaylistItemState( @@ -67,7 +70,7 @@ class _PlaylistSelectionDialogState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '添加到收藏夹', + context.l10n.playlistAddToFavorites, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), @@ -98,7 +101,7 @@ class _PlaylistSelectionDialogState extends State { const SizedBox(height: 8), ElevatedButton( onPressed: widget.onRetry, - child: const Text('重试'), + child: Text(context.l10n.retry), ), ], ], @@ -107,8 +110,8 @@ class _PlaylistSelectionDialogState extends State { } if (widget.playlists == null || widget.playlists!.isEmpty) { - return const Center( - child: Text('暂无收藏夹'), + return Center( + child: Text(context.l10n.playlistEmpty), ); } @@ -135,33 +138,39 @@ class _PlaylistSelectionDialogState extends State { try { await widget.onPlaylistTap!(state.playlist); - + if (mounted) { final newPlaylist = state.playlist.copyWith( exist: !(state.playlist.exist ?? false), ); - + _itemStates[state.playlist.id!] = _PlaylistItemState( playlist: newPlaylist, isLoading: false, ); + final playlistName = + localizedPlaylistName(newPlaylist.name, context.l10n); + final message = newPlaylist.exist! + ? context.l10n.playlistAddSuccess(playlistName) + : context.l10n.playlistRemoveSuccess(playlistName); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - '${newPlaylist.exist! ? '添加成功' : '移除成功'}: ${_getDisplayName(newPlaylist.name)}', + message, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), duration: const Duration(seconds: 1), - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, showCloseIcon: true, closeIconColor: Theme.of(context).colorScheme.onSurface, behavior: SnackBarBehavior.floating, ), ); - + setState(() {}); } } finally { @@ -172,17 +181,6 @@ class _PlaylistSelectionDialogState extends State { } } } - - String _getDisplayName(String? name) { - switch (name) { - case '__SYS_PLAYLIST_MARKED': - return '我标记的'; - case '__SYS_PLAYLIST_LIKED': - return '我喜欢的'; - default: - return name ?? ''; - } - } } class _PlaylistItem extends StatelessWidget { @@ -194,22 +192,15 @@ class _PlaylistItem extends StatelessWidget { this.onTap, }); - String _getDisplayName(String? name) { - switch (name) { - case '__SYS_PLAYLIST_MARKED': - return '我标记的'; - case '__SYS_PLAYLIST_LIKED': - return '我喜欢的'; - default: - return name ?? ''; - } - } - @override Widget build(BuildContext context) { return ListTile( - title: Text(_getDisplayName(state.playlist.name)), - subtitle: Text('${state.playlist.worksCount ?? 0} 个作品'), + title: Text( + localizedPlaylistName(state.playlist.name, context.l10n), + ), + subtitle: Text( + context.l10n.playlistWorksCount(state.playlist.worksCount ?? 0), + ), trailing: state.isLoading ? const SizedBox( width: 24, @@ -230,9 +221,9 @@ class _PlaylistItem extends StatelessWidget { class _PlaylistItemState { final Playlist playlist; bool isLoading; - + _PlaylistItemState({ required this.playlist, this.isLoading = false, }); -} \ No newline at end of file +} diff --git a/lib/widgets/detail/related_works_section.dart b/lib/widgets/detail/related_works_section.dart new file mode 100644 index 0000000..7dae23f --- /dev/null +++ b/lib/widgets/detail/related_works_section.dart @@ -0,0 +1,119 @@ +import 'package:asmrapp/data/models/works/work.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/widgets/work_card/work_card.dart'; +import 'package:flutter/material.dart'; + +class RelatedWorksSection extends StatelessWidget { + static const double _cardWidth = 188; + static const double _listHeight = 330; + + final List works; + final bool isLoading; + final String? error; + final bool hasRecommendations; + final VoidCallback? onSeeAll; + final VoidCallback? onRetry; + final ValueChanged onWorkTap; + + const RelatedWorksSection({ + super.key, + required this.works, + required this.isLoading, + required this.error, + required this.hasRecommendations, + required this.onSeeAll, + required this.onWorkTap, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + margin: const EdgeInsets.fromLTRB(8, 8, 8, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + child: Row( + children: [ + Text( + context.l10n.similarWorksTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: hasRecommendations ? onSeeAll : null, + icon: const Icon(Icons.chevron_right), + label: Text(context.l10n.similarWorksSeeAll), + ), + ], + ), + ), + Divider( + height: 1, + color: theme.colorScheme.surfaceContainerHighest, + ), + _buildBody(context), + ], + ), + ); + } + + Widget _buildBody(BuildContext context) { + if (isLoading) { + return const SizedBox( + height: _listHeight, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (works.isEmpty) { + if (error != null && onRetry != null) { + return SizedBox( + height: _listHeight, + child: Center( + child: ElevatedButton( + onPressed: onRetry, + child: Text(context.l10n.retry), + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(16), + child: Text(context.l10n.workActionNoRecommendation), + ); + } + + return SizedBox( + height: _listHeight, + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + scrollDirection: Axis.horizontal, + itemCount: works.length, + separatorBuilder: (context, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final work = works[index]; + return SizedBox( + width: _cardWidth, + child: HeroMode( + enabled: false, + child: WorkCard( + work: work, + onTap: () => onWorkTap(work), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/detail/work_action_buttons.dart b/lib/widgets/detail/work_action_buttons.dart index 3396250..015cc8f 100644 --- a/lib/widgets/detail/work_action_buttons.dart +++ b/lib/widgets/detail/work_action_buttons.dart @@ -1,59 +1,65 @@ import 'package:asmrapp/data/models/mark_status.dart'; import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/common/extensions/mark_status_localizations.dart'; class WorkActionButtons extends StatelessWidget { - final VoidCallback onRecommendationsTap; - final bool hasRecommendations; - final bool checkingRecommendations; final VoidCallback onFavoriteTap; final bool loadingFavorite; final VoidCallback onMarkTap; final MarkStatus? currentMarkStatus; final bool loadingMark; + final VoidCallback onRateTap; + final bool loadingRate; + final VoidCallback? onDownloadTap; + final bool loadingDownload; const WorkActionButtons({ super.key, - required this.onRecommendationsTap, - required this.hasRecommendations, - required this.checkingRecommendations, required this.onFavoriteTap, this.loadingFavorite = false, required this.onMarkTap, this.currentMarkStatus, this.loadingMark = false, + required this.onRateTap, + this.loadingRate = false, + this.onDownloadTap, + this.loadingDownload = false, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + child: Wrap( + alignment: WrapAlignment.spaceEvenly, + spacing: 12, + runSpacing: 8, children: [ _ActionButton( icon: Icons.favorite_border, - label: '收藏', + label: context.l10n.workActionFavorite, onTap: onFavoriteTap, loading: loadingFavorite, ), _ActionButton( icon: Icons.bookmark_border, - label: currentMarkStatus?.label ?? '标记', + label: currentMarkStatus?.localizedLabel(context.l10n) ?? + context.l10n.workActionMark, onTap: onMarkTap, loading: loadingMark, ), _ActionButton( icon: Icons.star_border, - label: '评分', - onTap: () { - // TODO: 实现评分功能 - }, + label: context.l10n.workActionRate, + onTap: onRateTap, + loading: loadingRate, ), _ActionButton( - icon: Icons.recommend, - label: checkingRecommendations ? '检查中' : (hasRecommendations ? '相关推荐' : '暂无推荐'), - onTap: hasRecommendations ? onRecommendationsTap : null, - loading: checkingRecommendations, + icon: Icons.download_outlined, + label: context.l10n.workActionDownload, + onTap: onDownloadTap, + loading: loadingDownload, ), ], ), @@ -78,7 +84,7 @@ class _ActionButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final disabled = onTap == null && !loading; - + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), @@ -99,8 +105,8 @@ class _ActionButton extends StatelessWidget { else Icon( icon, - color: disabled - ? theme.colorScheme.onSurface.withOpacity(0.38) + color: disabled + ? theme.colorScheme.onSurface.withValues(alpha: 0.38) : null, ), const SizedBox(height: 4), @@ -108,7 +114,7 @@ class _ActionButton extends StatelessWidget { label, style: theme.textTheme.bodySmall?.copyWith( color: disabled - ? theme.colorScheme.onSurface.withOpacity(0.38) + ? theme.colorScheme.onSurface.withValues(alpha: 0.38) : null, ), ), @@ -117,4 +123,4 @@ class _ActionButton extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/work_cover.dart b/lib/widgets/detail/work_cover.dart index 795bde2..e507b49 100644 --- a/lib/widgets/detail/work_cover.dart +++ b/lib/widgets/detail/work_cover.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; class WorkCover extends StatelessWidget { final String imageUrl; @@ -8,7 +8,6 @@ class WorkCover extends StatelessWidget { final String? releaseDate; final String? heroTag; - const WorkCover({ super.key, required this.imageUrl, @@ -20,55 +19,65 @@ class WorkCover extends StatelessWidget { @override Widget build(BuildContext context) { - Widget content = Stack( - children: [ - AspectRatio( + final screenHeight = MediaQuery.sizeOf(context).height; + + Widget content = Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: screenHeight), + child: AspectRatio( aspectRatio: 195 / 146, - child: CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - ), - Positioned( - left: 8, - top: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sourceId, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white, - fontSize: 12, + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + Positioned( + left: 8, + top: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: .7), + borderRadius: BorderRadius.circular(4), ), - ), - ), - ), - if (releaseDate != null) - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(4), + child: Text( + sourceId, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + fontSize: 12, + ), + ), + ), ), - child: Text( - releaseDate!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.white, - fontSize: 12, + if (releaseDate != null) + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(4), ), - ), - ), + child: Text( + releaseDate!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ), + ], ), - ], + ), + ), ); if (heroTag != null) { diff --git a/lib/widgets/detail/work_file_item.dart b/lib/widgets/detail/work_file_item.dart index 685bf2b..d71daa2 100644 --- a/lib/widgets/detail/work_file_item.dart +++ b/lib/widgets/detail/work_file_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/files/child.dart'; +import 'package:asmrapp/common/utils/file_preview_utils.dart'; import 'package:asmrapp/utils/logger.dart'; import 'package:asmrapp/utils/file_size_formatter.dart'; @@ -17,9 +18,28 @@ class WorkFileItem extends StatelessWidget { @override Widget build(BuildContext context) { - final bool isAudio = file.type?.toLowerCase() == 'audio'; + final isAudio = FilePreviewUtils.isAudio(file); + final isImage = FilePreviewUtils.isImage(file); + final isText = FilePreviewUtils.isText(file); + final isInteractive = isAudio || isImage || isText; final colorScheme = Theme.of(context).colorScheme; - + + final IconData iconData; + final Color iconColor; + if (isAudio) { + iconData = Icons.audio_file; + iconColor = Colors.green; + } else if (isImage) { + iconData = Icons.image; + iconColor = Colors.orange; + } else if (isText) { + iconData = Icons.text_snippet; + iconColor = Colors.teal; + } else { + iconData = Icons.insert_drive_file; + iconColor = Colors.blue; + } + return Padding( padding: EdgeInsets.only(left: indentation), child: ListTile( @@ -35,15 +55,14 @@ class WorkFileItem extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), ), - leading: Icon( - isAudio ? Icons.audio_file : Icons.insert_drive_file, - color: isAudio ? Colors.green : Colors.blue, - ), + leading: Icon(iconData, color: iconColor), dense: true, - onTap: isAudio ? () { - AppLogger.debug('点击音频文件: ${file.title}'); - onFileTap?.call(file); - } : null, + onTap: isInteractive + ? () { + AppLogger.debug('点击文件: ${file.title}, type=${file.type}'); + onFileTap?.call(file); + } + : null, ), ); } diff --git a/lib/widgets/detail/work_files_list.dart b/lib/widgets/detail/work_files_list.dart index 3411981..5e900f5 100644 --- a/lib/widgets/detail/work_files_list.dart +++ b/lib/widgets/detail/work_files_list.dart @@ -1,8 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:asmrapp/data/models/files/files.dart'; import 'package:asmrapp/data/models/files/child.dart'; -import 'package:asmrapp/widgets/detail/work_folder_item.dart'; +import 'package:asmrapp/data/models/files/files.dart'; import 'package:asmrapp/widgets/detail/work_file_item.dart'; +import 'package:asmrapp/widgets/detail/work_folder_item.dart'; +import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class WorkFilesList extends StatelessWidget { final Files files; @@ -18,7 +19,7 @@ class WorkFilesList extends StatelessWidget { Widget build(BuildContext context) { // 重置文件夹展开状态 WorkFolderItem.resetExpandState(); - + return Card( margin: const EdgeInsets.all(8), child: Column( @@ -27,7 +28,7 @@ class WorkFilesList extends StatelessWidget { Padding( padding: const EdgeInsets.all(16), child: Text( - '文件列表', + context.l10n.workFilesTitle, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -35,7 +36,7 @@ class WorkFilesList extends StatelessWidget { ), Divider( height: 1, - color: Theme.of(context).colorScheme.surfaceVariant, + color: Theme.of(context).colorScheme.surfaceContainerHighest, ), ...files.children ?.map((child) => child.type == 'folder' diff --git a/lib/widgets/detail/work_folder_item.dart b/lib/widgets/detail/work_folder_item.dart index bf02e91..060f120 100644 --- a/lib/widgets/detail/work_folder_item.dart +++ b/lib/widgets/detail/work_folder_item.dart @@ -30,9 +30,9 @@ class WorkFolderItem extends StatelessWidget { bool _shouldExpandFolder(Child folder) { // 如果还没有找到第一个音频文件夹,就搜索并记录 _audioFolderPath ??= FilePath.findFirstAudioFolderPath( - [folder], - formats: _audioFormats, - ); + [folder], + formats: _audioFormats, + ); // 判断当前文件夹是否在音频文件夹的路径上 return FilePath.isInPath(_audioFolderPath, folder.title); @@ -50,9 +50,9 @@ class WorkFolderItem extends StatelessWidget { dividerColor: Colors.transparent, // 确保子组件也能继承正确的文字颜色 textTheme: Theme.of(context).textTheme.apply( - bodyColor: colorScheme.onSurface, - displayColor: colorScheme.onSurface, - ), + bodyColor: colorScheme.onSurface, + displayColor: colorScheme.onSurface, + ), ), child: ExpansionTile( title: Text( diff --git a/lib/widgets/detail/work_info.dart b/lib/widgets/detail/work_info.dart index fbf5dfe..b261bdb 100644 --- a/lib/widgets/detail/work_info.dart +++ b/lib/widgets/detail/work_info.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/works/work.dart'; import 'package:asmrapp/data/models/works/tag.dart'; +import 'package:asmrapp/common/utils/work_localizations.dart'; import 'package:asmrapp/widgets/common/tag_chip.dart'; import 'package:asmrapp/widgets/detail/work_info_header.dart'; import 'package:asmrapp/utils/logger.dart'; @@ -13,16 +14,10 @@ class WorkInfo extends StatelessWidget { required this.work, }); - String _getLocalizedTagName(Tag tag) { - final zhName = tag.i18n?.zhCn?.name; - if (zhName != null) return zhName; - final jaName = tag.i18n?.jaJp?.name; - if (jaName != null) return jaName; - return tag.name ?? ''; - } - - void _onTagTap(BuildContext context, Tag tag) { - final keyword = tag.name ?? ''; + void _onTagTap(BuildContext context, Tag tag, Locale locale) { + final keyword = (tag.name ?? '').trim().isNotEmpty + ? (tag.name ?? '').trim() + : tag.localizedName(locale); if (keyword.isEmpty) return; AppLogger.debug('点击标签: $keyword'); @@ -35,6 +30,8 @@ class WorkInfo extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = Localizations.localeOf(context); + return Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -47,9 +44,10 @@ class WorkInfo extends StatelessWidget { spacing: 8, runSpacing: 8, children: work.tags! + .where((tag) => tag.localizedName(locale).isNotEmpty) .map((tag) => TagChip( - text: _getLocalizedTagName(tag), - onTap: () => _onTagTap(context, tag), + text: tag.localizedName(locale), + onTap: () => _onTagTap(context, tag, locale), )) .toList(), ), diff --git a/lib/widgets/detail/work_info_header.dart b/lib/widgets/detail/work_info_header.dart index 198e09e..f085121 100644 --- a/lib/widgets/detail/work_info_header.dart +++ b/lib/widgets/detail/work_info_header.dart @@ -1,8 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/works/work.dart'; +import 'package:asmrapp/common/utils/work_localizations.dart'; +import 'package:asmrapp/utils/logger.dart'; import 'package:asmrapp/widgets/common/tag_chip.dart'; import 'package:asmrapp/widgets/detail/work_stats_info.dart'; -import 'package:asmrapp/utils/logger.dart'; +import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class WorkInfoHeader extends StatelessWidget { final Work work; @@ -25,11 +27,15 @@ class WorkInfoHeader extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = Localizations.localeOf(context); + final localizedTitle = work.localizedTitle(locale); + final circleName = work.localizedCircleName(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - work.title ?? '', + localizedTitle, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), @@ -39,25 +45,25 @@ class WorkInfoHeader extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - if (work.circle?.name != null) + if (circleName.isNotEmpty) TagChip( - text: work.circle?.name ?? '', - backgroundColor: Colors.orange.withOpacity(0.2), + text: circleName, + backgroundColor: Colors.orange.withValues(alpha: 0.2), textColor: Colors.orange[700], - onTap: () => _onTagTap(context, work.circle?.name ?? ''), + onTap: () => _onTagTap(context, circleName), ), ...?work.vas?.map( (va) => TagChip( text: va['name'] ?? '', - backgroundColor: Colors.green.withOpacity(0.2), + backgroundColor: Colors.green.withValues(alpha: 0.2), textColor: Colors.green[700], onTap: () => _onTagTap(context, va['name'] ?? ''), ), ), if (work.hasSubtitle == true) TagChip( - text: '字幕', - backgroundColor: Colors.blue.withOpacity(0.2), + text: context.l10n.subtitleTag, + backgroundColor: Colors.blue.withValues(alpha: 0.2), textColor: Colors.blue[700], ), ], @@ -65,4 +71,4 @@ class WorkInfoHeader extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/detail/work_stats_info.dart b/lib/widgets/detail/work_stats_info.dart index c94bd36..a331fcc 100644 --- a/lib/widgets/detail/work_stats_info.dart +++ b/lib/widgets/detail/work_stats_info.dart @@ -65,4 +65,4 @@ class WorkStatsInfo extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/download/download_progress_panel.dart b/lib/widgets/download/download_progress_panel.dart new file mode 100644 index 0000000..da9b357 --- /dev/null +++ b/lib/widgets/download/download_progress_panel.dart @@ -0,0 +1,220 @@ +import 'package:asmrapp/core/download/download_progress_manager.dart'; +import 'package:asmrapp/core/download/download_task.dart'; +import 'package:asmrapp/l10n/l10n.dart'; +import 'package:asmrapp/utils/file_size_formatter.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class DownloadProgressPanel extends StatelessWidget { + const DownloadProgressPanel({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, manager, _) { + final activeTasks = manager.activeTasks; + final finishedTasks = manager.finishedTasks; + + if (activeTasks.isEmpty && finishedTasks.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + context.l10n.downloadProgressEmpty, + textAlign: TextAlign.center, + ), + ), + ); + } + + return Column( + children: [ + if (finishedTasks.isNotEmpty) + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: TextButton.icon( + onPressed: manager.clearFinished, + icon: const Icon(Icons.cleaning_services_outlined), + label: Text(context.l10n.downloadProgressClearFinished), + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 20), + children: [ + if (activeTasks.isNotEmpty) ...[ + _ProgressSectionTitle( + title: context.l10n.downloadProgressActiveSection, + ), + ...activeTasks.map((task) => _TaskCard(task: task)), + const SizedBox(height: 12), + ], + if (finishedTasks.isNotEmpty) ...[ + _ProgressSectionTitle( + title: context.l10n.downloadProgressHistorySection, + ), + ...finishedTasks.map((task) => _TaskCard(task: task)), + ], + ], + ), + ), + ], + ); + }, + ); + } +} + +class _ProgressSectionTitle extends StatelessWidget { + final String title; + + const _ProgressSectionTitle({required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall, + ), + ); + } +} + +class _TaskCard extends StatelessWidget { + final DownloadTask task; + + const _TaskCard({required this.task}); + + @override + Widget build(BuildContext context) { + final statusColor = _statusColor(context, task.status); + final statusIcon = _statusIcon(task.status); + final canShowKnownProgress = + task.totalBytes > 0 && task.status == DownloadTaskStatus.running; + final showIndeterminate = + task.totalBytes <= 0 && task.status == DownloadTaskStatus.running; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + margin: const EdgeInsets.only(top: 2), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + statusIcon, + size: 16, + color: statusColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.fileName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + task.workTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 6), + LinearProgressIndicator( + value: showIndeterminate ? null : task.progress, + minHeight: 4, + ), + const SizedBox(height: 6), + Text( + _buildProgressText(context, task, canShowKnownProgress), + style: Theme.of(context).textTheme.bodySmall, + ), + if (task.status == DownloadTaskStatus.failed && + task.errorMessage != null) ...[ + const SizedBox(height: 4), + Text( + task.errorMessage!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + String _buildProgressText( + BuildContext context, + DownloadTask task, + bool canShowKnownProgress, + ) { + switch (task.status) { + case DownloadTaskStatus.queued: + return context.l10n.downloadStatusQueued; + case DownloadTaskStatus.running: + if (canShowKnownProgress) { + final percent = (task.progress * 100).toStringAsFixed(0); + return '$percent% · ${FileSizeFormatter.format(task.receivedBytes)} / ' + '${FileSizeFormatter.format(task.totalBytes)}'; + } + return context.l10n.downloadStatusRunning; + case DownloadTaskStatus.completed: + return context.l10n.downloadStatusCompleted; + case DownloadTaskStatus.failed: + return context.l10n.downloadStatusFailed; + } + } + + IconData _statusIcon(DownloadTaskStatus status) { + switch (status) { + case DownloadTaskStatus.queued: + return Icons.schedule; + case DownloadTaskStatus.running: + return Icons.downloading_rounded; + case DownloadTaskStatus.completed: + return Icons.check_circle_rounded; + case DownloadTaskStatus.failed: + return Icons.error_rounded; + } + } + + Color _statusColor(BuildContext context, DownloadTaskStatus status) { + switch (status) { + case DownloadTaskStatus.queued: + return Theme.of(context).colorScheme.primary; + case DownloadTaskStatus.running: + return Theme.of(context).colorScheme.primary; + case DownloadTaskStatus.completed: + return Colors.green.shade600; + case DownloadTaskStatus.failed: + return Theme.of(context).colorScheme.error; + } + } +} diff --git a/lib/widgets/drawer_menu.dart b/lib/widgets/drawer_menu.dart index 4c99513..cfd93f7 100644 --- a/lib/widgets/drawer_menu.dart +++ b/lib/widgets/drawer_menu.dart @@ -1,13 +1,14 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:asmrapp/common/constants/strings.dart'; +import 'package:asmrapp/core/platform/wakelock_controller.dart'; +import 'package:asmrapp/core/theme/theme_controller.dart'; +import 'package:asmrapp/l10n/l10n.dart'; import 'package:asmrapp/presentation/viewmodels/auth_viewmodel.dart'; import 'package:asmrapp/presentation/widgets/auth/login_dialog.dart'; import 'package:asmrapp/screens/favorites_screen.dart'; import 'package:asmrapp/screens/settings/cache_manager_screen.dart'; -import 'package:asmrapp/core/theme/theme_controller.dart'; -import 'package:asmrapp/core/platform/wakelock_controller.dart'; +import 'package:asmrapp/screens/settings/settings_screen.dart'; +import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:provider/provider.dart'; class DrawerMenu extends StatelessWidget { const DrawerMenu({super.key}); @@ -19,6 +20,28 @@ class DrawerMenu extends StatelessWidget { ); } + Future _showLogoutConfirmDialog(BuildContext context) async { + final navigator = Navigator.of(context, rootNavigator: true); + return await showDialog( + context: navigator.context, + builder: (context) => AlertDialog( + title: Text(context.l10n.logoutConfirmTitle), + content: Text(context.l10n.logoutConfirmMessage), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.logoutAction), + ), + ], + ), + ) ?? + false; + } + @override Widget build(BuildContext context) { return Drawer( @@ -32,13 +55,13 @@ class DrawerMenu extends StatelessWidget { data: Theme.of(context).copyWith( dividerTheme: const DividerThemeData(color: Colors.transparent), ), - child: const DrawerHeader( - decoration: BoxDecoration( + child: DrawerHeader( + decoration: const BoxDecoration( color: Colors.deepPurple, ), child: Text( - Strings.appName, - style: TextStyle( + context.l10n.appName, + style: const TextStyle( color: Colors.white, fontSize: 24, ), @@ -50,12 +73,18 @@ class DrawerMenu extends StatelessWidget { return ListTile( leading: const Icon(Icons.person), title: Text( - authVM.isLoggedIn ? authVM.username ?? '' : '登录', + authVM.isLoggedIn + ? authVM.username ?? '' + : context.l10n.login, ), - onTap: () { + onTap: () async { Navigator.pop(context); if (authVM.isLoggedIn) { - authVM.logout(); + final shouldLogout = + await _showLogoutConfirmDialog(context); + if (shouldLogout) { + await authVM.logout(); + } } else { _showLoginDialog(context); } @@ -63,10 +92,9 @@ class DrawerMenu extends StatelessWidget { ); }, ), - ListTile( leading: const Icon(Icons.favorite), - title: const Text(Strings.favorites), + title: Text(context.l10n.favoritesTitle), onTap: () { Navigator.pop(context); // 检查用户是否已登录 @@ -87,15 +115,20 @@ class DrawerMenu extends StatelessWidget { ), ListTile( leading: const Icon(Icons.settings), - title: const Text(Strings.settings), + title: Text(context.l10n.settings), onTap: () { Navigator.pop(context); - // TODO: 导航到设置页面 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), + ); }, ), ListTile( leading: const Icon(Icons.storage), - title: const Text('缓存管理'), + title: Text(context.l10n.cacheManager), onTap: () { Navigator.pop(context); Navigator.push( @@ -106,15 +139,17 @@ class DrawerMenu extends StatelessWidget { ); }, ), - Divider( - color: Theme.of(context).colorScheme.surfaceVariant, - height: 1, - ), + Divider( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + height: 1, + ), Consumer( builder: (context, themeController, _) { return ListTile( leading: Icon(_getThemeIcon(themeController.themeMode)), - title: Text(_getThemeText(themeController.themeMode)), + title: Text( + _getThemeText(context, themeController.themeMode), + ), onTap: () => themeController.toggleThemeMode(), ); }, @@ -124,7 +159,7 @@ class DrawerMenu extends StatelessWidget { builder: (context, _) { final controller = GetIt.I(); return SwitchListTile( - title: const Text('屏幕常亮'), + title: Text(context.l10n.screenAlwaysOn), value: controller.enabled, onChanged: (_) => controller.toggle(), ); @@ -147,14 +182,14 @@ class DrawerMenu extends StatelessWidget { } } - String _getThemeText(ThemeMode mode) { + String _getThemeText(BuildContext context, ThemeMode mode) { switch (mode) { case ThemeMode.system: - return '跟随系统主题'; + return context.l10n.themeSystem; case ThemeMode.light: - return '浅色模式'; + return context.l10n.themeLight; case ThemeMode.dark: - return '深色模式'; + return context.l10n.themeDark; } } } diff --git a/lib/widgets/filter/filter_panel.dart b/lib/widgets/filter/filter_panel.dart index e18eb19..f9d654d 100644 --- a/lib/widgets/filter/filter_panel.dart +++ b/lib/widgets/filter/filter_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class FilterPanel extends StatelessWidget { final bool expanded; @@ -20,30 +21,30 @@ class FilterPanel extends StatelessWidget { required this.onSortDirectionChanged, }); - String _getOrderFieldText(String field) { + String _getOrderFieldText(BuildContext context, String field) { switch (field) { case 'create_date': - return '收录时间'; + return context.l10n.orderFieldCollectionTime; case 'release': - return '发售日期'; + return context.l10n.orderFieldReleaseDate; case 'dl_count': - return '销量'; + return context.l10n.orderFieldSales; case 'price': - return '价格'; + return context.l10n.orderFieldPrice; case 'rate_average_2dp': - return '评价'; + return context.l10n.orderFieldRating; case 'review_count': - return '评论数量'; + return context.l10n.orderFieldReviewCount; case 'id': - return 'RJ号'; + return context.l10n.orderFieldId; case 'rating': - return '我的评价'; + return context.l10n.orderFieldMyRating; case 'nsfw': - return '全年龄'; + return context.l10n.orderFieldAllAges; case 'random': - return '随机'; + return context.l10n.orderFieldRandom; default: - return '排序'; + return context.l10n.orderLabel; } } @@ -58,7 +59,10 @@ class FilterPanel extends StatelessWidget { Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), ), borderRadius: BorderRadius.circular(8), ), @@ -68,23 +72,26 @@ class FilterPanel extends StatelessWidget { onTap: () => onSubtitleChanged(!hasSubtitle), borderRadius: BorderRadius.circular(7), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - hasSubtitle ? Icons.check_box : Icons.check_box_outline_blank, + hasSubtitle + ? Icons.check_box + : Icons.check_box_outline_blank, size: 20, - color: hasSubtitle - ? Theme.of(context).colorScheme.primary + color: hasSubtitle + ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(width: 8), Text( - '有字幕', + context.l10n.subtitleAvailable, style: TextStyle( - color: hasSubtitle - ? Theme.of(context).colorScheme.primary + color: hasSubtitle + ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface, ), ), @@ -99,33 +106,38 @@ class FilterPanel extends StatelessWidget { Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), ), borderRadius: BorderRadius.circular(8), ), child: PopupMenuButton( + onSelected: onOrderFieldChanged, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(_getOrderFieldText(orderField)), + Text(_getOrderFieldText(context, orderField)), const SizedBox(width: 4), const Icon(Icons.arrow_drop_down, size: 20), ], ), ), itemBuilder: (context) => [ - _buildOrderMenuItem('收录时间', 'create_date'), - _buildOrderMenuItem('发售日期', 'release'), - _buildOrderMenuItem('销量', 'dl_count'), - _buildOrderMenuItem('价格', 'price'), - _buildOrderMenuItem('评价', 'rate_average_2dp'), - _buildOrderMenuItem('评论数量', 'review_count'), - _buildOrderMenuItem('RJ号', 'id'), - _buildOrderMenuItem('我的评价', 'rating'), - _buildOrderMenuItem('全年龄', 'nsfw'), - _buildOrderMenuItem('随机', 'random'), + _buildOrderMenuItem(context, 'create_date'), + _buildOrderMenuItem(context, 'release'), + _buildOrderMenuItem(context, 'dl_count'), + _buildOrderMenuItem(context, 'price'), + _buildOrderMenuItem(context, 'rate_average_2dp'), + _buildOrderMenuItem(context, 'review_count'), + _buildOrderMenuItem(context, 'id'), + _buildOrderMenuItem(context, 'rating'), + _buildOrderMenuItem(context, 'nsfw'), + _buildOrderMenuItem(context, 'random'), ], ), ), @@ -134,7 +146,10 @@ class FilterPanel extends StatelessWidget { Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), ), borderRadius: BorderRadius.circular(8), ), @@ -144,14 +159,19 @@ class FilterPanel extends StatelessWidget { onTap: () => onSortDirectionChanged(!isDescending), borderRadius: BorderRadius.circular(7), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(isDescending ? '降序' : '升序'), + Text(isDescending + ? context.l10n.orderDirectionDesc + : context.l10n.orderDirectionAsc), const SizedBox(width: 4), Icon( - isDescending ? Icons.arrow_downward : Icons.arrow_upward, + isDescending + ? Icons.arrow_downward + : Icons.arrow_upward, size: 20, color: Theme.of(context).colorScheme.onSurface, ), @@ -167,10 +187,13 @@ class FilterPanel extends StatelessWidget { ); } - PopupMenuItem _buildOrderMenuItem(String text, String value) { + PopupMenuItem _buildOrderMenuItem( + BuildContext context, + String value, + ) { return PopupMenuItem( value: value, - child: Text(text), + child: Text(_getOrderFieldText(context, value)), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/filter/filter_with_keyword.dart b/lib/widgets/filter/filter_with_keyword.dart index b240ef5..832256f 100644 --- a/lib/widgets/filter/filter_with_keyword.dart +++ b/lib/widgets/filter/filter_with_keyword.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class FilterWithKeyword extends StatelessWidget { final bool hasSubtitle; @@ -32,7 +33,10 @@ class FilterWithKeyword extends StatelessWidget { Container( decoration: BoxDecoration( border: Border.all( - color: Theme.of(context).colorScheme.outline.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), ), borderRadius: BorderRadius.circular(8), ), @@ -42,25 +46,26 @@ class FilterWithKeyword extends StatelessWidget { onTap: () => onSubtitleChanged(!hasSubtitle), borderRadius: BorderRadius.circular(7), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - hasSubtitle - ? Icons.check_box + hasSubtitle + ? Icons.check_box : Icons.check_box_outline_blank, size: 20, - color: hasSubtitle - ? colorScheme.primary + color: hasSubtitle + ? colorScheme.primary : colorScheme.onSurfaceVariant, ), const SizedBox(width: 8), Text( - '有字幕', + context.l10n.subtitleAvailable, style: TextStyle( - color: hasSubtitle - ? colorScheme.primary + color: hasSubtitle + ? colorScheme.primary : colorScheme.onSurface, ), ), @@ -75,4 +80,4 @@ class FilterWithKeyword extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/lyrics/components/lyric_line.dart b/lib/widgets/lyrics/components/lyric_line.dart index 0b8227c..fe5f0ac 100644 --- a/lib/widgets/lyrics/components/lyric_line.dart +++ b/lib/widgets/lyrics/components/lyric_line.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:asmrapp/core/audio/models/subtitle.dart'; +import 'package:flutter/material.dart'; class LyricLine extends StatelessWidget { final Subtitle subtitle; @@ -29,13 +29,16 @@ class LyricLine extends StatelessWidget { child: Text( subtitle.text, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontSize: 20, - height: 1.3, - color: isActive - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - ), + fontSize: 20, + height: 1.3, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), textAlign: TextAlign.center, ), ), @@ -43,4 +46,4 @@ class LyricLine extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/lyrics/components/player_lyric_view.dart b/lib/widgets/lyrics/components/player_lyric_view.dart index b619b9e..c52fc66 100644 --- a/lib/widgets/lyrics/components/player_lyric_view.dart +++ b/lib/widgets/lyrics/components/player_lyric_view.dart @@ -7,6 +7,7 @@ import 'package:asmrapp/core/subtitle/i_subtitle_service.dart'; import 'package:asmrapp/core/audio/models/subtitle.dart'; import 'lyric_line.dart'; import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class PlayerLyricView extends StatefulWidget { final bool immediateScroll; @@ -26,15 +27,16 @@ class _PlayerLyricViewState extends State { final ISubtitleService _subtitleService = GetIt.I(); final PlayerViewModel _viewModel = GetIt.I(); final ItemScrollController _itemScrollController = ItemScrollController(); - final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); - + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + bool _isFirstBuild = true; Subtitle? _lastScrolledSubtitle; - + // 用于控制视图切换的计时器和状态 // 当用户手动滚动时,暂时禁用视图切换功能,防止切换到封面 Timer? _scrollDebounceTimer; - + // 用于控制自动滚动的计时器和状态 // 当用户手动滚动时,暂时禁用自动滚动功能,让用户可以自由浏览歌词 bool _allowAutoScroll = true; @@ -48,21 +50,21 @@ class _PlayerLyricViewState extends State { @override void dispose() { // 清理所有计时器 - _scrollDebounceTimer?.cancel(); // 视图切换计时器 + _scrollDebounceTimer?.cancel(); // 视图切换计时器 _autoScrollDebounceTimer?.cancel(); // 自动滚动计时器 super.dispose(); } void _scrollToCurrentLyric(SubtitleWithState current) { if (!_itemScrollController.isAttached) return; - + // 如果当前禁用了自动滚动(用户正在手动浏览),则不执行自动滚动 if (!_allowAutoScroll) return; - + // 避免重复滚动到同一句歌词 if (_lastScrolledSubtitle == current.subtitle) return; _lastScrolledSubtitle = current.subtitle; - + if (_isFirstBuild) { _isFirstBuild = false; // 首次加载时直接跳转,不使用动画 @@ -85,7 +87,7 @@ class _PlayerLyricViewState extends State { Widget build(BuildContext context) { final screenHeight = MediaQuery.of(context).size.height; final baseUnit = screenHeight * 0.04; - + return StreamBuilder( stream: _subtitleService.currentSubtitleWithStateStream, initialData: _subtitleService.currentSubtitleWithState, @@ -94,8 +96,8 @@ class _PlayerLyricViewState extends State { final subtitleList = _subtitleService.subtitleList; if (subtitleList == null || subtitleList.subtitles.isEmpty) { - return const Center( - child: Text('无歌词'), + return Center( + child: Text(context.l10n.lyricsEmpty), ); } @@ -107,35 +109,40 @@ class _PlayerLyricViewState extends State { return NotificationListener( onNotification: (notification) { - if (notification is ScrollStartNotification && - notification.dragDetails != null) { // 用户开始手动滚动 + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + // 用户开始手动滚动 // 立即禁用视图切换功能 widget.onScrollStateChanged(false); - + // 禁用自动滚动功能 _allowAutoScroll = false; - + // 取消所有待执行的计时器 _scrollDebounceTimer?.cancel(); _autoScrollDebounceTimer?.cancel(); - } else if (notification is ScrollEndNotification) { // 用户结束滚动 + } else if (notification is ScrollEndNotification) { + // 用户结束滚动 // 延长视图切换的禁用时间到1秒 _scrollDebounceTimer?.cancel(); - _scrollDebounceTimer = Timer(const Duration(milliseconds: 1000), () { + _scrollDebounceTimer = + Timer(const Duration(milliseconds: 1000), () { if (mounted) { widget.onScrollStateChanged(true); } }); - + // 自动滚动计时器保持3秒 _autoScrollDebounceTimer?.cancel(); - _autoScrollDebounceTimer = Timer(const Duration(milliseconds: 3000), () { + _autoScrollDebounceTimer = + Timer(const Duration(milliseconds: 3000), () { if (mounted) { setState(() { _allowAutoScroll = true; // 恢复时立即滚动到当前播放位置 if (_subtitleService.currentSubtitleWithState != null) { - _scrollToCurrentLyric(_subtitleService.currentSubtitleWithState!); + _scrollToCurrentLyric( + _subtitleService.currentSubtitleWithState!); } }); } @@ -154,7 +161,7 @@ class _PlayerLyricViewState extends State { itemBuilder: (context, index) { final subtitle = subtitleList.subtitles[index]; final isActive = currentSubtitle?.subtitle == subtitle; - + return Padding( padding: EdgeInsets.symmetric( vertical: baseUnit * 0.35, @@ -165,9 +172,9 @@ class _PlayerLyricViewState extends State { opacity: isActive ? 1.0 : 0.5, onTap: () async { widget.onScrollStateChanged(false); - + await _viewModel.seek(subtitle.start); - + Future.delayed(const Duration(milliseconds: 500), () { if (mounted) { widget.onScrollStateChanged(true); @@ -182,4 +189,4 @@ class _PlayerLyricViewState extends State { }, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/mini_player/mini_player.dart b/lib/widgets/mini_player/mini_player.dart index 1d5a60d..f9b6f2f 100644 --- a/lib/widgets/mini_player/mini_player.dart +++ b/lib/widgets/mini_player/mini_player.dart @@ -1,19 +1,38 @@ +import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart'; import 'package:asmrapp/screens/player_screen.dart'; import 'package:flutter/material.dart'; -import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart'; -import 'mini_player_controls.dart'; -import 'mini_player_progress.dart'; import 'package:get_it/get_it.dart'; +import 'package:asmrapp/l10n/l10n.dart'; + +import 'mini_player_controls.dart'; import 'mini_player_cover.dart'; +import 'mini_player_progress.dart'; class MiniPlayer extends StatelessWidget { static const height = 48.0; - - const MiniPlayer({super.key}); + + final bool respectSafeArea; + + const MiniPlayer({ + super.key, + this.respectSafeArea = true, + }); + + static double heightWithSafeArea( + BuildContext context, { + bool respectSafeArea = true, + }) { + final bottomPadding = + respectSafeArea ? MediaQuery.of(context).padding.bottom : 0.0; + return height + bottomPadding; + } @override Widget build(BuildContext context) { final viewModel = GetIt.I(); + final bottomPadding = + respectSafeArea ? MediaQuery.of(context).padding.bottom : 0.0; + return ListenableBuilder( listenable: viewModel, builder: (context, _) { @@ -24,13 +43,14 @@ class MiniPlayer extends StatelessWidget { pageBuilder: (context, animation, secondaryAnimation) { return const PlayerScreen(); }, - transitionsBuilder: (context, animation, secondaryAnimation, child) { + transitionsBuilder: + (context, animation, secondaryAnimation, child) { // 创建一个曲线动画 final curvedAnimation = CurvedAnimation( parent: animation, curve: Curves.easeOutQuart, ); - + return Stack( children: [ // 背景淡入效果 @@ -62,12 +82,13 @@ class MiniPlayer extends StatelessWidget { ); }, child: Container( - height: height, + height: height + bottomPadding, + padding: EdgeInsets.only(bottom: bottomPadding), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, -1), ), @@ -96,7 +117,8 @@ class MiniPlayer extends StatelessWidget { child: Material( color: Colors.transparent, child: Text( - viewModel.currentTrackInfo?.title ?? '未在播放', + viewModel.currentTrackInfo?.title ?? + context.l10n.noPlaying, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall, diff --git a/lib/widgets/player/player_controls.dart b/lib/widgets/player/player_controls.dart index 8dc7e88..1f55f12 100644 --- a/lib/widgets/player/player_controls.dart +++ b/lib/widgets/player/player_controls.dart @@ -8,7 +8,7 @@ class PlayerControls extends StatelessWidget { @override Widget build(BuildContext context) { final viewModel = GetIt.I(); - + return ListenableBuilder( listenable: viewModel, builder: (context, _) { @@ -48,4 +48,4 @@ class PlayerControls extends StatelessWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/player/player_cover.dart b/lib/widgets/player/player_cover.dart index e86c89a..1f7d5e3 100644 --- a/lib/widgets/player/player_cover.dart +++ b/lib/widgets/player/player_cover.dart @@ -1,11 +1,11 @@ -import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; class PlayerCover extends StatelessWidget { final String? coverUrl; final double? maxWidth; - + const PlayerCover({ super.key, this.coverUrl, @@ -15,7 +15,7 @@ class PlayerCover extends StatelessWidget { @override Widget build(BuildContext context) { return AspectRatio( - aspectRatio: 4/3, + aspectRatio: 4 / 3, child: Container( constraints: BoxConstraints( maxWidth: maxWidth ?? 480, @@ -25,7 +25,7 @@ class PlayerCover extends StatelessWidget { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 8), ), @@ -38,7 +38,8 @@ class PlayerCover extends StatelessWidget { imageUrl: coverUrl!, fit: BoxFit.cover, placeholder: (context, url) => Shimmer.fromColors( - baseColor: Theme.of(context).colorScheme.surfaceContainerHighest, + baseColor: + Theme.of(context).colorScheme.surfaceContainerHighest, highlightColor: Theme.of(context).colorScheme.surface, child: Container( color: Colors.white, @@ -60,4 +61,4 @@ class PlayerCover extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/player/player_progress.dart b/lib/widgets/player/player_progress.dart index 0f1108b..2706adb 100644 --- a/lib/widgets/player/player_progress.dart +++ b/lib/widgets/player/player_progress.dart @@ -1,6 +1,6 @@ +import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:asmrapp/presentation/viewmodels/player_viewmodel.dart'; class PlayerProgress extends StatelessWidget { const PlayerProgress({super.key}); @@ -22,7 +22,7 @@ class PlayerProgress extends StatelessWidget { @override Widget build(BuildContext context) { final viewModel = GetIt.I(); - + return ListenableBuilder( listenable: viewModel, builder: (context, _) { @@ -39,10 +39,9 @@ class PlayerProgress extends StatelessWidget { ), child: Slider( value: _ensureValueInRange( - viewModel.position?.inMilliseconds.toDouble() ?? 0, - 0, - viewModel.duration?.inMilliseconds.toDouble() ?? 1 - ), + viewModel.position?.inMilliseconds.toDouble() ?? 0, + 0, + viewModel.duration?.inMilliseconds.toDouble() ?? 1), min: 0, max: viewModel.duration?.inMilliseconds.toDouble() ?? 1, onChanged: (value) { @@ -58,14 +57,20 @@ class PlayerProgress extends StatelessWidget { Text( _formatDuration(viewModel.position), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), ), Text( _formatDuration(viewModel.duration), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), ), ], ), @@ -76,4 +81,4 @@ class PlayerProgress extends StatelessWidget { }, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/player/player_seek_controls.dart b/lib/widgets/player/player_seek_controls.dart index 08831fd..5672261 100644 --- a/lib/widgets/player/player_seek_controls.dart +++ b/lib/widgets/player/player_seek_controls.dart @@ -8,7 +8,7 @@ class PlayerSeekControls extends StatelessWidget { @override Widget build(BuildContext context) { final viewModel = GetIt.I(); - + return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -71,4 +71,4 @@ class PlayerSeekControls extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/player/player_work_info.dart b/lib/widgets/player/player_work_info.dart index 2abed9d..79b218a 100644 --- a/lib/widgets/player/player_work_info.dart +++ b/lib/widgets/player/player_work_info.dart @@ -1,6 +1,8 @@ +import 'package:asmrapp/core/audio/models/playback_context.dart'; +import 'package:asmrapp/common/utils/work_localizations.dart'; import 'package:flutter/material.dart'; import 'package:marquee/marquee.dart'; -import 'package:asmrapp/core/audio/models/playback_context.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class PlayerWorkInfo extends StatelessWidget { final PlaybackContext? context; @@ -12,6 +14,9 @@ class PlayerWorkInfo extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = Localizations.localeOf(context); + final workTitle = this.context?.work.localizedTitle(locale) ?? ''; + return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), @@ -21,7 +26,9 @@ class PlayerWorkInfo extends StatelessWidget { SizedBox( height: Theme.of(context).textTheme.titleMedium!.fontSize! * 1.5, child: Marquee( - text: this.context?.work.title ?? '未知作品', + text: workTitle.isNotEmpty + ? workTitle + : context.l10n.unknownWorkTitle, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), @@ -39,13 +46,19 @@ class PlayerWorkInfo extends StatelessWidget { ), const SizedBox(height: 2), Text( - this.context?.work.vas + this + .context + ?.work + .vas ?.map((va) => va['name'] as String?) .where((name) => name != null) - .join('、') ?? - '未知演员', + .join('、') ?? + context.l10n.unknownArtist, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -54,4 +67,4 @@ class PlayerWorkInfo extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/work_card/components/work_cover_image.dart b/lib/widgets/work_card/components/work_cover_image.dart index 78a8a39..dfc5d69 100644 --- a/lib/widgets/work_card/components/work_cover_image.dart +++ b/lib/widgets/work_card/components/work_cover_image.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; class WorkCoverImage extends StatelessWidget { @@ -54,7 +54,7 @@ class WorkCoverImage extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), + color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(4), ), child: Text( diff --git a/lib/widgets/work_card/components/work_tags_panel.dart b/lib/widgets/work_card/components/work_tags_panel.dart index 6679f1a..528ec02 100644 --- a/lib/widgets/work_card/components/work_tags_panel.dart +++ b/lib/widgets/work_card/components/work_tags_panel.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/works/work.dart'; -import 'package:asmrapp/data/models/works/tag.dart'; +import 'package:asmrapp/common/utils/work_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class WorkTagsPanel extends StatelessWidget { final Work work; @@ -10,29 +11,24 @@ class WorkTagsPanel extends StatelessWidget { required this.work, }); - String _getLocalizedTagName(Tag tag) { - final zhName = tag.i18n?.zhCn?.name; - if (zhName != null) return zhName; - final jaName = tag.i18n?.jaJp?.name; - if (jaName != null) return jaName; - return tag.name ?? ''; - } - @override Widget build(BuildContext context) { + final locale = Localizations.localeOf(context); + final circleName = work.localizedCircleName(); + return Wrap( spacing: 4, runSpacing: 2, children: [ - if (work.circle?.name != null) + if (circleName.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.orange.withOpacity(0.2), + color: Colors.orange.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: Text( - work.circle?.name ?? '', + circleName, style: TextStyle( fontSize: 10, color: Colors.orange[700], @@ -42,7 +38,7 @@ class WorkTagsPanel extends StatelessWidget { ...?work.vas?.map((va) => Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.green.withOpacity(0.2), + color: Colors.green.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: Text( @@ -57,37 +53,36 @@ class WorkTagsPanel extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.2), + color: Colors.blue.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), ), child: Text( - '字幕', + context.l10n.subtitleTag, style: TextStyle( fontSize: 10, color: Colors.blue[700], ), ), ), - ...work.tags - ?.map((tag) => Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _getLocalizedTagName(tag), - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - )) - .toList() ?? - [], + ...(work.tags ?? const []) + .map((tag) => tag.localizedName(locale)) + .where((localizedName) => localizedName.isNotEmpty) + .map( + (localizedName) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + localizedName, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), ], ); } diff --git a/lib/widgets/work_card/components/work_title.dart b/lib/widgets/work_card/components/work_title.dart index 5e7476b..a2ab959 100644 --- a/lib/widgets/work_card/components/work_title.dart +++ b/lib/widgets/work_card/components/work_title.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/works/work.dart'; +import 'package:asmrapp/common/utils/work_localizations.dart'; class WorkTitle extends StatelessWidget { final Work work; @@ -11,8 +12,10 @@ class WorkTitle extends StatelessWidget { @override Widget build(BuildContext context) { + final locale = Localizations.localeOf(context); + return Text( - work.title ?? '', + work.localizedTitle(locale), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontSize: 14, ), diff --git a/lib/widgets/work_card/work_card.dart b/lib/widgets/work_card/work_card.dart index 6561dbc..1554d9b 100644 --- a/lib/widgets/work_card/work_card.dart +++ b/lib/widgets/work_card/work_card.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:asmrapp/data/models/works/work.dart'; +import 'package:flutter/material.dart'; + import 'components/work_cover_image.dart'; import 'components/work_info_section.dart'; @@ -16,12 +17,12 @@ class WorkCard extends StatelessWidget { @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - + return Card( clipBehavior: Clip.antiAlias, elevation: isDark ? 0 : 1, - color: isDark - ? Theme.of(context).colorScheme.surfaceVariant + color: isDark + ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surface, child: InkWell( onTap: onTap, diff --git a/lib/widgets/work_grid.dart b/lib/widgets/work_grid.dart index 24ee888..df49d45 100644 --- a/lib/widgets/work_grid.dart +++ b/lib/widgets/work_grid.dart @@ -33,6 +33,7 @@ class WorkGrid extends StatelessWidget { works: rows[index], onWorkTap: onWorkTap, spacing: columnSpacing, + columnsCount: columnsCount, ), ); }, diff --git a/lib/widgets/work_grid/components/grid_content.dart b/lib/widgets/work_grid/components/grid_content.dart index 46dc2c8..6174ad1 100644 --- a/lib/widgets/work_grid/components/grid_content.dart +++ b/lib/widgets/work_grid/components/grid_content.dart @@ -59,8 +59,8 @@ class GridContent extends StatelessWidget { }, ), ), - if (config?.enablePagination != false && - currentPage != null && + if (config?.enablePagination != false && + currentPage != null && totalPages != null) SliverToBoxAdapter( child: PaginationControls( @@ -78,4 +78,4 @@ class GridContent extends StatelessWidget { ], ); } -} \ No newline at end of file +} diff --git a/lib/widgets/work_grid/components/grid_empty.dart b/lib/widgets/work_grid/components/grid_empty.dart index fa04847..db87749 100644 --- a/lib/widgets/work_grid/components/grid_empty.dart +++ b/lib/widgets/work_grid/components/grid_empty.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class GridEmpty extends StatelessWidget { final String? message; @@ -27,7 +28,7 @@ class GridEmpty extends StatelessWidget { ), const SizedBox(height: 16), Text( - message ?? '暂无内容', + message ?? context.l10n.emptyContent, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.outline, ), @@ -36,4 +37,4 @@ class GridEmpty extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/work_grid/components/grid_error.dart b/lib/widgets/work_grid/components/grid_error.dart index e2a26f9..f7f73e4 100644 --- a/lib/widgets/work_grid/components/grid_error.dart +++ b/lib/widgets/work_grid/components/grid_error.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class GridError extends StatelessWidget { final String error; @@ -32,11 +33,11 @@ class GridError extends StatelessWidget { FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), - label: const Text('重试'), + label: Text(context.l10n.retry), ), ], ], ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/work_grid/components/grid_loading.dart b/lib/widgets/work_grid/components/grid_loading.dart index a782249..1dffd9d 100644 --- a/lib/widgets/work_grid/components/grid_loading.dart +++ b/lib/widgets/work_grid/components/grid_loading.dart @@ -1,32 +1,42 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:asmrapp/presentation/layouts/work_layout_config.dart'; class GridLoading extends StatelessWidget { const GridLoading({super.key}); @override Widget build(BuildContext context) { - return Shimmer.fromColors( - baseColor: Theme.of(context).colorScheme.surfaceContainerHighest, - highlightColor: Theme.of(context).colorScheme.surface, - child: GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 0.75, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: 6, - itemBuilder: (context, index) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), + return LayoutBuilder( + builder: (context, constraints) { + final deviceType = DeviceType.fromWidth(constraints.maxWidth); + final columnsCount = WorkLayoutConfig.getColumnsCount(deviceType); + final spacing = WorkLayoutConfig.getSpacing(deviceType); + final padding = WorkLayoutConfig.getPadding(deviceType); + + return Shimmer.fromColors( + baseColor: Theme.of(context).colorScheme.surfaceContainerHighest, + highlightColor: Theme.of(context).colorScheme.surface, + child: GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnsCount, + childAspectRatio: 0.75, + crossAxisSpacing: spacing, + mainAxisSpacing: spacing, ), - ); - }, - ), + itemCount: columnsCount * 3, + itemBuilder: (context, index) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + ); + }, + ), + ); + }, ); } -} \ No newline at end of file +} diff --git a/lib/widgets/work_grid/enhanced_work_grid_view.dart b/lib/widgets/work_grid/enhanced_work_grid_view.dart index b05145c..5dcf356 100644 --- a/lib/widgets/work_grid/enhanced_work_grid_view.dart +++ b/lib/widgets/work_grid/enhanced_work_grid_view.dart @@ -79,4 +79,4 @@ class EnhancedWorkGridView extends StatelessWidget { return content; } -} \ No newline at end of file +} diff --git a/lib/widgets/work_grid/models/grid_config.dart b/lib/widgets/work_grid/models/grid_config.dart index a77b6ba..6064491 100644 --- a/lib/widgets/work_grid/models/grid_config.dart +++ b/lib/widgets/work_grid/models/grid_config.dart @@ -18,4 +18,4 @@ class GridConfig { }); static const GridConfig defaultConfig = GridConfig(); -} \ No newline at end of file +} diff --git a/lib/widgets/work_grid_view.dart b/lib/widgets/work_grid_view.dart index 3336176..cb96168 100644 --- a/lib/widgets/work_grid_view.dart +++ b/lib/widgets/work_grid_view.dart @@ -3,6 +3,7 @@ import 'package:asmrapp/data/models/works/work.dart'; import 'package:asmrapp/widgets/work_grid.dart'; import 'package:asmrapp/presentation/layouts/work_layout_strategy.dart'; import 'package:asmrapp/screens/detail_screen.dart'; +import 'package:asmrapp/l10n/l10n.dart'; class WorkGridView extends StatelessWidget { final List works; @@ -46,7 +47,7 @@ class WorkGridView extends StatelessWidget { const SizedBox(height: 16), ElevatedButton( onPressed: onRetry, - child: const Text('重试'), + child: Text(context.l10n.retry), ), ], ], diff --git a/lib/widgets/work_row.dart b/lib/widgets/work_row.dart index 4a8b34f..94d5f8f 100644 --- a/lib/widgets/work_row.dart +++ b/lib/widgets/work_row.dart @@ -6,40 +6,41 @@ class WorkRow extends StatelessWidget { final List works; final void Function(Work work)? onWorkTap; final double spacing; + final int columnsCount; const WorkRow({ super.key, required this.works, this.onWorkTap, this.spacing = 8.0, + this.columnsCount = 2, }); @override Widget build(BuildContext context) { + final children = []; + + for (var i = 0; i < columnsCount; i++) { + if (i > 0) { + children.add(SizedBox(width: spacing)); + } + + children.add( + Expanded( + child: i < works.length + ? WorkCard( + work: works[i], + onTap: onWorkTap != null ? () => onWorkTap!(works[i]) : null, + ) + : const SizedBox.shrink(), + ), + ); + } + return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 第一个卡片 - Expanded( - child: works.isNotEmpty - ? WorkCard( - work: works[0], - onTap: onWorkTap != null ? () => onWorkTap!(works[0]) : null, - ) - : const SizedBox.shrink(), - ), - SizedBox(width: spacing), - // 第二个卡片或占位符 - Expanded( - child: works.length > 1 - ? WorkCard( - work: works[1], - onTap: onWorkTap != null ? () => onWorkTap!(works[1]) : null, - ) - : const SizedBox.shrink(), // 空占位符,保持两列布局 - ), - ], + children: children, ), ); } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 76c5153..af97a5a 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -91,6 +91,11 @@ set_target_properties(${BINARY_NAME} # them to the application. include(flutter/generated_plugins.cmake) +# Use mimalloc provided by media_kit when available to reduce allocator issues. +if(MIMALLOC_LIB) + target_link_libraries(${BINARY_NAME} PRIVATE ${MIMALLOC_LIB}) +endif() + # === Installation === # By default, "installing" just makes a relocatable bundle in the build diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..b637f77 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,18 @@ #include "generated_plugin_registrant.h" +#include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f3570f9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + media_kit_libs_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index c3cc6ff..9bb8652 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,20 +7,22 @@ import Foundation import audio_service import audio_session +import file_selector_macos import just_audio import package_info_plus -import path_provider_foundation import shared_preferences_foundation import sqflite_darwin +import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f68bb19..5c1ac0e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,135 +5,122 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "76.0.0" - _macros: - dependency: transitive - description: dart - source: sdk - version: "0.3.3" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "10.0.1" archive: dependency: transitive description: name: archive - sha256: "08064924cbf0ab88280a0c3f60db9dd24fec693927e725ecb176f16c629d1cb8" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.9" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" audio_service: dependency: "direct main" description: name: audio_service - sha256: "9dd5ba7e77567b290c35908b1950d61485b4dfdd3a0ac398e98cfeec04651b75" + sha256: cb122c7c2639d2a992421ef96b67948ad88c5221da3365ccef1031393a76e044 url: "https://pub.dev" source: hosted - version: "0.18.15" + version: "0.18.18" audio_service_platform_interface: dependency: transitive description: name: audio_service_platform_interface - sha256: "8431a455dac9916cc9ee6f7da5620a666436345c906ad2ebb7fa41d18b3c1bf4" + sha256: "6283782851f6c8b501b60904a32fc7199dc631172da0629d7301e66f672ab777" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.3" audio_service_web: dependency: transitive description: name: audio_service_web - sha256: "4cdc2127cd4562b957fb49227dc58e3303fafb09bde2573bc8241b938cf759d9" + sha256: b8ea9243201ee53383157fbccf13d5d2a866b5dda922ec19d866d1d5d70424df url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.1.4" audio_session: dependency: "direct main" description: name: audio_session - sha256: "343e83bc7809fbda2591a49e525d6b63213ade10c76f15813be9aed6657b3261" + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" url: "https://pub.dev" source: hosted - version: "0.1.21" + version: "0.2.2" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "2ea5322fe836c0aaf96aefd29ef1936771c71927f687cf18168dcc119666a45f" + url: "https://pub.dev" + source: hosted + version: "9.5.2" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.4" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "4.1.1" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" - url: "https://pub.dev" - source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734" url: "https://pub.dev" source: hosted - version: "7.3.2" + version: "2.11.1" built_collection: dependency: transitive description: @@ -146,10 +133,10 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.12.3" cached_network_image: dependency: "direct main" description: @@ -178,18 +165,18 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" checked_yaml: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" cli_util: dependency: transitive description: @@ -202,26 +189,34 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.1" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" convert: dependency: transitive description: @@ -230,14 +225,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: "direct main" description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -250,34 +253,34 @@ packages: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "3.1.5" dbus: dependency: transitive description: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" dio: dependency: "direct main" description: name: dio - sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 url: "https://pub.dev" source: hosted - version: "5.7.0" + version: "5.9.1" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" fading_edge_scrollview: dependency: transitive description: @@ -290,18 +293,18 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" file: dependency: transitive description: @@ -310,6 +313,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "51e8fd0446de75e4b62c065b76db2210c704562d072339d333bd89c57a7f8a7c" + url: "https://pub.dev" + source: hosted + version: "0.5.2+4" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -335,18 +402,23 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.4" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -361,42 +433,34 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "44c19278dd9d89292cf46e97dc0c1e52ce03275f40a97c5a348e802a924bf40e" + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 url: "https://pub.dev" source: hosted - version: "2.5.7" + version: "3.2.5" freezed_annotation: dependency: "direct main" description: name: freezed_annotation - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 - url: "https://pub.dev" - source: hosted - version: "2.4.4" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "3.1.0" get_it: dependency: "direct main" description: name: get_it - sha256: c49895c1ecb0ee2a0ec568d39de882e2c299ba26355aa6744ab1001f98cebd15 + sha256: "1d648d2dd2047d7f7450d5727ca24ee435f240385753d90b49650e3cdff32e56" url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "9.2.0" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" graphs: dependency: transitive description: @@ -405,134 +469,166 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" http: dependency: transitive description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.6.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" image: dependency: transitive description: name: image - sha256: "20842a5ad1555be624c314b0c0cc0566e8ece412f61e859a42efeb6d4101a26c" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "0.20.2" io: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" js: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" json_annotation: dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" + json_schema: + dependency: transitive + description: + name: json_schema + sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a + url: "https://pub.dev" + source: hosted + version: "5.2.2" json_serializable: dependency: "direct dev" description: name: json_serializable - sha256: c2fcb3920cf2b6ae6845954186420fca40bc0a8abcc84903b7801f17d7050d7c + sha256: "93fba3ad139dab2b1ce59ecc6fdce6da46a42cdb6c4399ecda30f1e7e725760d" url: "https://pub.dev" source: hosted - version: "6.9.0" + version: "6.12.0" just_audio: dependency: "direct main" description: name: just_audio - sha256: a49e7120b95600bd357f37a2bb04cd1e88252f7cdea8f3368803779b925b1049 + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" url: "https://pub.dev" source: hosted - version: "0.9.42" + version: "0.10.5" + just_audio_media_kit: + dependency: "direct main" + description: + name: just_audio_media_kit + sha256: f3cf04c3a50339709e87e90b4e841eef4364ab4be2bdbac0c54cc48679f84d23 + url: "https://pub.dev" + source: hosted + version: "2.1.0" just_audio_platform_interface: dependency: transitive description: name: just_audio_platform_interface - sha256: "0243828cce503c8366cc2090cefb2b3c871aa8ed2f520670d76fd47aa1ab2790" + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.6.0" just_audio_web: dependency: transitive description: name: just_audio_web - sha256: "9a98035b8b24b40749507687520ec5ab404e291d2b0937823ff45d92cb18d448" + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" url: "https://pub.dev" source: hosted - version: "0.4.13" + version: "0.4.16" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "6.1.0" logger: dependency: "direct main" description: name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.2" logging: dependency: transitive description: @@ -541,14 +637,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" - macros: - dependency: transitive - description: - name: macros - sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.dev" - source: hosted - version: "0.1.3-main.0" marquee: dependency: "direct main" description: @@ -561,26 +649,50 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + media_kit: + dependency: transitive + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_linux: + dependency: "direct main" + description: + name: media_kit_libs_linux + sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + media_kit_libs_windows_audio: + dependency: "direct main" + description: + name: media_kit_libs_windows_audio + sha256: c2fd558cc87b9d89a801141fcdffe02e338a3b21a41a18fbd63d5b221a1b8e53 url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "1.0.9" meta: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.17.0" mime: dependency: transitive description: @@ -589,6 +701,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" nested: dependency: transitive description: @@ -597,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" octo_image: dependency: transitive description: @@ -609,34 +737,34 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" package_info_plus: dependency: transitive description: name: package_info_plus - sha256: "70c421fe9d9cc1a9a7f3b05ae56befd469fe4f8daa3b484823141a55442d858d" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: a5ef9986efc7bf772f2696183a3992615baa76c1ffb1189318dd8803778fb05b + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.2.1" path: dependency: "direct overridden" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: dependency: "direct main" description: @@ -649,18 +777,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "8c4967f8b7cb46dc914e178daa29813d83ae502e0529d7b0478330616a691ef7" + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e url: "https://pub.dev" source: hosted - version: "2.2.14" + version: "2.2.22" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -689,26 +817,26 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 url: "https://pub.dev" source: hosted - version: "11.3.1" + version: "12.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" url: "https://pub.dev" source: hosted - version: "12.0.13" + version: "13.0.1" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0 + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 url: "https://pub.dev" source: hosted - version: "9.4.5" + version: "9.4.7" permission_handler_html: dependency: transitive description: @@ -721,10 +849,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.3.0" permission_handler_windows: dependency: transitive description: @@ -737,10 +865,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "7.0.2" platform: dependency: transitive description: @@ -761,42 +889,58 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" posix: dependency: transitive description: name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.0.3" provider: dependency: "direct main" description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.5+1" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + rfc_6901: + dependency: transitive + description: + name: rfc_6901 + sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" + url: "https://pub.dev" + source: hosted + version: "0.2.1" rxdart: dependency: "direct main" description: @@ -805,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + url: "https://pub.dev" + source: hosted + version: "2.0.3" scrollable_positioned_list: dependency: "direct main" description: @@ -817,26 +969,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "7f172d1b06de5da47b6264c2692ee2ead20bbbc246690427cdb4fc301cd0c549" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -857,10 +1009,10 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" shared_preferences_windows: dependency: transitive description: @@ -873,18 +1025,18 @@ packages: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" shimmer: dependency: "direct main" description: @@ -902,66 +1054,58 @@ packages: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "4.2.0" source_helper: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.10" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.dev" - source: hosted - version: "7.0.0" + version: "1.10.2" sqflite: dependency: transitive description: name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2+2" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" url: "https://pub.dev" source: hosted - version: "2.5.4+6" + version: "2.5.6" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474" + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" sqflite_platform_interface: dependency: transitive description: @@ -974,66 +1118,58 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.3.0+3" + version: "3.4.0" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" - url: "https://pub.dev" - source: hosted - version: "0.7.3" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "0.7.9" typed_data: dependency: transitive description: @@ -1042,86 +1178,174 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.2" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "15.0.2" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484 + sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" url: "https://pub.dev" source: hosted - version: "1.2.8" + version: "1.4.0" wakelock_plus_platform_interface: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16" + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.3.0" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" win32: dependency: transitive description: name: win32 - sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69" + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.15.0" xdg_directories: dependency: transitive description: @@ -1134,18 +1358,18 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 710a7d4..828cbdb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: asmrapp description: "asmr one third party app." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -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.1.11 +version: 1.2.3 environment: - sdk: '>=3.2.3 <4.0.0' + sdk: ">=3.2.3 <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 @@ -30,7 +30,10 @@ environment: dependencies: flutter: sdk: flutter - freezed_annotation: ^2.4.1 + flutter_localizations: + sdk: flutter + intl: ^0.20.2 + freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 # The following adds the Cupertino Icons font to your application. @@ -41,45 +44,51 @@ dependencies: cached_network_image: ^3.3.0 logger: ^2.5.0 shimmer: ^3.0.0 - just_audio: ^0.9.36 - audio_session: ^0.1.18 - get_it: ^8.0.2 + just_audio: ^0.10.5 + just_audio_media_kit: ^2.1.0 + media_kit_libs_linux: ^1.2.0 + media_kit_libs_windows_audio: ^1.0.9 + audio_session: ^0.2.2 + get_it: ^9.2.0 audio_service: ^0.18.12 rxdart: ^0.28.0 path_provider: ^2.1.5 crypto: ^3.0.6 shared_preferences: ^2.2.2 flutter_cache_manager: ^3.4.1 - permission_handler: ^11.3.1 + permission_handler: ^12.0.1 + file_selector: ^1.0.3 scrollable_positioned_list: ^0.3.8 marquee: ^2.3.0 wakelock_plus: ^1.2.8 + url_launcher: ^6.3.2 + background_downloader: ^9.5.2 dev_dependencies: flutter_test: sdk: flutter build_runner: ^2.4.7 - freezed: ^2.4.6 + freezed: ^3.2.5 json_serializable: ^6.7.1 - flutter_launcher_icons: ^0.13.1 + flutter_launcher_icons: ^0.14.4 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your # package. See that file for information about deactivating specific lint # rules and activating additional ones. - flutter_lints: ^2.0.0 + flutter_lints: ^6.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + generate: true # To add assets to your application, add an assets section, like this: # assets: @@ -112,11 +121,5 @@ flutter: # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package -flutter_launcher_icons: - android: "ic_launcher" - ios: true - image_path: "assets/icon/icon.png" - min_sdk_android: 21 - dependency_overrides: - path: ^1.9.0 # 强制使用更高版本的 path 包 + path: ^1.9.0 # 强制使用更高版本的 path 包 diff --git a/test/widget_test.dart b/test/widget_test.dart index d2f885a..f76a716 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,11 +5,10 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. +import 'package:asmrapp/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:asmrapp/main.dart'; - void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 48de52b..1f7a72f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,18 @@ #include "generated_plugin_registrant.h" +#include +#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + MediaKitLibsWindowsAudioPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKitLibsWindowsAudioPluginCApi")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0e69e40..c5f2556 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + media_kit_libs_windows_audio permission_handler_windows + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index c04e20c..04173b0 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ