347 lines
11 KiB
Dart
347 lines
11 KiB
Dart
// 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,
|
|
);
|
|
|
|
Episode withStats({int? likeCount, int? commentCount}) => Episode(
|
|
id: id,
|
|
number: number,
|
|
title: title,
|
|
isFree: isFree,
|
|
videoUrl: videoUrl,
|
|
durationSec: durationSec,
|
|
locked: locked,
|
|
likeCount: likeCount ?? this.likeCount,
|
|
commentCount: commentCount ?? this.commentCount,
|
|
thumbnailsUrl: thumbnailsUrl,
|
|
posterUrl: posterUrl,
|
|
);
|
|
|
|
factory Episode.fromJson(Map<String, dynamic> 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<String, String> titleLocales;
|
|
final Map<String, String> synopsisLocales;
|
|
final String tag;
|
|
final Map<String, String> tagLocales;
|
|
final String tagColor;
|
|
final String posterUrl;
|
|
final List<Episode> episodes;
|
|
final String category;
|
|
final Map<String, String> categoryLocales;
|
|
final List<Map<String, String>> 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<String, dynamic> 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<String, dynamic>
|
|
? [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<String, dynamic> 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<String, dynamic>>()
|
|
.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<String> localizedTypeLabels(String languageCode) {
|
|
final labels = <String>[];
|
|
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<Map<String, String>> _typeLocales(Object? raw) {
|
|
if (raw is! List) return const [];
|
|
return raw.whereType<Map>().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<String, String> titleLocales;
|
|
final Map<String, String> synopsisLocales;
|
|
final String tag;
|
|
final Map<String, String> tagLocales;
|
|
final String category;
|
|
final Map<String, String> categoryLocales;
|
|
final List<Map<String, String>> types;
|
|
final String genre;
|
|
|
|
factory _DramaText.fromJson(Map<String, dynamic> 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<String, String> locales, Object? fallback, [List<Map<String, String>>? 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<Map<String, String>> 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 : '';
|
|
}
|