// Author: fruit.com.cn import 'dart:ui'; import 'json_numbers.dart'; import 'locale_text.dart'; /// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。 /// /// 封面与播放地址都是后端返回的相对路径(`/files/...`), /// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。 class Episode { const Episode({ required this.id, required this.number, required this.title, required this.isFree, this.videoUrl = '', this.durationSec = 0, this.locked = false, this.likeCount, this.commentCount, this.thumbnailsUrl, this.posterUrl, }); final String id; final int number; final String title; final bool isFree; /// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。 final String videoUrl; final int durationSec; /// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。 final bool locked; final int? likeCount; final int? commentCount; final String? thumbnailsUrl; final String? posterUrl; Episode withVideoUrl( String url, { int? likeCount, int? commentCount, String? thumbnailsUrl, String? posterUrl, }) => Episode( id: id, number: number, title: title, isFree: isFree, videoUrl: url, durationSec: durationSec, locked: locked, likeCount: likeCount ?? this.likeCount, commentCount: commentCount ?? this.commentCount, thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl, posterUrl: posterUrl ?? this.posterUrl, ); factory Episode.fromJson(Map json) { return Episode( id: (json['id'] ?? '').toString(), number: (json['number'] as num?)?.toInt() ?? 0, title: json['title'] as String? ?? '', isFree: json['isFree'] == true, durationSec: (json['durationSec'] as num?)?.toInt() ?? 0, locked: json['locked'] == true, videoUrl: json['playUrl'] as String? ?? '', likeCount: _optionalInt(json['likeCount']), commentCount: _optionalInt(json['commentCount']), thumbnailsUrl: _optionalString(json['thumbnailsUrl']), posterUrl: _optionalString(json['posterUrl']), ); } } class Drama { const Drama({ required this.id, required this.title, required this.synopsis, required this.posterUrl, this.episodes = const [], this.titleLocales = const {}, this.synopsisLocales = const {}, this.tag = '', this.tagLocales = const {}, this.tagColor = '', this.category = '', this.categoryLocales = const {}, this.types = const [], this.genre = '', this.totalEpisodes = 0, this.freeEpisodes = 0, this.isHot = false, this.viewCount = 0, this.likeCount, this.favoriteCount, this.commentCount, this.language = '', this.dramaType = '', this.createdAt, this.detailLoaded = false, }); final String id; final String title; final String synopsis; final Map titleLocales; final Map synopsisLocales; final String tag; final Map tagLocales; final String tagColor; final String posterUrl; final List episodes; final String category; final Map categoryLocales; final List> types; final String genre; final int totalEpisodes; final int freeEpisodes; final bool isHot; final int viewCount; /// 详情下发的互动数量;卡片或旧接口没有这些字段时为 null(客户端不显示数字)。 final int? likeCount; final int? favoriteCount; final int? commentCount; /// 卡片语种(如 zh-CN / id),用于剧场语言筛选。 final String language; /// 内容类型:ANIME 漫剧,LIVE 真人剧。 final String dramaType; /// 上架时间;卡片接口未直接下发时可由 Snowflake id 估算。 final DateTime? createdAt; /// 详情接口是否已经取过完整剧集列表。 /// 列表卡片只带一集可播预览,需要完整剧集时再进详情。 final bool detailLoaded; /// 卡片只带一集预览,集数以后端给的总集数为准。 int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length; /// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。 factory Drama.fromCardJson(Map json) { final preview = json['preview']; final text = _DramaText.fromJson(json); return Drama( id: (json['id'] ?? '').toString(), title: text.title, synopsis: text.synopsis, titleLocales: text.titleLocales, synopsisLocales: text.synopsisLocales, tag: text.tag, tagLocales: text.tagLocales, tagColor: json['tagColor'] as String? ?? '', posterUrl: json['cover'] as String? ?? '', episodes: preview is Map ? [Episode.fromJson(preview)] : const [], category: text.category, categoryLocales: text.categoryLocales, types: text.types, genre: text.genre, totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), likeCount: _optionalInt(json['likeCount']), favoriteCount: _optionalInt(json['favoriteCount']), commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', dramaType: json['dramaType'] as String? ?? '', createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), ); } /// 详情:带剧集列表。 factory Drama.fromDetailJson(Map json) { final text = _DramaText.fromJson(json); return Drama( id: (json['id'] ?? '').toString(), title: text.title, synopsis: text.synopsis, titleLocales: text.titleLocales, synopsisLocales: text.synopsisLocales, tag: text.tag, tagLocales: text.tagLocales, tagColor: json['tagColor'] as String? ?? '', posterUrl: json['cover'] as String? ?? '', category: text.category, categoryLocales: text.categoryLocales, types: text.types, genre: text.genre, totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0, freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0, isHot: json['isHot'] == true, viewCount: jsonToInt(json['viewCount']), likeCount: _optionalInt(json['likeCount']), favoriteCount: _optionalInt(json['favoriteCount']), commentCount: _optionalInt(json['commentCount']), language: json['language'] as String? ?? '', dramaType: json['dramaType'] as String? ?? '', createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''), detailLoaded: true, episodes: (json['episodes'] as List? ?? const []) .whereType>() .map(Episode.fromJson) .toList(), ); } String localizedTitle(String languageCode) { return localizedText(languageCode, title, titleLocales); } String localizedSynopsis(String languageCode) { return localizedText(languageCode, synopsis, synopsisLocales); } String localizedTag(String languageCode) { return localizedText(languageCode, tag, tagLocales); } /// 标签角标背景色。接口没给合法色值时用 fallback。 Color tagRibbonColor(Color fallback) { final parsed = _colorFromHex(tagColor); return parsed ?? fallback; } /// 按当前语言取类型名称。接口没带语言表时返回空,调用方继续用中文 genre。 List localizedTypeLabels(String languageCode) { final labels = []; for (final item in types) { final label = localizedText(languageCode, item['zh'] ?? '', item); if (label.isNotEmpty && !labels.contains(label)) labels.add(label); } if (labels.isNotEmpty) return labels; final primary = localizedText(languageCode, '', categoryLocales); if (primary.isNotEmpty) return [primary]; return const []; } } int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value); String? _optionalString(dynamic value) { final text = value is String ? value.trim() : ''; return text.isEmpty ? null : text; } Color? _colorFromHex(String raw) { final text = raw.trim(); if (text.length != 7 || !text.startsWith('#')) return null; final value = int.tryParse(text.substring(1), radix: 16); if (value == null) return null; return Color(0xFF000000 | value); } List> _typeLocales(Object? raw) { if (raw is! List) return const []; return raw.whereType().map(localeMap).toList(growable: false); } /// 从语言表还原中文展示字段。列表接口不再单独下发 title / synopsis / tag。 class _DramaText { const _DramaText({ required this.title, required this.synopsis, required this.titleLocales, required this.synopsisLocales, required this.tag, required this.tagLocales, required this.category, required this.categoryLocales, required this.types, required this.genre, }); final String title; final String synopsis; final Map titleLocales; final Map synopsisLocales; final String tag; final Map tagLocales; final String category; final Map categoryLocales; final List> types; final String genre; factory _DramaText.fromJson(Map json) { final titleLocales = localeMap(json['titleLocales']); final synopsisLocales = localeMap(json['synopsisLocales']); final tagLocales = localeMap(json['tagLocales']); final categoryLocales = localeMap(json['categoryLocales']); final types = _typeLocales(json['types']); final category = _firstText(categoryLocales, json['category'], types); return _DramaText( title: _firstText(titleLocales, json['title']), synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']), titleLocales: titleLocales, synopsisLocales: synopsisLocales, tag: _firstText(tagLocales, json['tag']), tagLocales: tagLocales, category: category, categoryLocales: categoryLocales, types: types, genre: _genreText(types, json['genre']), ); } } String _firstText(Map locales, Object? fallback, [List>? types]) { final chinese = locales['zh'] ?? ''; if (chinese.isNotEmpty) return chinese; if (types != null && types.isNotEmpty) { final label = types.first['zh'] ?? ''; if (label.isNotEmpty) return label; } return fallback is String ? fallback : ''; } String _genreText(List> types, Object? fallback) { final names = types.map((item) => item['zh'] ?? '').where((value) => value.isNotEmpty); final joined = names.join(' / '); if (joined.isNotEmpty) return joined; return fallback is String ? fallback : ''; }