Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ class NullFeedApp extends ConsumerWidget {
themeMode: ThemeMode.dark,
routerConfig: router,
debugShowCheckedModeBanner: false,
// Honor the platform Dynamic Type setting, but clamp the extremes so the
// fixed-size cards and rows don't break at very large accessibility sizes.
builder: (context, child) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(
textScaler: mq.textScaler.clamp(
minScaleFactor: 0.8,
maxScaleFactor: 1.3,
),
),
child: child!,
);
},
);
}
}
4 changes: 3 additions & 1 deletion lib/config/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ class NullFeedTheme {
static const Color backgroundColor = Color(0xFF0A0A0A);
static const Color textPrimary = Color(0xFFFFFFFF);
static const Color textSecondary = Color(0xFFB3B3B3);
static const Color textMuted = Color(0xFF666666);
// Lightened from 0xFF666666 to meet WCAG AA (>=4.5:1): 4.5:1 on cardColor,
// 5.4:1 on backgroundColor. The old value was ~2.9:1 on cards.
static const Color textMuted = Color(0xFF858585);
static const Color dividerColor = Color(0xFF2A2A2A);
static const Color errorColor = Color(0xFFCF6679);
static const Color successColor = Color(0xFF4CAF50);
Expand Down
53 changes: 37 additions & 16 deletions lib/screens/video_player_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,7 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> {
alignment: Alignment.topLeft,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
tooltip: 'Back',
onPressed: _navigateBack,
),
),
Expand All @@ -604,21 +605,24 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> {
top: 16,
right: 16,
child: SafeArea(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'360p',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.bold,
child: Semantics(
label: 'Playing preview quality, 360p',
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'360p',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
Expand Down Expand Up @@ -683,6 +687,7 @@ class _ControlsOverlay extends StatelessWidget {
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
tooltip: 'Back',
onPressed: onBack,
),
const Spacer(),
Expand All @@ -704,6 +709,8 @@ class _ControlsOverlay extends StatelessWidget {
IconButton(
iconSize: 48,
icon: const Icon(Icons.replay_10, color: Colors.white),
tooltip:
'Skip back ${AppConstants.skipBackwardSeconds} seconds',
onPressed: () {
onSeekRelative(-AppConstants.skipBackwardSeconds);
onInteraction();
Expand All @@ -720,6 +727,7 @@ class _ControlsOverlay extends StatelessWidget {
: Icons.play_circle_filled,
color: Colors.white,
),
tooltip: value.isPlaying ? 'Pause' : 'Play',
onPressed: () {
onPlayPause();
onInteraction();
Expand All @@ -730,6 +738,8 @@ class _ControlsOverlay extends StatelessWidget {
IconButton(
iconSize: 48,
icon: const Icon(Icons.forward_10, color: Colors.white),
tooltip:
'Skip forward ${AppConstants.skipForwardSeconds} seconds',
onPressed: () {
onSeekRelative(AppConstants.skipForwardSeconds);
onInteraction();
Expand Down Expand Up @@ -760,6 +770,15 @@ class _ControlsOverlay extends StatelessWidget {
? position.inMilliseconds / duration.inMilliseconds
: 0,
height: 4,
semanticLabel: 'Video position',
semanticValueBuilder: (fraction) {
final at = Duration(
milliseconds: (fraction * duration.inMilliseconds)
.round(),
);
return '${_formatDuration(at)} of '
'${_formatDuration(duration)}';
},
onSeek: (fraction) {
final target = Duration(
milliseconds: (fraction * duration.inMilliseconds)
Expand Down Expand Up @@ -864,7 +883,9 @@ class _SpeedButton extends StatelessWidget {
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// vertical: 12 keeps the tap target at the 44pt minimum (20pt icon +
// 24pt padding).
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Expand Down
218 changes: 118 additions & 100 deletions lib/widgets/channel_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,118 +40,136 @@ class _ChannelCardState extends State<ChannelCard> {
curve: Curves.easeOut,
child: Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: widget.onTap == null ? null : _handleTap,
onLongPress: widget.onMenu,
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
child: Stack(
fit: StackFit.expand,
children: [
// Banner image
if (widget.channel.bannerUrl != null)
CachedNetworkImage(
imageUrl: widget.channel.bannerUrl!,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
Container(color: NullFeedTheme.cardColor),
placeholder: (_, __) =>
Container(color: NullFeedTheme.cardColor),
)
else
Container(
color: NullFeedTheme.cardColor,
child: Center(
child: Icon(
Icons.subscriptions,
size: 40,
color: NullFeedTheme.primaryColor.withValues(alpha: 0.3),
),
),
),

// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
stops: const [0.3, 1.0],
),
),
),

// Actions menu button
if (widget.onMenu != null)
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: const Icon(Icons.more_vert, size: 20),
color: Colors.white70,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.4),
),
visualDensity: VisualDensity.compact,
onPressed: widget.onMenu,
tooltip: 'Channel actions',
),
),

// Channel info
Positioned(
left: 12,
right: 12,
bottom: 12,
child: Row(
child: Stack(
fit: StackFit.expand,
children: [
// Main tappable surface: one merged node labelled with the channel
// name. The actions menu lives in its own node below.
Semantics(
button: widget.onTap != null,
label: '${widget.channel.name} channel',
excludeSemantics: true,
onTap: widget.onTap == null ? null : _handleTap,
child: InkWell(
onTap: widget.onTap == null ? null : _handleTap,
onLongPress: widget.onMenu,
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
child: Stack(
fit: StackFit.expand,
children: [
if (widget.channel.avatarUrl != null)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(
widget.channel.avatarUrl!,
),
// Banner image
if (widget.channel.bannerUrl != null)
CachedNetworkImage(
imageUrl: widget.channel.bannerUrl!,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
Container(color: NullFeedTheme.cardColor),
placeholder: (_, __) =>
Container(color: NullFeedTheme.cardColor),
)
else
CircleAvatar(
radius: 18,
backgroundColor: NullFeedTheme.primaryColor.withValues(
alpha: 0.3,
),
child: Text(
widget.channel.name.isNotEmpty
? widget.channel.name[0].toUpperCase()
: '?',
style: const TextStyle(
color: NullFeedTheme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 14,
Container(
color: NullFeedTheme.cardColor,
child: Center(
child: Icon(
Icons.subscriptions,
size: 40,
color: NullFeedTheme.primaryColor.withValues(
alpha: 0.3,
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,

// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.85),
],
stops: const [0.3, 1.0],
),
),
),

// Channel info
Positioned(
left: 12,
right: 12,
bottom: 12,
child: Row(
children: [
if (widget.channel.avatarUrl != null)
CircleAvatar(
radius: 18,
backgroundImage: CachedNetworkImageProvider(
widget.channel.avatarUrl!,
),
)
else
CircleAvatar(
radius: 18,
backgroundColor: NullFeedTheme.primaryColor
.withValues(alpha: 0.3),
child: Text(
widget.channel.name.isNotEmpty
? widget.channel.name[0].toUpperCase()
: '?',
style: const TextStyle(
color: NullFeedTheme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
),
],
),
),

// Actions menu button — a separate, operable node on top.
if (widget.onMenu != null)
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: const Icon(Icons.more_vert, size: 20),
color: Colors.white70,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.4),
),
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(
minWidth: 44,
minHeight: 44,
),
onPressed: widget.onMenu,
tooltip: 'Channel actions',
),
),
],
),
),
);
Expand Down
7 changes: 6 additions & 1 deletion lib/widgets/content_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class ContentRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final padding = AdaptiveLayout.contentPadding(context);
// Grow the row with the (root-clamped) text scale so card titles keep their
// room as Dynamic Type gets larger.
final rowHeight =
AppConstants.contentRowHeight *
MediaQuery.textScalerOf(context).scale(1.0);

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
Expand All @@ -36,7 +41,7 @@ class ContentRow extends StatelessWidget {
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
),
SizedBox(
height: AppConstants.contentRowHeight,
height: rowHeight,
child: errorText != null
? _buildError(context, padding)
: isLoading
Expand Down
Loading
Loading