feat: Initialize XtremFlow project with core structure, IPTV features, authentication, and video player.

This commit is contained in:
Michael committed 2025-12-05 09:17:11 +01:00
commit 2a28eb44d8
28 files changed
+3279

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart';
import '../models/xtream_models.dart';
class EPGWidget extends ConsumerStatefulWidget {
final String channelId;
final PlaylistConfig playlist;
const EPGWidget({
super.key,
required this.channelId,
required this.playlist,
});
@override
ConsumerState<EPGWidget> createState() => _EPGWidgetState();
}
class _EPGWidgetState extends ConsumerState<EPGWidget> {
ShortEPG? _epg;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadEPG();
}
Future<void> _loadEPG() async {
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final epg = await service.getShortEPG(widget.channelId);
if (mounted) {
setState(() {
_epg = epg;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Container(
padding: const EdgeInsets.all(8),
child: const Row(
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
SizedBox(width: 8),
Text('Loading EPG...', style: TextStyle(fontSize: 10)),
],
),
);
}
if (_epg == null ||
(_epg!.nowPlaying == null && _epg!.nextPlaying == null)) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Now Playing
if (_epg!.nowPlaying != null) ...[
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'LIVE',
style: GoogleFonts.roboto(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
_epg!.nowPlaying!,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
// Progress bar
if (_epg!.progress != null) ...[
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: _epg!.progress!.clamp(0.0, 1.0),
backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
minHeight: 3,
),
),
],
],
// Next Playing
if (_epg!.nextPlaying != null) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.upcoming,
size: 12,
color: Colors.grey.shade400,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Next: ${_epg!.nextPlaying}',
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
],
),
);
}
}
+197
View File
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart';
import '../models/xtream_models.dart';
import '../screens/video_player_screen.dart';
import 'epg_widget.dart';
class LiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const LiveTVTab({super.key, required this.playlist});
@override
ConsumerState<LiveTVTab> createState() => _LiveTVTabState();
}
class _LiveTVTabState extends ConsumerState<LiveTVTab> {
final ScrollController _scrollController = ScrollController();
final List<LiveChannel> _channels = [];
bool _isLoading = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
@override
void initState() {
super.initState();
_loadMoreChannels();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreChannels();
}
}
Future<void> _loadMoreChannels() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newChannels = await service.getLiveStreams(
offset: _currentOffset,
limit: _pageSize,
);
setState(() {
_channels.addAll(newChannels);
_currentOffset += _pageSize;
_hasMore = newChannels.length == _pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load channels: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_channels.isEmpty && !_isLoading) {
return const Center(child: Text('No channels available'));
}
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.75,
),
itemCount: _channels.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _channels.length) {
return const Center(child: CircularProgressIndicator());
}
final channel = _channels[index];
return _ChannelCard(
channel: channel,
playlist: widget.playlist,
);
},
);
}
}
class _ChannelCard extends StatelessWidget {
final LiveChannel channel;
final PlaylistConfig playlist;
const _ChannelCard({
required this.channel,
required this.playlist,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
final streamUrl = channel.getStreamUrl(
playlist.dns,
playlist.username,
playlist.password,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
streamUrl: streamUrl,
title: channel.name,
posterUrl: channel.streamIcon,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: channel.streamIcon != null
? CachedNetworkImage(
imageUrl: channel.streamIcon!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey.shade800,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey.shade800,
child: const Icon(Icons.live_tv, size: 48),
),
)
: Container(
color: Colors.grey.shade800,
child: const Icon(Icons.live_tv, size: 48),
),
),
Container(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
channel.name,
style: GoogleFonts.roboto(
fontSize: 12,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
// EPG Display
if (channel.epgChannelId != null)
EPGWidget(
channelId: channel.streamId,
playlist: playlist,
),
],
),
),
],
),
),
);
}
}
+208
View File
@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart';
import '../models/xtream_models.dart';
import '../screens/video_player_screen.dart';
class MoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MoviesTab({super.key, required this.playlist});
@override
ConsumerState<MoviesTab> createState() => _MoviesTabState();
}
class _MoviesTabState extends ConsumerState<MoviesTab> {
final ScrollController _scrollController = ScrollController();
final List<Movie> _movies = [];
bool _isLoading = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
@override
void initState() {
super.initState();
_loadMoreMovies();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreMovies();
}
}
Future<void> _loadMoreMovies() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newMovies = await service.getMovies(
offset: _currentOffset,
limit: _pageSize,
);
setState(() {
_movies.addAll(newMovies);
_currentOffset += _pageSize;
_hasMore = newMovies.length == _pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load movies: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_movies.isEmpty && !_isLoading) {
return const Center(child: Text('No movies available'));
}
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.7,
),
itemCount: _movies.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _movies.length) {
return const Center(child: CircularProgressIndicator());
}
final movie = _movies[index];
return _MovieCard(
movie: movie,
playlist: widget.playlist,
);
},
);
}
}
class _MovieCard extends StatelessWidget {
final Movie movie;
final PlaylistConfig playlist;
const _MovieCard({
required this.movie,
required this.playlist,
});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
final streamUrl = movie.getStreamUrl(
playlist.dns,
playlist.username,
playlist.password,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => VideoPlayerScreen(
streamUrl: streamUrl,
title: movie.name,
posterUrl: movie.streamIcon,
),
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: movie.streamIcon != null
? CachedNetworkImage(
imageUrl: movie.streamIcon!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey.shade800,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey.shade800,
child: const Icon(Icons.movie, size: 48),
),
)
: Container(
color: Colors.grey.shade800,
child: const Icon(Icons.movie, size: 48),
),
),
Container(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
movie.name,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (movie.rating != null) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.star,
size: 12,
color: Colors.amber.shade700,
),
const SizedBox(width: 2),
Text(
movie.rating!,
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade600,
),
),
],
),
],
],
),
),
],
),
),
);
}
}
+202
View File
@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../services/xtream_service.dart';
import '../models/xtream_models.dart';
class SeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const SeriesTab({super.key, required this.playlist});
@override
ConsumerState<SeriesTab> createState() => _SeriesTabState();
}
class _SeriesTabState extends ConsumerState<SeriesTab> {
final ScrollController _scrollController = ScrollController();
final List<Series> _series = [];
bool _isLoading = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 100;
@override
void initState() {
super.initState();
_loadMoreSeries();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreSeries();
}
}
Future<void> _loadMoreSeries() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newSeries = await service.getSeries(
offset: _currentOffset,
limit: _pageSize,
);
setState(() {
_series.addAll(newSeries);
_currentOffset += _pageSize;
_hasMore = newSeries.length == _pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load series: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_series.isEmpty && !_isLoading) {
return const Center(child: Text('No series available'));
}
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.7,
),
itemCount: _series.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _series.length) {
return const Center(child: CircularProgressIndicator());
}
final serie = _series[index];
return _SeriesCard(series: serie);
},
);
}
}
class _SeriesCard extends StatelessWidget {
final Series series;
const _SeriesCard({required this.series});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Series require episode selection screen (not implemented)
// For now, show info dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(series.name),
content: const Text(
'Series playback requires episode selection.\n\n'
'Use Xtream API endpoint get_series_info to fetch episodes.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: series.cover != null
? CachedNetworkImage(
imageUrl: series.cover!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey.shade800,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey.shade800,
child: const Icon(Icons.tv, size: 48),
),
)
: Container(
color: Colors.grey.shade800,
child: const Icon(Icons.tv, size: 48),
),
),
Container(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
series.name,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (series.rating != null) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.star,
size: 12,
color: Colors.amber.shade700,
),
const SizedBox(width: 2),
Text(
series.rating!,
style: GoogleFonts.roboto(
fontSize: 10,
color: Colors.grey.shade600,
),
),
],
),
],
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../auth/providers/auth_provider.dart';
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(authProvider).currentUser;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.person),
title: Text(
'Logged in as',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey),
),
subtitle: Text(
currentUser?.username ?? 'Unknown',
style: GoogleFonts.roboto(fontWeight: FontWeight.w600),
),
trailing: currentUser?.isAdmin ?? false
? Chip(
label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11)),
backgroundColor: Colors.blue.shade100,
)
: null,
),
),
const SizedBox(height: 16),
if (currentUser?.isAdmin ?? false) ...[
Card(
child: ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: Text('Admin Panel', style: GoogleFonts.roboto()),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/admin'),
),
),
const SizedBox(height: 8),
],
Card(
child: ListTile(
leading: const Icon(Icons.playlist_play),
title: Text('Change Playlist', style: GoogleFonts.roboto()),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/playlists'),
),
),
const SizedBox(height: 8),
Card(
child: ListTile(
leading: const Icon(Icons.info_outline),
title: Text('About', style: GoogleFonts.roboto()),
subtitle: Text(
'XtremFlow IPTV v1.0.0',
style: GoogleFonts.roboto(fontSize: 12),
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade700,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
],
);
}
}