mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-20 05:38:15 +02:00
feat: add mobile settings tab with player and content filter options, a new mobile scaffold, and logo asset.
This commit is contained in:
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 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user