提交 Nontonki 短剧原型,准备推送到远程仓库。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
VickyandCursor committed 2026-10-13 16:23:08 +08:00
commit b1f6ce4bf9
148 files changed
+11175

No files matched your search

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/engagement_controller.dart';
import '../../../l10n/app_localizations.dart';
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama});
final Drama drama;
@override
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
}
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
final _input = TextEditingController();
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _post() {
final text = _input.text.trim();
if (text.isEmpty) return;
context.read<EngagementController>().addComment(widget.drama.id, text);
_input.clear();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final comments = context.watch<EngagementController>().commentsFor(
widget.drama.id,
);
final available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: available.clamp(240.0, 460.0),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.comments,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.localOnlyComments,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13),
),
const SizedBox(height: 14),
Expanded(
child: comments.isEmpty
? Center(
child: Text(
l10n.noComments,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
: ListView.separated(
itemCount: comments.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: const CircleAvatar(
radius: 16,
child: Icon(Icons.person_rounded, size: 18),
),
title: Text(comments[index]),
contentPadding: EdgeInsets.zero,
),
),
),
Row(
children: [
Expanded(
child: TextField(
key: const Key('comment-input'),
controller: _input,
maxLength: 300,
maxLines: 1,
decoration: InputDecoration(
hintText: l10n.writeComment,
counterText: '',
filled: true,
fillColor: const Color(0xFF35353A),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _post(),
),
),
const SizedBox(width: 8),
IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
onPressed: _post,
icon: const Icon(Icons.send_rounded),
),
],
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
/// Keeps seeks within the playable range so dragging to the edge does not
/// trigger the player's completion callback before playback resumes.
Duration seekPositionForFraction(double fraction, Duration duration) {
final total = duration.inMilliseconds;
if (total <= 0) return Duration.zero;
final lastPlayable = total > 250 ? total - 250 : total - 1;
final requested = (fraction.clamp(0.0, 1.0) * total).round();
return Duration(milliseconds: requested.clamp(0, lastPlayable));
}
class SeekProgressBar extends StatefulWidget {
const SeekProgressBar({
super.key,
required this.valueListenable,
required this.semanticsLabel,
this.onScrubStart,
this.onSeek,
this.showTimes = true,
this.compactIdle = false,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
final String semanticsLabel;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
final bool compactIdle;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
}
class _SeekProgressBarState extends State<SeekProgressBar> {
double? _previewFraction;
@override
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.valueListenable != widget.valueListenable) {
_previewFraction = null;
}
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.valueListenable;
setState(() => _previewFraction = fraction);
try {
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
} finally {
if (mounted && widget.valueListenable == source) {
setState(() => _previewFraction = null);
}
}
}
@override
Widget build(BuildContext context) {
final source = widget.valueListenable;
if (source == null) {
return _buildTrack(context, const VideoPlayerValue.uninitialized());
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) => _buildTrack(context, value),
);
}
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
final played = total > 0
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
final fraction = _previewFraction ?? played;
final previewPosition = _previewFraction == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final scrubbing = _previewFraction != null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: widget.compactIdle && !scrubbing ? 20 : 36,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
activeTrackColor: const Color(0xFFFF5C37),
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
onChangeStart: enabled
? (value) {
setState(() => _previewFraction = value);
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => setState(() => _previewFraction = value)
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
),
),
),
if (widget.showTimes && (!widget.compactIdle || scrubbing))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(previewPosition),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
Text(
_format(video.duration),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
],
),
],
);
}
String _format(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
}