Files
xtremobile/lib/mobile/widgets/tv_focusable.dart
T
Claude 9b6b7faf4a feat: Apple glass gray theme, lower live latency, audio fix, remote focus memory
Theme — replace the near-black "Warm Cinema" palette with a neutral
"Apple Glass Gray" system: graphite surfaces (#26262C base, lighter
containers), Apple-white text, Apple system blue accent, white glass
borders/highlights. Noticeably less dark overall; light theme moves
from kraft paper to iOS light gray. All AppColors names kept so no
call site changes.

Player latency — live buffering drops from 8s cache / 10s readahead /
150MB to 3s / 5s / 60MB, keeping enough headroom for network jitter
while staying much closer to the live edge.

Audio — the forced audio filter chain
(af=lavrresample,format=channels=stereo:sample_fmts=s16) mixed FFmpeg
and mpv syntax; when it failed to init for a channel's codec, mpv
muted the stream entirely. Replaced by mpv's native
audio-channels=stereo downmix and AO-negotiated output format. Probe
caps raised (2MB/3s) so late-declared audio tracks (AAC-LATM) are
detected without slowing healthy streams.

Remote control — the player OSD now remembers the last focused button
and restores focus to it whenever the OSD reopens (arrows, OK) and
across channel switches, so one press re-zaps instead of five. On
live TV, OK with hidden controls only reopens the OSD instead of
pausing the stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:41:18 +00:00

193 lines
5.5 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class TVFocusable extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final VoidCallback? onLongPress;
final VoidCallback? onFocus;
final FocusNode? focusNode;
final bool autofocus;
final double scale;
final double borderWidth;
final Color focusColor;
final BorderRadius? borderRadius;
final Duration longPressDuration;
const TVFocusable({
super.key,
required this.child,
this.onPressed,
this.onLongPress,
this.onFocus,
this.focusNode,
this.autofocus = false,
this.scale = 1.05, // Apple TV subtle lift
this.borderWidth = 2.0,
this.focusColor = AppColors.onSurface,
this.borderRadius,
this.longPressDuration = const Duration(seconds: 3),
});
@override
State<TVFocusable> createState() => _TVFocusableState();
}
class _TVFocusableState extends State<TVFocusable> {
late FocusNode _node;
bool _isFocused = false;
Timer? _longPressTimer;
bool _isKeyDown = false;
bool _longPressTriggered = false;
@override
void initState() {
super.initState();
_node = widget.focusNode ?? FocusNode();
_node.addListener(_onFocusChanged);
}
@override
void dispose() {
_longPressTimer?.cancel();
if (widget.focusNode == null) {
_node.dispose();
} else {
_node.removeListener(_onFocusChanged);
}
super.dispose();
}
void _onFocusChanged() {
if (_node.hasFocus != _isFocused) {
setState(() => _isFocused = _node.hasFocus);
if (_isFocused) {
widget.onFocus?.call();
_scrollToVisible();
}
}
}
void _scrollToVisible() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
});
}
void _handlePress() {
widget.onPressed?.call();
}
void _handleLongPress() {
widget.onLongPress?.call();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _node,
autofocus: widget.autofocus,
onKeyEvent: (node, event) {
final key = event.logicalKey;
final isSelectKey = key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.space ||
key == LogicalKeyboardKey.gameButtonA ||
key == LogicalKeyboardKey.numpadEnter;
if (event is KeyDownEvent && isSelectKey) {
if (!_isKeyDown) {
_isKeyDown = true;
_longPressTriggered = false;
// Start long press timer only if onLongPress is defined
if (widget.onLongPress != null) {
_longPressTimer?.cancel();
_longPressTimer = Timer(widget.longPressDuration, () {
if (_isKeyDown && mounted) {
_longPressTriggered = true;
_handleLongPress();
}
});
}
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent && isSelectKey) {
_longPressTimer?.cancel();
// If long press wasn't triggered, it's a normal press
if (_isKeyDown && !_longPressTriggered) {
_handlePress();
}
_isKeyDown = false;
_longPressTriggered = false;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: GestureDetector(
onTap: _handlePress,
onLongPress: widget.onLongPress,
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic, // Apple-like snappy curve
tween: Tween<double>(
begin: 1.0,
end: _isFocused ? widget.scale : 1.0,
),
builder: (context, double scale, child) {
return Transform.scale(
scale: scale,
child: Container(
decoration: _isFocused
? BoxDecoration(
borderRadius:
widget.borderRadius ?? BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black
.withOpacity(0.5), // Deep shadow for lift
blurRadius: 16,
offset: const Offset(0, 6),
),
const BoxShadow(
color: Color(0x590A84FF), // Apple blue glow
blurRadius: 12,
spreadRadius: 0,
),
],
)
: null,
foregroundDecoration: _isFocused
? BoxDecoration(
borderRadius:
widget.borderRadius ?? BorderRadius.circular(12),
border: Border.all(
color: widget.focusColor,
width: widget.borderWidth,
),
)
: null,
child: child,
),
);
},
child: widget.child,
),
),
);
}
}