Files

355 lines
8.7 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
import 'package:flutter/painting.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'danmaku.freezed.dart';
part 'danmaku.g.dart';
class DanmakuPanelSettings {
final bool enabled;
final double opacity;
final double area;
final double fontSize;
final double speed;
final double offset;
final bool showTop;
final bool showScroll;
final bool showBottom;
final bool colored;
final double strokeWidth;
final bool bold;
final String? fontFamily;
final double lineHeight;
final bool fixedToScroll;
final int maxRows;
final bool heatmapEnabled;
const DanmakuPanelSettings({
this.enabled = true,
this.opacity = 1.0,
this.area = 0.85,
this.fontSize = 20,
this.speed = 1.0,
this.offset = 0.0,
this.showTop = true,
this.showScroll = true,
this.showBottom = false,
this.colored = true,
this.strokeWidth = 1.5,
this.bold = false,
this.fontFamily,
this.lineHeight = 1.6,
this.fixedToScroll = false,
this.maxRows = 0,
this.heatmapEnabled = true,
});
DanmakuPanelSettings copyWith({
bool? enabled,
double? opacity,
double? area,
double? fontSize,
double? speed,
double? offset,
bool? showTop,
bool? showScroll,
bool? showBottom,
bool? colored,
double? strokeWidth,
bool? bold,
Object? fontFamily = _sentinel,
double? lineHeight,
bool? fixedToScroll,
int? maxRows,
bool? heatmapEnabled,
}) => DanmakuPanelSettings(
enabled: enabled ?? this.enabled,
opacity: opacity ?? this.opacity,
area: area ?? this.area,
fontSize: fontSize ?? this.fontSize,
speed: speed ?? this.speed,
offset: offset ?? this.offset,
showTop: showTop ?? this.showTop,
showScroll: showScroll ?? this.showScroll,
showBottom: showBottom ?? this.showBottom,
colored: colored ?? this.colored,
strokeWidth: strokeWidth ?? this.strokeWidth,
bold: bold ?? this.bold,
fontFamily: fontFamily == _sentinel
? this.fontFamily
: fontFamily as String?,
lineHeight: lineHeight ?? this.lineHeight,
fixedToScroll: fixedToScroll ?? this.fixedToScroll,
maxRows: maxRows ?? this.maxRows,
heatmapEnabled: heatmapEnabled ?? this.heatmapEnabled,
);
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is DanmakuPanelSettings &&
enabled == other.enabled &&
opacity == other.opacity &&
area == other.area &&
fontSize == other.fontSize &&
speed == other.speed &&
offset == other.offset &&
showTop == other.showTop &&
showScroll == other.showScroll &&
showBottom == other.showBottom &&
colored == other.colored &&
strokeWidth == other.strokeWidth &&
bold == other.bold &&
fontFamily == other.fontFamily &&
lineHeight == other.lineHeight &&
fixedToScroll == other.fixedToScroll &&
maxRows == other.maxRows &&
heatmapEnabled == other.heatmapEnabled;
}
@override
int get hashCode => Object.hash(
enabled,
opacity,
area,
fontSize,
speed,
offset,
showTop,
showScroll,
showBottom,
colored,
strokeWidth,
bold,
fontFamily,
lineHeight,
fixedToScroll,
maxRows,
heatmapEnabled,
);
}
const Object _sentinel = Object();
class DanmakuSource {
final String id;
final String name;
final String url;
final bool enabled;
const DanmakuSource({
required this.id,
required this.name,
required this.url,
this.enabled = true,
});
DanmakuSource copyWith({
String? id,
String? name,
String? url,
bool? enabled,
}) => DanmakuSource(
id: id ?? this.id,
name: name ?? this.name,
url: url ?? this.url,
enabled: enabled ?? this.enabled,
);
String get displayName {
final trimmed = name.trim();
if (trimmed.isNotEmpty) return trimmed;
return url;
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'url': url,
'enabled': enabled,
};
factory DanmakuSource.fromJson(Map<String, dynamic> json) {
final url = (json['url'] ?? '').toString().trim();
return DanmakuSource(
id: (json['id'] ?? url).toString(),
name: (json['name'] ?? '').toString(),
url: url,
enabled: json['enabled'] is bool ? json['enabled'] as bool : true,
);
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is DanmakuSource &&
id == other.id &&
name == other.name &&
url == other.url &&
enabled == other.enabled;
}
@override
int get hashCode => Object.hash(id, name, url, enabled);
}
enum DanmakuCommentStatus { loading, ready, failed }
sealed class DanmakuSessionState {
const DanmakuSessionState();
}
class DanmakuSessionIdle extends DanmakuSessionState {
const DanmakuSessionIdle();
}
class DanmakuSessionMatching extends DanmakuSessionState {
const DanmakuSessionMatching();
}
class DanmakuSessionMatched extends DanmakuSessionState {
final List<DanmakuMatchCandidate> matches;
final int selectedIndex;
final DanmakuCommentStatus commentStatus;
final List<DanmakuComment> comments;
const DanmakuSessionMatched({
required this.matches,
required this.selectedIndex,
required this.commentStatus,
this.comments = const [],
});
DanmakuSessionMatched copyWith({
List<DanmakuMatchCandidate>? matches,
int? selectedIndex,
DanmakuCommentStatus? commentStatus,
List<DanmakuComment>? comments,
}) => DanmakuSessionMatched(
matches: matches ?? this.matches,
selectedIndex: selectedIndex ?? this.selectedIndex,
commentStatus: commentStatus ?? this.commentStatus,
comments: comments ?? this.comments,
);
}
class DanmakuSessionEmpty extends DanmakuSessionState {
const DanmakuSessionEmpty();
}
class DanmakuSessionFailed extends DanmakuSessionState {
final String message;
const DanmakuSessionFailed(this.message);
}
@freezed
abstract class DanmakuMatchContext with _$DanmakuMatchContext {
const factory DanmakuMatchContext({
required String itemId,
required String name,
required String? type,
required String? seriesName,
required int? season,
required int? episode,
required int durationSec,
}) = _DanmakuMatchContext;
}
@freezed
abstract class DanmakuMatch with _$DanmakuMatch {
const factory DanmakuMatch({
@JsonKey(fromJson: _intRequired) required int episodeId,
@JsonKey(fromJson: _intOrZero) @Default(0) int animeId,
@Default('') String animeTitle,
@Default('') String episodeTitle,
}) = _DanmakuMatch;
factory DanmakuMatch.fromJson(Map<String, dynamic> json) =>
_$DanmakuMatchFromJson(json);
}
class DanmakuMatchCandidate {
final DanmakuSource source;
final DanmakuMatch match;
const DanmakuMatchCandidate({required this.source, required this.match});
int get episodeId => match.episodeId;
int get animeId => match.animeId;
String get animeTitle => match.animeTitle;
String get episodeTitle => match.episodeTitle;
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is DanmakuMatchCandidate &&
source == other.source &&
match == other.match;
}
@override
int get hashCode => Object.hash(source, match);
}
@freezed
abstract class DanmakuBangumiEpisode with _$DanmakuBangumiEpisode {
const factory DanmakuBangumiEpisode({
@JsonKey(fromJson: _intRequired) required int episodeId,
@Default('') String episodeTitle,
@JsonKey(fromJson: _stringFromAny) @Default('') String episodeNumber,
}) = _DanmakuBangumiEpisode;
factory DanmakuBangumiEpisode.fromJson(Map<String, dynamic> json) =>
_$DanmakuBangumiEpisodeFromJson(json);
}
int _intRequired(Object? value) => (value as num).toInt();
int _intOrZero(Object? value) => (value as num?)?.toInt() ?? 0;
String _stringFromAny(Object? value) => value?.toString() ?? '';
class DanmakuComment {
final int cid;
final double time;
final int type;
final Color color;
final String text;
int lane;
DanmakuComment({
required this.cid,
required this.time,
required this.type,
required this.color,
required this.text,
this.lane = 0,
});
factory DanmakuComment.fromRaw(int cid, String p, String m) {
final parts = p.split(',');
final time = parts.isNotEmpty ? (double.tryParse(parts[0]) ?? 0.0) : 0.0;
final type = parts.length > 1 ? (int.tryParse(parts[1]) ?? 1) : 1;
final colorInt = parts.length > 2
? (int.tryParse(parts[2]) ?? 0xFFFFFF)
: 0xFFFFFF;
return DanmakuComment(
cid: cid,
time: time,
type: type,
color: Color(0xFF000000 | (colorInt & 0xFFFFFF)),
text: m,
);
}
}