diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml
new file mode 100644
index 00000000..1e67d542
--- /dev/null
+++ b/.github/workflows/build-windows.yml
@@ -0,0 +1,91 @@
+name: Build Windows
+
+permissions:
+ contents: write
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Release tag (e.g. v1.0.0). Required if create_release is true."
+ required: false
+ type: string
+ create_release:
+ description: "Publish a GitHub Release with the Windows artifact"
+ required: true
+ default: false
+ type: boolean
+ prerelease:
+ description: "Mark the release as pre-release"
+ required: true
+ default: true
+ type: boolean
+
+env:
+ FLUTTER_VERSION: "3.44.5"
+
+jobs:
+ build-windows:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v7.0.0
+ with:
+ fetch-depth: 0
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@v2.23.0
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ channel: stable
+
+ - name: Enable Windows desktop
+ run: flutter config --enable-windows-desktop
+
+ - name: Get dependencies
+ run: flutter pub get
+
+ - name: Build Windows
+ run: flutter build windows --release
+
+ - name: Archive build output
+ run: |
+ $zipPath = Join-Path $env:RUNNER_TEMP "windows-release.zip"
+ if (Test-Path $zipPath) { Remove-Item $zipPath }
+ Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath $zipPath
+ echo "WINDOWS_ZIP=$zipPath" >> $env:GITHUB_ENV
+
+ - name: Upload Windows artifact
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: windows-build
+ path: ${{ env.WINDOWS_ZIP }}
+
+ release:
+ needs: build-windows
+ runs-on: ubuntu-latest
+ if: ${{ inputs.create_release == true }}
+
+ steps:
+ - name: Ensure tag provided
+ run: |
+ if [ -z "${{ inputs.tag }}" ]; then
+ echo "A tag name is required when create_release is enabled." >&2
+ exit 1
+ fi
+
+ - name: Download artifact
+ uses: actions/download-artifact@v8.0.1
+ with:
+ name: windows-build
+ path: dist
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v3.0.1
+ with:
+ tag_name: ${{ inputs.tag }}
+ name: ${{ inputs.tag }}
+ prerelease: ${{ inputs.prerelease }}
+ files: dist/windows-release.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 22f14eb1..47271f4c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -6,32 +6,32 @@ 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.44.5'
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v7.0.0
with:
- fetch-depth: 0 # 获取完整的 git 历史
+ fetch-depth: 0 # 获取完整的 git 历史
- name: Setup Java
- uses: actions/setup-java@v3
+ uses: actions/setup-java@v5.5.0
with:
- distribution: 'zulu'
- java-version: '17'
+ distribution: "zulu"
+ java-version: "21"
- name: Setup Flutter
- uses: subosito/flutter-action@v2
+ uses: subosito/flutter-action@v2.23.0
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- channel: 'stable'
+ channel: "stable"
- name: Get dependencies
run: flutter pub get
@@ -53,7 +53,7 @@ jobs:
run: flutter build appbundle --release
- name: Upload Android artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7.0.1
with:
name: android-build
path: |
@@ -64,14 +64,14 @@ jobs:
runs-on: macos-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v7.0.0
with:
- fetch-depth: 0 # 获取完整的 git 历史
+ fetch-depth: 0 # 获取完整的 git 历史
- name: Setup Flutter
- uses: subosito/flutter-action@v2
+ uses: subosito/flutter-action@v2.23.0
with:
- flutter-version: '3.27.0'
+ flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
- name: Get dependencies
@@ -88,22 +88,53 @@ jobs:
zip -qq -r -9 app-release.ipa Payload
- name: Upload iOS artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7.0.1
with:
name: ios-build
path: build/ios/iphoneos/app-release.ipa
+ build-windows:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v7.0.0
+ with:
+ fetch-depth: 0 # 获取完整的 git 历史
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@v2.23.0
+ 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@v7.0.1
+ 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
+ - uses: actions/checkout@v7.0.0
with:
- fetch-depth: 0 # 获取完整的 git 历史
+ fetch-depth: 0 # 获取完整的 git 历史
- name: Download artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8.0.1
with:
path: ./dist/
merge-multiple: true
@@ -135,47 +166,50 @@ jobs:
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v3.0.1
with:
prerelease: true
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)
-
+ - Minimum Android SDK: 24 (Android 7.0)
+ - Target Android SDK: 36 (Android 16)
+
> **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 }}
- name: Upload artifacts if not release
if: startsWith(github.ref, 'refs/tags/') == false
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7.0.1
with:
name: everything
path: dist/
diff --git a/.gitignore b/.gitignore
index bd805d8f..d651c53f 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 27a899eb..fd3dbf22 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 bb1a452d..d26830db 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 00000000..a42cdba4
--- /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/analysis_options.yaml b/analysis_options.yaml
index 1d053857..10e2c3e8 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -21,6 +21,8 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
+ # Public named parameters intentionally initialize private fields.
+ prefer_initializing_formals: false
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
@@ -31,4 +33,4 @@ analyzer:
- "**/*.g.dart"
- "**/*.freezed.dart"
errors:
- invalid_annotation_target: ignore
\ No newline at end of file
+ invalid_annotation_target: ignore
diff --git a/android/.gitignore b/android/.gitignore
index 55afd919..fcf9965e 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -1,5 +1,7 @@
gradle-wrapper.jar
-/.gradle
+/.cxx/
+/.gradle/
+/build/
/captures/
/gradlew
/gradlew.bat
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d2a7e5bc..9a27add7 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -1,6 +1,5 @@
plugins {
id "com.android.application"
- id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
@@ -17,12 +16,8 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
@@ -47,15 +42,23 @@ android {
buildTypes {
release {
- signingConfig signingConfigs.release
+ signingConfig keystorePropertiesFile.exists()
+ ? signingConfigs.release
+ : signingConfigs.debug
// 如果还有问题,可以临时禁用混淆
minifyEnabled true // 改为 false 可以禁用混淆
shrinkResources true // 改为 false 可以禁用资源压缩
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
flutter {
source = "../.."
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 17e651eb..f17d3be1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,8 +1,12 @@
+
+
+
+
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 14.0
diff --git a/ios/Podfile b/ios/Podfile
index d97f17e2..cbb8dfb3 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,5 +1,4 @@
-# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+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 58ef42a4..4f037a1d 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 d36b1fab..d0d98aa1 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 318a3f4c..558b4650 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 0c4bddc7..e5ee72b6 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 f4c893b5..8fc0f846 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 525b4f1a..34150674 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 c051f63a..aeac6a98 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/ios/Runner/Info.plist b/ios/Runner/Info.plist
index c6427475..ced6303f 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -24,6 +24,16 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ NSAppTransportSecurity
+
+
+ NSAllowsArbitraryLoads
+
+
+ UIBackgroundModes
+
+ audio
+
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
diff --git a/l10n.yaml b/l10n.yaml
new file mode 100644
index 00000000..bcf09d34
--- /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 00000000..c9627cb9
--- /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 00000000..443fc8cf
--- /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 00000000..95c9bfc8
--- /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/text_encoding_decoder.dart b/lib/common/utils/text_encoding_decoder.dart
new file mode 100644
index 00000000..f7294a40
--- /dev/null
+++ b/lib/common/utils/text_encoding_decoder.dart
@@ -0,0 +1,111 @@
+import 'dart:convert';
+
+import 'package:charset/charset.dart';
+
+/// Decodes text files served by the API without assuming that every file is
+/// UTF-8. Older Japanese text files are commonly encoded as Shift_JIS.
+class TextEncodingDecoder {
+ const TextEncodingDecoder._();
+
+ static String decode(List bytes, {String? contentType}) {
+ if (bytes.isEmpty) return '';
+
+ // Check BOMs before the HTTP header. A BOM is the strongest signal and
+ // also gives us the byte order for UTF-16/UTF-32.
+ if (_startsWith(bytes, const [0x00, 0x00, 0xfe, 0xff]) ||
+ _startsWith(bytes, const [0xff, 0xfe, 0x00, 0x00])) {
+ return _withoutBom(utf32.decode(bytes));
+ }
+ if (_startsWith(bytes, const [0xfe, 0xff]) ||
+ _startsWith(bytes, const [0xff, 0xfe])) {
+ return _withoutBom(utf16.decode(bytes));
+ }
+ if (_startsWith(bytes, const [0xef, 0xbb, 0xbf])) {
+ return utf8.decode(bytes.sublist(3));
+ }
+
+ final declaredCharset = _charsetFromContentType(contentType);
+ if (declaredCharset != null) {
+ final declaredResult = _tryDecodeDeclared(bytes, declaredCharset);
+ if (declaredResult != null) return _withoutBom(declaredResult);
+ }
+
+ // UTF-8 must be attempted strictly. Using allowMalformed here hides a
+ // wrong encoding by replacing Japanese characters with U+FFFD.
+ final utf8Result = _tryDecode(utf8, bytes);
+ if (utf8Result != null) return _withoutBom(utf8Result);
+
+ // The service primarily hosts Japanese works, so prefer the two common
+ // Japanese legacy encodings before the broader GBK fallback.
+ for (final encoding in [shiftJis, eucJp, gbk]) {
+ final result = _tryDecode(encoding, bytes);
+ if (result != null) return _withoutBom(result);
+ }
+
+ // Preserve every byte as a last resort instead of returning replacement
+ // characters or failing the entire preview.
+ return latin1.decode(bytes);
+ }
+
+ static String? _charsetFromContentType(String? contentType) {
+ if (contentType == null || contentType.isEmpty) return null;
+ final match = RegExp(
+ r'''charset\s*=\s*["']?([^;\s"']+)''',
+ caseSensitive: false,
+ ).firstMatch(contentType);
+ return match?.group(1)?.trim().toLowerCase();
+ }
+
+ static String? _tryDecodeDeclared(List bytes, String charsetName) {
+ final normalized = charsetName.replaceAll('_', '-');
+
+ try {
+ switch (normalized) {
+ case 'utf-16le':
+ return const Utf16Decoder().decodeUtf16Le(bytes);
+ case 'utf-16be':
+ return const Utf16Decoder().decodeUtf16Be(bytes);
+ case 'utf-32le':
+ return const Utf32Decoder().decodeUtf32Le(bytes);
+ case 'utf-32be':
+ return const Utf32Decoder().decodeUtf32Be(bytes);
+ case 'cp932':
+ case 'ms932':
+ case 'windows-31j':
+ case 'x-sjis':
+ case 'sjis':
+ return shiftJis.decode(bytes);
+ }
+ } on FormatException {
+ return null;
+ } on ArgumentError {
+ return null;
+ }
+
+ return _tryDecode(Charset.getByName(normalized), bytes);
+ }
+
+ static String? _tryDecode(Encoding? encoding, List bytes) {
+ if (encoding == null) return null;
+ try {
+ return encoding.decode(bytes);
+ } on FormatException {
+ return null;
+ } on ArgumentError {
+ return null;
+ }
+ }
+
+ static bool _startsWith(List bytes, List prefix) {
+ if (bytes.length < prefix.length) return false;
+ for (var index = 0; index < prefix.length; index++) {
+ if (bytes[index] != prefix[index]) return false;
+ }
+ return true;
+ }
+
+ static String _withoutBom(String value) {
+ if (value.startsWith('\ufeff')) return value.substring(1);
+ return value;
+ }
+}
diff --git a/lib/common/utils/work_localizations.dart b/lib/common/utils/work_localizations.dart
new file mode 100644
index 00000000..765c0956
--- /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 ab973e39..258b5e2f 100644
--- a/lib/core/audio/audio_player_handler.dart
+++ b/lib/core/audio/audio_player_handler.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:asmrapp/core/audio/events/playback_event_hub.dart';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
@@ -6,23 +8,65 @@ import 'package:asmrapp/utils/logger.dart';
class AudioPlayerHandler extends BaseAudioHandler {
final AudioPlayer _player;
final PlaybackEventHub _eventHub;
+ final Future Function() _onPlay;
+ final Future Function() _onPause;
+ final Future Function() _onStop;
+ final Future Function(Duration position) _onSeek;
+ final Future Function() _onPrevious;
+ final Future Function() _onNext;
+ final List _subscriptions = [];
+
+ late PlayerState _latestState;
+ Duration _latestPosition = Duration.zero;
+ Duration _latestBufferedPosition = Duration.zero;
- AudioPlayerHandler(this._player, this._eventHub) {
+ AudioPlayerHandler({
+ required AudioPlayer player,
+ required PlaybackEventHub eventHub,
+ required Future Function() onPlay,
+ required Future Function() onPause,
+ required Future Function() onStop,
+ required Future Function(Duration position) onSeek,
+ required Future Function() onPrevious,
+ required Future Function() onNext,
+ }) : _player = player,
+ _eventHub = eventHub,
+ _onPlay = onPlay,
+ _onPause = onPause,
+ _onStop = onStop,
+ _onSeek = onSeek,
+ _onPrevious = onPrevious,
+ _onNext = onNext {
AppLogger.debug('AudioPlayerHandler 初始化');
-
- // 改为监听 EventHub
- _eventHub.playbackState.listen((event) {
- final state = PlaybackState(
+ _latestState = _player.playerState;
+
+ _subscriptions.add(
+ _eventHub.playbackState.listen((event) {
+ _latestState = event.state;
+ _latestPosition = event.position;
+ _broadcastState();
+ }),
+ );
+ // Spotube と同様、position/buffer の更新でも OS 側の状態を同期する。
+ _subscriptions.add(
+ _eventHub.playbackProgress.listen((event) {
+ _latestPosition = event.position;
+ _latestBufferedPosition = event.bufferedPosition;
+ _broadcastState();
+ }),
+ );
+ }
+
+ void _broadcastState() {
+ playbackState.add(
+ PlaybackState(
controls: [
MediaControl.skipToPrevious,
- event.state.playing ? MediaControl.pause : MediaControl.play,
+ _latestState.playing ? MediaControl.pause : MediaControl.play,
MediaControl.skipToNext,
+ MediaControl.stop,
],
- systemActions: const {
- MediaAction.seek,
- MediaAction.seekForward,
- MediaAction.seekBackward,
- },
+ systemActions: const {MediaAction.seek},
androidCompactActionIndices: const [0, 1, 2],
processingState: const {
ProcessingState.idle: AudioProcessingState.idle,
@@ -30,38 +74,56 @@ class AudioPlayerHandler extends BaseAudioHandler {
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
- }[event.state.processingState]!,
- playing: event.state.playing,
- updatePosition: event.position,
- bufferedPosition: _player.bufferedPosition,
+ }[_latestState.processingState]!,
+ playing: _latestState.playing,
+ updatePosition: _latestPosition,
+ bufferedPosition: _latestBufferedPosition,
speed: _player.speed,
- queueIndex: 0,
- );
- playbackState.add(state);
- });
+ queueIndex: _player.currentIndex,
+ ),
+ );
}
@override
Future play() async {
AppLogger.debug('AudioHandler: 播放命令');
- _player.play();
+ await _onPlay();
}
@override
Future pause() async {
AppLogger.debug('AudioHandler: 暂停命令');
- _player.pause();
+ await _onPause();
}
@override
Future seek(Duration position) async {
AppLogger.debug('AudioHandler: 跳转命令 position=$position');
- await _player.seek(position);
+ await _onSeek(position);
}
@override
Future stop() async {
AppLogger.debug('AudioHandler: 停止命令');
- await _player.stop();
+ await _onStop();
+ }
+
+ @override
+ Future skipToPrevious() async {
+ AppLogger.debug('AudioHandler: 上一曲命令');
+ await _onPrevious();
+ }
+
+ @override
+ Future skipToNext() async {
+ AppLogger.debug('AudioHandler: 下一曲命令');
+ await _onNext();
+ }
+
+ Future disposeHandler() async {
+ for (final subscription in _subscriptions) {
+ await subscription.cancel();
+ }
+ _subscriptions.clear();
}
}
diff --git a/lib/core/audio/audio_player_service.dart b/lib/core/audio/audio_player_service.dart
index 33deb0e6..589fb8f1 100644
--- a/lib/core/audio/audio_player_service.dart
+++ b/lib/core/audio/audio_player_service.dart
@@ -1,56 +1,55 @@
import 'dart:async';
+
import 'package:asmrapp/utils/logger.dart';
-import 'package:just_audio/just_audio.dart';
import 'package:audio_session/audio_session.dart';
+import 'package:just_audio/just_audio.dart';
+
+import './controllers/playback_controller.dart';
+import './events/playback_event_hub.dart';
import './i_audio_player_service.dart';
import './models/audio_track_info.dart';
import './models/playback_context.dart';
import './notification/audio_notification_service.dart';
+import './state/playback_state_manager.dart';
import './storage/i_playback_state_repository.dart';
import './utils/audio_error_handler.dart';
-import './state/playback_state_manager.dart';
-import './controllers/playback_controller.dart';
-import './events/playback_event_hub.dart';
class AudioPlayerService implements IAudioPlayerService {
late final AudioPlayer _player;
late final AudioNotificationService _notificationService;
- late final ConcatenatingAudioSource _playlist;
late final PlaybackStateManager _stateManager;
late final PlaybackController _playbackController;
final PlaybackEventHub _eventHub;
final IPlaybackStateRepository _stateRepository;
+ int _contextRequest = 0;
+ bool _shouldPlay = false;
+ bool _disposed = false;
- AudioPlayerService._internal({
+ AudioPlayerService._({
required PlaybackEventHub eventHub,
required IPlaybackStateRepository stateRepository,
}) : _eventHub = eventHub,
- _stateRepository = stateRepository {
- _init();
- }
+ _stateRepository = stateRepository;
- static AudioPlayerService? _instance;
-
- factory AudioPlayerService({
+ static Future create({
required PlaybackEventHub eventHub,
required IPlaybackStateRepository stateRepository,
- }) {
- _instance ??= AudioPlayerService._internal(
+ }) async {
+ final service = AudioPlayerService._(
eventHub: eventHub,
stateRepository: stateRepository,
);
- return _instance!;
+ await service._init();
+ return service;
}
Future _init() async {
try {
- _player = AudioPlayer();
- _notificationService = AudioNotificationService(
- _player,
- _eventHub,
+ _player = AudioPlayer(
+ userAgent: 'Yuro audio player',
+ // 壊れた URL が 1 件あってもプレイリスト全体を停止させない。
+ maxSkipsOnError: 3,
);
- _playlist = ConcatenatingAudioSource(children: []);
-
_stateManager = PlaybackStateManager(
player: _player,
stateRepository: _stateRepository,
@@ -60,39 +59,50 @@ class AudioPlayerService implements IAudioPlayerService {
_playbackController = PlaybackController(
player: _player,
stateManager: _stateManager,
- playlist: _playlist,
);
+ _stateManager.initStateListeners();
+
+ _notificationService = AudioNotificationService(
+ player: _player,
+ eventHub: _eventHub,
+ onPlay: resume,
+ onPause: pause,
+ onStop: stop,
+ onSeek: seek,
+ onPrevious: previous,
+ onNext: next,
+ );
+ await _notificationService.init();
+
+ // すべての音声プラグイン初期化後に、アプリの設定を最後に適用する。
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
- await _notificationService.init();
- _stateManager.initStateListeners();
await restorePlaybackState();
} catch (e, stack) {
- AudioErrorHandler.handleError(
- AudioErrorType.init,
- '音频播放器初始化',
- e,
- stack,
- );
- AudioErrorHandler.throwError(
- AudioErrorType.init,
- '音频播放器初始化',
- e,
- );
+ AudioErrorHandler.handleError(AudioErrorType.init, '音频播放器初始化', e, stack);
+ AudioErrorHandler.throwError(AudioErrorType.init, '音频播放器初始化', e);
}
}
// 基础播放控制
@override
- Future pause() => _playbackController.pause();
+ Future pause() async {
+ _shouldPlay = false;
+ await _playbackController.pause();
+ }
@override
- Future resume() => _playbackController.play();
+ Future resume() async {
+ _shouldPlay = true;
+ await _playbackController.play();
+ }
@override
Future stop() async {
+ _contextRequest++;
+ _shouldPlay = false;
await _playbackController.stop();
_stateManager.clearState();
}
@@ -109,9 +119,11 @@ class AudioPlayerService implements IAudioPlayerService {
// 上下文管理
@override
Future playWithContext(PlaybackContext context) async {
- await _playbackController.setPlaybackContext(context);
- // 添加自动播放
- await resume();
+ final request = ++_contextRequest;
+ _shouldPlay = true;
+ final applied = await _playbackController.setPlaybackContext(context);
+ if (!applied || request != _contextRequest || !_shouldPlay) return;
+ await _playbackController.play();
}
// 状态访问
@@ -130,14 +142,16 @@ class AudioPlayerService implements IAudioPlayerService {
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('保存的播放列表为空,跳过恢复');
@@ -161,19 +175,21 @@ class AudioPlayerService implements IAudioPlayerService {
AppLogger.error('设置播放上下文失败,跳过状态恢复', e);
}
} catch (e, stack) {
- AudioErrorHandler.handleError(
- AudioErrorType.init,
- '恢复播放状态',
- e,
- stack,
- );
+ AudioErrorHandler.handleError(AudioErrorType.init, '恢复播放状态', e, stack);
rethrow;
}
}
@override
Future dispose() async {
- _player.dispose();
- _notificationService.dispose();
+ if (_disposed) return;
+ _disposed = true;
+ _shouldPlay = false;
+ _contextRequest++;
+ await _stateManager.saveState();
+ await _playbackController.stop();
+ await _stateManager.dispose();
+ await _notificationService.dispose();
+ await _player.dispose();
}
}
diff --git a/lib/core/audio/cache/audio_cache_manager.dart b/lib/core/audio/cache/audio_cache_manager.dart
index 9ccea7bc..21191de7 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,10 @@ class AudioCacheManager {
/// 创建缓存音频源
static AudioSource _createCachingSource(String url, File cacheFile) {
- return LockCachingAudioSource(
- Uri.parse(url),
- cacheFile: cacheFile
- );
+ // LockCachingAudioSource is the just_audio API that supports a custom
+ // cache file. Its experimental annotation is expected here.
+ // ignore: experimental_member_use
+ return LockCachingAudioSource(Uri.parse(url), cacheFile: cacheFile);
}
/// 检查缓存是否有效
@@ -112,9 +112,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 +153,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 4f772c48..21420a0d 100644
--- a/lib/core/audio/controllers/playback_controller.dart
+++ b/lib/core/audio/controllers/playback_controller.dart
@@ -1,32 +1,65 @@
+import 'dart:async';
+
import 'package:asmrapp/utils/logger.dart';
import 'package:just_audio/just_audio.dart';
import '../models/playback_context.dart';
+import '../models/play_mode.dart';
import '../state/playback_state_manager.dart';
import '../utils/playlist_builder.dart';
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;
+ int _contextGeneration = 0;
+ bool _isSwitchingContext = false;
+ String? _activeContextKey;
+ Future? _activeContextFuture;
PlaybackController({
required AudioPlayer player,
required PlaybackStateManager stateManager,
- required ConcatenatingAudioSource playlist,
}) : _player = player,
- _stateManager = stateManager,
- _playlist = playlist;
+ _stateManager = stateManager;
// 基础播放控制
- Future play() => _player.play();
+ bool get isSwitchingContext => _isSwitchingContext;
+
+ Future play() async {
+ if (_isSwitchingContext || _stateManager.currentContext == null) return;
+
+ if (_player.processingState == ProcessingState.completed) {
+ await _player.seek(Duration.zero, index: _player.currentIndex);
+ }
+
+ // just_audio の play() は一時停止/完了まで Future が完了しない。
+ // コマンド自体は即座に返し、再生中エラーは errorStream とここで回収する。
+ unawaited(
+ _player.play().catchError((Object error, StackTrace stackTrace) {
+ AppLogger.error('播放命令失败', error, stackTrace);
+ }),
+ );
+ }
+
Future pause() => _player.pause();
- Future stop() => _player.stop();
- Future seek(Duration position, {int? index}) => _player.seek(position, index: index);
-
+
+ Future stop() async {
+ cancelPendingContext();
+ await _player.stop();
+ }
+
+ Future seek(Duration position, {int? index}) async {
+ if (_isSwitchingContext || _stateManager.currentContext == null) return;
+
+ final duration = _player.duration;
+ final target = position < Duration.zero
+ ? Duration.zero
+ : duration != null && position > duration
+ ? duration
+ : position;
+ await _player.seek(target, index: index);
+ }
+
// 播放列表控制
Future next() async {
try {
@@ -36,6 +69,8 @@ class PlaybackController {
return;
}
+ if (_isSwitchingContext) return;
+
if (_player.hasNext) {
AppLogger.debug('执行切换到下一曲');
await _player.seekToNext();
@@ -44,12 +79,7 @@ class PlaybackController {
}
} catch (e, stack) {
AppLogger.error('切换下一曲失败', e, stack);
- AudioErrorHandler.handleError(
- AudioErrorType.playback,
- '切换下一曲',
- e,
- stack,
- );
+ AudioErrorHandler.handleError(AudioErrorType.playback, '切换下一曲', e, stack);
}
}
@@ -61,37 +91,62 @@ class PlaybackController {
return;
}
+ if (_isSwitchingContext) return;
+
if (_player.hasPrevious) {
- final previousFile = _stateManager.currentContext!.getPreviousFile();
- AppLogger.debug('获取到上一个文件: ${previousFile?.title}');
- if (previousFile != null) {
- _updateTrackAndContext(
- previousFile,
- _stateManager.currentContext!.work
- );
- AppLogger.debug('执行切换到上一曲');
- await _player.seekToPrevious();
- }
+ AppLogger.debug('执行切换到上一曲');
+ await _player.seekToPrevious();
} else {
AppLogger.debug('没有上一曲可切换');
}
} catch (e, stack) {
AppLogger.error('切换上一曲失败', e, stack);
- AudioErrorHandler.handleError(
- AudioErrorType.playback,
- '切换上一曲',
- e,
- stack,
- );
+ AudioErrorHandler.handleError(AudioErrorType.playback, '切换上一曲', e, stack);
}
}
// 播放上下文设置
- Future setPlaybackContext(PlaybackContext context, {Duration? initialPosition}) async {
+ Future setPlaybackContext(
+ PlaybackContext context, {
+ Duration? initialPosition,
+ }) {
+ final key = [
+ context.work.id,
+ context.currentFile.mediaDownloadUrl,
+ context.playMode.name,
+ initialPosition?.inMilliseconds ?? 0,
+ ].join('|');
+ if (_isSwitchingContext &&
+ key == _activeContextKey &&
+ _activeContextFuture != null) {
+ return _activeContextFuture!;
+ }
+
+ final future = _setPlaybackContext(
+ context,
+ initialPosition: initialPosition,
+ );
+ _activeContextKey = key;
+ _activeContextFuture = future;
+ return future;
+ }
+
+ Future _setPlaybackContext(
+ PlaybackContext context, {
+ Duration? initialPosition,
+ }) async {
+ final generation = ++_contextGeneration;
+ _isSwitchingContext = true;
+ _stateManager.beginContextTransition();
+
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,45 +154,52 @@ class PlaybackController {
AppLogger.error('播放上下文验证失败', e);
rethrow;
}
-
- // 1. 先停止当前播放
- AppLogger.debug('停止当前播放');
- await _player.stop();
-
- // 2. 等待播放器就绪
- AppLogger.debug('暂停播放器');
+
+ // setAudioSources 自身が旧ロードを安全に中断する。stop() を挟むと
+ // idle/旧 position が余分に流れるため、再生意図だけを止める。
await _player.pause();
-
- // 3. 更新上下文
- AppLogger.debug('更新播放上下文');
- _stateManager.updateContext(context);
-
- // 4. 设置新的播放源
+ if (generation != _contextGeneration) return false;
+
+ // ロード成功までは context を公開しない。
AppLogger.debug('设置播放源: 初始位置=${initialPosition?.inMilliseconds}ms');
- try {
- await PlaylistBuilder.setPlaylistSource(
- player: _player,
- playlist: _playlist,
- files: context.playlist,
- initialIndex: context.currentIndex,
- initialPosition: initialPosition ?? Duration.zero,
- );
- } catch (e, stack) {
- AppLogger.error('设置播放源失败', e, stack);
- rethrow;
+ final loadedDuration = await PlaylistBuilder.setPlaylistSource(
+ player: _player,
+ files: context.playlist,
+ initialIndex: context.currentIndex,
+ initialPosition: initialPosition ?? Duration.zero,
+ );
+ if (generation != _contextGeneration) return false;
+
+ await _applyPlayMode(context.playMode);
+ if (generation != _contextGeneration) return false;
+
+ // 保存位置が曲末以上なら、完了状態を復元せず先頭から再開可能にする。
+ if (initialPosition != null &&
+ loadedDuration != null &&
+ initialPosition >= loadedDuration) {
+ await _player.seek(Duration.zero, index: context.currentIndex);
+ if (generation != _contextGeneration) return false;
}
- // 5. 等待播放器准备完成
- // 删掉,会导致播放器索引回到0
- // AppLogger.debug('等待播放器加载');
- // await _player.load();
-
- // 6. 更新轨道信息
- AppLogger.debug('更新轨道信息');
- _updateTrackAndContext(context.currentFile, context.work);
-
+ _stateManager.commitContextTransition(
+ context,
+ duration: _player.duration ?? loadedDuration,
+ );
+
AppLogger.debug('播放上下文设置完成');
+ return true;
+ } on PlayerInterruptedException catch (e, stack) {
+ if (generation != _contextGeneration) {
+ AppLogger.debug('旧的播放源加载已被新的请求取消');
+ return false;
+ }
+ _stateManager.abortContextTransition();
+ AppLogger.error('设置播放源被中断', e, stack);
+ rethrow;
} catch (e, stack) {
+ if (generation == _contextGeneration) {
+ _stateManager.abortContextTransition();
+ }
AppLogger.error('设置播放上下文失败', e, stack);
AudioErrorHandler.handleError(
AudioErrorType.context,
@@ -146,12 +208,27 @@ class PlaybackController {
stack,
);
rethrow;
+ } finally {
+ if (generation == _contextGeneration) {
+ _isSwitchingContext = false;
+ _activeContextKey = null;
+ _activeContextFuture = null;
+ }
}
}
- // 私有辅助方法
- void _updateTrackAndContext(Child file, Work work) {
- AppLogger.debug('更新轨道和上下文: file=${file.title}');
- _stateManager.updateTrackAndContext(file, work);
+ void cancelPendingContext() {
+ _contextGeneration++;
+ _isSwitchingContext = false;
+ _activeContextKey = null;
+ _activeContextFuture = null;
+ }
+
+ Future _applyPlayMode(PlayMode playMode) {
+ return _player.setLoopMode(switch (playMode) {
+ PlayMode.single => LoopMode.one,
+ PlayMode.loop => LoopMode.all,
+ PlayMode.sequence => LoopMode.off,
+ });
}
-}
\ 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 cc48b0aa..46dbebbb 100644
--- a/lib/core/audio/events/playback_event.dart
+++ b/lib/core/audio/events/playback_event.dart
@@ -12,13 +12,22 @@ class PlaybackStateEvent extends PlaybackEvent {
final PlayerState state;
final Duration position;
final Duration? duration;
- PlaybackStateEvent(this.state, this.position, this.duration);
+ final int revision;
+
+ PlaybackStateEvent(
+ this.state,
+ this.position,
+ this.duration, {
+ required this.revision,
+ });
}
/// 播放上下文事件
class PlaybackContextEvent extends PlaybackEvent {
final PlaybackContext context;
- PlaybackContextEvent(this.context);
+ final int revision;
+
+ PlaybackContextEvent(this.context, {required this.revision});
}
/// 音轨变更事件
@@ -26,7 +35,9 @@ class TrackChangeEvent extends PlaybackEvent {
final AudioTrackInfo track;
final Child file;
final Work work;
- TrackChangeEvent(this.track, this.file, this.work);
+ final int revision;
+
+ TrackChangeEvent(this.track, this.file, this.work, {required this.revision});
}
/// 播放错误事件
@@ -46,8 +57,16 @@ class PlaybackCompletedEvent extends PlaybackEvent {
/// 播放进度事件
class PlaybackProgressEvent extends PlaybackEvent {
final Duration position;
- final Duration? bufferedPosition;
- PlaybackProgressEvent(this.position, this.bufferedPosition);
+ final Duration bufferedPosition;
+ final Duration? duration;
+ final int revision;
+
+ PlaybackProgressEvent(
+ this.position,
+ this.bufferedPosition,
+ this.duration, {
+ required this.revision,
+ });
}
/// 添加初始状态相关事件
@@ -56,5 +75,19 @@ class RequestInitialStateEvent extends PlaybackEvent {}
class InitialStateEvent extends PlaybackEvent {
final AudioTrackInfo? track;
final PlaybackContext? context;
- InitialStateEvent(this.track, this.context);
-}
\ No newline at end of file
+ final PlayerState state;
+ final Duration position;
+ final Duration bufferedPosition;
+ final Duration? duration;
+ final int revision;
+
+ InitialStateEvent({
+ required this.track,
+ required this.context,
+ required this.state,
+ required this.position,
+ required this.bufferedPosition,
+ required this.duration,
+ required this.revision,
+ });
+}
diff --git a/lib/core/audio/events/playback_event_hub.dart b/lib/core/audio/events/playback_event_hub.dart
index 90fc9a15..c9f25dd4 100644
--- a/lib/core/audio/events/playback_event_hub.dart
+++ b/lib/core/audio/events/playback_event_hub.dart
@@ -9,30 +9,36 @@ class PlaybackEventHub {
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);
-
+ .distinct(
+ (prev, next) =>
+ prev.revision == next.revision &&
+ prev.position == next.position &&
+ prev.bufferedPosition == next.bufferedPosition &&
+ prev.duration == next.duration,
+ );
+
late final Stream errors = _eventSubject
.whereType();
// 添加新的事件流
late final Stream initialState = _eventSubject
.whereType();
-
- late final Stream requestInitialState = _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 1c6685df..0766c84a 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/audio_track_info.dart b/lib/core/audio/models/audio_track_info.dart
index 0a709700..dd791520 100644
--- a/lib/core/audio/models/audio_track_info.dart
+++ b/lib/core/audio/models/audio_track_info.dart
@@ -12,4 +12,14 @@ class AudioTrackInfo {
required this.url,
this.duration,
});
+
+ AudioTrackInfo copyWithDuration(Duration? duration) {
+ return AudioTrackInfo(
+ title: title,
+ artist: artist,
+ coverUrl: coverUrl,
+ url: url,
+ duration: duration,
+ );
+ }
}
diff --git a/lib/core/audio/models/file_path.dart b/lib/core/audio/models/file_path.dart
index 9dbbf775..25363557 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 e549b85b..8dcc92d8 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 4508b12e..ff86234f 100644
--- a/lib/core/audio/models/playback_context.dart
+++ b/lib/core/audio/models/playback_context.dart
@@ -1,12 +1,22 @@
+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 {
+ static const _supportedExtensions = {
+ 'mp3',
+ 'wav',
+ 'm4a',
+ 'aac',
+ 'flac',
+ 'ogg',
+ 'opus',
+ };
+
final Work work;
final Files files;
final Child currentFile;
@@ -16,12 +26,9 @@ class PlaybackContext {
void validate() {
if (playlist.isEmpty) {
- throw AudioError(
- AudioErrorType.state,
- '无效的播放列表状态:播放列表为空',
- );
+ throw AudioError(AudioErrorType.state, '无效的播放列表状态:播放列表为空');
}
-
+
if (currentIndex < 0 || currentIndex >= playlist.length) {
throw AudioError(
AudioErrorType.state,
@@ -30,10 +37,12 @@ class PlaybackContext {
}
if (!playlist.contains(currentFile)) {
- throw AudioError(
- AudioErrorType.state,
- '当前文件不在播放列表中',
- );
+ throw AudioError(AudioErrorType.state, '当前文件不在播放列表中');
+ }
+
+ if (currentFile.mediaDownloadUrl == null ||
+ currentFile.mediaDownloadUrl!.isEmpty) {
+ throw AudioError(AudioErrorType.state, '当前文件没有可用的播放地址');
}
}
@@ -55,8 +64,8 @@ class PlaybackContext {
PlayMode playMode = PlayMode.sequence,
}) {
final playlist = _getPlaylistFromSameDirectory(currentFile, files);
- final currentIndex = playlist.indexWhere((file) => file.title == currentFile.title);
-
+ final currentIndex = playlist.indexOf(currentFile);
+
return PlaybackContext._(
work: work,
files: files,
@@ -68,7 +77,10 @@ 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,25 +88,29 @@ class PlaybackContext {
// 获取当前文件的扩展名
final extension = currentFile.title?.split('.').last.toLowerCase();
// AppLogger.debug('当前文件扩展名: $extension');
-
- if (extension != 'mp3' && extension != 'wav') {
+
+ if (!_supportedExtensions.contains(extension)) {
AppLogger.debug('不支持的文件类型: $extension');
return [];
}
// 使用 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) &&
+ file.mediaDownloadUrl?.isNotEmpty == true,
+ )
+ .toList();
+
// AppLogger.debug('找到 ${playlist.length} 个可播放文件:');
// for (var file in playlist) {
// AppLogger.debug('- [${file.type}] ${file.title} (URL: ${file.mediaDownloadUrl != null ? '有' : '无'})');
// }
-
+
return playlist;
}
@@ -107,10 +123,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 +139,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 +184,12 @@ 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 3d6388e8..47dd5e6a 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/notification/audio_notification_service.dart b/lib/core/audio/notification/audio_notification_service.dart
index b2680737..74c79921 100644
--- a/lib/core/audio/notification/audio_notification_service.dart
+++ b/lib/core/audio/notification/audio_notification_service.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:asmrapp/core/audio/events/playback_event_hub.dart';
import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
@@ -9,23 +11,54 @@ import '../audio_player_handler.dart';
class AudioNotificationService {
final AudioPlayer _player;
final PlaybackEventHub _eventHub;
- AudioHandler? _audioHandler;
+ final Future Function() _onPlay;
+ final Future Function() _onPause;
+ final Future Function() _onStop;
+ final Future Function(Duration position) _onSeek;
+ final Future Function() _onPrevious;
+ final Future Function() _onNext;
+ AudioPlayerHandler? _audioHandler;
final _mediaItem = BehaviorSubject();
+ final List _subscriptions = [];
- AudioNotificationService(
- this._player,
- this._eventHub,
- );
+ AudioNotificationService({
+ required AudioPlayer player,
+ required PlaybackEventHub eventHub,
+ required Future Function() onPlay,
+ required Future Function() onPause,
+ required Future Function() onStop,
+ required Future Function(Duration position) onSeek,
+ required Future Function() onPrevious,
+ required Future Function() onNext,
+ }) : _player = player,
+ _eventHub = eventHub,
+ _onPlay = onPlay,
+ _onPause = onPause,
+ _onStop = onStop,
+ _onSeek = onSeek,
+ _onPrevious = onPrevious,
+ _onNext = onNext;
Future init() async {
try {
- _audioHandler = await AudioService.init(
- builder: () => AudioPlayerHandler(_player, _eventHub),
+ _audioHandler = await AudioService.init(
+ builder: () => AudioPlayerHandler(
+ player: _player,
+ eventHub: _eventHub,
+ onPlay: _onPlay,
+ onPause: _onPause,
+ onStop: _onStop,
+ onSeek: _onSeek,
+ onPrevious: _onPrevious,
+ onNext: _onNext,
+ ),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.asmrapp.audio',
androidNotificationChannelName: 'ASMR One 播放器',
- androidNotificationOngoing: true,
- androidStopForegroundOnPause: true,
+ androidNotificationOngoing: false,
+ // Android 12+ でバックグラウンドから foreground service を
+ // 再起動できず再生が止まるケースを避ける。
+ androidStopForegroundOnPause: false,
),
);
@@ -39,28 +72,33 @@ class AudioNotificationService {
void _setupEventListeners() {
// 监听轨道变更事件来更新媒体信息
- _eventHub.trackChange.listen((event) {
- updateMetadata(event.track);
- });
+ _subscriptions.add(
+ _eventHub.trackChange.listen((event) {
+ updateMetadata(event.track);
+ }),
+ );
}
void updateMetadata(AudioTrackInfo trackInfo) {
+ final coverUri = Uri.tryParse(trackInfo.coverUrl);
final mediaItem = MediaItem(
id: trackInfo.url,
title: trackInfo.title,
artist: trackInfo.artist,
- artUri: Uri.parse(trackInfo.coverUrl),
+ artUri: coverUri?.hasScheme == true ? coverUri : null,
duration: trackInfo.duration,
);
_mediaItem.add(mediaItem);
- if (_audioHandler != null) {
- (_audioHandler as BaseAudioHandler).mediaItem.add(mediaItem);
- }
+ _audioHandler?.mediaItem.add(mediaItem);
}
Future dispose() async {
- await _audioHandler?.stop();
+ for (final subscription in _subscriptions) {
+ await subscription.cancel();
+ }
+ _subscriptions.clear();
+ await _audioHandler?.disposeHandler();
await _mediaItem.close();
}
}
diff --git a/lib/core/audio/state/playback_state_manager.dart b/lib/core/audio/state/playback_state_manager.dart
index 5f9cfd8d..db16a9f2 100644
--- a/lib/core/audio/state/playback_state_manager.dart
+++ b/lib/core/audio/state/playback_state_manager.dart
@@ -1,24 +1,33 @@
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' hide PlaybackEvent;
+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 _isTransitioning = false;
+ bool _listenersInitialized = false;
+ bool _disposed = false;
+ int _revision = 0;
+ int? _completedRevision;
+ int? _lastScheduledSaveSecond;
+ PlayerState? _lastSavedPlayerState;
+ Future _saveQueue = Future.value();
final List _subscriptions = [];
@@ -32,61 +41,104 @@ class PlaybackStateManager {
// 初始化状态监听
void initStateListeners() {
+ if (_listenersInitialized) return;
+ _listenersInitialized = true;
+
+ _setupEventListeners();
+
// 监听播放器索引变化
- _player.currentIndexStream.listen((index) {
- if (index != null && _currentContext != null) {
- final newFile = _currentContext!.playlist[index];
- updateTrackAndContext(newFile, _currentContext!.work);
- }
- });
+ _subscriptions.add(_player.currentIndexStream.listen(_handleCurrentIndex));
- // 直接监听 AudioPlayer 的原始流
- _player.playerStateStream.listen((state) async {
- final position = _player.position;
- final duration = _player.duration;
-
- // 转换并发送到 EventHub
- _eventHub.emit(PlaybackStateEvent(state, position, duration));
+ // playbackEvent 同时携带 index、position、duration 和 buffer,使用同一
+ // 个原子快照可避免切歌边界混入上一首的时刻。
+ _subscriptions.add(
+ _player.playbackEventStream.listen(_handlePlaybackEvent),
+ );
- if (state.processingState == ProcessingState.completed) {
- _onPlaybackCompleted();
- }
- saveState();
- });
+ // positionStream 仅用于平滑刷新 UI。曲目身份仍由 currentIndex 校验。
+ _subscriptions.add(_player.positionStream.listen(_handlePosition));
- _player.positionStream.listen((position) {
- _eventHub.emit(PlaybackProgressEvent(
- position,
- _player.bufferedPosition
- ));
- });
+ _subscriptions.add(
+ _player.errorStream.listen((error) {
+ _eventHub.emit(
+ PlaybackErrorEvent('audio playback', error, StackTrace.current),
+ );
+ }),
+ );
+ }
+
+ /// 新しい音源のロード中は、旧音源から遅れて届くイベントを遮断する。
+ void beginContextTransition() {
+ _revision++;
+ _isTransitioning = true;
+ _completedRevision = null;
+ _lastScheduledSaveSecond = null;
+ _emitResetSnapshot(ProcessingState.loading);
}
- // 状态更新方法
- void updateContext(PlaybackContext? context) {
+ /// ロードが完了してから context と track を同時に公開する。
+ void commitContextTransition(PlaybackContext context, {Duration? duration}) {
_currentContext = context;
+ _currentTrack = TrackInfoCreator.createFromFile(
+ context.currentFile,
+ context.work,
+ ).copyWithDuration(duration);
+ _isTransitioning = false;
+
+ _emitContext();
+ _emitTrack();
+ _emitCurrentSnapshot();
+ unawaited(saveState());
+ }
+
+ void abortContextTransition() {
+ if (!_isTransitioning) return;
+ _isTransitioning = false;
+ _emitCurrentSnapshot();
+ }
+
+ void _emitContext() {
+ final context = _currentContext;
if (context != null) {
- _eventHub.emit(PlaybackContextEvent(context));
+ _eventHub.emit(PlaybackContextEvent(context, revision: _revision));
}
}
- void updateTrackInfo(AudioTrackInfo track) {
- _currentTrack = track;
- _eventHub.emit(TrackChangeEvent(track, _currentContext!.currentFile, _currentContext!.work));
+ void _emitTrack() {
+ final context = _currentContext;
+ final track = _currentTrack;
+ if (context == null || track == null) return;
+
+ _eventHub.emit(
+ TrackChangeEvent(
+ track,
+ context.currentFile,
+ context.work,
+ revision: _revision,
+ ),
+ );
}
void updateTrackAndContext(Child file, Work work) {
- if (_currentContext != null) {
- final newContext = _currentContext!.copyWithFile(file);
- updateContext(newContext);
- }
-
- final trackInfo = TrackInfoCreator.createFromFile(file, work);
- updateTrackInfo(trackInfo);
+ final context = _currentContext;
+ if (context == null || context.currentFile == file) return;
+
+ _revision++;
+ _completedRevision = null;
+ _lastScheduledSaveSecond = null;
+ _currentContext = context.copyWithFile(file);
+ _currentTrack = TrackInfoCreator.createFromFile(file, work);
+
+ _emitResetSnapshot(ProcessingState.loading);
+ _emitContext();
+ _emitTrack();
}
void _onPlaybackCompleted() {
if (_currentContext == null) return;
+ // just_audio は completed でも playing=true を維持するため、明示的に
+ // pause して UI/通知の再生ボタンを正しい状態へ戻す。
+ unawaited(_player.pause());
_eventHub.emit(PlaybackCompletedEvent(_currentContext!));
}
@@ -95,48 +147,46 @@ class PlaybackStateManager {
PlaybackContext? get currentContext => _currentContext;
void clearState() {
+ _revision++;
+ _isTransitioning = false;
_currentTrack = null;
_currentContext = null;
- updateContext(null);
+ _completedRevision = null;
+ _lastScheduledSaveSecond = null;
+ _emitResetSnapshot(ProcessingState.idle);
}
// 状态持久化
Future saveState() async {
- if (_currentContext == null) return;
+ final context = _currentContext;
+ if (context == null || _isTransitioning || _disposed) return;
- try {
- final state = PlaybackState(
- work: _currentContext!.work,
- files: _currentContext!.files,
- currentFile: _currentContext!.currentFile,
- playlist: _currentContext!.playlist,
- currentIndex: _currentContext!.currentIndex,
- playMode: _currentContext!.playMode,
- position: (_player.position).inMilliseconds,
- timestamp: DateTime.now().toIso8601String(),
- );
-
- await _stateRepository.saveState(state);
- } catch (e, stack) {
- AudioErrorHandler.handleError(
- AudioErrorType.state,
- '保存播放状态',
- e,
- stack,
- );
- }
+ final state = PlaybackState(
+ work: context.work,
+ files: context.files,
+ currentFile: context.currentFile,
+ playlist: context.playlist,
+ currentIndex: context.currentIndex,
+ playMode: context.playMode,
+ position: _player.position.inMilliseconds,
+ timestamp: DateTime.now().toIso8601String(),
+ );
+
+ _saveQueue = _saveQueue.then((_) async {
+ try {
+ await _stateRepository.saveState(state);
+ } catch (e, stack) {
+ AudioErrorHandler.handleError(AudioErrorType.state, '保存播放状态', e, stack);
+ }
+ });
+ await _saveQueue;
}
Future loadState() async {
try {
return await _stateRepository.loadState();
} catch (e, stack) {
- AudioErrorHandler.handleError(
- AudioErrorType.state,
- '加载播放状态',
- e,
- stack,
- );
+ AudioErrorHandler.handleError(AudioErrorType.state, '加载播放状态', e, stack);
return null;
}
}
@@ -145,18 +195,151 @@ class PlaybackStateManager {
// 处理初始状态请求
_subscriptions.add(
_eventHub.requestInitialState.listen((_) {
- _eventHub.emit(InitialStateEvent(
- _currentTrack,
- _currentContext
- ));
+ _eventHub.emit(
+ InitialStateEvent(
+ track: _currentTrack,
+ context: _currentContext,
+ state: _isTransitioning
+ ? PlayerState(false, ProcessingState.loading)
+ : _player.playerState,
+ position: _isTransitioning ? Duration.zero : _player.position,
+ bufferedPosition: _isTransitioning
+ ? Duration.zero
+ : _player.bufferedPosition,
+ duration: _isTransitioning ? null : _player.duration,
+ revision: _revision,
+ ),
+ );
}),
);
}
- void dispose() {
+ void _handleCurrentIndex(int? index) {
+ final context = _currentContext;
+ if (_isTransitioning || context == null || index == null) return;
+ if (index < 0 || index >= context.playlist.length) return;
+
+ updateTrackAndContext(context.playlist[index], context.work);
+ }
+
+ void _handlePlaybackEvent(PlaybackEvent event) {
+ if (_isTransitioning || !_isCurrentTrackEvent(event.currentIndex)) return;
+
+ final state = _player.playerState;
+ _eventHub.emit(
+ PlaybackStateEvent(
+ state,
+ _player.position,
+ event.duration,
+ revision: _revision,
+ ),
+ );
+ _emitProgress(
+ position: _player.position,
+ bufferedPosition: event.bufferedPosition,
+ duration: event.duration,
+ );
+ _updateTrackDuration(event.duration);
+
+ if (state.processingState == ProcessingState.completed &&
+ _completedRevision != _revision) {
+ _completedRevision = _revision;
+ _onPlaybackCompleted();
+ }
+
+ final previousState = _lastSavedPlayerState;
+ if (previousState == null ||
+ previousState.playing != state.playing ||
+ previousState.processingState != state.processingState) {
+ _lastSavedPlayerState = state;
+ unawaited(saveState());
+ }
+ }
+
+ void _handlePosition(Duration position) {
+ if (_isTransitioning || !_isCurrentTrackEvent(_player.currentIndex)) return;
+
+ _emitProgress(
+ position: position,
+ bufferedPosition: _player.bufferedPosition,
+ duration: _player.duration,
+ );
+
+ final second = position.inSeconds;
+ final lastSecond = _lastScheduledSaveSecond;
+ if (lastSecond == null || (second - lastSecond).abs() >= 5) {
+ _lastScheduledSaveSecond = second;
+ unawaited(saveState());
+ }
+ }
+
+ bool _isCurrentTrackEvent(int? index) {
+ final context = _currentContext;
+ return context != null && index == context.currentIndex;
+ }
+
+ void _emitCurrentSnapshot() {
+ final state = _player.playerState;
+ _eventHub.emit(
+ PlaybackStateEvent(
+ state,
+ _player.position,
+ _player.duration,
+ revision: _revision,
+ ),
+ );
+ _emitProgress(
+ position: _player.position,
+ bufferedPosition: _player.bufferedPosition,
+ duration: _player.duration,
+ );
+ _updateTrackDuration(_player.duration);
+ }
+
+ void _emitResetSnapshot(ProcessingState processingState) {
+ _eventHub.emit(
+ PlaybackStateEvent(
+ PlayerState(false, processingState),
+ Duration.zero,
+ null,
+ revision: _revision,
+ ),
+ );
+ _emitProgress(
+ position: Duration.zero,
+ bufferedPosition: Duration.zero,
+ duration: null,
+ );
+ }
+
+ void _emitProgress({
+ required Duration position,
+ required Duration bufferedPosition,
+ required Duration? duration,
+ }) {
+ _eventHub.emit(
+ PlaybackProgressEvent(
+ position,
+ bufferedPosition,
+ duration,
+ revision: _revision,
+ ),
+ );
+ }
+
+ void _updateTrackDuration(Duration? duration) {
+ final track = _currentTrack;
+ if (track == null || duration == null || track.duration == duration) return;
+ _currentTrack = track.copyWithDuration(duration);
+ _emitTrack();
+ }
+
+ Future dispose() async {
+ _disposed = true;
for (var subscription in _subscriptions) {
- subscription.cancel();
+ await subscription.cancel();
}
_subscriptions.clear();
+ await _saveQueue;
}
-}
\ 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 3c56acc4..6a910bfc 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 1ac1604b..aa10091d 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 e832cdfc..3ca6e339 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 be281d27..3660a9e9 100644
--- a/lib/core/audio/utils/playlist_builder.dart
+++ b/lib/core/audio/utils/playlist_builder.dart
@@ -7,32 +7,21 @@ class PlaylistBuilder {
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);
- }
-
- static Future setPlaylistSource({
+ 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,
+ return 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 4bd728a4..ce74b8bb 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 4237de6a..0bc0313c 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 741f6c13..5521d09d 100644
--- a/lib/core/di/service_locator.dart
+++ b/lib/core/di/service_locator.dart
@@ -16,10 +16,15 @@ 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';
+import '../download/bulk_save_controller.dart';
+import '../../data/services/dlsite_service.dart';
final getIt = GetIt.instance;
@@ -37,13 +42,13 @@ Future setupServiceLocator() async {
() => PlaybackStateRepository(getIt()),
);
- // 核心服务
- getIt.registerLazySingleton(
- () => AudioPlayerService(
- eventHub: getIt(),
- stateRepository: getIt(),
- ),
+ // 音声サービスは非同期初期化を完了してから公開する。これにより起動直後の
+ // 操作が未初期化の late フィールドへ到達しない。
+ final audioPlayerService = await AudioPlayerService.create(
+ eventHub: getIt(),
+ stateRepository: getIt(),
);
+ getIt.registerSingleton(audioPlayerService);
// 注册 PlayerViewModel
getIt.registerLazySingleton(
@@ -58,6 +63,9 @@ Future setupServiceLocator() async {
getIt.registerLazySingleton(
() => ApiService(),
);
+ getIt.registerLazySingleton(
+ () => DlsiteService(),
+ );
// 添加 AuthService 注册
getIt.registerLazySingleton(
@@ -91,22 +99,41 @@ Future setupServiceLocator() async {
getIt.registerLazySingleton(
() => ThemeController(prefs),
);
+ getIt.registerLazySingleton(
+ () => LocaleController(prefs),
+ );
// 注册 WakeLockController
getIt.registerLazySingleton(() => WakeLockController(prefs));
+
+ // 注册下载设置与进度
+ getIt.registerLazySingleton(
+ () => DownloadDirectoryController(prefs),
+ );
+ getIt.registerLazySingleton(
+ () => DownloadProgressManager(),
+ );
+ getIt.registerLazySingleton(
+ () => BulkSaveController(
+ apiService: getIt(),
+ directoryController: getIt(),
+ ),
+ );
}
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/bulk_save_controller.dart b/lib/core/download/bulk_save_controller.dart
new file mode 100644
index 00000000..b56923be
--- /dev/null
+++ b/lib/core/download/bulk_save_controller.dart
@@ -0,0 +1,1166 @@
+import 'dart:async';
+import 'dart:collection';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:asmrapp/common/utils/work_localizations.dart';
+import 'package:asmrapp/core/download/bulk_save_path_utils.dart';
+import 'package:asmrapp/core/download/bulk_save_request_executor.dart';
+import 'package:asmrapp/core/download/download_directory_controller.dart';
+import 'package:asmrapp/data/models/files/child.dart';
+import 'package:asmrapp/data/models/my_lists/my_playlists/playlist.dart';
+import 'package:asmrapp/data/models/works/work.dart';
+import 'package:asmrapp/data/services/api_service.dart';
+import 'package:asmrapp/utils/logger.dart';
+import 'package:crypto/crypto.dart';
+import 'package:dio/dio.dart';
+import 'package:flutter/widgets.dart';
+
+enum BulkSaveRunState { idle, running, completed, cancelled, failed }
+
+class BulkSaveResult {
+ final int savedWorks;
+ final int skippedWorks;
+ final int failedWorks;
+ final int reusedFiles;
+ final int downloadedFiles;
+
+ const BulkSaveResult({
+ required this.savedWorks,
+ required this.skippedWorks,
+ required this.failedWorks,
+ required this.reusedFiles,
+ required this.downloadedFiles,
+ });
+}
+
+class BulkSaveController extends ChangeNotifier {
+ static const String _completeMarkerName = '.yuro-complete.json';
+ static const String _partialMarkerName = '.yuro-partial.json';
+ static const int _pageSize = 100;
+
+ final ApiService _apiService;
+ final DownloadDirectoryController _directoryController;
+ final BulkSaveRequestExecutor _requestExecutor;
+
+ BulkSaveRunState _state = BulkSaveRunState.idle;
+ CancelToken? _cancelToken;
+ BulkSaveResult? _result;
+ String? _error;
+ String? _currentPlaylistName;
+ String? _currentWorkCode;
+ String? _currentFileName;
+ int _totalPlaylists = 0;
+ int _processedPlaylists = 0;
+ int _totalWorks = 0;
+ int _processedWorks = 0;
+ int _totalFiles = 0;
+ int _processedFiles = 0;
+ bool _playlistCountKnown = false;
+ bool _workCountKnown = false;
+ bool _fileCountKnown = false;
+ double? _currentFileProgress;
+ final List _logLines = [];
+ DateTime _lastProgressNotification = DateTime.fromMillisecondsSinceEpoch(0);
+
+ BulkSaveController({
+ required ApiService apiService,
+ required DownloadDirectoryController directoryController,
+ BulkSaveRequestExecutor? requestExecutor,
+ }) : _apiService = apiService,
+ _directoryController = directoryController,
+ _requestExecutor = requestExecutor ?? BulkSaveRequestExecutor();
+
+ BulkSaveRunState get state => _state;
+ bool get isRunning => _state == BulkSaveRunState.running;
+ BulkSaveResult? get result => _result;
+ String? get error => _error;
+ String? get currentPlaylistName => _currentPlaylistName;
+ String? get currentWorkCode => _currentWorkCode;
+ String? get currentFileName => _currentFileName;
+ int get totalPlaylists => _totalPlaylists;
+ int get processedPlaylists => _processedPlaylists;
+ int get totalWorks => _totalWorks;
+ int get processedWorks => _processedWorks;
+ int get totalFiles => _totalFiles;
+ int get processedFiles => _processedFiles;
+ double? get currentFileProgress => _currentFileProgress;
+ List get logLines => UnmodifiableListView(_logLines);
+ String get logText => _logLines.join('\n');
+ double? get playlistProgress => _countProgress(
+ processed: _processedPlaylists,
+ total: _totalPlaylists,
+ countKnown: _playlistCountKnown,
+ );
+ double? get workProgress => _countProgress(
+ processed: _processedWorks,
+ total: _totalWorks,
+ countKnown: _workCountKnown,
+ );
+ double? get fileProgress => _countProgress(
+ processed: _processedFiles,
+ total: _totalFiles,
+ countKnown: _fileCountKnown,
+ );
+
+ Future saveLikes({required Locale locale}) async {
+ await _start(
+ locale: locale,
+ loadCollections: () async => <_CollectionSavePlan>[
+ _CollectionSavePlan(
+ collectionDirectory: 'likes',
+ loadWorks: _loadAllLikes,
+ ),
+ ],
+ );
+ }
+
+ Future savePlaylist({
+ required Playlist playlist,
+ required String playlistName,
+ required Locale locale,
+ }) async {
+ final playlistId = playlist.id?.trim();
+ if (playlistId == null || playlistId.isEmpty) {
+ throw StateError('プレイリストIDがありません');
+ }
+ final safePlaylistName = BulkSavePathUtils.sanitizePathSegment(
+ playlistName,
+ fallback: playlistId,
+ );
+ await _start(
+ locale: locale,
+ loadCollections: () async => <_CollectionSavePlan>[
+ _CollectionSavePlan(
+ displayName: playlistName,
+ collectionDirectory: [
+ 'playlists',
+ safePlaylistName,
+ ].join(Platform.pathSeparator),
+ loadWorks: () => _loadAllPlaylistWorks(playlistId),
+ ),
+ ],
+ );
+ }
+
+ Future saveAllPlaylists({
+ required Locale locale,
+ required String Function(Playlist playlist) playlistNameFor,
+ }) async {
+ await _start(
+ locale: locale,
+ loadCollections: () => _loadAllPlaylistCollections(playlistNameFor),
+ );
+ }
+
+ void cancel() {
+ if (!isRunning) return;
+ _cancelToken?.cancel('Bulk save cancelled');
+ }
+
+ Future _start({
+ required Locale locale,
+ required Future> Function() loadCollections,
+ }) async {
+ if (isRunning) return;
+
+ _state = BulkSaveRunState.running;
+ _result = null;
+ _error = null;
+ _currentPlaylistName = null;
+ _currentWorkCode = null;
+ _currentFileName = null;
+ _totalPlaylists = 0;
+ _processedPlaylists = 0;
+ _totalWorks = 0;
+ _processedWorks = 0;
+ _totalFiles = 0;
+ _processedFiles = 0;
+ _playlistCountKnown = false;
+ _workCountKnown = false;
+ _fileCountKnown = false;
+ _currentFileProgress = null;
+ _logLines.clear();
+ _cancelToken = CancelToken();
+ _logInfo('一括保存を開始します');
+ notifyListeners();
+
+ var savedWorks = 0;
+ var skippedWorks = 0;
+ var failedWorks = 0;
+ var reusedFiles = 0;
+ var downloadedFiles = 0;
+
+ try {
+ final rootDirectory = await _directoryController
+ .resolveBulkSaveRootDirectory();
+ _logInfo('保存先: ${rootDirectory.path}');
+ final collections = await loadCollections();
+ _throwIfCancelled();
+ _totalPlaylists = collections.length;
+ _playlistCountKnown = true;
+ notifyListeners();
+
+ Directory? legacyDownloadRoot;
+ try {
+ legacyDownloadRoot = await _directoryController
+ .resolveDownloadRootDirectory();
+ } catch (error) {
+ _logInfo('既存ダウンロード先の再利用をスキップ: $error');
+ }
+
+ for (final collection in collections) {
+ _throwIfCancelled();
+ _currentPlaylistName = collection.displayName;
+ _totalWorks = 0;
+ _processedWorks = 0;
+ _workCountKnown = false;
+ _totalFiles = 0;
+ _processedFiles = 0;
+ _fileCountKnown = false;
+ _currentWorkCode = null;
+ _currentFileName = null;
+ _currentFileProgress = null;
+ notifyListeners();
+ _logInfo(
+ collection.displayName == null
+ ? 'コレクションの保存を開始します'
+ : 'コレクションを開始: ${collection.displayName}',
+ );
+
+ final destinationRoot = Directory(
+ '${rootDirectory.path}${Platform.pathSeparator}'
+ '${collection.collectionDirectory}',
+ );
+ await destinationRoot.create(recursive: true);
+
+ final works = _deduplicateWorks(await collection.loadWorks());
+ _throwIfCancelled();
+ _totalWorks = works.length;
+ _workCountKnown = true;
+ notifyListeners();
+
+ final candidateIndex = await _buildCandidateIndex(
+ rootDirectory,
+ legacyDownloadRoot,
+ works.map(BulkSavePathUtils.workCode).toSet(),
+ );
+
+ for (final work in works) {
+ _throwIfCancelled();
+ final code = BulkSavePathUtils.workCode(work);
+ final normalizedCode = BulkSavePathUtils.normalizeCode(code);
+ _currentWorkCode = code;
+ _totalFiles = 0;
+ _processedFiles = 0;
+ _fileCountKnown = false;
+ _currentFileName = null;
+ _currentFileProgress = null;
+ notifyListeners();
+ _logInfo('作品を開始: $code');
+
+ try {
+ final outcome = await _saveWork(
+ work: work,
+ title: work.localizedTitle(locale),
+ code: code,
+ destinationRoot: destinationRoot,
+ candidateDirectories:
+ candidateIndex[normalizedCode] ?? const [],
+ );
+ reusedFiles += outcome.reusedFiles;
+ downloadedFiles += outcome.downloadedFiles;
+ if (outcome.failedFiles > 0) {
+ failedWorks++;
+ _logWarning(
+ '作品の保存は未完了: $code '
+ '(${outcome.failedFiles}ファイル失敗)',
+ );
+ } else if (outcome.skipped) {
+ skippedWorks++;
+ _logInfo('作品は更新不要: $code');
+ } else {
+ savedWorks++;
+ _logInfo('作品の保存が完了: $code');
+ }
+ if (outcome.failedFiles == 0) {
+ final candidates = candidateIndex.putIfAbsent(
+ normalizedCode,
+ () => [],
+ );
+ if (!candidates.any(
+ (directory) => directory.path == outcome.directory.path,
+ )) {
+ candidates.add(outcome.directory);
+ }
+ }
+ } catch (error, stackTrace) {
+ if (_isCancellation(error)) rethrow;
+ failedWorks++;
+ _logError('一括保存に失敗: $code', error, stackTrace);
+ }
+
+ _processedWorks++;
+ _currentFileName = null;
+ _currentFileProgress = null;
+ notifyListeners();
+ }
+
+ _processedPlaylists++;
+ notifyListeners();
+ }
+
+ _result = BulkSaveResult(
+ savedWorks: savedWorks,
+ skippedWorks: skippedWorks,
+ failedWorks: failedWorks,
+ reusedFiles: reusedFiles,
+ downloadedFiles: downloadedFiles,
+ );
+ _state = BulkSaveRunState.completed;
+ _logInfo(
+ '一括保存が完了: 保存 $savedWorks作品、'
+ '更新不要 $skippedWorks作品、失敗 $failedWorks作品',
+ );
+ } catch (error, stackTrace) {
+ _result = BulkSaveResult(
+ savedWorks: savedWorks,
+ skippedWorks: skippedWorks,
+ failedWorks: failedWorks,
+ reusedFiles: reusedFiles,
+ downloadedFiles: downloadedFiles,
+ );
+ if (_isCancellation(error)) {
+ _state = BulkSaveRunState.cancelled;
+ _logWarning('一括保存を中断しました');
+ } else {
+ _state = BulkSaveRunState.failed;
+ _error = error.toString();
+ _logError('一括保存の開始に失敗', error, stackTrace);
+ }
+ } finally {
+ _cancelToken = null;
+ _currentPlaylistName = null;
+ _currentFileName = null;
+ _currentFileProgress = null;
+ notifyListeners();
+ }
+ }
+
+ Future> _loadAllLikes() async {
+ final first = await _runApiRequest(
+ 'お気に入り 1ページ目',
+ () => _apiService.getFavorites(
+ page: 1,
+ pageSize: _pageSize,
+ cancelToken: _cancelToken,
+ ),
+ );
+ return _loadRemainingPages(
+ first: first,
+ loadPage: (page) => _runApiRequest(
+ 'お気に入り $pageページ目',
+ () => _apiService.getFavorites(
+ page: page,
+ pageSize: _pageSize,
+ cancelToken: _cancelToken,
+ ),
+ ),
+ );
+ }
+
+ Future> _loadAllPlaylistCollections(
+ String Function(Playlist playlist) playlistNameFor,
+ ) async {
+ final playlists = await _loadAllPlaylists();
+ final usedDirectoryNames = {};
+ final collections = <_CollectionSavePlan>[];
+ for (final playlist in playlists) {
+ _throwIfCancelled();
+ final playlistId = playlist.id?.trim();
+ if (playlistId == null || playlistId.isEmpty) {
+ _logWarning('IDのないプレイリストを一括保存から除外しました');
+ continue;
+ }
+ final directoryName = _uniqueDirectoryName(
+ BulkSavePathUtils.sanitizePathSegment(
+ playlistNameFor(playlist),
+ fallback: playlistId,
+ ),
+ usedDirectoryNames,
+ );
+ collections.add(
+ _CollectionSavePlan(
+ displayName: playlistNameFor(playlist),
+ collectionDirectory: [
+ 'playlists',
+ directoryName,
+ ].join(Platform.pathSeparator),
+ loadWorks: () => _loadAllPlaylistWorks(playlistId),
+ ),
+ );
+ }
+ return collections;
+ }
+
+ Future> _loadAllPlaylists() async {
+ final first = await _runApiRequest(
+ 'プレイリスト 1ページ目',
+ () => _apiService.getMyPlaylists(page: 1, cancelToken: _cancelToken),
+ );
+ final playlists = [...?first.playlists];
+ final pageSize = first.pagination?.pageSize ?? playlists.length;
+ final totalCount = first.pagination?.totalCount ?? playlists.length;
+ if (pageSize > 0 && totalCount > playlists.length) {
+ final totalPages = (totalCount / pageSize).ceil();
+ for (var page = 2; page <= totalPages; page++) {
+ _throwIfCancelled();
+ final response = await _runApiRequest(
+ 'プレイリスト $pageページ目',
+ () =>
+ _apiService.getMyPlaylists(page: page, cancelToken: _cancelToken),
+ );
+ playlists.addAll(response.playlists ?? const []);
+ }
+ }
+
+ final byId = {};
+ for (final playlist in playlists) {
+ final id = playlist.id?.trim();
+ if (id != null && id.isNotEmpty) {
+ byId.putIfAbsent(id, () => playlist);
+ }
+ }
+ return byId.values.toList(growable: false);
+ }
+
+ Future> _loadAllPlaylistWorks(String playlistId) async {
+ final first = await _runApiRequest(
+ 'プレイリスト作品 $playlistId 1ページ目',
+ () => _apiService.getPlaylistWorks(
+ playlistId: playlistId,
+ page: 1,
+ pageSize: _pageSize,
+ cancelToken: _cancelToken,
+ ),
+ );
+ return _loadRemainingPages(
+ first: first,
+ loadPage: (page) => _runApiRequest(
+ 'プレイリスト作品 $playlistId $pageページ目',
+ () => _apiService.getPlaylistWorks(
+ playlistId: playlistId,
+ page: page,
+ pageSize: _pageSize,
+ cancelToken: _cancelToken,
+ ),
+ ),
+ );
+ }
+
+ Future> _loadRemainingPages({
+ required WorksResponse first,
+ required Future Function(int page) loadPage,
+ }) async {
+ final works = [...first.works];
+ final pageSize = first.pagination.pageSize ?? first.works.length;
+ final totalCount = first.pagination.totalCount ?? first.works.length;
+ if (pageSize <= 0 || totalCount <= works.length) return works;
+
+ final totalPages = (totalCount / pageSize).ceil();
+ for (var page = 2; page <= totalPages; page++) {
+ _throwIfCancelled();
+ final response = await loadPage(page);
+ works.addAll(response.works);
+ }
+ return works;
+ }
+
+ List _deduplicateWorks(List works) {
+ final byCode = {};
+ for (final work in works) {
+ byCode.putIfAbsent(
+ BulkSavePathUtils.normalizeCode(BulkSavePathUtils.workCode(work)),
+ () => work,
+ );
+ }
+ return byCode.values.toList(growable: false);
+ }
+
+ Future<_WorkSaveOutcome> _saveWork({
+ required Work work,
+ required String title,
+ required String code,
+ required Directory destinationRoot,
+ required List candidateDirectories,
+ }) async {
+ final files = await _runApiRequest(
+ '作品ファイル $code',
+ () => _apiService.getWorkFiles(
+ work.id.toString(),
+ cancelToken: _cancelToken,
+ ),
+ );
+ _throwIfCancelled();
+
+ final specs = _collectFileSpecs(files.children ?? const []);
+ _totalFiles = specs.length;
+ _processedFiles = 0;
+ _fileCountKnown = true;
+ notifyListeners();
+ final signature = _signatureFor(specs);
+ final targetDirectory = await _resolveTargetDirectory(
+ destinationRoot,
+ code,
+ title,
+ );
+
+ final completeManifest = await _readManifest(
+ File(
+ '${targetDirectory.path}${Platform.pathSeparator}'
+ '$_completeMarkerName',
+ ),
+ );
+ if (completeManifest?.code == BulkSavePathUtils.normalizeCode(code) &&
+ completeManifest?.signature == signature &&
+ await _allFilesValid(targetDirectory, specs)) {
+ _processedFiles = _totalFiles;
+ _currentFileProgress = 1;
+ notifyListeners();
+ return _WorkSaveOutcome(skipped: true, directory: targetDirectory);
+ }
+
+ final stageDirectory = Directory(
+ '${destinationRoot.path}${Platform.pathSeparator}'
+ '.${_baseName(targetDirectory.path)}.yuro-partial',
+ );
+ final partialMarker = File(
+ '${stageDirectory.path}${Platform.pathSeparator}$_partialMarkerName',
+ );
+ final previousPlan = await _readManifest(partialMarker);
+ if (await stageDirectory.exists() &&
+ (previousPlan?.code != BulkSavePathUtils.normalizeCode(code) ||
+ previousPlan?.signature != signature)) {
+ await stageDirectory.delete(recursive: true);
+ }
+ await stageDirectory.create(recursive: true);
+ await _writeManifest(
+ partialMarker,
+ _BulkManifest.fromSpecs(code: code, title: title, specs: specs),
+ );
+
+ final candidatePaths = {
+ targetDirectory.path,
+ stageDirectory.path,
+ ...candidateDirectories.map((directory) => directory.path),
+ };
+ final candidates = <_CandidateDirectory>[];
+ for (final path in candidatePaths) {
+ final directory = Directory(path);
+ if (!await directory.exists()) continue;
+ candidates.add(await _CandidateDirectory.load(directory));
+ }
+
+ var reusedFiles = 0;
+ var downloadedFiles = 0;
+ var failedFiles = 0;
+ for (final spec in specs) {
+ _throwIfCancelled();
+ _currentFileName = spec.displayName;
+ _currentFileProgress = 0;
+ notifyListeners();
+ _logInfo('ファイルを開始: $code / ${spec.relativePath}');
+
+ var succeeded = false;
+ try {
+ final stagedFile = File(_filePath(stageDirectory, spec));
+ if (await _fileIsValid(stagedFile, spec)) {
+ reusedFiles++;
+ succeeded = true;
+ _logInfo('部分保存から再利用: $code / ${spec.relativePath}');
+ continue;
+ }
+ if (await stagedFile.exists()) await stagedFile.delete();
+ await stagedFile.parent.create(recursive: true);
+
+ _CandidateDirectory? reusableCandidate;
+ for (final candidate in candidates) {
+ if (candidate.directory.path == stageDirectory.path) continue;
+ if (await candidate.canReuse(spec)) {
+ reusableCandidate = candidate;
+ break;
+ }
+ }
+
+ if (reusableCandidate != null) {
+ final source = File(_filePath(reusableCandidate.directory, spec));
+ final copyingFile = File('${stagedFile.path}.yuro-copying');
+ if (await copyingFile.exists()) await copyingFile.delete();
+ await source.copy(copyingFile.path);
+ if (!await _fileIsValid(copyingFile, spec)) {
+ await copyingFile.delete();
+ throw FileSystemException('コピーしたファイルを検証できません', source.path);
+ }
+ await copyingFile.rename(stagedFile.path);
+ reusedFiles++;
+ succeeded = true;
+ _logInfo('既存ファイルを再利用: $code / ${spec.relativePath}');
+ continue;
+ }
+
+ final downloadingFile = File('${stagedFile.path}.yuro-downloading');
+ if (await downloadingFile.exists()) await downloadingFile.delete();
+ await _runApiRequest('ファイル ${spec.displayName}', () {
+ _currentFileProgress = 0;
+ notifyListeners();
+ return _apiService.downloadFile(
+ spec.url,
+ downloadingFile.path,
+ cancelToken: _cancelToken,
+ onReceiveProgress: (received, total) {
+ final denominator = total > 0 ? total : spec.size;
+ _currentFileProgress = denominator > 0
+ ? (received / denominator).clamp(0, 1)
+ : null;
+ _notifyProgressThrottled();
+ },
+ );
+ });
+ _throwIfCancelled();
+ if (!await _fileIsValid(downloadingFile, spec)) {
+ if (await downloadingFile.exists()) await downloadingFile.delete();
+ throw FileSystemException(
+ 'ダウンロードしたファイルのサイズが一致しません',
+ spec.relativePath,
+ );
+ }
+ await downloadingFile.rename(stagedFile.path);
+ downloadedFiles++;
+ succeeded = true;
+ _logInfo('ファイルの保存が完了: $code / ${spec.relativePath}');
+ } catch (error, stackTrace) {
+ if (_isCancellation(error)) rethrow;
+ failedFiles++;
+ _logError(
+ 'ファイルの保存に失敗: $code / ${spec.relativePath}',
+ error,
+ stackTrace,
+ );
+ } finally {
+ _finishCurrentFile(succeeded: succeeded);
+ }
+ }
+
+ if (failedFiles > 0) {
+ return _WorkSaveOutcome(
+ skipped: false,
+ directory: stageDirectory,
+ reusedFiles: reusedFiles,
+ downloadedFiles: downloadedFiles,
+ failedFiles: failedFiles,
+ );
+ }
+ if (!await _allFilesValid(stageDirectory, specs)) {
+ throw FileSystemException('作品内のファイル検証に失敗しました', code);
+ }
+ await _pruneUnexpectedFiles(stageDirectory, specs);
+ if (await partialMarker.exists()) await partialMarker.delete();
+ await _finalizeWorkDirectory(
+ stageDirectory: stageDirectory,
+ targetDirectory: targetDirectory,
+ manifest: _BulkManifest.fromSpecs(code: code, title: title, specs: specs),
+ );
+
+ return _WorkSaveOutcome(
+ skipped: false,
+ directory: targetDirectory,
+ reusedFiles: reusedFiles,
+ downloadedFiles: downloadedFiles,
+ );
+ }
+
+ Future