196 lines
5.7 KiB
Dart
196 lines
5.7 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/contracts/playback.dart';
|
|
import '../../../core/infra/player_engine/player_engine.dart';
|
|
import '../../../providers/subtitle_settings_provider.dart';
|
|
|
|
|
|
class StyledSubtitleOverlay extends StatefulWidget {
|
|
final PlayerEngine player;
|
|
final SubtitleStyleSettings settings;
|
|
|
|
|
|
final ValueListenable<List<String>>? externalLines;
|
|
|
|
|
|
final ValueListenable<EmbySubtitleTrack?>? externalActive;
|
|
|
|
const StyledSubtitleOverlay({
|
|
super.key,
|
|
required this.player,
|
|
required this.settings,
|
|
this.externalLines,
|
|
this.externalActive,
|
|
});
|
|
|
|
@override
|
|
State<StyledSubtitleOverlay> createState() => _StyledSubtitleOverlayState();
|
|
}
|
|
|
|
class _StyledSubtitleOverlayState extends State<StyledSubtitleOverlay> {
|
|
static const _mpvReferenceHeight = 720.0;
|
|
static const _baseEdgeInset = 24.0;
|
|
|
|
List<String> _subtitle = const [];
|
|
StreamSubscription<List<String>>? _subscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_bindPlayer(widget.player);
|
|
widget.externalLines?.addListener(_onExternalChanged);
|
|
widget.externalActive?.addListener(_onExternalChanged);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant StyledSubtitleOverlay oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.player != widget.player) {
|
|
unawaited(_subscription?.cancel());
|
|
_bindPlayer(widget.player);
|
|
}
|
|
if (oldWidget.externalLines != widget.externalLines) {
|
|
oldWidget.externalLines?.removeListener(_onExternalChanged);
|
|
widget.externalLines?.addListener(_onExternalChanged);
|
|
}
|
|
if (oldWidget.externalActive != widget.externalActive) {
|
|
oldWidget.externalActive?.removeListener(_onExternalChanged);
|
|
widget.externalActive?.addListener(_onExternalChanged);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
unawaited(_subscription?.cancel());
|
|
widget.externalLines?.removeListener(_onExternalChanged);
|
|
widget.externalActive?.removeListener(_onExternalChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
void _bindPlayer(PlayerEngine player) {
|
|
_subtitle = player.state.subtitle;
|
|
_subscription = player.stream.subtitle.listen((value) {
|
|
if (mounted) setState(() => _subtitle = value);
|
|
});
|
|
}
|
|
|
|
void _onExternalChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
bool get _externalIsActive => widget.externalActive?.value != null;
|
|
|
|
List<String> get _activeLines =>
|
|
_externalIsActive ? (widget.externalLines?.value ?? const []) : _subtitle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primaryText = _joinLines(_activeLines);
|
|
if (primaryText.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return IgnorePointer(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final height = constraints.maxHeight;
|
|
if (!width.isFinite ||
|
|
!height.isFinite ||
|
|
width <= 0 ||
|
|
height <= 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final fontSize = _scaleFontSize(widget.settings.fontSize, height);
|
|
|
|
final position = widget.settings.position.clamp(0, 150).toDouble();
|
|
final verticalProgress = math.min(position, 100.0) / 100.0;
|
|
final extraLowering = math.max(0.0, position - 100.0) / 50.0;
|
|
final bottomInset =
|
|
_baseEdgeInset * (1.0 - extraLowering.clamp(0.0, 1.0));
|
|
|
|
final lineCount = math.max(1, primaryText.split('\n').length);
|
|
final estimatedBlockHeight = math.min(
|
|
height * 0.5,
|
|
lineCount * fontSize * 1.32 + 12.0,
|
|
);
|
|
|
|
final verticalRange = math.max(
|
|
0.0,
|
|
height - _baseEdgeInset - bottomInset - estimatedBlockHeight,
|
|
);
|
|
final top = _baseEdgeInset + verticalRange * verticalProgress;
|
|
final horizontalInset = math.min(_baseEdgeInset, width * 0.06);
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Positioned(
|
|
left: horizontalInset,
|
|
right: horizontalInset,
|
|
top: top,
|
|
child: _buildLine(
|
|
text: primaryText,
|
|
fontSize: fontSize,
|
|
bgOpacity: widget.settings.bgOpacity.clamp(0.0, 1.0),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
String _joinLines(List<String> lines) => lines
|
|
.map((line) => line.trim())
|
|
.where((line) => line.isNotEmpty)
|
|
.join('\n');
|
|
|
|
double _scaleFontSize(double settingsFontSize, double height) =>
|
|
(settingsFontSize * height / _mpvReferenceHeight).clamp(8.0, 200.0);
|
|
|
|
Widget _buildLine({
|
|
required String text,
|
|
required double fontSize,
|
|
required double bgOpacity,
|
|
}) {
|
|
final child = Text(
|
|
text,
|
|
textAlign: TextAlign.center,
|
|
softWrap: true,
|
|
textScaler: TextScaler.noScaling,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: fontSize,
|
|
height: 1.32,
|
|
letterSpacing: 0.0,
|
|
fontWeight: FontWeight.w600,
|
|
shadows: const [
|
|
Shadow(color: Colors.black, blurRadius: 2, offset: Offset(0, 1)),
|
|
Shadow(color: Colors.black87, blurRadius: 4),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (bgOpacity > 0) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(bgOpacity),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
child: child,
|
|
),
|
|
);
|
|
}
|
|
return child;
|
|
}
|
|
}
|