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