@@ -25,6 +25,8 @@ class _HomeScreenState extends State<HomeScreen> with WindowListener {
2525 List <PlaylistInfo > _playlists = [];
2626 PlaylistInfo ? _activePl;
2727 List <Channel > _filtered = [];
28+ Map <String , List <Channel >> _groupedChannels = {};
29+ List <String > _categories = [];
2830 bool _showFavoritesOnly = false ;
2931 final _searchCtrl = TextEditingController ();
3032 String ? _profilePicPath;
@@ -140,6 +142,13 @@ class _HomeScreenState extends State<HomeScreen> with WindowListener {
140142 }
141143 setState (() {
142144 _filtered = list;
145+
146+ // Pre-calcular grupos para eficiencia en la UI
147+ _groupedChannels = {};
148+ for (var c in _filtered) {
149+ _groupedChannels.update (c.group, (val) => val..add (c), ifAbsent: () => [c]);
150+ }
151+ _categories = _groupedChannels.keys.toList ();
143152 });
144153 }
145154
@@ -529,6 +538,91 @@ class _HomeScreenState extends State<HomeScreen> with WindowListener {
529538 }
530539 }
531540
541+ Future <void > _addFromMacPortal () async {
542+ final serverCtrl = TextEditingController ();
543+ final macCtrl = TextEditingController ();
544+
545+ if (! mounted) return ;
546+
547+ final result = await showDialog <Map <String , String >>(
548+ context: context,
549+ builder: (ctx) => AlertDialog (
550+ backgroundColor: const Color (0xFF1E3C6F ),
551+ title: const Text ('Añadir IPTV MAC (Stalker)' , style: TextStyle (color: Colors .white)),
552+ content: Column (
553+ mainAxisSize: MainAxisSize .min,
554+ children: [
555+ TextField (
556+ controller: serverCtrl,
557+ style: const TextStyle (color: Colors .white),
558+ decoration: const InputDecoration (
559+ labelText: 'URL del Portal (ej. http://dominio/c/)' ,
560+ labelStyle: TextStyle (color: Colors .white54),
561+ enabledBorder: UnderlineInputBorder (borderSide: BorderSide (color: Colors .orange)),
562+ focusedBorder: UnderlineInputBorder (borderSide: BorderSide (color: Colors .orange)),
563+ ),
564+ ),
565+ const SizedBox (height: 8 ),
566+ TextField (
567+ controller: macCtrl,
568+ style: const TextStyle (color: Colors .white),
569+ decoration: const InputDecoration (
570+ labelText: 'Dirección MAC (ej. 00:1A:79:...)' ,
571+ labelStyle: TextStyle (color: Colors .white54),
572+ hintText: '00:1A:79:XX:XX:XX' ,
573+ hintStyle: TextStyle (color: Colors .white24),
574+ enabledBorder: UnderlineInputBorder (borderSide: BorderSide (color: Colors .orange)),
575+ focusedBorder: UnderlineInputBorder (borderSide: BorderSide (color: Colors .orange)),
576+ ),
577+ ),
578+ ],
579+ ),
580+ actions: [
581+ TextButton (
582+ onPressed: () => Navigator .pop (ctx),
583+ child: const Text ('Cancelar' , style: TextStyle (color: Colors .white54)),
584+ ),
585+ TextButton (
586+ onPressed: () {
587+ if (serverCtrl.text.isNotEmpty && macCtrl.text.isNotEmpty) {
588+ Navigator .pop (ctx, {
589+ 'server' : serverCtrl.text.trim (),
590+ 'mac' : macCtrl.text.trim (),
591+ });
592+ }
593+ },
594+ child: const Text ('Conectar' , style: TextStyle (color: Colors .orange)),
595+ ),
596+ ],
597+ ),
598+ );
599+
600+ if (result == null ) return ;
601+
602+ if (mounted) {
603+ ScaffoldMessenger .of (context).showSnackBar (const SnackBar (content: Text ('Conectando al portal MAC...' )));
604+ }
605+
606+ try {
607+ final channels = await PlaylistService .loadFromMacPortal (result['server' ]! , result['mac' ]! );
608+ final pl = PlaylistInfo (
609+ id: DateTime .now ().millisecondsSinceEpoch.toString (),
610+ name: "Portal MAC - ${result ['mac' ]}" ,
611+ channels: channels
612+ );
613+ await PlaylistService .addPlaylist (pl);
614+ await PlaylistService .setActiveId (pl.id);
615+ await _load ();
616+ if (mounted) {
617+ ScaffoldMessenger .of (context).showSnackBar (const SnackBar (content: Text ('¡Conectado! Canales cargados.' ), backgroundColor: Colors .green));
618+ }
619+ } catch (e) {
620+ if (mounted) {
621+ ScaffoldMessenger .of (context).showSnackBar (const SnackBar (content: Text ('Error al conectar. Comprueba la URL y la MAC' ), backgroundColor: Colors .red));
622+ }
623+ }
624+ }
625+
532626 Future <void > _addVideoAndSave () async {
533627 final result = await FilePicker .platform.pickFiles (type: FileType .video);
534628 if (result != null && result.files.isNotEmpty) {
@@ -778,6 +872,15 @@ class _HomeScreenState extends State<HomeScreen> with WindowListener {
778872 _addFromXtream ();
779873 },
780874 ),
875+ _drawerItem (
876+ icon: Icons .settings_input_component,
877+ title: 'Añadir IPTV MAC' ,
878+ subtitle: 'Usar dirección MAC 00:1A:79...' ,
879+ onTap: () {
880+ Navigator .pop (context);
881+ _addFromMacPortal ();
882+ },
883+ ),
781884 _drawerItem (
782885 icon: _showFavoritesOnly ? Icons .star : Icons .star_border,
783886 title: 'Solo Favoritos' ,
@@ -1161,68 +1264,88 @@ class _HomeScreenState extends State<HomeScreen> with WindowListener {
11611264 ],
11621265 ),
11631266 ),
1164- // List of Channels
1165- if (_filtered .isEmpty)
1267+ // List of Channels Grouped by Category
1268+ if (_categories .isEmpty)
11661269 const SliverToBoxAdapter (
11671270 child: Center (child: Text ('Sin canales' , style: TextStyle (color: Colors .white54))),
11681271 )
11691272 else
11701273 SliverList (
11711274 delegate: SliverChildBuilderDelegate (
11721275 (context, index) {
1173- final channel = _filtered[index];
1174- final isGlobalActive = (_currentChannel == channel);
1175- return Material (
1176- color: Colors .transparent,
1177- child: InkWell (
1178- focusColor: appBarColor.withValues (alpha: 0.15 ),
1179- onTap: () {
1180- final list = _isLocalSource ? _myVideos : _channels;
1181- final pickedIdx = list.indexOf (channel);
1182- if (pickedIdx != - 1 ) {
1183- if (_isLocalSource) {
1184- _playLocalVideo (channel);
1185- } else {
1186- _playChannel (pickedIdx);
1187- }
1188- }
1189- },
1190- child: ListTile (
1191- leading: Container (
1192- width: 44 ,
1193- height: 44 ,
1194- decoration: BoxDecoration (
1195- color: bgColor,
1196- borderRadius: BorderRadius .circular (8 ),
1197- ),
1198- child: channel.logo != null && channel.logo! .isNotEmpty
1199- ? ClipRRect (
1276+ if (index >= _categories.length) return null ;
1277+
1278+ final catName = _categories[index];
1279+ final catChannels = _groupedChannels[catName]! ;
1280+
1281+ return Theme (
1282+ data: Theme .of (context).copyWith (dividerColor: Colors .transparent),
1283+ child: ExpansionTile (
1284+ backgroundColor: Colors .white.withValues (alpha: 0.03 ),
1285+ collapsedIconColor: Colors .white54,
1286+ iconColor: appBarColor,
1287+ title: Text (
1288+ catName,
1289+ style: const TextStyle (color: Colors .white, fontWeight: FontWeight .bold, fontSize: 16 ),
1290+ ),
1291+ subtitle: Text ('${catChannels .length } canales' , style: const TextStyle (color: Colors .white38, fontSize: 12 )),
1292+ children: catChannels.map ((channel) {
1293+ final isGlobalActive = (_currentChannel == channel);
1294+ return Material (
1295+ color: Colors .transparent,
1296+ child: InkWell (
1297+ focusColor: appBarColor.withValues (alpha: 0.15 ),
1298+ onTap: () {
1299+ final list = _isLocalSource ? _myVideos : _channels;
1300+ final pickedIdx = list.indexOf (channel);
1301+ if (pickedIdx != - 1 ) {
1302+ if (_isLocalSource) {
1303+ _playLocalVideo (channel);
1304+ } else {
1305+ _playChannel (pickedIdx);
1306+ }
1307+ }
1308+ },
1309+ child: ListTile (
1310+ contentPadding: const EdgeInsets .only (left: 32 , right: 16 ),
1311+ leading: Container (
1312+ width: 40 ,
1313+ height: 40 ,
1314+ decoration: BoxDecoration (
1315+ color: bgColor,
12001316 borderRadius: BorderRadius .circular (8 ),
1201- child: Image .network (
1202- channel.logo! ,
1203- fit: BoxFit .cover,
1204- errorBuilder: (_, __, ___) => const Icon (Icons .tv, color: Colors .white54),
1205- ))
1206- : const Icon (Icons .tv, color: Colors .white54),
1207- ),
1208- title: Text (
1209- channel.name,
1210- style: TextStyle (
1211- color: isGlobalActive ? appBarColor : Colors .white,
1212- fontWeight: isGlobalActive ? FontWeight .bold : FontWeight .normal,
1317+ ),
1318+ child: channel.logo != null && channel.logo! .isNotEmpty
1319+ ? ClipRRect (
1320+ borderRadius: BorderRadius .circular (8 ),
1321+ child: Image .network (
1322+ channel.logo! ,
1323+ fit: BoxFit .cover,
1324+ errorBuilder: (_, __, ___) => const Icon (Icons .tv, color: Colors .white54),
1325+ ))
1326+ : const Icon (Icons .tv, color: Colors .white54),
1327+ ),
1328+ title: Text (
1329+ channel.name,
1330+ style: TextStyle (
1331+ color: isGlobalActive ? appBarColor : Colors .white,
1332+ fontWeight: isGlobalActive ? FontWeight .bold : FontWeight .normal,
1333+ fontSize: 14 ,
1334+ ),
1335+ maxLines: 1 ,
1336+ overflow: TextOverflow .ellipsis,
1337+ ),
1338+ trailing: isGlobalActive
1339+ ? const Icon (Icons .volume_up, color: appBarColor, size: 18 )
1340+ : null ,
1341+ ),
12131342 ),
1214- maxLines: 1 ,
1215- overflow: TextOverflow .ellipsis,
1216- ),
1217- subtitle: Text (channel.group, style: const TextStyle (color: Colors .white54, fontSize: 12 )),
1218- trailing: isGlobalActive
1219- ? const Icon (Icons .volume_up, color: appBarColor, size: 20 )
1220- : null ,
1221- ),
1343+ );
1344+ }).toList (),
12221345 ),
12231346 );
12241347 },
1225- childCount: _filtered .length,
1348+ childCount: _categories .length,
12261349 ),
12271350 ),
12281351 ],
0 commit comments