feat: add mobile settings tab with player and content filter options, a new mobile scaffold, and logo asset.

This commit is contained in:
Michael committed 2025-12-20 10:23:16 +01:00
1 parent 4e00cc7dc7
commit 7e836f8ad3
8 files changed
+287 -121

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 477 KiB

+40
View File
@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
class IpService {
static final IpService _instance = IpService._internal();
factory IpService() {
return _instance;
}
IpService._internal();
String? _cachedCountry;
String? _cachedCountryCode;
Future<void> fetchIpDetails() async {
if (_cachedCountry != null) return;
try {
// Using ip-api.com (free tier, HTTP)
final response = await http.get(Uri.parse('http://ip-api.com/json'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'success') {
_cachedCountry = data['country'];
_cachedCountryCode = data['countryCode'];
debugPrint(
'IpService: Detected country: $_cachedCountry ($_cachedCountryCode)');
}
}
} catch (e) {
debugPrint('IpService: Error fetching IP details: $e');
}
}
String? get country => _cachedCountry;
String? get countryCode => _cachedCountryCode;
}
@@ -291,6 +291,15 @@ class _MobileSeriesDetailScreenState
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
final resumePos = watchHistory.getResumePosition(episodeKey);
// Calculate progress
double progress = 0.0;
if (episode.durationSecs != null &&
episode.durationSecs! > 0 &&
resumePos > 0) {
progress = (resumePos / episode.durationSecs!).clamp(0.0, 1.0);
}
// Force dark mode colors since background is hardcoded dark
const textColor = Colors.white;
@@ -300,8 +309,6 @@ class _MobileSeriesDetailScreenState
return InkWell(
onTap: () {
// Watch progress is tracked in player at 80% completion
// Enforce Native Player for Series (VOD)
Navigator.push(
context,
@@ -316,61 +323,78 @@ class _MobileSeriesDetailScreenState
seriesId: widget.series.seriesId,
season: episode.seasonNum,
episodeNum: episode.episodeNum,
// Pass explicit initial position for resume
initialPosition:
resumePos > 0 ? Duration(seconds: resumePos) : null,
),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
),
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
? Colors.black // Checkmark on white bg
: primaryColor, // Play icon
size: 20,
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
),
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,
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),
),
if (episode.durationSecs != null && episode.durationSecs! > 0)
Text(
_formatDuration(episode.durationSecs!),
style: TextStyle(color: secondaryTextColor, fontSize: 12),
),
],
),
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched
? Colors.black // 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.durationSecs != null &&
episode.durationSecs! > 0)
Text(
_formatDuration(episode.durationSecs!),
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>(Colors.red),
minHeight: 2,
),
],
),
);
}
@@ -44,11 +44,14 @@ class NativePlayerScreen extends ConsumerStatefulWidget {
this.containerExtension,
this.channels,
this.initialIndex = 0,
this.initialPosition,
this.seriesId,
this.season,
this.episodeNum,
});
final Duration? initialPosition;
@override
ConsumerState<NativePlayerScreen> createState() => _NativePlayerScreenState();
}
@@ -646,12 +649,22 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Resume from saved position (VOD/Series only)
if (widget.streamType != StreamType.live) {
final resumePos =
ref.read(mobileWatchHistoryProvider).getResumePosition(_contentId);
debugPrint(
'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos');
if (resumePos > 30) {
_seekToResume(resumePos);
// Priority 1: Explicit initial position passed from caller verification
if (widget.initialPosition != null &&
widget.initialPosition!.inSeconds > 0) {
debugPrint(
'MediaKitPlayer: Using explicit initial position: ${widget.initialPosition!.inSeconds}s');
_seekToResume(widget.initialPosition!.inSeconds);
} else {
// Priority 2: Internal lookup (fallback)
final resumePos = ref
.read(mobileWatchHistoryProvider)
.getResumePosition(_contentId);
debugPrint(
'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos');
if (resumePos > 30) {
_seekToResume(resumePos);
}
}
}
} catch (e) {
@@ -14,6 +14,7 @@ import '../../../theme/mobile_theme.dart';
import 'package:xtremflow/mobile/widgets/tv_focusable.dart';
import 'package:xtremflow/mobile/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart';
import '../../../../core/services/ip_service.dart';
class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -41,6 +42,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
_searchQuery = _searchController.text.toLowerCase();
});
});
// Trigger IP fetch for smart sorting
IpService().fetchIpDetails().then((_) {
if (mounted) setState(() {});
});
}
@override
@@ -111,8 +116,22 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
categories = categories
.where((cat) => settings.matchesLiveTvFilter(cat))
.toList();
categories.sort();
} else {
// Smart Sort (GeoIP)
final userCountry = IpService().country;
categories.sort((a, b) {
if (userCountry != null) {
final aMatches =
a.toLowerCase().contains(userCountry.toLowerCase());
final bMatches =
b.toLowerCase().contains(userCountry.toLowerCase());
if (aMatches && !bMatches) return -1;
if (!aMatches && bMatches) return 1;
}
return a.compareTo(b);
});
}
categories.sort();
// Prepare channels
List<Channel> displayedChannels = [];
@@ -151,6 +170,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
children: [
// Search Bar - Wrapped in TVFocusable for remote access
TVFocusable(
scale: 1.0, // Disable scaling to prevent overflow
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -258,6 +258,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TVFocusable(
scale: 1.0, // Disable scaling to prevent overflow
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -75,11 +75,13 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
Container(
width: 48,
height: 48,
// No decoration for free-floating logo
child: Image.asset(
'assets/images/logo_xtremflow.png',
fit: BoxFit.cover,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.play_circle_filled,
color: AppColors.primary,
+127 -61
View File
@@ -1,9 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/theme/app_colors.dart';
class MobileScaffold extends ConsumerWidget {
class MobileScaffold extends ConsumerStatefulWidget {
final Widget child;
final int currentIndex;
final Function(int) onIndexChanged;
@@ -16,59 +18,130 @@ class MobileScaffold extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<MobileScaffold> createState() => _MobileScaffoldState();
}
class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
late Stream<DateTime> _clockStream;
@override
void initState() {
super.initState();
_clockStream =
Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: Column(
children: [
// Top Navigation Bar (Apple TV Style)
// Top Navigation Bar (Redesigned)
Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.6),
Colors.transparent,
],
),
padding: const EdgeInsets.symmetric(
vertical: 24,
horizontal:
24), // Increased vertical padding for floating effect
decoration: const BoxDecoration(
color: Colors.transparent, // Transparent background
),
child: SafeArea(
bottom: false,
child: Container(
height: 64, // Taller pill to accommodate larger logo
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A)
.withOpacity(0.9), // Slightly more opaque pill
borderRadius: BorderRadius.circular(40),
border: Border.all(color: Colors.white.withOpacity(0.1)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 2,
)
],
),
child: Row(
children: [
// LOGO (Left side)
// --- LEFT: LOGO & BRAND ---
Padding(
padding: const EdgeInsets.only(right: 16),
child: Image.asset(
'assets/images/logo_xtremflow.png',
height: 40,
width: 40,
errorBuilder: (context, error, stackTrace) =>
const SizedBox(width: 40), // Fallback if missing
padding: const EdgeInsets.only(
left: 8, right: 32), // More spacing after logo
child: Row(
children: [
Container(
width: 52, // Larger Logo
height: 52,
// No decoration for free-floating logo
child: Image.asset(
'assets/images/logo_xtremflow.png',
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.play_circle_filled,
color: Colors.white,
size: 40,
),
),
),
const SizedBox(width: 12),
const Text(
'XtremFlow',
style: TextStyle(
color: Colors.white,
fontSize: 20, // Slightly larger text
fontWeight: FontWeight.bold,
fontFamily: 'Inter',
),
),
],
),
),
// NAVIGATION PILL (Expanded)
// --- CENTER: NAVIGATION ---
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildNavItem(0, 'Live TV', Icons.tv),
const SizedBox(width: 32), // Increased spacing
_buildNavItem(1, 'Movies', Icons.movie),
const SizedBox(width: 32),
_buildNavItem(2, 'Series', Icons.video_library),
const SizedBox(width: 32),
_buildNavItem(3, 'Settings', Icons.settings),
],
),
),
// --- RIGHT: CLOCK ONLY ---
Padding(
padding: const EdgeInsets.only(left: 32, right: 16),
child: Container(
height: 50,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(30),
border:
Border.all(color: Colors.white.withOpacity(0.15)),
color: Colors.white
.withOpacity(0.08), // Subtle background
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(0, 'Live TV', Icons.tv),
_buildNavItem(1, 'Films', Icons.movie),
_buildNavItem(2, 'Séries', Icons.video_library),
_buildNavItem(3, 'Paramètres', Icons.settings),
],
child: StreamBuilder<DateTime>(
stream: _clockStream,
initialData: DateTime.now(),
builder: (context, snapshot) {
final time =
DateFormat('HH:mm').format(snapshot.data!);
return Text(
time,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
);
},
),
),
),
@@ -79,20 +152,19 @@ class MobileScaffold extends ConsumerWidget {
),
// Main Content
Expanded(child: child),
Expanded(child: widget.child),
],
),
);
}
Widget _buildNavItem(int index, String label, IconData icon) {
final isSelected = currentIndex == index;
final isSelected = widget.currentIndex == index;
return _AppleTVNavItem(
isSelected: isSelected,
icon: icon,
label: label,
onPressed: () => onIndexChanged(index),
onPressed: () => widget.onIndexChanged(index),
);
}
}
@@ -138,11 +210,13 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
@override
Widget build(BuildContext context) {
// Selected OR Focused = black text (white bg), Normal = white70 text
final Color textColor =
(widget.isSelected || _isFocused) ? Colors.black : Colors.white70;
final Color iconColor =
(widget.isSelected || _isFocused) ? Colors.black : Colors.white70;
// Selected: Black text on White bg
// Focused: White text on Translucent bg
// Normal: White70 text on Transparent bg
final Color textColor = widget.isSelected ? Colors.black : Colors.white;
final Color iconColor = widget.isSelected ? Colors.black : Colors.white;
final FontWeight fontWeight =
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
return Focus(
focusNode: _focusNode,
@@ -163,38 +237,30 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
decoration: BoxDecoration(
// White pill when selected OR focused
color: widget.isSelected
? Colors.white
: (_isFocused
? Colors.white.withOpacity(0.9)
? Colors.white.withOpacity(0.2) // Explicit focus highlight
: Colors.transparent),
borderRadius: BorderRadius.circular(25),
// Strong glow when focused
boxShadow: _isFocused
? [
BoxShadow(
color: Colors.white.withOpacity(0.5),
blurRadius: 16,
spreadRadius: 4,
),
]
borderRadius: BorderRadius.circular(20), // Matches pill shape
border: _isFocused && !widget.isSelected
? Border.all(
color: Colors.white
.withOpacity(0.3)) // Subtle border for focus
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(widget.icon, color: iconColor, size: 18),
const SizedBox(width: 6),
const SizedBox(width: 8),
Text(
widget.label,
style: TextStyle(
color: textColor,
fontWeight:
widget.isSelected ? FontWeight.w700 : FontWeight.w400,
fontSize: 13,
fontWeight: fontWeight,
fontSize: 14,
),
),
],