Files
xtremobile/lib/features/iptv/screens/mobile_series_detail_screen.dart
T
MichaelandClaude Opus 5 694b1257b9 fix(theme): remove every hardcoded Apple/Material colour left in the UI
Porting the palette was not enough: the Warm Cinema charter only reached the
widgets that read from AppColors. Nineteen screen and widget files still painted
Apple TV values inline, so the app kept showing blue — most visibly on the
selected navigation pill.

Swept out:
- 0xFF0A84FF (Apple blue) on the selected nav pill, category card icons and the
  "add playlist" button
- 0xFF4FC3F7 and 0xFF1565C0 in the player overlays
- Colors.blueAccent / Colors.blue in dialogs
- Apple greys 1C1C1E / 2C2C2E / 3A3A3C / 8E8E93 -> warm charcoals
- 128 Colors.white* -> the onSurfaceNN cream ladder (a pure white on a warm
  ground reads blue, which is the charter's whole point)
- Colors.red / green / amber / yellow / grey* -> error, success, ratingGold,
  outlineVariant
- pure black ink on light fills -> onPrimaryFixed, a warm near-black
- the white focus glow -> an ember glow

Deliberately left true black: the video surface backgrounds and the scrims over
posters and video, where a warm tint would clash with the picture.

Adds the onPrimaryFixed / primaryFixed / onSurface70 tokens the sweep needed.

Verified: no Material or Apple colour literal remains outside lib/core/theme;
flutter analyze reports no errors; 19/19 tests pass; release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:39:58 +02:00

431 lines
15 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
final model.Series series;
final PlaylistConfig playlist;
const MobileSeriesDetailScreen({
super.key,
required this.series,
required this.playlist,
});
@override
ConsumerState<MobileSeriesDetailScreen> createState() =>
_MobileSeriesDetailScreenState();
}
class _MobileSeriesDetailScreenState
extends ConsumerState<MobileSeriesDetailScreen> {
model.SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
String _selectedSeason = "1";
@override
void initState() {
super.initState();
_loadSeriesInfo();
}
String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating);
if (value != null) {
return value.toStringAsFixed(1);
}
return rating;
}
Future<void> _loadSeriesInfo() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final info =
await service.getSeriesInfo(widget.series.seriesId.toString());
if (mounted) {
setState(() {
_seriesInfo = info;
_isLoading = false;
if (_seriesInfo!.episodes.isNotEmpty) {
_selectedSeason = _seriesInfo!.episodes.keys.first.toString();
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16),
Text(
'Error: $_error',
style: const TextStyle(color: AppColors.textSecondary),
),
TextButton(
onPressed: _loadSeriesInfo,
child: const Text('Retry'),
),
],
),
)
: _buildMobileContent(),
);
}
Widget _buildMobileContent() {
if (_seriesInfo == null) return const SizedBox.shrink();
final currentEpisodes = _seriesInfo!.episodes[_selectedSeason] ?? [];
return CustomScrollView(
slivers: [
// App Bar with Cover
SliverAppBar(
expandedHeight: 280, // Reduced from 400 for better TV visibility
pinned: true,
backgroundColor: AppColors.background,
leading: Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.black45,
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
onPressed: () => Navigator.pop(context),
tooltip: 'Back',
),
),
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (_seriesInfo!.coverUrl != null)
// [P1-2 FIX] Optimize series cover image cache (400x600 display)
CachedNetworkImage(
imageUrl: _seriesInfo!.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: 480,
memCacheHeight: 720,
maxWidthDiskCache: 480,
maxHeightDiskCache: 720,
cacheManager: AppCacheManager.instance,
errorWidget: (_, __, ___) =>
Container(color: AppColors.surfaceContainerLowest),
),
// Gradient Overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
AppColors.background.withOpacity(0.8),
AppColors.background,
],
stops: const [0.5, 0.8, 1.0],
),
),
),
// Info Overlay
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_seriesInfo!.title,
style: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
if (_seriesInfo!.rating != null) ...[
const Icon(
Icons.star,
size: 16,
color: AppColors.ratingGold,
),
const SizedBox(width: 4),
Text(
_formatRating(_seriesInfo!.rating!)!,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
const SizedBox(width: 16),
],
Text(
'${_seriesInfo!.episodes.keys.length} Saisons',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
],
),
],
),
),
],
),
),
),
// Plot
if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_seriesInfo?.plot ?? '',
style: const TextStyle(
color: AppColors.textSecondary,
height: 1.4,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
),
// Seasons Filter
SliverToBoxAdapter(
child: SizedBox(
height: 50,
child: Builder(
builder: (context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: _seriesInfo!.episodes.keys.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final seasonNum =
_seriesInfo!.episodes.keys.elementAt(index);
final isSelected = seasonNum == _selectedSeason;
return ChoiceChip(
label: Text('Saison $seasonNum'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSeason = seasonNum.toString());
}
},
backgroundColor: isDark
? AppColors.surfaceContainer
: AppColors.surfaceContainerHigh,
selectedColor: isDark ? AppColors.onSurface : AppColors.primaryFill,
labelStyle: TextStyle(
color: isSelected
? (isDark ? AppColors.onPrimaryFixed : AppColors.onSurface)
: (isDark
? AppColors.textSecondary
: AppColors.outlineVariant),
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
showCheckmark: false,
side: BorderSide.none,
);
},
);
},
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
// Episodes List
SliverPadding(
padding: const EdgeInsets.only(bottom: 32),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = currentEpisodes[index];
return _buildEpisodeTile(episode);
},
childCount: currentEpisodes.length,
),
),
),
],
);
}
Widget _buildEpisodeTile(model.Episode episode) {
final watchHistory = ref.watch(mobileWatchHistoryProvider);
final episodeKey = MobileWatchHistory.episodeKey(
widget.series.seriesId,
int.tryParse(_selectedSeason) ?? 1,
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
final resumePos = watchHistory.getResumePosition(episodeKey);
// Calculate progress
double progress = 0.0;
if (episode.duration > 0 &&
resumePos > 0) {
progress = (resumePos / episode.duration).clamp(0.0, 1.0);
}
// Force dark mode colors since background is hardcoded dark
const textColor = AppColors.onSurface;
final secondaryTextColor = AppColors.textSecondary;
const surfaceColor = AppColors.surfaceContainer;
const primaryColor = AppColors.onSurface; // Or accent color if preferred
return InkWell(
onTap: () {
// Enforce Native Player for Series (VOD)
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
streamId: episode.id,
title:
"${widget.series.name} S${episode.season}E${episode.episodeNum}",
playlist: widget.playlist,
streamType: model.StreamType.series,
containerExtension: episode.containerExtension,
seriesId: widget.series.seriesId,
season: int.tryParse(episode.season.toString()),
episodeNum: episode.episodeNum,
// Pass explicit initial position for resume
initialPosition:
resumePos > 0 ? Duration(seconds: resumePos) : null,
),
),
);
},
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.onSurface12)),
),
child: Row(
children: [
// Play Button / Indicator
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isWatched ? primaryColor : surfaceColor,
shape: BoxShape.circle,
border: isWatched
? null
: Border.all(color: primaryColor, width: 2),
),
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched
? AppColors.onPrimaryFixed // Checkmark on white bg
: primaryColor, // Play icon
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'E${episode.episodeNum} - ${episode.title}',
style: TextStyle(
color: isWatched ? secondaryTextColor : textColor,
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.duration > 0)
Text(
_formatDuration(episode.duration),
style: TextStyle(
color: secondaryTextColor,
fontSize: 12,
),
),
],
),
),
],
),
),
// Progress Bar (Red like YouTube/Netflix)
if (progress > 0 && !isWatched)
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.error),
minHeight: 2,
),
],
),
);
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
if (hours > 0) return '${hours}h ${minutes}m';
return '${minutes}m';
}
}