Merge remote-tracking branch 'origin/main' into main

Resolved conflicts by keeping remote functional features (RecordingScheduler,
FFmpeg HLS transcoding, mobile player, channel cards) and restoring local
Stitch theme foundation (app_colors, app_theme, glass_container, mobile_theme).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.7 committed 2026-04-27 22:17:52 +02:00
commit 2ba16caf55
68 files changed
+18812 -1794

No files matched your search

@@ -34,16 +34,23 @@ class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
_currentIndex = index;
});
},
child: IndexedStack(
index: _currentIndex,
children: [
MobileLiveTVTab(playlist: widget.playlist),
MobileMoviesTab(playlist: widget.playlist),
MobileSeriesTab(playlist: widget.playlist),
const MobileSettingsTab(),
],
),
child: _buildActiveTab(),
),
);
}
Widget _buildActiveTab() {
switch (_currentIndex) {
case 0:
return MobileLiveTVTab(playlist: widget.playlist);
case 1:
return MobileMoviesTab(playlist: widget.playlist);
case 2:
return MobileSeriesTab(playlist: widget.playlist);
case 3:
return const MobileSettingsTab();
default:
return MobileLiveTVTab(playlist: widget.playlist);
}
}
}
@@ -65,8 +65,11 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 48, color: AppColors.error),
const Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.inter()),
TextButton(
@@ -82,12 +85,16 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.playlist_remove,
size: 48, color: AppColors.onSurfaceVariant),
const Icon(
Icons.playlist_remove,
size: 48,
color: AppColors.textSecondary,
),
const SizedBox(height: 16),
Text('No playlists available',
style:
GoogleFonts.inter(color: AppColors.onSurfaceVariant)),
Text(
'No playlists available',
style: GoogleFonts.inter(color: AppColors.textSecondary),
),
],
),
);
@@ -133,10 +140,10 @@ class _MobilePlaylistCard extends StatelessWidget {
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.outlineVariant.withOpacity(0.5)),
border: Border.all(color: AppColors.border.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: AppColors.background.withOpacity(0.2),
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -158,8 +165,11 @@ class _MobilePlaylistCard extends StatelessWidget {
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.playlist_play,
color: AppColors.primary, size: 28),
child: const Icon(
Icons.playlist_play,
color: AppColors.primary,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
@@ -171,7 +181,7 @@ class _MobilePlaylistCard extends StatelessWidget {
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -181,7 +191,7 @@ class _MobilePlaylistCard extends StatelessWidget {
Uri.tryParse(playlist.dns)?.host ?? playlist.dns,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.onSurfaceVariant,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -48,14 +48,19 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Loading channels...', style: TextStyle(color: AppColors.onSurfaceVariant)),
const Text(
'Loading channels...',
style: TextStyle(color: Colors.white70),
),
const SizedBox(height: 24),
FutureBuilder(
future: Future.delayed(const Duration(seconds: 8)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return TextButton(
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
onPressed: () => ref.refresh(
liveChannelsByPlaylistProvider(widget.playlist),
),
child: const Text('Taking too long? Tap to retry'),
);
}
@@ -71,14 +76,26 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: AppColors.error, size: 48),
const Icon(Icons.error_outline, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text('Failed to load channels', style: GoogleFonts.inter(color: AppColors.onSurface, fontSize: 18, fontWeight: FontWeight.bold)),
Text(
'Failed to load channels',
style: GoogleFonts.inter(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text('$e', style: const TextStyle(color: AppColors.onSurfaceVariant), textAlign: TextAlign.center),
Text(
'$e',
style: const TextStyle(color: Colors.white54),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => ref.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
onPressed: () => ref
.refresh(liveChannelsByPlaylistProvider(widget.playlist)),
child: const Text('Retry'),
),
],
@@ -127,8 +144,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
// Header & Search
Padding(
padding: const EdgeInsets.all(16),
child: GlassContainer.glass(
child: GlassContainer(
borderRadius: 16,
opacity: 0.1,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
@@ -136,15 +154,15 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
Expanded(
child: TextField(
controller: _searchController,
style: GoogleFonts.inter(color: AppColors.onSurface),
style: GoogleFonts.inter(color: Colors.white),
decoration: InputDecoration(
hintText: 'Search channels...',
hintStyle:
GoogleFonts.inter(color: AppColors.onSurfaceVariant),
GoogleFonts.inter(color: Colors.white54),
border: InputBorder.none,
prefixIcon: const Icon(
Icons.search,
color: AppColors.onSurfaceVariant,
color: Colors.white54,
),
isDense: true,
),
@@ -154,7 +172,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
GestureDetector(
onTap: () => _searchController.clear(),
child:
const Icon(Icons.close, color: AppColors.onSurfaceVariant),
const Icon(Icons.close, color: Colors.white54),
),
],
),
@@ -184,15 +202,15 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
),
decoration: BoxDecoration(
color: isSelected
? AppColors.primary
: AppColors.surfaceContainer,
? Colors.white
: Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(20),
),
child: Text(
category,
style: GoogleFonts.inter(
color:
isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant,
isSelected ? Colors.black : Colors.white70,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
@@ -217,7 +235,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
? 'Favorites'
: _selectedCategory ?? 'Channels',
style: GoogleFonts.inter(
color: AppColors.onSurface,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18,
),
@@ -232,7 +250,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
decoration: BoxDecoration(
color: _showFavoritesOnly
? AppColors.live.withOpacity(0.2)
: AppColors.surfaceContainer,
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: _showFavoritesOnly
? Border.all(color: AppColors.live)
@@ -244,7 +262,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.live
: AppColors.onSurface,
: Colors.white,
size: 20,
),
),
@@ -262,13 +280,13 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [
const Icon(
Icons.tv_off,
color: AppColors.outlineVariant,
color: Colors.white24,
size: 48,
),
const SizedBox(height: 16),
Text(
'No channels found',
style: GoogleFonts.inter(color: AppColors.outline),
style: GoogleFonts.inter(color: Colors.white38),
),
],
),
@@ -282,9 +300,11 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
final channel = displayedChannels[index];
return _MobileChannelTile(
channel: channel,
playlist: widget.playlist,
onTap: () => _playChannel(
context, channel, displayedChannels),
context,
channel,
displayedChannels,
),
);
},
),
@@ -298,7 +318,10 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
void _playChannel(
BuildContext context, Channel channel, List<Channel> channels) {
BuildContext context,
Channel channel,
List<Channel> channels,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
@@ -313,33 +336,25 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
}
}
class _MobileChannelTile extends ConsumerWidget {
class _MobileChannelTile extends StatelessWidget {
final Channel channel;
final PlaylistConfig playlist;
final VoidCallback onTap;
const _MobileChannelTile({
required this.channel,
required this.playlist,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final iconUrl =
channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
: null;
// Fetch EPG
final epgAsync = ref.watch(
epgByPlaylistProvider(
EpgRequestKey(playlist: playlist, streamId: channel.streamId),
),
);
return GlassContainer.glass(
return GlassContainer(
borderRadius: 12,
opacity: 0.15,
padding: EdgeInsets.zero,
child: InkWell(
onTap: onTap,
@@ -352,7 +367,7 @@ class _MobileChannelTile extends ConsumerWidget {
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(4),
@@ -361,73 +376,32 @@ class _MobileChannelTile extends ConsumerWidget {
iconUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: AppColors.outlineVariant),
const Icon(Icons.tv, color: Colors.white24),
)
: const Icon(Icons.tv, color: AppColors.outlineVariant),
: const Icon(Icons.tv, color: Colors.white24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.name,
style: GoogleFonts.inter(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// EPG Only (Replaces Channel Number)
epgAsync.when(
data: (epgList) {
if (epgList.isEmpty) {
return Text(
'No Info',
style: GoogleFonts.inter(
color: AppColors.outline,
fontSize: 12,
),
);
}
final current = epgList.first;
return Text(
current.title,
style: GoogleFonts.inter(
color: const Color(
0xFFFFD700,
), // Gold/Amber for visibility check
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
loading: () => const Text(
'...',
style: TextStyle(color: AppColors.outline, fontSize: 12),
),
error: (err, stack) => const Text(
'Err',
style: TextStyle(color: AppColors.error, fontSize: 10),
),
),
],
child: Text(
channel.name,
style: GoogleFonts.inter(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onSurface,
color: Colors.white,
size: 16,
),
),