mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-17 20:29:30 +02:00
feat: introduce Live TV tab for browsing IPTV channels with category filtering, search, and favorites.
This commit is contained in:
1 parent
26ac5fb0b8
commit
00a07cf778
3 files changed
+157
-72
No files matched your search
@@ -188,8 +188,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
..style.border = 'none'
|
..style.border = 'none'
|
||||||
..style.width = '100%'
|
..style.width = '100%'
|
||||||
..style.height = '100%'
|
..style.height = '100%'
|
||||||
..allow = 'autoplay; fullscreen'
|
..style.height = '100%'
|
||||||
..allowFullscreen = true;
|
..allow = 'autoplay; fullscreen; picture-in-picture';
|
||||||
return iframe;
|
return iframe;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
// Filter Logic
|
// Filter Logic
|
||||||
List<Channel> displayedChannels = [];
|
List<Channel> displayedChannels = [];
|
||||||
|
bool showingCategoryGrid = false;
|
||||||
|
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
// Global Search
|
// Global Search
|
||||||
@@ -93,37 +94,47 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
.where((c) => favorites.contains(c.streamId))
|
.where((c) => favorites.contains(c.streamId))
|
||||||
.toList();
|
.toList();
|
||||||
} else if (_selectedCategory != null) {
|
} else if (_selectedCategory != null) {
|
||||||
// Selected Category
|
// Selected Category View
|
||||||
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||||
} else {
|
} else {
|
||||||
// Default: Show first category or All if practical, but "All" is too big.
|
// Category Grid View (Default)
|
||||||
// Apple TV pattern: Auto-select first category if nothing selected.
|
showingCategoryGrid = true;
|
||||||
if (categories.isNotEmpty) {
|
|
||||||
// We don't want to trigger setState during build, so we just use the first category functionality
|
|
||||||
// But visualized as "All" might be confusing with map keys.
|
|
||||||
// Let's default to the first category in the list.
|
|
||||||
_selectedCategory ??= categories.first;
|
|
||||||
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Minimalist Header
|
// Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Back Button (if in category view and not searching)
|
||||||
|
if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedCategory = null;
|
||||||
|
_mainScrollController.jumpTo(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Back to Categories',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Live TV',
|
_searchQuery.isNotEmpty ? 'Search Results' :
|
||||||
|
_showFavoritesOnly ? 'Favorites' :
|
||||||
|
_selectedCategory ?? 'Live TV',
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
// Search Pill
|
// Search Pill
|
||||||
Container(
|
Container(
|
||||||
width: 250,
|
width: 300,
|
||||||
height: 36,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
@@ -132,17 +143,17 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.search, size: 18, color: AppColors.textSecondary),
|
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
|
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Search channels',
|
hintText: 'Search channels',
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.only(bottom: 12),
|
contentPadding: EdgeInsets.only(bottom: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -166,63 +177,41 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_showFavoritesOnly = !_showFavoritesOnly;
|
_showFavoritesOnly = !_showFavoritesOnly;
|
||||||
if (_showFavoritesOnly) _searchController.clear();
|
if (_showFavoritesOnly) _searchController.clear();
|
||||||
|
_selectedCategory = null; // Reset selection when toggling favorites
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip: 'Favorites Only',
|
tooltip: 'Favorites Only',
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
// View Toggle (Only show when not in category grid)
|
||||||
_isGridView ? Icons.view_list : Icons.grid_view,
|
if (!showingCategoryGrid)
|
||||||
color: AppColors.textSecondary,
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isGridView ? Icons.view_list : Icons.grid_view,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||||
|
tooltip: 'Toggle View',
|
||||||
),
|
),
|
||||||
onPressed: () => setState(() => _isGridView = !_isGridView),
|
|
||||||
tooltip: 'Toggle View',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Categories Horizontal List (Only if not searching/favorites)
|
// Content
|
||||||
if (_searchQuery.isEmpty && !_showFavoritesOnly)
|
|
||||||
Container(
|
|
||||||
height: 48,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: ListView.separated(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: categories.length,
|
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final category = categories[index];
|
|
||||||
final isSelected = category == _selectedCategory;
|
|
||||||
return CategoryChip(
|
|
||||||
label: category,
|
|
||||||
isSelected: isSelected,
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_selectedCategory = category;
|
|
||||||
_mainScrollController.jumpTo(0); // Reset scroll
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Channels Content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: displayedChannels.isEmpty
|
child: showingCategoryGrid
|
||||||
? Center(
|
? _buildCategoryGrid(categories, groupedChannels)
|
||||||
child: Text(
|
: displayedChannels.isEmpty
|
||||||
_showFavoritesOnly ? 'No favorites' : 'No channels found',
|
? Center(
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
|
child: Text(
|
||||||
),
|
_searchQuery.isNotEmpty ? 'No channels found' :
|
||||||
)
|
_showFavoritesOnly ? 'No favorites' : 'No channels in this group',
|
||||||
: _isGridView
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
|
||||||
? _buildGridView(displayedChannels)
|
),
|
||||||
: _buildListView(displayedChannels),
|
)
|
||||||
|
: _isGridView
|
||||||
|
? _buildChannelGrid(displayedChannels)
|
||||||
|
: _buildChannelList(displayedChannels),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -231,7 +220,94 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridView(List<Channel> channels) {
|
Widget _buildCategoryGrid(List<String> categories, Map<String, List<Channel>> groupedChannels) {
|
||||||
|
final columns = ResponsiveLayout.value(
|
||||||
|
context,
|
||||||
|
mobile: 2,
|
||||||
|
tablet: 3,
|
||||||
|
desktop: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
return GridView.builder(
|
||||||
|
controller: _mainScrollController,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: columns,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
),
|
||||||
|
itemCount: categories.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final category = categories[index];
|
||||||
|
final count = groupedChannels[category]?.length ?? 0;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
elevation: 2,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedCategory = category;
|
||||||
|
_mainScrollController.jumpTo(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
AppColors.surface,
|
||||||
|
AppColors.surface.withOpacity(0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(color: AppColors.border.withOpacity(0.5)),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.folder_open, color: AppColors.primary, size: 32),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
category,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count Channels',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChannelGrid(List<Channel> channels) {
|
||||||
final columns = ResponsiveLayout.value(
|
final columns = ResponsiveLayout.value(
|
||||||
context,
|
context,
|
||||||
mobile: 2,
|
mobile: 2,
|
||||||
@@ -241,12 +317,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
controller: _mainScrollController,
|
controller: _mainScrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: columns,
|
crossAxisCount: columns,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
childAspectRatio: 1.3, // Card ratio 4:3ish
|
childAspectRatio: 1.3,
|
||||||
),
|
),
|
||||||
itemCount: channels.length,
|
itemCount: channels.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -262,10 +338,10 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildListView(List<Channel> channels) {
|
Widget _buildChannelList(List<Channel> channels) {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
controller: _mainScrollController,
|
controller: _mainScrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||||
itemCount: channels.length,
|
itemCount: channels.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|||||||
@@ -289,6 +289,11 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mpegts.getLoggingControl) {
|
||||||
|
mpegts.getLoggingControl().enableAll = false;
|
||||||
|
mpegts.getLoggingControl().enableError = true;
|
||||||
|
}
|
||||||
|
|
||||||
const player = mpegts.createPlayer({
|
const player = mpegts.createPlayer({
|
||||||
type: 'mpegts',
|
type: 'mpegts',
|
||||||
isLive: true,
|
isLive: true,
|
||||||
@@ -298,6 +303,10 @@
|
|||||||
enableStashBuffer: true,
|
enableStashBuffer: true,
|
||||||
stashInitialSize: 384 * 1024,
|
stashInitialSize: 384 * 1024,
|
||||||
autoCleanupSourceBuffer: true,
|
autoCleanupSourceBuffer: true,
|
||||||
|
// Make the player more resilient to timestamp gaps without spamming
|
||||||
|
fixAudioTimestampGap: true,
|
||||||
|
lazyLoadMaxDuration: 3 * 60,
|
||||||
|
seekType: 'range',
|
||||||
});
|
});
|
||||||
|
|
||||||
player.attachMediaElement(video);
|
player.attachMediaElement(video);
|
||||||
|
|||||||
Reference in new issue
Block a user