696 lines
22 KiB
Dart
696 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:worldhopper/models/downloaded_chapter.dart';
|
|
import 'package:worldhopper/models/downloaded_series.dart';
|
|
import 'package:worldhopper/models/entry.dart';
|
|
import 'package:worldhopper/models/feed.dart';
|
|
import 'package:worldhopper/models/server.dart';
|
|
import 'package:worldhopper/repositories/downloaded_chapters_repository.dart';
|
|
import 'package:worldhopper/repositories/downloaded_series_repository.dart';
|
|
import 'package:worldhopper/repositories/feed_repository.dart';
|
|
import 'package:worldhopper/services/background_download_service.dart' as bg;
|
|
import 'package:worldhopper/services/download_storage_service.dart';
|
|
import 'package:worldhopper/services/image_stream_download_service.dart';
|
|
import 'package:worldhopper/services/server_software/server_software_service.dart';
|
|
|
|
/// Progress information for an active download
|
|
class DownloadProgress {
|
|
final String seriesId;
|
|
final String? chapterId;
|
|
final String seriesTitle;
|
|
final String? chapterTitle;
|
|
final int completedChapters;
|
|
final int totalChapters;
|
|
final int? currentPageInChapter;
|
|
final int? totalPagesInChapter;
|
|
final int? bytesDownloaded;
|
|
final int? bytesTotal;
|
|
final DownloadStatus status;
|
|
|
|
const DownloadProgress({
|
|
required this.seriesId,
|
|
this.chapterId,
|
|
required this.seriesTitle,
|
|
this.chapterTitle,
|
|
required this.completedChapters,
|
|
required this.totalChapters,
|
|
this.currentPageInChapter,
|
|
this.totalPagesInChapter,
|
|
this.bytesDownloaded,
|
|
this.bytesTotal,
|
|
required this.status,
|
|
});
|
|
|
|
double get overallProgress {
|
|
if (totalChapters == 0) return 0.0;
|
|
return (completedChapters / totalChapters).clamp(0.0, 1.0);
|
|
}
|
|
|
|
/// Progress within the current chapter (0.0 to 1.0)
|
|
double get chapterProgress {
|
|
if (currentPageInChapter != null &&
|
|
totalPagesInChapter != null &&
|
|
totalPagesInChapter! > 0) {
|
|
return (currentPageInChapter! / totalPagesInChapter!).clamp(0.0, 1.0);
|
|
}
|
|
if (bytesDownloaded != null && bytesTotal != null && bytesTotal! > 0) {
|
|
return (bytesDownloaded! / bytesTotal!).clamp(0.0, 1.0);
|
|
}
|
|
return 0.0;
|
|
}
|
|
}
|
|
|
|
/// Orchestrates full series downloads
|
|
class SeriesDownloadService {
|
|
final DownloadedSeriesRepository _seriesRepo;
|
|
final DownloadedChaptersRepository _chaptersRepo;
|
|
final FeedRepository _opdsRepo;
|
|
final DownloadStorageService _storageService;
|
|
final ImageStreamDownloadService _imageStreamService;
|
|
final ServerSoftwareService _softwareService;
|
|
|
|
final _progressController = StreamController<DownloadProgress>.broadcast();
|
|
final Map<String, CancelToken> _activeCancelTokens = {};
|
|
|
|
// Tracking state for progress callbacks
|
|
String? _currentSeriesTitle;
|
|
int _currentCompletedCount = 0;
|
|
int _currentTotalChapters = 0;
|
|
|
|
SeriesDownloadService({
|
|
required DownloadedSeriesRepository seriesRepo,
|
|
required DownloadedChaptersRepository chaptersRepo,
|
|
required FeedRepository opdsRepo,
|
|
DownloadStorageService? storageService,
|
|
ImageStreamDownloadService? imageStreamService,
|
|
ServerSoftwareService? softwareService,
|
|
}) : _seriesRepo = seriesRepo,
|
|
_chaptersRepo = chaptersRepo,
|
|
_opdsRepo = opdsRepo,
|
|
_storageService = storageService ?? DownloadStorageService(),
|
|
_imageStreamService =
|
|
imageStreamService ?? ImageStreamDownloadService(),
|
|
_softwareService = softwareService ?? ServerSoftwareService();
|
|
|
|
/// Stream of download progress updates
|
|
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
|
|
|
/// Start downloading an entire series
|
|
Future<void> startSeriesDownload({
|
|
required Server server,
|
|
required String feedUrl,
|
|
required String feedTitle,
|
|
required Feed initialFeed,
|
|
String? coverUrl,
|
|
String? thumbnailUrl,
|
|
}) async {
|
|
const uuid = Uuid();
|
|
final seriesId = uuid.v4();
|
|
final cancelToken = CancelToken();
|
|
_activeCancelTokens[seriesId] = cancelToken;
|
|
|
|
try {
|
|
// 1. Fetch all paginated pages to get complete chapter list
|
|
final allEntries = await _fetchAllEntries(
|
|
server.id,
|
|
initialFeed,
|
|
cancelToken: cancelToken,
|
|
);
|
|
|
|
// Filter to only downloadable entries
|
|
final downloadableEntries =
|
|
allEntries.where((e) => e.isEpub || e.isImageStream).toList();
|
|
|
|
if (downloadableEntries.isEmpty) {
|
|
throw Exception('No downloadable content found in this series');
|
|
}
|
|
|
|
// 2. Create the series record
|
|
final now = DateTime.now();
|
|
final series = DownloadedSeries(
|
|
id: seriesId,
|
|
serverId: server.id,
|
|
seriesFeedUrl: feedUrl,
|
|
title: feedTitle,
|
|
coverUrl: coverUrl,
|
|
thumbnailUrl: thumbnailUrl,
|
|
status: DownloadStatus.downloading,
|
|
totalChapters: downloadableEntries.length,
|
|
downloadedChaptersCount: 0,
|
|
updatedAt: now,
|
|
);
|
|
await _seriesRepo.save(series);
|
|
|
|
// 3. Create chapter records
|
|
for (var i = 0; i < downloadableEntries.length; i++) {
|
|
final entry = downloadableEntries[i];
|
|
final chapterId = uuid.v4();
|
|
final contentType = entry.isImageStream
|
|
? ChapterContentType.imageStream
|
|
: ChapterContentType.epub;
|
|
|
|
final chapter = DownloadedChapter(
|
|
id: chapterId,
|
|
seriesId: seriesId,
|
|
serverId: server.id,
|
|
entryId: entry.id,
|
|
title: entry.title,
|
|
contentType: contentType,
|
|
status: DownloadStatus.pending,
|
|
totalPages: entry.streamLink?.pageCount ?? 0,
|
|
entryJson: jsonEncode(entry.toJson()),
|
|
coverUrl: entry.thumbnailUrl ?? entry.coverUrl,
|
|
sortOrder: i,
|
|
updatedAt: now,
|
|
);
|
|
await _chaptersRepo.save(chapter);
|
|
}
|
|
|
|
// 4. Download chapters (main isolate + foreground notification)
|
|
_currentSeriesTitle = feedTitle;
|
|
_currentCompletedCount = 0;
|
|
_currentTotalChapters = downloadableEntries.length;
|
|
|
|
await _downloadChaptersWithNotification(
|
|
seriesId: seriesId,
|
|
server: server,
|
|
);
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Series download failed for "$feedTitle" '
|
|
'(id=$seriesId): $e\n$stackTrace');
|
|
await _seriesRepo.updateStatus(seriesId, DownloadStatus.failed);
|
|
_emitProgress(
|
|
seriesId: seriesId,
|
|
seriesTitle: feedTitle,
|
|
completedChapters: 0,
|
|
totalChapters: 0,
|
|
status: DownloadStatus.failed,
|
|
);
|
|
} finally {
|
|
_activeCancelTokens.remove(seriesId);
|
|
}
|
|
}
|
|
|
|
/// Download a single chapter (can be called standalone)
|
|
Future<void> downloadSingleChapter({
|
|
required Server server,
|
|
required Entry entry,
|
|
required String seriesId,
|
|
required String feedUrl,
|
|
required String feedTitle,
|
|
}) async {
|
|
const uuid = Uuid();
|
|
|
|
// Ensure series record exists
|
|
var series = await _seriesRepo.getByFeedUrl(server.id, feedUrl);
|
|
if (series == null) {
|
|
final now = DateTime.now();
|
|
series = DownloadedSeries(
|
|
id: seriesId.isEmpty ? uuid.v4() : seriesId,
|
|
serverId: server.id,
|
|
seriesFeedUrl: feedUrl,
|
|
title: feedTitle,
|
|
status: DownloadStatus.partial,
|
|
totalChapters: 1,
|
|
downloadedChaptersCount: 0,
|
|
updatedAt: now,
|
|
);
|
|
await _seriesRepo.save(series);
|
|
}
|
|
|
|
// Check if chapter already exists
|
|
var existing = await _chaptersRepo.getByEntryId(server.id, entry.id);
|
|
if (existing != null && existing.isComplete) return;
|
|
|
|
final chapterId = existing?.id ?? uuid.v4();
|
|
final contentType = entry.isImageStream
|
|
? ChapterContentType.imageStream
|
|
: ChapterContentType.epub;
|
|
final now = DateTime.now();
|
|
|
|
final chapter = DownloadedChapter(
|
|
id: chapterId,
|
|
seriesId: series.id,
|
|
serverId: server.id,
|
|
entryId: entry.id,
|
|
title: entry.title,
|
|
contentType: contentType,
|
|
status: DownloadStatus.pending,
|
|
totalPages: entry.streamLink?.pageCount ?? 0,
|
|
entryJson: jsonEncode(entry.toJson()),
|
|
coverUrl: entry.thumbnailUrl ?? entry.coverUrl,
|
|
sortOrder: 0,
|
|
updatedAt: now,
|
|
);
|
|
await _chaptersRepo.save(chapter);
|
|
|
|
final cancelToken = CancelToken();
|
|
_activeCancelTokens[chapterId] = cancelToken;
|
|
|
|
try {
|
|
await _downloadChapter(server, chapter, cancelToken);
|
|
|
|
// Update series counts
|
|
final allChapters = await _chaptersRepo.getChaptersForSeries(series.id);
|
|
final completedCount = allChapters.where((c) => c.isComplete).length;
|
|
await _seriesRepo.updateDownloadedCount(series.id, completedCount);
|
|
|
|
if (completedCount == series.totalChapters) {
|
|
await _seriesRepo.updateStatus(series.id, DownloadStatus.complete);
|
|
} else {
|
|
await _seriesRepo.updateStatus(series.id, DownloadStatus.partial);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Single chapter download failed '
|
|
'(id=$chapterId, series=${series.id}): $e\n$stackTrace');
|
|
await _chaptersRepo.updateStatus(
|
|
chapterId,
|
|
DownloadStatus.failed,
|
|
errorMessage: _describeError(e),
|
|
);
|
|
rethrow;
|
|
} finally {
|
|
_activeCancelTokens.remove(chapterId);
|
|
}
|
|
}
|
|
|
|
/// Retry a single failed chapter
|
|
Future<void> retryChapter({
|
|
required Server server,
|
|
required DownloadedChapter chapter,
|
|
}) async {
|
|
final cancelToken = CancelToken();
|
|
_activeCancelTokens[chapter.id] = cancelToken;
|
|
|
|
// Set up tracking state
|
|
final series = await _seriesRepo.getById(chapter.seriesId);
|
|
if (series != null) {
|
|
_currentSeriesTitle = series.title;
|
|
_currentTotalChapters = series.totalChapters;
|
|
_currentCompletedCount = series.downloadedChaptersCount;
|
|
}
|
|
|
|
try {
|
|
await _chaptersRepo.updateStatus(chapter.id, DownloadStatus.pending);
|
|
await _downloadChapter(server, chapter, cancelToken);
|
|
|
|
// Update series counts
|
|
final allChapters =
|
|
await _chaptersRepo.getChaptersForSeries(chapter.seriesId);
|
|
final completedCount = allChapters.where((c) => c.isComplete).length;
|
|
await _seriesRepo.updateDownloadedCount(chapter.seriesId, completedCount);
|
|
|
|
final hasFailures =
|
|
allChapters.any((c) => c.status == DownloadStatus.failed);
|
|
final newStatus = completedCount == allChapters.length
|
|
? DownloadStatus.complete
|
|
: hasFailures
|
|
? DownloadStatus.partial
|
|
: DownloadStatus.downloading;
|
|
await _seriesRepo.updateStatus(chapter.seriesId, newStatus);
|
|
} catch (e, stackTrace) {
|
|
debugPrint('Retry failed for chapter "${chapter.title}" '
|
|
'(id=${chapter.id}): $e\n$stackTrace');
|
|
await _chaptersRepo.updateStatus(
|
|
chapter.id,
|
|
DownloadStatus.failed,
|
|
errorMessage: _describeError(e),
|
|
);
|
|
} finally {
|
|
_activeCancelTokens.remove(chapter.id);
|
|
}
|
|
}
|
|
|
|
/// Retry all failed chapters in a series
|
|
Future<void> retryFailedChapters({
|
|
required Server server,
|
|
required String seriesId,
|
|
}) async {
|
|
final series = await _seriesRepo.getById(seriesId);
|
|
if (series == null) {
|
|
throw StateError('Cannot retry: series $seriesId not found in database');
|
|
}
|
|
|
|
// Set up tracking state
|
|
_currentSeriesTitle = series.title;
|
|
_currentTotalChapters = series.totalChapters;
|
|
_currentCompletedCount = series.downloadedChaptersCount;
|
|
|
|
// Reset failed chapters to pending
|
|
final pendingChapters = await _chaptersRepo.getPendingChapters(seriesId);
|
|
for (final chapter in pendingChapters) {
|
|
if (chapter.status == DownloadStatus.failed) {
|
|
await _chaptersRepo.updateStatus(chapter.id, DownloadStatus.pending);
|
|
}
|
|
}
|
|
|
|
await _seriesRepo.updateStatus(seriesId, DownloadStatus.downloading);
|
|
|
|
await _downloadChaptersWithNotification(
|
|
seriesId: seriesId,
|
|
server: server,
|
|
);
|
|
}
|
|
|
|
/// Cancel an active download
|
|
void cancelDownload(String id) {
|
|
_activeCancelTokens[id]?.cancel();
|
|
_activeCancelTokens.remove(id);
|
|
}
|
|
|
|
/// Delete a downloaded series and its files
|
|
Future<void> deleteSeries(String seriesId) async {
|
|
cancelDownload(seriesId);
|
|
await _storageService.deleteSeriesFiles(seriesId);
|
|
await _chaptersRepo.deleteForSeries(seriesId);
|
|
await _seriesRepo.delete(seriesId);
|
|
}
|
|
|
|
/// Delete a single downloaded chapter and its files
|
|
Future<void> deleteChapter(DownloadedChapter chapter) async {
|
|
cancelDownload(chapter.id);
|
|
await _storageService.deleteChapterFiles(
|
|
chapter.seriesId,
|
|
chapter.id,
|
|
chapter.contentType == ChapterContentType.epub,
|
|
);
|
|
await _chaptersRepo.delete(chapter.id);
|
|
|
|
// Update series counts
|
|
final remaining =
|
|
await _chaptersRepo.getChaptersForSeries(chapter.seriesId);
|
|
if (remaining.isEmpty) {
|
|
await _seriesRepo.delete(chapter.seriesId);
|
|
} else {
|
|
final completedCount = remaining.where((c) => c.isComplete).length;
|
|
await _seriesRepo.updateDownloadedCount(chapter.seriesId, completedCount);
|
|
final series = await _seriesRepo.getById(chapter.seriesId);
|
|
if (series != null) {
|
|
await _seriesRepo.save(series.copyWith(
|
|
totalChapters: remaining.length,
|
|
status: completedCount == remaining.length
|
|
? DownloadStatus.complete
|
|
: DownloadStatus.partial,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Download chapters in the main isolate while keeping a foreground
|
|
/// notification alive via the background service.
|
|
Future<void> _downloadChaptersWithNotification({
|
|
required String seriesId,
|
|
required Server server,
|
|
}) async {
|
|
final cancelToken = _activeCancelTokens[seriesId] ?? CancelToken();
|
|
_activeCancelTokens[seriesId] = cancelToken;
|
|
|
|
// Start the Android foreground service so the OS keeps us alive
|
|
await bg.startForegroundService();
|
|
|
|
int completedCount = 0;
|
|
bool hasFailures = false;
|
|
DownloadStatus finalStatus = DownloadStatus.partial;
|
|
|
|
try {
|
|
final chapters = await _chaptersRepo.getChaptersForSeries(seriesId);
|
|
|
|
for (final chapter in chapters) {
|
|
if (cancelToken.isCancelled) break;
|
|
if (chapter.isComplete) {
|
|
completedCount++;
|
|
continue;
|
|
}
|
|
|
|
bg.updateDownloadNotification(
|
|
title: _currentSeriesTitle ?? '',
|
|
content: '${chapter.title} ($completedCount/$_currentTotalChapters)',
|
|
progress: completedCount * 100,
|
|
maxProgress: _currentTotalChapters * 100,
|
|
force: true,
|
|
);
|
|
|
|
_emitProgress(
|
|
seriesId: seriesId,
|
|
chapterId: chapter.id,
|
|
seriesTitle: _currentSeriesTitle ?? '',
|
|
chapterTitle: chapter.title,
|
|
completedChapters: completedCount,
|
|
totalChapters: _currentTotalChapters,
|
|
status: DownloadStatus.downloading,
|
|
);
|
|
|
|
try {
|
|
await _downloadChapter(server, chapter, cancelToken);
|
|
completedCount++;
|
|
_currentCompletedCount = completedCount;
|
|
await _seriesRepo.updateDownloadedCount(seriesId, completedCount);
|
|
} catch (e, stackTrace) {
|
|
if (cancelToken.isCancelled) break;
|
|
debugPrint('Download failed for chapter "${chapter.title}" '
|
|
'(id=${chapter.id}, series=$seriesId): $e\n$stackTrace');
|
|
hasFailures = true;
|
|
try {
|
|
await _chaptersRepo.updateStatus(
|
|
chapter.id,
|
|
DownloadStatus.failed,
|
|
errorMessage: _describeError(e),
|
|
);
|
|
} catch (dbError) {
|
|
debugPrint('Failed to persist error status for chapter '
|
|
'${chapter.id}: $dbError');
|
|
}
|
|
}
|
|
}
|
|
|
|
finalStatus = (cancelToken.isCancelled || hasFailures)
|
|
? DownloadStatus.partial
|
|
: DownloadStatus.complete;
|
|
await _seriesRepo.updateStatus(seriesId, finalStatus);
|
|
|
|
_emitProgress(
|
|
seriesId: seriesId,
|
|
seriesTitle: _currentSeriesTitle ?? '',
|
|
completedChapters: completedCount,
|
|
totalChapters: _currentTotalChapters,
|
|
status: finalStatus,
|
|
);
|
|
} finally {
|
|
// Always tear down the foreground service and notification
|
|
bg.dismissDownloadNotification();
|
|
await bg.stopForegroundService();
|
|
}
|
|
|
|
if (finalStatus == DownloadStatus.complete) {
|
|
bg.showDownloadCompleteNotification(
|
|
title: 'Download complete',
|
|
body:
|
|
'${_currentSeriesTitle ?? 'Series'} — $completedCount chapters downloaded',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fetch all entries across paginated feed pages
|
|
Future<List<Entry>> _fetchAllEntries(
|
|
String serverId,
|
|
Feed initialFeed, {
|
|
CancelToken? cancelToken,
|
|
}) async {
|
|
final allEntries = List<Entry>.from(initialFeed.entries);
|
|
var nextUrl = initialFeed.nextLink?.href;
|
|
|
|
while (nextUrl != null) {
|
|
if (cancelToken?.isCancelled == true) break;
|
|
final nextFeed = await _opdsRepo.fetchFeed(
|
|
serverId,
|
|
nextUrl,
|
|
cancelToken: cancelToken,
|
|
);
|
|
allEntries.addAll(nextFeed.entries);
|
|
nextUrl = nextFeed.nextLink?.href;
|
|
}
|
|
|
|
return allEntries;
|
|
}
|
|
|
|
/// Download a single chapter's content
|
|
Future<void> _downloadChapter(
|
|
Server server,
|
|
DownloadedChapter chapter,
|
|
CancelToken cancelToken,
|
|
) async {
|
|
await _chaptersRepo.updateStatus(chapter.id, DownloadStatus.downloading);
|
|
|
|
if (chapter.contentType == ChapterContentType.epub) {
|
|
await _downloadEpubChapter(server, chapter, cancelToken);
|
|
} else {
|
|
await _downloadImageStreamChapter(server, chapter, cancelToken);
|
|
}
|
|
}
|
|
|
|
/// Download an EPUB chapter directly to persistent storage
|
|
Future<void> _downloadEpubChapter(
|
|
Server server,
|
|
DownloadedChapter chapter,
|
|
CancelToken cancelToken,
|
|
) async {
|
|
final entry = chapter.toEntry();
|
|
final epubPath =
|
|
await _storageService.getEpubPath(chapter.seriesId, chapter.id);
|
|
|
|
final software = _softwareService.getImplementation(server);
|
|
await software.downloadEpub(
|
|
server,
|
|
entry,
|
|
savePath: epubPath,
|
|
cancelToken: cancelToken,
|
|
onProgress: (progress) {
|
|
_emitProgress(
|
|
seriesId: chapter.seriesId,
|
|
chapterId: chapter.id,
|
|
seriesTitle: _currentSeriesTitle ?? '',
|
|
chapterTitle: chapter.title,
|
|
completedChapters: _currentCompletedCount,
|
|
totalChapters: _currentTotalChapters,
|
|
bytesDownloaded: (progress * 100).toInt(),
|
|
bytesTotal: 100,
|
|
status: DownloadStatus.downloading,
|
|
);
|
|
},
|
|
);
|
|
|
|
await _chaptersRepo.updateStatus(
|
|
chapter.id,
|
|
DownloadStatus.complete,
|
|
filePath: epubPath,
|
|
);
|
|
}
|
|
|
|
/// Download an image stream chapter
|
|
Future<void> _downloadImageStreamChapter(
|
|
Server server,
|
|
DownloadedChapter chapter,
|
|
CancelToken cancelToken,
|
|
) async {
|
|
final entry = chapter.toEntry();
|
|
final streamLink = entry.streamLink;
|
|
if (streamLink == null) {
|
|
throw Exception('No stream link for ${chapter.title}');
|
|
}
|
|
|
|
final chapterPath = await _imageStreamService.downloadChapter(
|
|
server: server,
|
|
streamLink: streamLink,
|
|
seriesId: chapter.seriesId,
|
|
chapterId: chapter.id,
|
|
cancelToken: cancelToken,
|
|
onProgress: (downloaded, total) {
|
|
_chaptersRepo.updateDownloadedPages(chapter.id, downloaded).catchError(
|
|
(e) => debugPrint('Failed to update download progress for '
|
|
'chapter ${chapter.id}: $e'),
|
|
);
|
|
_emitProgress(
|
|
seriesId: chapter.seriesId,
|
|
chapterId: chapter.id,
|
|
seriesTitle: _currentSeriesTitle ?? '',
|
|
chapterTitle: chapter.title,
|
|
completedChapters: _currentCompletedCount,
|
|
totalChapters: _currentTotalChapters,
|
|
currentPageInChapter: downloaded,
|
|
totalPagesInChapter: total,
|
|
status: DownloadStatus.downloading,
|
|
);
|
|
},
|
|
);
|
|
|
|
await _chaptersRepo.updateStatus(
|
|
chapter.id,
|
|
DownloadStatus.complete,
|
|
filePath: chapterPath,
|
|
);
|
|
}
|
|
|
|
void _emitProgress({
|
|
required String seriesId,
|
|
String? chapterId,
|
|
required String seriesTitle,
|
|
String? chapterTitle,
|
|
required int completedChapters,
|
|
required int totalChapters,
|
|
int? currentPageInChapter,
|
|
int? totalPagesInChapter,
|
|
int? bytesDownloaded,
|
|
int? bytesTotal,
|
|
required DownloadStatus status,
|
|
}) {
|
|
final progress = DownloadProgress(
|
|
seriesId: seriesId,
|
|
chapterId: chapterId,
|
|
seriesTitle: seriesTitle,
|
|
chapterTitle: chapterTitle,
|
|
completedChapters: completedChapters,
|
|
totalChapters: totalChapters,
|
|
currentPageInChapter: currentPageInChapter,
|
|
totalPagesInChapter: totalPagesInChapter,
|
|
bytesDownloaded: bytesDownloaded,
|
|
bytesTotal: bytesTotal,
|
|
status: status,
|
|
);
|
|
_progressController.add(progress);
|
|
|
|
// Update Android notification with progress bar
|
|
if (status == DownloadStatus.downloading && totalChapters > 0) {
|
|
final chapterPct = progress.chapterProgress;
|
|
final overallPct = (completedChapters * 100 + (chapterPct * 100).round())
|
|
.clamp(0, totalChapters * 100);
|
|
bg.updateDownloadNotification(
|
|
title: seriesTitle,
|
|
content: chapterTitle != null
|
|
? '$chapterTitle ($completedChapters/$totalChapters)'
|
|
: '$completedChapters/$totalChapters chapters',
|
|
progress: overallPct,
|
|
maxProgress: totalChapters * 100,
|
|
);
|
|
}
|
|
}
|
|
|
|
String _describeError(Object error) {
|
|
if (error is DioException) {
|
|
switch (error.type) {
|
|
case DioExceptionType.connectionTimeout:
|
|
case DioExceptionType.sendTimeout:
|
|
case DioExceptionType.receiveTimeout:
|
|
return 'Connection timed out';
|
|
case DioExceptionType.cancel:
|
|
return 'Download cancelled';
|
|
case DioExceptionType.badResponse:
|
|
final statusCode = error.response?.statusCode;
|
|
if (statusCode != null) {
|
|
return 'Server returned $statusCode';
|
|
}
|
|
return 'Server error';
|
|
default:
|
|
return 'Network error';
|
|
}
|
|
}
|
|
if (error is FileSystemException) {
|
|
return 'Disk error: ${error.message}';
|
|
}
|
|
return error.toString().length > 100
|
|
? '${error.toString().substring(0, 100)}...'
|
|
: error.toString();
|
|
}
|
|
|
|
void dispose() {
|
|
for (final token in _activeCancelTokens.values) {
|
|
token.cancel();
|
|
}
|
|
_activeCancelTokens.clear();
|
|
_progressController.close();
|
|
}
|
|
}
|