Files
cxyz/cxyz-android/lib/sources/comicland/comicland_source.dart
T
nsrb a7c6058540 Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration.
- Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files.
- Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown.
- Configured analysis options and dependencies in pubspec.yaml to support Flutter development.
- Established a pnpm workspace and lockfile for dependency management.
- Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
2026-08-29 17:50:31 +07:00

236 lines
7.5 KiB
Dart

import 'package:dio/dio.dart';
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// ComicLand JSON adapter (port of cxyz `src/comicland/*`).
class ComicLandSource implements ComicSource {
ComicLandSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
baseUrl: comicLandApiBase,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: comicLandApiHeaders,
responseType: ResponseType.json,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
@override
SourceId get id => SourceId.comicland;
@override
String get displayName => 'ComicLand';
@override
String get blurb =>
'Newest, popular, official, uncensored, recommended, and search via ComicLand API';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Newest'),
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Popular'),
CatalogMenu(kind: CatalogMenuKind.official, title: 'Official'),
CatalogMenu(kind: CatalogMenuKind.uncensored, title: 'Uncensored'),
CatalogMenu(kind: CatalogMenuKind.recommended, title: 'Recommended'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders => Map.unmodifiable(comicLandCdnHeaders);
Map<String, dynamic> _unwrap(dynamic payload, String label) {
if (payload is! Map) {
throw StateError('ComicLand $label: empty response');
}
final map = Map<String, dynamic>.from(payload);
final code = map['code'];
if (code != 0) {
final msg = (map['message'] as String?)?.trim();
throw StateError(
'ComicLand $label failed: ${msg?.isNotEmpty == true ? msg : 'code $code'}',
);
}
final data = map['data'];
if (data is! Map) {
throw StateError('ComicLand $label: missing data');
}
return Map<String, dynamic>.from(data);
}
Future<Map<String, dynamic>> _get(
String path, {
Map<String, dynamic>? query,
String label = 'request',
}) async {
final res = await _dio.get<dynamic>(path, queryParameters: query);
return _unwrap(res.data, label);
}
List<CatalogComic> _listFrom(Map<String, dynamic> data, {String listKey = 'list'}) {
final raw = data[listKey];
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((e) => catalogFromJson(Map<String, dynamic>.from(e)))
.toList();
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
switch (kind) {
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
case CatalogMenuKind.directory:
throw UnsupportedError('ComicLand has no ${kind.name} catalog feed');
case CatalogMenuKind.popular:
final data = await _get('/comics/popular', label: 'comics/popular');
final all = _listFrom(data);
if (offset == 0 && all.length <= limit) return all;
final end = (offset + limit).clamp(0, all.length);
if (offset >= all.length) return const [];
return all.sublist(offset, end);
case CatalogMenuKind.latest:
final data = await _get(
'/comics',
query: {'offset': offset, 'limit': limit, 'status': 'ongoing'},
label: 'comics/newest',
);
return _listFrom(data);
case CatalogMenuKind.official:
final data = await _get(
'/comics/official',
query: {'offset': offset, 'limit': limit},
label: 'comics/official',
);
return _listFrom(data);
case CatalogMenuKind.uncensored:
final data = await _get(
'/comic/search',
query: {'q': 'uncensored', 'offset': offset, 'limit': limit},
label: 'comic/search/uncensored',
);
return _listFrom(data, listKey: 'items');
case CatalogMenuKind.recommended:
final data = await _get(
'/comics',
query: {'offset': offset, 'limit': limit},
label: 'comics/recommended',
);
return _listFrom(data);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
final data = await _get(
'/comic/search',
query: {'q': q, 'offset': offset, 'limit': limit},
label: 'comic/search',
);
return _listFrom(data, listKey: 'items');
}
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final parts = parseComicLandUrl(listingUrl);
final data = await _get(
'/comic/detail',
query: {'slug': parts.slug},
label: 'comic/detail',
);
final title = (data['title'] as String?)?.trim().isNotEmpty == true
? (data['title'] as String).trim()
: parts.slug;
final rawChapters = data['chapters'];
final chapters = <ChapterLink>[];
if (rawChapters is List) {
for (final row in rawChapters) {
if (row is! Map) continue;
final map = Map<String, dynamic>.from(row);
final index = _chapterIndexString(map['chapter_index']);
if (index == null) continue;
final label = (map['title'] as String?)?.trim().replaceAll(RegExp(r'\s+'), ' ');
final chapterTitle =
(label != null && label.isNotEmpty) ? label : 'Chapter $index';
final url = chapterUrlFromParts(parts.origin, parts.slug, index);
chapters.add(
ChapterLink(
url: url,
title: chapterTitle,
chapterKey: chapterKeyFromUrl(url),
chapterNumber: chapterNumberFromTitle(chapterTitle) ?? index,
),
);
}
}
chapters.sort((a, b) {
final na = double.tryParse(Uri.parse(a.url).pathSegments.last);
final nb = double.tryParse(Uri.parse(b.url).pathSegments.last);
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.url.compareTo(b.url);
});
final listing = listingUrlForSlug(parts.slug);
return SeriesDetail(
source: SourceId.comicland,
listingUrl: listing,
slug: parts.slug,
title: title,
coverUrl: data['cover_url'] as String?,
description: data['description'] as String?,
chapters: chapters,
);
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
final parts = parseComicLandUrl(chapterUrl);
if (parts.chapterIndex == null) {
throw StateError('ComicLand chapter URL is missing an index: $chapterUrl');
}
final data = await _get(
'/chapter/pages_by_index',
query: {'slug': parts.slug, 'index': parts.chapterIndex},
label: 'chapter/pages_by_index',
);
final pages = data['pages'];
if (pages is! List) return const [];
final urls = <String>[];
for (final raw in pages) {
if (raw is! String || raw.trim().isEmpty) continue;
try {
urls.add(Uri.parse(raw.trim()).toString());
} catch (_) {}
}
return urls;
}
String? _chapterIndexString(dynamic rawIndex) {
if (rawIndex is num && rawIndex.isFinite) {
if (rawIndex == rawIndex.roundToDouble()) {
return rawIndex.toInt().toString();
}
return rawIndex.toString();
}
if (rawIndex is String && rawIndex.trim().isNotEmpty) {
return rawIndex.trim();
}
return null;
}
}