From 4f03a8399371a3b7b6890f733f828a4307c2e192 Mon Sep 17 00:00:00 2001 From: David Zhuang Date: Tue, 16 Dec 2025 19:24:57 -0700 Subject: [PATCH 1/9] fest: WIP Download --- lib/bean/anime/anime_panel.dart | 189 ++++++++-- lib/bean/download/download_anime.dart | 76 ++++ lib/bean/download/download_task.dart | 155 ++++++++ lib/i18n/strings.i18n.json | 18 + lib/i18n/strings_zh-CN.i18n.json | 18 + lib/i18n/strings_zh-HK.i18n.json | 18 + lib/i18n/strings_zh-TW.i18n.json | 18 + lib/pages/download/download_controller.dart | 366 +++++++++++++++++++ lib/pages/download/download_module.dart | 10 + lib/pages/download/download_page.dart | 376 ++++++++++++++++++++ lib/pages/index_module.dart | 4 + lib/pages/my/my_page.dart | 8 + lib/pages/video/video_controller.dart | 28 +- lib/pages/video/video_page.dart | 4 + lib/utils/storage.dart | 12 + 15 files changed, 1262 insertions(+), 38 deletions(-) create mode 100644 lib/bean/download/download_anime.dart create mode 100644 lib/bean/download/download_task.dart create mode 100644 lib/pages/download/download_controller.dart create mode 100644 lib/pages/download/download_module.dart create mode 100644 lib/pages/download/download_page.dart diff --git a/lib/bean/anime/anime_panel.dart b/lib/bean/anime/anime_panel.dart index bac6800..6389f5e 100644 --- a/lib/bean/anime/anime_panel.dart +++ b/lib/bean/anime/anime_panel.dart @@ -2,6 +2,8 @@ import 'package:oneanime/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:oneanime/i18n/strings.g.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:oneanime/pages/download/download_controller.dart'; class BangumiPanel extends StatelessWidget { const BangumiPanel({ @@ -10,17 +12,22 @@ class BangumiPanel extends StatelessWidget { required this.episodeLength, required this.currentEpisode, required this.onChangeEpisode, + this.animeLink, + this.tokens, }); final String title; final int episodeLength; final int currentEpisode; final Future Function(int episode) onChangeEpisode; + final int? animeLink; + final List? tokens; @override Widget build(BuildContext context) { final Translations i18n = Translations.of(context); final ScrollController listViewScrollCtr = ScrollController(); + final DownloadController downloadController = Modular.get(); return Expanded( child: Column( @@ -124,6 +131,12 @@ class BangumiPanel extends StatelessWidget { ), itemCount: episodeLength, itemBuilder: (BuildContext context, int i) { + final episode = i + 1; + final isDownloaded = animeLink != null && + downloadController.isEpisodeDownloaded(animeLink!, episode); + final task = animeLink != null ? + downloadController.getTaskForEpisode(animeLink!, episode) : null; + return Container( // width: 150, margin: const EdgeInsets.only(bottom: 10), // 改为bottom间距 @@ -133,43 +146,76 @@ class BangumiPanel extends StatelessWidget { clipBehavior: Clip.hardEdge, child: InkWell( onTap: () { - onChangeEpisode(i + 1); + onChangeEpisode(episode); }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (i == (currentEpisode - 1)) ...[ - Image.asset( - 'assets/images/live.png', - color: - Theme.of(context).colorScheme.primary, - height: 12, - ), - const SizedBox(width: 6) - ], - Text( - i18n.toast.currentEpisode(episode: i + 1), - style: TextStyle( - fontSize: 13, - color: i == (currentEpisode - 1) - ? Theme.of(context) - .colorScheme - .primary - : Theme.of(context) - .colorScheme - .onSurface), + onLongPress: animeLink != null && tokens != null && episode <= tokens!.length + ? () { + _showDownloadMenu( + context, + downloadController, + episode, + isDownloaded, + task, + i18n, + ); + } + : null, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (i == (currentEpisode - 1)) ...[ + Image.asset( + 'assets/images/live.png', + color: + Theme.of(context).colorScheme.primary, + height: 12, + ), + const SizedBox(width: 6) + ], + Expanded( + child: Text( + i18n.toast.currentEpisode(episode: episode), + style: TextStyle( + fontSize: 13, + color: i == (currentEpisode - 1) + ? Theme.of(context) + .colorScheme + .primary + : Theme.of(context) + .colorScheme + .onSurface), + ), + ), + const SizedBox(width: 2), + if (isDownloaded) + Icon( + Icons.download_done, + size: 16, + color: Theme.of(context).colorScheme.primary, + ) + else if (task != null && task.isDownloading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + value: task.progress > 0 ? task.progress : null, + ), + ), + ], ), - const SizedBox(width: 2), + const SizedBox(height: 3), ], ), - const SizedBox(height: 3), - ], - ), + ), + ], ), ), ), @@ -182,4 +228,81 @@ class BangumiPanel extends StatelessWidget { ), ); } + + void _showDownloadMenu( + BuildContext context, + DownloadController downloadController, + int episode, + bool isDownloaded, + dynamic task, + Translations i18n, + ) { + if (animeLink == null || tokens == null || episode > tokens!.length) return; + + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isDownloaded && task == null) + ListTile( + leading: const Icon(Icons.download), + title: Text('Download Episode $episode'), + onTap: () async { + Navigator.pop(context); + final token = tokens![tokens!.length - episode]; + final result = await downloadController.enqueueEpisode( + link: animeLink!, + title: title, + episode: episode, + token: token, + ); + SmartDialog.showToast(result); + }, + ), + if (task != null && task.isDownloading) + ListTile( + leading: const Icon(Icons.pause), + title: Text(i18n.my.downloads.pause), + onTap: () { + Navigator.pop(context); + downloadController.pauseTask(task); + }, + ), + if (task != null && (task.isPaused || task.isFailed)) + ListTile( + leading: const Icon(Icons.play_arrow), + title: Text(i18n.my.downloads.resume), + onTap: () { + Navigator.pop(context); + downloadController.resumeTask(task); + }, + ), + if (isDownloaded) + ListTile( + leading: const Icon(Icons.delete), + title: Text(i18n.my.downloads.delete), + onTap: () { + Navigator.pop(context); + downloadController.deleteTask(task); + SmartDialog.showToast('Download deleted'); + }, + ), + if (task != null && !isDownloaded) + ListTile( + leading: const Icon(Icons.cancel), + title: Text(i18n.my.downloads.cancel), + onTap: () { + Navigator.pop(context); + downloadController.cancelTask(task); + }, + ), + ], + ), + ); + }, + ); + } } diff --git a/lib/bean/download/download_anime.dart b/lib/bean/download/download_anime.dart new file mode 100644 index 0000000..1e586ce --- /dev/null +++ b/lib/bean/download/download_anime.dart @@ -0,0 +1,76 @@ +import 'package:hive/hive.dart'; +import 'dart:convert'; + +/// Represents an anime with its tokens for download management +class DownloadAnime extends HiveObject { + @HiveField(0) + int? link; // anime cat id + + @HiveField(1) + String? title; // anime title + + @HiveField(2) + String? tokensJson; // jsonEncode(List) + + @HiveField(3) + int? episodeTotal; // total number of episodes + + DownloadAnime({ + this.link, + this.title, + this.tokensJson, + this.episodeTotal, + }); + + DownloadAnime.fromJson(Map? json) { + if (json == null) return; + link = json['link']; + title = json['title']; + tokensJson = json['tokensJson']; + episodeTotal = json['episodeTotal']; + } + + Map toJson() => { + 'link': link, + 'title': title, + 'tokensJson': tokensJson, + 'episodeTotal': episodeTotal, + }; + + List get tokens { + if (tokensJson == null || tokensJson!.isEmpty) return []; + try { + return List.from(jsonDecode(tokensJson!)); + } catch (e) { + return []; + } + } + + void setTokens(List tokens) { + tokensJson = jsonEncode(tokens); + } +} + +class DownloadAnimeAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + DownloadAnime read(BinaryReader reader) { + return DownloadAnime( + link: reader.readInt(), + title: reader.readString(), + tokensJson: reader.readString(), + episodeTotal: reader.readInt(), + ); + } + + @override + void write(BinaryWriter writer, DownloadAnime obj) { + writer.writeInt(obj.link ?? 0); + writer.writeString(obj.title ?? ''); + writer.writeString(obj.tokensJson ?? '[]'); + writer.writeInt(obj.episodeTotal ?? 0); + } +} + diff --git a/lib/bean/download/download_task.dart b/lib/bean/download/download_task.dart new file mode 100644 index 0000000..c7c6025 --- /dev/null +++ b/lib/bean/download/download_task.dart @@ -0,0 +1,155 @@ +import 'package:hive/hive.dart'; + +/// Represents a single episode download task +class DownloadTask extends HiveObject { + @HiveField(0) + int? link; // anime cat id + + @HiveField(1) + String? title; // anime title + + @HiveField(2) + int? episode; // episode number + + @HiveField(3) + String? token; // video token from VideoRequest.getVideoToken + + @HiveField(4) + String? resolvedUrl; // last resolved video URL + + @HiveField(5) + String? userAgent; // generated once per task + + @HiveField(6) + String? status; // queued/downloading/paused/completed/failed + + @HiveField(7) + int? receivedBytes; + + @HiveField(8) + int? totalBytes; + + @HiveField(9) + String? savePath; // local file path + + @HiveField(10) + String? error; // error message if failed + + @HiveField(11) + int? createdAt; // timestamp + + @HiveField(12) + int? updatedAt; // timestamp + + @HiveField(13) + String? videoCookie; // cookie for video playback + + DownloadTask({ + this.link, + this.title, + this.episode, + this.token, + this.resolvedUrl, + this.userAgent, + this.status, + this.receivedBytes, + this.totalBytes, + this.savePath, + this.error, + this.createdAt, + this.updatedAt, + this.videoCookie, + }); + + DownloadTask.fromJson(Map? json) { + if (json == null) return; + link = json['link']; + title = json['title']; + episode = json['episode']; + token = json['token']; + resolvedUrl = json['resolvedUrl']; + userAgent = json['userAgent']; + status = json['status']; + receivedBytes = json['receivedBytes']; + totalBytes = json['totalBytes']; + savePath = json['savePath']; + error = json['error']; + createdAt = json['createdAt']; + updatedAt = json['updatedAt']; + videoCookie = json['videoCookie']; + } + + Map toJson() => { + 'link': link, + 'title': title, + 'episode': episode, + 'token': token, + 'resolvedUrl': resolvedUrl, + 'userAgent': userAgent, + 'status': status, + 'receivedBytes': receivedBytes, + 'totalBytes': totalBytes, + 'savePath': savePath, + 'error': error, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'videoCookie': videoCookie, + }; + + String get taskId => '${link}_$episode'; + + double get progress { + if (totalBytes == null || totalBytes == 0) return 0.0; + return (receivedBytes ?? 0) / totalBytes!; + } + + bool get isCompleted => status == 'completed'; + bool get isDownloading => status == 'downloading'; + bool get isFailed => status == 'failed'; + bool get isPaused => status == 'paused'; + bool get isQueued => status == 'queued'; +} + +class DownloadTaskAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + DownloadTask read(BinaryReader reader) { + return DownloadTask( + link: reader.readInt(), + title: reader.readString(), + episode: reader.readInt(), + token: reader.readString(), + resolvedUrl: reader.readString(), + userAgent: reader.readString(), + status: reader.readString(), + receivedBytes: reader.readInt(), + totalBytes: reader.readInt(), + savePath: reader.readString(), + error: reader.readString(), + createdAt: reader.readInt(), + updatedAt: reader.readInt(), + videoCookie: reader.readString(), + ); + } + + @override + void write(BinaryWriter writer, DownloadTask obj) { + writer.writeInt(obj.link ?? 0); + writer.writeString(obj.title ?? ''); + writer.writeInt(obj.episode ?? 0); + writer.writeString(obj.token ?? ''); + writer.writeString(obj.resolvedUrl ?? ''); + writer.writeString(obj.userAgent ?? ''); + writer.writeString(obj.status ?? 'queued'); + writer.writeInt(obj.receivedBytes ?? 0); + writer.writeInt(obj.totalBytes ?? 0); + writer.writeString(obj.savePath ?? ''); + writer.writeString(obj.error ?? ''); + writer.writeInt(obj.createdAt ?? 0); + writer.writeInt(obj.updatedAt ?? 0); + writer.writeString(obj.videoCookie ?? ''); + } +} + diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 2d19afc..f7b8800 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -72,6 +72,24 @@ "title": "History", "empty": "Ah Lie (⊙.⊙) There is no viewing record." }, + "downloads": { + "title": "Downloads", + "empty": "No downloads yet", + "downloading": "Downloading", + "queued": "Queued", + "paused": "Paused", + "completed": "Completed", + "failed": "Failed", + "pause": "Pause", + "resume": "Resume", + "retry": "Retry", + "delete": "Delete", + "cancel": "Cancel", + "play": "Play", + "clearCompleted": "Clear Completed", + "confirmDelete": "Delete this download?", + "confirmClearCompleted": "Delete all completed downloads?" + }, "playerSettings": { "title": "Player Settings", "hardwareAcceleration": "Hardware Acceleration", diff --git a/lib/i18n/strings_zh-CN.i18n.json b/lib/i18n/strings_zh-CN.i18n.json index a7962f3..da35a39 100644 --- a/lib/i18n/strings_zh-CN.i18n.json +++ b/lib/i18n/strings_zh-CN.i18n.json @@ -72,6 +72,24 @@ "title": "历史记录", "empty": "啊咧(⊙.⊙) 没有观看记录的说" }, + "downloads": { + "title": "番剧下载", + "empty": "还没有下载任务", + "downloading": "下载中", + "queued": "等待中", + "paused": "已暂停", + "completed": "已完成", + "failed": "失败", + "pause": "暂停", + "resume": "继续", + "retry": "重试", + "delete": "删除", + "cancel": "取消", + "play": "播放", + "clearCompleted": "清除已完成", + "confirmDelete": "确定要删除此下载吗?", + "confirmClearCompleted": "确定要清除所有已完成的下载吗?" + }, "playerSettings": { "title": "播放设置", "hardwareAcceleration": "硬件解码", diff --git a/lib/i18n/strings_zh-HK.i18n.json b/lib/i18n/strings_zh-HK.i18n.json index ce454eb..1d91fb9 100644 --- a/lib/i18n/strings_zh-HK.i18n.json +++ b/lib/i18n/strings_zh-HK.i18n.json @@ -72,6 +72,24 @@ "title": "歷史記錄", "empty": "啊咧(⊙.⊙) 沒有觀看記錄的說" }, + "downloads": { + "title": "番劇下載", + "empty": "仲未有下載任務", + "downloading": "下載中", + "queued": "等待中", + "paused": "已暫停", + "completed": "已完成", + "failed": "失敗", + "pause": "暫停", + "resume": "繼續", + "retry": "重試", + "delete": "刪除", + "cancel": "取消", + "play": "播放", + "clearCompleted": "清除已完成", + "confirmDelete": "確定要刪除此下載嗎?", + "confirmClearCompleted": "確定要清除所有已完成嘅下載嗎?" + }, "playerSettings": { "title": "播放設置", "hardwareAcceleration": "硬件解碼", diff --git a/lib/i18n/strings_zh-TW.i18n.json b/lib/i18n/strings_zh-TW.i18n.json index ce454eb..5b59bfa 100644 --- a/lib/i18n/strings_zh-TW.i18n.json +++ b/lib/i18n/strings_zh-TW.i18n.json @@ -72,6 +72,24 @@ "title": "歷史記錄", "empty": "啊咧(⊙.⊙) 沒有觀看記錄的說" }, + "downloads": { + "title": "番劇下載", + "empty": "還沒有下載任務", + "downloading": "下載中", + "queued": "等待中", + "paused": "已暫停", + "completed": "已完成", + "failed": "失敗", + "pause": "暫停", + "resume": "繼續", + "retry": "重試", + "delete": "刪除", + "cancel": "取消", + "play": "播放", + "clearCompleted": "清除已完成", + "confirmDelete": "確定要刪除此下載嗎?", + "confirmClearCompleted": "確定要清除所有已完成的下載嗎?" + }, "playerSettings": { "title": "播放設置", "hardwareAcceleration": "硬件解碼", diff --git a/lib/pages/download/download_controller.dart b/lib/pages/download/download_controller.dart new file mode 100644 index 0000000..b001d2e --- /dev/null +++ b/lib/pages/download/download_controller.dart @@ -0,0 +1,366 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:mobx/mobx.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:oneanime/bean/download/download_task.dart'; +import 'package:oneanime/bean/download/download_anime.dart'; +import 'package:oneanime/utils/storage.dart'; +import 'package:oneanime/utils/utils.dart'; +import 'package:oneanime/request/video.dart'; +import 'package:oneanime/utils/constans.dart'; + +part 'download_controller.g.dart'; + +class DownloadController = _DownloadController with _$DownloadController; + +abstract class _DownloadController with Store { + final Dio _dio = Dio(); + CancelToken? _currentCancelToken; + + @observable + ObservableList tasks = ObservableList.of([]); + + @observable + DownloadTask? currentDownloading; + + @observable + bool isProcessing = false; + + Future init() async { + await loadTasks(); + // Resume any interrupted downloads + resumeQueue(); + } + + @action + Future loadTasks() async { + tasks.clear(); + final allTasks = GStorage.downloadTasks.values.toList(); + tasks.addAll(allTasks); + // Sort by creation time (newest first) + tasks.sort((a, b) => (b.createdAt ?? 0).compareTo(a.createdAt ?? 0)); + } + + @action + Future enqueueEpisode({ + required int link, + required String title, + required int episode, + required String token, + }) async { + // Check if task already exists + final existingTask = tasks.firstWhere( + (t) => t.link == link && t.episode == episode, + orElse: () => DownloadTask(), + ); + + if (existingTask.link != null) { + if (existingTask.isCompleted) { + return 'Episode already downloaded'; + } else if (existingTask.isDownloading) { + return 'Episode is currently downloading'; + } else if (existingTask.isQueued) { + return 'Episode is already in queue'; + } else if (existingTask.isFailed) { + // Retry failed task + await retryTask(existingTask); + return 'Retrying download'; + } else if (existingTask.isPaused) { + await resumeTask(existingTask); + return 'Resuming download'; + } + } + + // Save anime info if not exists + final animeKey = link.toString(); + if (!GStorage.downloadAnime.containsKey(animeKey)) { + final downloadAnime = DownloadAnime( + link: link, + title: title, + tokensJson: '[]', // Will be updated when needed + episodeTotal: 0, + ); + await GStorage.downloadAnime.put(animeKey, downloadAnime); + } + + // Create new task + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final task = DownloadTask( + link: link, + title: title, + episode: episode, + token: token, + userAgent: Utils.getRandomUA(), + status: 'queued', + receivedBytes: 0, + totalBytes: 0, + createdAt: now, + updatedAt: now, + ); + + // Save to hive + await GStorage.downloadTasks.put(task.taskId, task); + tasks.insert(0, task); + + // Start processing queue + processQueue(); + + return 'Download queued'; + } + + @action + Future processQueue() async { + if (isProcessing) return; + isProcessing = true; + + try { + while (true) { + // Find next queued task + final nextTask = tasks.firstWhere( + (t) => t.isQueued, + orElse: () => DownloadTask(), + ); + + if (nextTask.link == null) break; + + await _downloadTask(nextTask); + } + } finally { + isProcessing = false; + currentDownloading = null; + } + } + + Future _downloadTask(DownloadTask task) async { + currentDownloading = task; + _updateTaskStatus(task, 'downloading'); + + try { + // Resolve video URL and cookie + debugPrint('Resolving video URL for ${task.title} Episode ${task.episode}'); + final result = await VideoRequest.getVideoLink(task.token ?? ''); + final videoUrl = result['link'] as String; + final videoCookie = result['cookie'] as String; + + if (videoUrl.isEmpty) { + throw Exception('Failed to resolve video URL'); + } + + task.resolvedUrl = videoUrl; + task.videoCookie = videoCookie; + await task.save(); + + // Check if URL is HLS (m3u8) + if (videoUrl.contains('.m3u8')) { + _updateTaskStatus(task, 'failed', error: 'HLS streaming not supported yet. Direct file download only.'); + return; + } + + // Prepare save path + final dir = await _getDownloadDirectory(); + final fileName = _generateFileName(task); + final savePath = '${dir.path}/$fileName'; + task.savePath = savePath; + await task.save(); + + // Prepare headers + final headers = { + 'user-agent': task.userAgent ?? Utils.getRandomUA(), + 'referer': HttpString.baseUrl, + 'Cookie': videoCookie, + }; + + // Download file + _currentCancelToken = CancelToken(); + await _dio.download( + videoUrl, + savePath, + options: Options(headers: headers), + cancelToken: _currentCancelToken, + onReceiveProgress: (received, total) { + task.receivedBytes = received; + task.totalBytes = total; + task.updatedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + task.save(); + // Trigger UI update + tasks = ObservableList.of(tasks); + }, + ); + + // Verify file exists + final file = File(savePath); + if (await file.exists()) { + _updateTaskStatus(task, 'completed'); + debugPrint('Download completed: ${task.title} Episode ${task.episode}'); + } else { + throw Exception('Downloaded file not found'); + } + } catch (e) { + if (e is DioException && e.type == DioExceptionType.cancel) { + _updateTaskStatus(task, 'paused', error: 'Download cancelled'); + } else { + _updateTaskStatus(task, 'failed', error: e.toString()); + } + debugPrint('Download error: ${e.toString()}'); + } finally { + _currentCancelToken = null; + } + } + + Future _getDownloadDirectory() async { + Directory dir; + if (Platform.isAndroid) { + dir = await getApplicationDocumentsDirectory(); + } else if (Platform.isIOS) { + dir = await getApplicationDocumentsDirectory(); + } else { + dir = await getApplicationSupportDirectory(); + } + + final downloadDir = Directory('${dir.path}/downloads'); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + return downloadDir; + } + + String _generateFileName(DownloadTask task) { + final sanitizedTitle = task.title?.replaceAll(RegExp(r'[^\w\s-]'), '').replaceAll(' ', '_') ?? 'anime'; + final timestamp = task.createdAt ?? 0; + return '${sanitizedTitle}_ep${task.episode}_$timestamp.mp4'; + } + + void _updateTaskStatus(DownloadTask task, String status, {String? error}) { + task.status = status; + task.error = error; + task.updatedAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + task.save(); + // Trigger UI update + tasks = ObservableList.of(tasks); + } + + @action + Future pauseTask(DownloadTask task) async { + if (task.isDownloading && currentDownloading?.taskId == task.taskId) { + _currentCancelToken?.cancel('User paused'); + } + _updateTaskStatus(task, 'paused'); + } + + @action + Future resumeTask(DownloadTask task) async { + if (task.isPaused || task.isFailed) { + _updateTaskStatus(task, 'queued'); + processQueue(); + } + } + + @action + Future retryTask(DownloadTask task) async { + task.error = null; + task.receivedBytes = 0; + task.totalBytes = 0; + _updateTaskStatus(task, 'queued'); + processQueue(); + } + + @action + Future cancelTask(DownloadTask task) async { + if (task.isDownloading && currentDownloading?.taskId == task.taskId) { + _currentCancelToken?.cancel('User cancelled'); + } + + // Delete file if exists + if (task.savePath != null) { + final file = File(task.savePath!); + if (await file.exists()) { + await file.delete(); + } + } + + // Remove from storage + await GStorage.downloadTasks.delete(task.taskId); + tasks.remove(task); + } + + @action + Future deleteTask(DownloadTask task) async { + // Delete file if exists + if (task.savePath != null) { + final file = File(task.savePath!); + if (await file.exists()) { + await file.delete(); + debugPrint('Deleted file: ${task.savePath}'); + } + } + + // Remove from storage + await GStorage.downloadTasks.delete(task.taskId); + tasks.remove(task); + } + + @action + Future clearCompleted() async { + final completedTasks = tasks.where((t) => t.isCompleted).toList(); + for (final task in completedTasks) { + // Delete file + if (task.savePath != null) { + final file = File(task.savePath!); + if (await file.exists()) { + await file.delete(); + } + } + // Remove from storage + await GStorage.downloadTasks.delete(task.taskId); + tasks.remove(task); + } + } + + @action + void resumeQueue() { + // Resume interrupted downloads + final downloadingTasks = tasks.where((t) => t.isDownloading).toList(); + for (final task in downloadingTasks) { + _updateTaskStatus(task, 'queued'); + } + + if (downloadingTasks.isNotEmpty) { + processQueue(); + } + } + + DownloadTask? getTaskForEpisode(int link, int episode) { + try { + return tasks.firstWhere( + (t) => t.link == link && t.episode == episode, + ); + } catch (e) { + return null; + } + } + + bool isEpisodeDownloaded(int link, int episode) { + final task = getTaskForEpisode(link, episode); + if (task == null) return false; + if (!task.isCompleted) return false; + + // Verify file exists + if (task.savePath == null) return false; + final file = File(task.savePath!); + return file.existsSync(); + } + + String? getLocalPath(int link, int episode) { + final task = getTaskForEpisode(link, episode); + if (task == null || !task.isCompleted) return null; + + // Verify file exists + if (task.savePath == null) return null; + final file = File(task.savePath!); + if (!file.existsSync()) return null; + + return task.savePath; + } +} + diff --git a/lib/pages/download/download_module.dart b/lib/pages/download/download_module.dart new file mode 100644 index 0000000..85f82ee --- /dev/null +++ b/lib/pages/download/download_module.dart @@ -0,0 +1,10 @@ +import 'package:oneanime/pages/download/download_page.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +class DownloadModule extends Module { + @override + void routes(r) { + r.child("/", child: (_) => const DownloadPage()); + } +} + diff --git a/lib/pages/download/download_page.dart b/lib/pages/download/download_page.dart new file mode 100644 index 0000000..61cbe20 --- /dev/null +++ b/lib/pages/download/download_page.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:oneanime/pages/download/download_controller.dart'; +import 'package:oneanime/bean/download/download_task.dart'; +import 'package:oneanime/bean/appbar/sys_app_bar.dart'; +import 'package:oneanime/i18n/strings.g.dart'; +import 'package:oneanime/utils/utils.dart'; +import 'package:oneanime/pages/video/video_controller.dart'; +import 'package:oneanime/pages/popular/popular_controller.dart'; + +class DownloadPage extends StatefulWidget { + const DownloadPage({super.key}); + + @override + State createState() => _DownloadPageState(); +} + +class _DownloadPageState extends State { + late Translations i18n; + final DownloadController downloadController = Modular.get(); + final VideoController videoController = Modular.get(); + final PopularController popularController = Modular.get(); + + @override + void initState() { + super.initState(); + downloadController.init(); + } + + void _showDeleteDialog(DownloadTask task) { + SmartDialog.show( + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (context) { + return AlertDialog( + title: Text(i18n.my.downloads.confirmDelete), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: Text( + i18n.dialog.dismiss, + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () { + downloadController.deleteTask(task); + SmartDialog.dismiss(); + }, + child: Text(i18n.my.downloads.delete), + ), + ], + ); + }, + ); + } + + void _showClearCompletedDialog() { + SmartDialog.show( + animationType: SmartAnimationType.centerFade_otherSlide, + builder: (context) { + return AlertDialog( + title: Text(i18n.my.downloads.confirmClearCompleted), + actions: [ + TextButton( + onPressed: () => SmartDialog.dismiss(), + child: Text( + i18n.dialog.dismiss, + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + TextButton( + onPressed: () { + downloadController.clearCompleted(); + SmartDialog.dismiss(); + }, + child: Text(i18n.my.downloads.clearCompleted), + ), + ], + ); + }, + ); + } + + Future _playDownloadedEpisode(DownloadTask task) async { + SmartDialog.showLoading(msg: i18n.toast.loading); + try { + // Set video controller state for offline playback + videoController.link = task.link!; + videoController.title = task.title!; + videoController.episode = task.episode!; + videoController.videoUrl = task.savePath!; + videoController.videoCookie = ''; + videoController.offset = 0; + + // Try to get anime info + final animeInfo = popularController.lookupAnime(task.title ?? ""); + videoController.follow = animeInfo?.follow ?? false; + + SmartDialog.dismiss(); + Modular.to.pushNamed('/video/'); + } catch (e) { + SmartDialog.dismiss(); + SmartDialog.showToast('Failed to play: ${e.toString()}'); + } + } + + @override + Widget build(BuildContext context) { + i18n = Translations.of(context); + return PopScope( + canPop: true, + child: Scaffold( + appBar: SysAppBar(title: Text(i18n.my.downloads.title)), + body: Observer(builder: (context) { + if (downloadController.tasks.isEmpty) { + return Center( + child: Text( + i18n.my.downloads.empty, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + } + + return ListView.builder( + itemCount: downloadController.tasks.length, + itemBuilder: (context, index) { + final task = downloadController.tasks[index]; + return _buildTaskItem(task); + }, + ); + }), + floatingActionButton: Observer(builder: (context) { + final hasCompleted = downloadController.tasks.any((t) => t.isCompleted); + if (!hasCompleted) return const SizedBox(); + + return FloatingActionButton.extended( + onPressed: _showClearCompletedDialog, + icon: const Icon(Icons.clear_all), + label: Text(i18n.my.downloads.clearCompleted), + ); + }), + ), + ); + } + + Widget _buildTaskItem(DownloadTask task) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title and episode + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.title ?? 'Unknown', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + i18n.toast.currentEpisode(episode: task.episode.toString()), + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + _buildStatusChip(task), + ], + ), + + // Progress bar for downloading tasks + if (task.isDownloading || task.isQueued) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: task.progress > 0 ? task.progress : null, + ), + const SizedBox(height: 4), + Text( + _formatProgress(task), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + + // Error message for failed tasks + if (task.isFailed && task.error != null) ...[ + const SizedBox(height: 8), + Text( + task.error!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + + // Action buttons + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: _buildActionButtons(task), + ), + ], + ), + ), + ); + } + + Widget _buildStatusChip(DownloadTask task) { + String label; + Color? backgroundColor; + Color? textColor; + + if (task.isCompleted) { + label = i18n.my.downloads.completed; + backgroundColor = Theme.of(context).colorScheme.primaryContainer; + textColor = Theme.of(context).colorScheme.onPrimaryContainer; + } else if (task.isDownloading) { + label = i18n.my.downloads.downloading; + backgroundColor = Theme.of(context).colorScheme.secondaryContainer; + textColor = Theme.of(context).colorScheme.onSecondaryContainer; + } else if (task.isQueued) { + label = i18n.my.downloads.queued; + backgroundColor = Theme.of(context).colorScheme.tertiaryContainer; + textColor = Theme.of(context).colorScheme.onTertiaryContainer; + } else if (task.isPaused) { + label = i18n.my.downloads.paused; + backgroundColor = Theme.of(context).colorScheme.surfaceContainerHighest; + textColor = Theme.of(context).colorScheme.onSurface; + } else { + label = i18n.my.downloads.failed; + backgroundColor = Theme.of(context).colorScheme.errorContainer; + textColor = Theme.of(context).colorScheme.onErrorContainer; + } + + return Chip( + label: Text(label), + backgroundColor: backgroundColor, + labelStyle: TextStyle(color: textColor, fontSize: 12), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } + + List _buildActionButtons(DownloadTask task) { + List buttons = []; + + if (task.isCompleted) { + buttons.add( + TextButton.icon( + onPressed: () => _playDownloadedEpisode(task), + icon: const Icon(Icons.play_arrow, size: 18), + label: Text(i18n.my.downloads.play), + ), + ); + buttons.add(const SizedBox(width: 8)); + buttons.add( + TextButton.icon( + onPressed: () => _showDeleteDialog(task), + icon: const Icon(Icons.delete, size: 18), + label: Text(i18n.my.downloads.delete), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ); + } else if (task.isDownloading) { + buttons.add( + TextButton.icon( + onPressed: () => downloadController.pauseTask(task), + icon: const Icon(Icons.pause, size: 18), + label: Text(i18n.my.downloads.pause), + ), + ); + buttons.add(const SizedBox(width: 8)); + buttons.add( + TextButton.icon( + onPressed: () => downloadController.cancelTask(task), + icon: const Icon(Icons.close, size: 18), + label: Text(i18n.my.downloads.cancel), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ); + } else if (task.isPaused) { + buttons.add( + TextButton.icon( + onPressed: () => downloadController.resumeTask(task), + icon: const Icon(Icons.play_arrow, size: 18), + label: Text(i18n.my.downloads.resume), + ), + ); + buttons.add(const SizedBox(width: 8)); + buttons.add( + TextButton.icon( + onPressed: () => downloadController.cancelTask(task), + icon: const Icon(Icons.close, size: 18), + label: Text(i18n.my.downloads.cancel), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ); + } else if (task.isFailed) { + buttons.add( + TextButton.icon( + onPressed: () => downloadController.retryTask(task), + icon: const Icon(Icons.refresh, size: 18), + label: Text(i18n.my.downloads.retry), + ), + ); + buttons.add(const SizedBox(width: 8)); + buttons.add( + TextButton.icon( + onPressed: () => downloadController.cancelTask(task), + icon: const Icon(Icons.close, size: 18), + label: Text(i18n.my.downloads.cancel), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ); + } else if (task.isQueued) { + buttons.add( + TextButton.icon( + onPressed: () => downloadController.cancelTask(task), + icon: const Icon(Icons.close, size: 18), + label: Text(i18n.my.downloads.cancel), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + ), + ); + } + + return buttons; + } + + String _formatProgress(DownloadTask task) { + final received = task.receivedBytes ?? 0; + final total = task.totalBytes ?? 0; + + if (total == 0) return 'Waiting...'; + + final receivedMB = received / (1024 * 1024); + final totalMB = total / (1024 * 1024); + final percentage = (task.progress * 100).toStringAsFixed(1); + + return '${receivedMB.toStringAsFixed(1)} MB / ${totalMB.toStringAsFixed(1)} MB ($percentage%)'; + } +} + diff --git a/lib/pages/index_module.dart b/lib/pages/index_module.dart index 155154d..3b4aec0 100644 --- a/lib/pages/index_module.dart +++ b/lib/pages/index_module.dart @@ -7,8 +7,10 @@ import 'package:oneanime/pages/video/video_controller.dart'; import 'package:oneanime/pages/timeline/timeline_controller.dart'; import 'package:oneanime/pages/my/my_controller.dart'; import 'package:oneanime/pages/history/history_controller.dart'; +import 'package:oneanime/pages/download/download_controller.dart'; import 'package:oneanime/pages/video/video_module.dart'; import 'package:oneanime/pages/history/history_module.dart'; +import 'package:oneanime/pages/download/download_module.dart'; import 'package:oneanime/pages/settings/setting_module.dart'; import 'package:oneanime/pages/error/error.dart'; @@ -23,6 +25,7 @@ class IndexModule extends Module { i.addSingleton(TimelineController.new); i.addSingleton(MyController.new); i.addSingleton(HistoryController.new); + i.addSingleton(DownloadController.new); } @override @@ -43,6 +46,7 @@ class IndexModule extends Module { children: menu.routes); r.module("/video", module: VideoModule()); r.module("/history", module: HistoryModule()); + r.module("/download", module: DownloadModule()); r.module("/settings", module: SettingsModule()); } } diff --git a/lib/pages/my/my_page.dart b/lib/pages/my/my_page.dart index 80f9272..7827c39 100644 --- a/lib/pages/my/my_page.dart +++ b/lib/pages/my/my_page.dart @@ -68,6 +68,14 @@ class _MyPageState extends State { title: Text(i18n.my.history.title), // trailing: const Icon(Icons.navigate_next), ), + ListTile( + onTap: () async { + Modular.to.pushNamed('/download/'); + }, + dense: false, + title: Text(i18n.my.downloads.title), + // trailing: const Icon(Icons.navigate_next), + ), ListTile( onTap: () async { Modular.to.pushNamed('/settings/player'); diff --git a/lib/pages/video/video_controller.dart b/lib/pages/video/video_controller.dart index 0dcc03f..228c472 100644 --- a/lib/pages/video/video_controller.dart +++ b/lib/pages/video/video_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:mobx/mobx.dart'; import 'package:oneanime/bean/anime/anime_bangumi_info.dart'; @@ -9,6 +10,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:oneanime/request/danmaku.dart'; import 'package:oneanime/bean/danmaku/danmaku_module.dart'; import 'package:oneanime/utils/storage.dart'; +import 'package:oneanime/pages/download/download_controller.dart'; import 'package:hive/hive.dart'; part 'video_controller.g.dart'; @@ -88,12 +90,28 @@ abstract class _VideoController with Store { final PlayerController playerController = Modular.get(); final PopularController popularController = Modular.get(); + final DownloadController downloadController = Modular.get(); + popularController.updateAnimeProgress(episode, title); - var result = await VideoRequest.getVideoLink(token[token.length - episode]); - videoUrl = result['link']; - videoCookie = result['cookie']; - playerController.videoUrl = videoUrl; - playerController.videoCookie = videoCookie; + + // Check if episode is downloaded locally + final localPath = downloadController.getLocalPath(link, episode); + if (localPath != null && File(localPath).existsSync()) { + // Use local file + debugPrint('Playing from local file: $localPath'); + videoUrl = localPath; + videoCookie = ''; + playerController.videoUrl = videoUrl; + playerController.videoCookie = ''; + } else { + // Download from server + var result = await VideoRequest.getVideoLink(token[token.length - episode]); + videoUrl = result['link']; + videoCookie = result['cookie']; + playerController.videoUrl = videoUrl; + playerController.videoCookie = videoCookie; + } + this.episode = episode; playing = false; currentPosition = Duration.zero; diff --git a/lib/pages/video/video_page.dart b/lib/pages/video/video_page.dart index 0f9f025..073831f 100644 --- a/lib/pages/video/video_page.dart +++ b/lib/pages/video/video_page.dart @@ -521,6 +521,8 @@ class _VideoPageState extends State currentEpisode: videoController.episode, onChangeEpisode: videoController.changeEpisode, + animeLink: videoController.link, + tokens: videoController.token, ), ], ) @@ -540,6 +542,8 @@ class _VideoPageState extends State currentEpisode: videoController.episode, onChangeEpisode: videoController.changeEpisode, + animeLink: videoController.link, + tokens: videoController.token, ), ], )), diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 6480ea4..d23e938 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -5,6 +5,8 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:path_provider/path_provider.dart'; import 'package:oneanime/bean/anime/anime_info.dart'; import 'package:oneanime/bean/anime/anime_history.dart'; +import 'package:oneanime/bean/download/download_task.dart'; +import 'package:oneanime/bean/download/download_anime.dart'; class GStorage { static late final Box localCache; @@ -12,6 +14,8 @@ class GStorage { static late final Box setting; static late final Box listCahce; static late final Box history; + static late final Box downloadTasks; + static late final Box downloadAnime; static Future init() async { final Directory dir = await getApplicationSupportDirectory(); @@ -22,6 +26,8 @@ class GStorage { setting = await Hive.openBox('setting'); listCahce = await Hive.openBox('anime_info_box'); history = await Hive.openBox('history'); + downloadTasks = await Hive.openBox('download_tasks'); + downloadAnime = await Hive.openBox('download_anime'); // 本地缓存 localCache = await Hive.openBox( @@ -37,6 +43,8 @@ class GStorage { static void regAdapter() { Hive.registerAdapter(AnimeInfoAdapter()); Hive.registerAdapter(AnimeHistoryAdapter()); + Hive.registerAdapter(DownloadTaskAdapter()); + Hive.registerAdapter(DownloadAnimeAdapter()); } static Future close() async { @@ -46,6 +54,10 @@ class GStorage { localCache.close(); setting.compact(); setting.close(); + downloadTasks.compact(); + downloadTasks.close(); + downloadAnime.compact(); + downloadAnime.close(); } } From b1f9b5582b396a242a2b489d54d1f67686a30dab Mon Sep 17 00:00:00 2001 From: David Zhuang Date: Tue, 16 Dec 2025 21:44:40 -0700 Subject: [PATCH 2/9] fix: build error --- .github/workflows/pr.yaml | 24 ++++++++++++++++++++++++ .github/workflows/release.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 86325eb..6f315fc 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -37,6 +37,12 @@ - name: Get Flutter dependencies run: flutter pub get shell: bash + - name: Generate i18n translations + run: dart run slang + shell: bash + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs + shell: bash - name: Inject DanDan API Credentials run: | sed -i "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart @@ -74,6 +80,10 @@ distribution: 'temurin' java-version: '18' - run: flutter pub get + - name: Generate i18n translations + run: dart run slang + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs - name: Inject DanDan API Credentials env: DANDANAPI_APPID: ${{ secrets.DANDANAPI_APPID }} @@ -118,6 +128,12 @@ - name: Get Flutter dependencies run: flutter pub get shell: bash + - name: Generate i18n translations + run: dart run slang + shell: bash + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs + shell: bash - name: Inject DanDan API Credentials run: | sed -i "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart @@ -157,6 +173,10 @@ channel: stable flutter-version-file: pubspec.yaml - run: flutter pub get + - name: Generate i18n translations + run: dart run slang + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs - name: Inject DanDan API Credentials run: | sed -i '' "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart @@ -188,6 +208,10 @@ channel: stable flutter-version-file: pubspec.yaml - run: flutter pub get + - name: Generate i18n translations + run: dart run slang + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs - name: Inject DanDan API Credentials run: | sed -i '' "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2ebc45d..048091d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -44,6 +44,12 @@ - name: Get Flutter dependencies run: flutter pub get shell: bash + - name: Generate i18n translations + run: dart run slang + shell: bash + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs + shell: bash - name: Inject DanDan API Credentials run: | sed -i "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart @@ -85,6 +91,10 @@ distribution: 'temurin' java-version: '18' - run: flutter pub get + - name: Generate i18n translations + run: dart run slang + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs - name: Inject DanDan API Credentials env: DANDANAPI_APPID: ${{ secrets.DANDANAPI_APPID }} @@ -135,6 +145,12 @@ - name: Get Flutter dependencies run: flutter pub get shell: bash + - name: Generate i18n translations + run: dart run slang + shell: bash + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs + shell: bash - name: Inject DanDan API Credentials run: | sed -i "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart @@ -205,6 +221,10 @@ channel: stable flutter-version-file: pubspec.yaml - run: flutter pub get + - name: Generate i18n translations + run: dart run slang + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs - name: Inject DanDan API Credentials run: | sed -i '' "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart @@ -236,6 +256,10 @@ channel: stable flutter-version-file: pubspec.yaml - run: flutter pub get + - name: Generate i18n translations + run: dart run slang + - name: Generate MobX code + run: flutter pub run build_runner build --delete-conflicting-outputs - name: Inject DanDan API Credentials run: | sed -i '' "s/kvpx7qkqjh/${{ secrets.DANDANAPI_APPID }}/g" lib/utils/mortis.dart From 80ed3afc09f6cff03154a0bd198897d96f47b425 Mon Sep 17 00:00:00 2001 From: David Zhuang Date: Tue, 16 Dec 2025 22:04:06 -0700 Subject: [PATCH 3/9] fix: build --- .github/workflows/release.yaml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 048091d..63b3030 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,7 +21,7 @@ - name: Clone repository uses: actions/checkout@v4 - name: Extract tag name - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + run: echo "tag=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV shell: bash - name: Echo build progress run: echo "oneAnime_android_${{ env.tag }}.apk build progress" @@ -58,7 +58,7 @@ run: flutter build apk --split-per-abi shell: bash - name: Package android build output - run: cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk oneAnime_android_${env:tag}.apk + run: cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk oneAnime_android_${{ env.tag }}.apk shell: bash - name: Upload android outputs @@ -76,8 +76,8 @@ - name: Clone repository uses: actions/checkout@v4 - run: | - $tag = "${{ github.ref }}".Replace('refs/tags/', '') - echo "tag=$(echo $tag)" >> $env:GITHUB_ENV + $tag = "${{ github.ref_name }}".Replace('/', '-').Replace('\', '-') + echo "tag=$tag" >> $env:GITHUB_ENV - run: echo "oneAnime_windows_${env:tag}.zip build progress" - run: choco install yq - name: Set up Flutter @@ -127,7 +127,7 @@ - name: Clone repository uses: actions/checkout@v4 - name: Extract tag name - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + run: echo "tag=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV shell: bash - name: Echo build progress run: echo "oneAnime_linux_${{ env.tag }}.tar.gz build progress" @@ -212,7 +212,7 @@ - name: Clone repository uses: actions/checkout@v4 - name: Extract tag name - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + run: echo "tag=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV - name: Echo build progress run: echo "oneAnime_macos_${{ env.tag }}.dmg build progress" - name: Set up Flutter @@ -247,7 +247,7 @@ - name: Clone repository uses: actions/checkout@v4 - name: Extract tag name - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + run: echo "tag=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV - name: Echo build progress run: echo "oneAnime_ios_${{ env.tag }}.ipa build progress" - name: Set up Flutter @@ -287,7 +287,7 @@ - name: Clone repository uses: actions/checkout@v4 - name: Extract tag name - run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + run: echo "tag=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV shell: bash - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -367,9 +367,23 @@ run: cp ${{steps.sign_app.outputs.signedReleaseFile}} build/signed/oneAnime_android_${{ env.tag }}.apk - name: Create release + if: github.ref_type == 'tag' uses: softprops/action-gh-release@v2 with: files: | + build/signed/*.apk + oneAnime_windows_*.zip + oneAnime_macos_*.dmg + oneAnime_ios_*.ipa + oneAnime_linux_*.tar.gz + oneAnime_linux_*.AppImage + + - name: Upload release bundle as artifact + if: github.ref_type != 'tag' + uses: actions/upload-artifact@v4 + with: + name: release_bundle + path: | build/signed/*.apk oneAnime_windows_*.zip oneAnime_macos_*.dmg From 99cafb88bc589ac224682b68bf63531e2a3737aa Mon Sep 17 00:00:00 2001 From: David Zhuang Date: Tue, 16 Dec 2025 22:38:12 -0700 Subject: [PATCH 4/9] fest: download button --- lib/bean/anime/anime_panel.dart | 239 +++++++++++++------- lib/pages/download/download_controller.dart | 8 + lib/pages/video/video_page.dart | 130 +++++++++++ 3 files changed, 295 insertions(+), 82 deletions(-) diff --git a/lib/bean/anime/anime_panel.dart b/lib/bean/anime/anime_panel.dart index 6389f5e..1b106a2 100644 --- a/lib/bean/anime/anime_panel.dart +++ b/lib/bean/anime/anime_panel.dart @@ -4,6 +4,7 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:oneanime/i18n/strings.g.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:oneanime/pages/download/download_controller.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; class BangumiPanel extends StatelessWidget { const BangumiPanel({ @@ -132,94 +133,121 @@ class BangumiPanel extends StatelessWidget { itemCount: episodeLength, itemBuilder: (BuildContext context, int i) { final episode = i + 1; - final isDownloaded = animeLink != null && - downloadController.isEpisodeDownloaded(animeLink!, episode); - final task = animeLink != null ? - downloadController.getTaskForEpisode(animeLink!, episode) : null; - return Container( - // width: 150, - margin: const EdgeInsets.only(bottom: 10), // 改为bottom间距 - child: Material( - color: Theme.of(context).colorScheme.onInverseSurface, - borderRadius: BorderRadius.circular(6), - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () { - onChangeEpisode(episode); - }, - onLongPress: animeLink != null && tokens != null && episode <= tokens!.length - ? () { - _showDownloadMenu( - context, - downloadController, - episode, - isDownloaded, - task, - i18n, - ); - } - : null, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, horizontal: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (i == (currentEpisode - 1)) ...[ - Image.asset( - 'assets/images/live.png', - color: - Theme.of(context).colorScheme.primary, - height: 12, - ), - const SizedBox(width: 6) - ], - Expanded( - child: Text( - i18n.toast.currentEpisode(episode: episode), - style: TextStyle( - fontSize: 13, - color: i == (currentEpisode - 1) - ? Theme.of(context) - .colorScheme - .primary - : Theme.of(context) - .colorScheme - .onSurface), - ), - ), - const SizedBox(width: 2), - if (isDownloaded) - Icon( - Icons.download_done, - size: 16, - color: Theme.of(context).colorScheme.primary, - ) - else if (task != null && task.isDownloading) - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - value: task.progress > 0 ? task.progress : null, + return Observer(builder: (context) { + final isDownloaded = animeLink != null && + downloadController.isEpisodeDownloaded(animeLink!, episode); + final task = animeLink != null ? + downloadController.getTaskForEpisode(animeLink!, episode) : null; + + return Container( + // width: 150, + margin: const EdgeInsets.only(bottom: 10), // 改为bottom间距 + child: Material( + color: Theme.of(context).colorScheme.onInverseSurface, + borderRadius: BorderRadius.circular(6), + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () { + onChangeEpisode(episode); + }, + onLongPress: animeLink != null && tokens != null && episode <= tokens!.length + ? () { + _showDownloadMenu( + context, + downloadController, + episode, + isDownloaded, + task, + i18n, + ); + } + : null, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (i == (currentEpisode - 1)) ...[ + Image.asset( + 'assets/images/live.png', + color: + Theme.of(context).colorScheme.primary, + height: 12, + ), + const SizedBox(width: 6) + ], + Expanded( + child: Text( + i18n.toast.currentEpisode(episode: episode), + style: TextStyle( + fontSize: 13, + color: i == (currentEpisode - 1) + ? Theme.of(context) + .colorScheme + .primary + : Theme.of(context) + .colorScheme + .onSurface), ), ), - ], - ), - const SizedBox(height: 3), - ], + const SizedBox(width: 2), + if (isDownloaded) + Icon( + Icons.download_done, + size: 16, + color: Theme.of(context).colorScheme.primary, + ) + else if (task != null && task.isDownloading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + value: task.progress > 0 ? task.progress : null, + ), + ), + ], + ), + const SizedBox(height: 3), + ], + ), ), - ), - ], + // Download button positioned at bottom-right + if (animeLink != null && tokens != null && episode <= tokens!.length) + Positioned( + bottom: 0, + right: 0, + child: IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + iconSize: 18, + icon: Icon( + _getDownloadIcon(isDownloaded, task), + size: 18, + color: Theme.of(context).colorScheme.primary.withOpacity(0.7), + ), + onPressed: () => _handleDownloadTap( + context, + downloadController, + episode, + isDownloaded, + task, + i18n, + ), + ), + ), + ], + ), ), ), - ), - ); + ); + }); }, ), ), @@ -229,6 +257,53 @@ class BangumiPanel extends StatelessWidget { ); } + IconData _getDownloadIcon(bool isDownloaded, dynamic task) { + if (isDownloaded) { + return Icons.download_done; + } else if (task != null && task.isDownloading) { + return Icons.downloading; + } else if (task != null && (task.isPaused || task.isFailed)) { + return Icons.download; + } else if (task != null && task.isQueued) { + return Icons.schedule; + } + return Icons.download; + } + + void _handleDownloadTap( + BuildContext context, + DownloadController downloadController, + int episode, + bool isDownloaded, + dynamic task, + Translations i18n, + ) { + if (animeLink == null || tokens == null || episode > tokens!.length) return; + + // Hybrid behavior: if not downloaded and no task, start download directly + if (!isDownloaded && task == null) { + final token = tokens![tokens!.length - episode]; + downloadController.enqueueEpisode( + link: animeLink!, + title: title, + episode: episode, + token: token, + ).then((result) { + SmartDialog.showToast(result); + }); + } else { + // Otherwise, show the action menu + _showDownloadMenu( + context, + downloadController, + episode, + isDownloaded, + task, + i18n, + ); + } + } + void _showDownloadMenu( BuildContext context, DownloadController downloadController, diff --git a/lib/pages/download/download_controller.dart b/lib/pages/download/download_controller.dart index b001d2e..8dcd761 100644 --- a/lib/pages/download/download_controller.dart +++ b/lib/pages/download/download_controller.dart @@ -17,6 +17,7 @@ class DownloadController = _DownloadController with _$DownloadController; abstract class _DownloadController with Store { final Dio _dio = Dio(); CancelToken? _currentCancelToken; + bool _initialized = false; @observable ObservableList tasks = ObservableList.of([]); @@ -27,7 +28,14 @@ abstract class _DownloadController with Store { @observable bool isProcessing = false; + _DownloadController() { + // Auto-initialize on first access + init(); + } + Future init() async { + if (_initialized) return; + _initialized = true; await loadTasks(); // Resume any interrupted downloads resumeQueue(); diff --git a/lib/pages/video/video_page.dart b/lib/pages/video/video_page.dart index 073831f..cc73340 100644 --- a/lib/pages/video/video_page.dart +++ b/lib/pages/video/video_page.dart @@ -11,6 +11,7 @@ import 'package:oneanime/pages/video/video_controller.dart'; import 'package:oneanime/pages/popular/popular_controller.dart'; import 'package:oneanime/pages/player/player_controller.dart'; import 'package:oneanime/pages/player/player_item.dart'; +import 'package:oneanime/pages/download/download_controller.dart'; import 'package:oneanime/bean/danmaku/danmaku_module.dart'; import 'package:oneanime/bean/anime/anime_panel.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -43,6 +44,7 @@ class _VideoPageState extends State final PopularController popularController = Modular.get(); final PlayerController playerController = Modular.get(); final HistoryController historyController = Modular.get(); + final DownloadController downloadController = Modular.get(); // 弹幕 final _danmuKey = GlobalKey(); @@ -966,6 +968,29 @@ class _VideoPageState extends State .tertiary .withOpacity(0.5), ), + Observer(builder: (context) { + final currentEpisode = videoController.episode; + final isDownloaded = downloadController.isEpisodeDownloaded( + videoController.link, currentEpisode); + final task = downloadController.getTaskForEpisode( + videoController.link, currentEpisode); + + return IconButton( + icon: Icon( + _getDownloadIconForHeader(isDownloaded, task), + color: Colors.white, + ), + onPressed: () => _handleHeaderDownloadTap( + isDownloaded, + task, + currentEpisode, + ), + splashColor: Theme.of(context) + .colorScheme + .tertiary + .withOpacity(0.5), + ); + }), ], ), ), @@ -1110,4 +1135,109 @@ class _VideoPageState extends State ), ); } + + IconData _getDownloadIconForHeader(bool isDownloaded, dynamic task) { + if (isDownloaded) { + return Icons.download_done; + } else if (task != null && task.isDownloading) { + return Icons.downloading; + } else if (task != null && (task.isPaused || task.isFailed)) { + return Icons.download; + } else if (task != null && task.isQueued) { + return Icons.schedule; + } + return Icons.download; + } + + void _handleHeaderDownloadTap(bool isDownloaded, dynamic task, int currentEpisode) { + // Check if we have valid token for current episode + if (videoController.token.isEmpty || currentEpisode > videoController.token.length) { + SmartDialog.showToast('Episode not available for download'); + return; + } + + // Hybrid behavior: if not downloaded and no task, start download directly + if (!isDownloaded && task == null) { + final token = videoController.token[videoController.token.length - currentEpisode]; + downloadController.enqueueEpisode( + link: videoController.link, + title: videoController.title, + episode: currentEpisode, + token: token, + ).then((result) { + SmartDialog.showToast(result); + }); + } else { + // Otherwise, show action menu + _showHeaderDownloadMenu(isDownloaded, task, currentEpisode); + } + } + + void _showHeaderDownloadMenu(bool isDownloaded, dynamic task, int currentEpisode) { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isDownloaded && task == null) + ListTile( + leading: const Icon(Icons.download), + title: Text('Download Episode $currentEpisode'), + onTap: () async { + Navigator.pop(context); + final token = videoController.token[videoController.token.length - currentEpisode]; + final result = await downloadController.enqueueEpisode( + link: videoController.link, + title: videoController.title, + episode: currentEpisode, + token: token, + ); + SmartDialog.showToast(result); + }, + ), + if (task != null && task.isDownloading) + ListTile( + leading: const Icon(Icons.pause), + title: Text(i18n.my.downloads.pause), + onTap: () { + Navigator.pop(context); + downloadController.pauseTask(task); + }, + ), + if (task != null && (task.isPaused || task.isFailed)) + ListTile( + leading: const Icon(Icons.play_arrow), + title: Text(i18n.my.downloads.resume), + onTap: () { + Navigator.pop(context); + downloadController.resumeTask(task); + }, + ), + if (isDownloaded) + ListTile( + leading: const Icon(Icons.delete), + title: Text(i18n.my.downloads.delete), + onTap: () { + Navigator.pop(context); + downloadController.deleteTask(task); + SmartDialog.showToast('Download deleted'); + }, + ), + if (task != null && !isDownloaded) + ListTile( + leading: const Icon(Icons.cancel), + title: Text(i18n.my.downloads.cancel), + onTap: () { + Navigator.pop(context); + downloadController.cancelTask(task); + }, + ), + ], + ), + ); + }, + ); + } } From c5a8ad0f5dce7a71e8c70355ba25f3a99ddb129d Mon Sep 17 00:00:00 2001 From: David Zhuang Date: Tue, 16 Dec 2025 23:55:27 -0700 Subject: [PATCH 5/9] fest: background download & UX for mobile --- android/app/src/main/AndroidManifest.xml | 11 +- ios/Runner/AppDelegate.swift | 5 + ios/Runner/Info.plist | 4 + lib/bean/anime/anime_panel.dart | 152 +++--- lib/pages/download/download_controller.dart | 539 +++++++++++++------- pubspec.yaml | 1 + 6 files changed, 466 insertions(+), 246 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 883b77d..01480f5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + + + + + +