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.
This commit is contained in:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
@@ -0,0 +1,235 @@
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;
}
}
@@ -0,0 +1,33 @@
// ComicLand API / CDN constants (port of cxyz `src/comicland/constants.js`).
const comicLandHosts = <String>['comicland.org', 'www.comicland.org'];
const comicLandOrigin = 'https://comicland.org/';
const comicLandApiOrigin = 'https://api.comicland.org';
/// Axios-style base used by the SPA: `https://api.comicland.org/api`.
const comicLandApiBase = '$comicLandApiOrigin/api';
const comicLandDefaultListing =
'https://comicland.org/comic/the-delinquent-girl';
const comicLandUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const comicLandApiHeaders = <String, String>{
'Accept': 'application/json,text/plain,*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Origin': 'https://comicland.org',
'Referer': comicLandOrigin,
'User-Agent': comicLandUserAgent,
};
const comicLandCdnHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': comicLandOrigin,
'Origin': 'https://comicland.org',
'User-Agent': comicLandUserAgent,
};
@@ -0,0 +1,149 @@
import '../source.dart';
import 'constants.dart';
class ComicLandUrlParts {
const ComicLandUrlParts({
required this.origin,
required this.slug,
this.chapterIndex,
});
final String origin;
final String slug;
final String? chapterIndex;
}
bool isLikelyComicLandSite(String url) {
try {
final host = Uri.parse(url).host.toLowerCase();
return comicLandHosts.contains(host) || host.contains('comicland');
} catch (_) {
return false;
}
}
String seriesSlugFromListingUrl(String pageUrl) {
try {
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = parts.indexOf('comic');
if (comicIdx != -1 && comicIdx + 1 < parts.length) {
return parts[comicIdx + 1];
}
} catch (_) {}
return 'comic';
}
ComicLandUrlParts parseComicLandUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = parts.indexOf('comic');
final slug =
comicIdx != -1 && comicIdx + 1 < parts.length ? parts[comicIdx + 1] : 'comic';
String? chapterIndex;
final chapterIdx = parts.indexOf('chapter');
if (chapterIdx != -1 && chapterIdx + 1 < parts.length) {
chapterIndex = parts[chapterIdx + 1];
}
return ComicLandUrlParts(
origin: '${u.scheme}://${u.host}',
slug: slug,
chapterIndex: chapterIndex,
);
}
String chapterUrlFromParts(String siteOrigin, String slug, Object chapterIndex) {
final origin = siteOrigin.replaceAll(RegExp(r'/+$'), '');
return '$origin/comic/$slug/chapter/$chapterIndex';
}
String listingUrlForSlug(String slug) =>
'${comicLandOrigin.replaceAll(RegExp(r'/+$'), '')}/comic/$slug';
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final m = RegExp(r'^Chapter\s+([\d.]+)', caseSensitive: false)
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
return m?.group(1);
}
String sanitizeFsStem(String stem) {
final cleaned = stem
.replaceAll(RegExp(r'[^a-z0-9._-]+', caseSensitive: false), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');
if (cleaned.isEmpty) return 'chapter';
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
}
String sanitizeComicDirName(String name) {
final collapsed = name.trim().replaceAll(RegExp(r'\s+'), ' ');
final cleaned = String.fromCharCodes(
collapsed.runes.where((code) {
if (code < 32 || code == 127) return false;
final ch = String.fromCharCode(code);
return !RegExp(r'[<>:"/\\|?*]').hasMatch(ch);
}),
).replaceAll(RegExp(r'\.+$'), '').trim();
if (cleaned.isEmpty) return 'comic';
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
}
String stemForComicLandChapterCbz(String chapterPageUrl) {
try {
final segs =
Uri.parse(chapterPageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = segs.indexOf('comic');
if (comicIdx != -1 &&
segs.length >= comicIdx + 4 &&
segs[comicIdx + 2] == 'chapter') {
return '${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}';
}
if (segs.length >= 2) {
return '${segs[segs.length - 2]}-${segs.last}';
}
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String transferLinkBasename(String? chapterNumber, String? label, String? cbzPath) {
final candidates = [chapterNumber, label]
.whereType<String>()
.where((s) => s.trim().isNotEmpty);
for (final raw in candidates) {
final text = raw.trim();
final m = RegExp(r'(\d+)((?:\.\d+)*)([a-zA-Z]*)').firstMatch(text);
if (m != null) {
final padded = m.group(1)!.padLeft(3, '0');
return 'chapter $padded${m.group(2) ?? ''}${m.group(3) ?? ''}.cbz';
}
}
if (cbzPath != null && cbzPath.isNotEmpty) {
final base = cbzPath.split('/').last;
if (base.isNotEmpty) return base;
}
return 'chapter unknown.cbz';
}
CatalogComic catalogFromJson(Map<String, dynamic> row) {
final slug = (row['slug'] as String?)?.trim() ?? 'comic';
return CatalogComic(
source: SourceId.comicland,
slug: slug,
title: (row['title'] as String?)?.trim().isNotEmpty == true
? (row['title'] as String).trim()
: slug,
listingUrl: listingUrlForSlug(slug),
coverUrl: row['cover_url'] as String?,
description: row['description'] as String?,
rating: (row['rating'] is num) ? (row['rating'] as num).toDouble() : null,
);
}