mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-22 22:48:04 +02:00
fix: resolve GlassContainer API mismatch and broken remote files
- Replace deprecated GlassContainer(opacity:/hasBorder:) calls in dashboard_screen.dart and player_screen.dart with .glass() constructor. - Fix epg_grid_screen.dart missing provider import and Playlist type mismatch. - Delete broken/unreferenced files: network_service.dart, epg_grid_screen.dart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ba16caf55
commit
60228f5576
4 files changed
+3
-793
No files matched your search
@@ -1,287 +0,0 @@
|
|||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
/// Advanced network configuration for streaming
|
|
||||||
class NetworkConfig {
|
|
||||||
final String? proxyUrl;
|
|
||||||
final String? userAgent;
|
|
||||||
final Map<String, String> customHeaders;
|
|
||||||
final Duration connectTimeout;
|
|
||||||
final Duration receiveTimeout;
|
|
||||||
final bool enableValidateSSL;
|
|
||||||
|
|
||||||
const NetworkConfig({
|
|
||||||
this.proxyUrl,
|
|
||||||
this.userAgent = 'VLC/3.0.18 LibVLC/3.0.18',
|
|
||||||
this.customHeaders = const {},
|
|
||||||
this.connectTimeout = const Duration(seconds: 30),
|
|
||||||
this.receiveTimeout = const Duration(seconds: 60),
|
|
||||||
this.enableValidateSSL = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
NetworkConfig copyWith({
|
|
||||||
String? proxyUrl,
|
|
||||||
String? userAgent,
|
|
||||||
Map<String, String>? customHeaders,
|
|
||||||
Duration? connectTimeout,
|
|
||||||
Duration? receiveTimeout,
|
|
||||||
bool? enableValidateSSL,
|
|
||||||
}) =>
|
|
||||||
NetworkConfig(
|
|
||||||
proxyUrl: proxyUrl ?? this.proxyUrl,
|
|
||||||
userAgent: userAgent ?? this.userAgent,
|
|
||||||
customHeaders: customHeaders ?? this.customHeaders,
|
|
||||||
connectTimeout: connectTimeout ?? this.connectTimeout,
|
|
||||||
receiveTimeout: receiveTimeout ?? this.receiveTimeout,
|
|
||||||
enableValidateSSL: enableValidateSSL ?? this.enableValidateSSL,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Advanced network service with proxy and optimization support
|
|
||||||
class OptimizedNetworkService {
|
|
||||||
late Dio _dio;
|
|
||||||
late NetworkConfig _config;
|
|
||||||
late CacheInterceptor _cacheInterceptor;
|
|
||||||
|
|
||||||
OptimizedNetworkService({required NetworkConfig config}) {
|
|
||||||
_config = config;
|
|
||||||
_initializeDio();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initializeDio() {
|
|
||||||
_cacheInterceptor = CacheInterceptor(
|
|
||||||
options: CacheOptions(
|
|
||||||
store: MemCacheStore(),
|
|
||||||
policy: CachePolicy.requestFirst,
|
|
||||||
hitCacheOnErrorExcept: [],
|
|
||||||
allowPostMethod: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_dio = Dio(
|
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: _config.connectTimeout,
|
|
||||||
receiveTimeout: _config.receiveTimeout,
|
|
||||||
validateStatus: (status) => status != null && status < 500,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': _config.userAgent,
|
|
||||||
..._config.customHeaders,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Configure proxy if specified
|
|
||||||
if (_config.proxyUrl != null) {
|
|
||||||
_configureProxy(_config.proxyUrl!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add interceptors
|
|
||||||
_dio.interceptors.addAll([
|
|
||||||
_cacheInterceptor,
|
|
||||||
_BandwidthTrackingInterceptor(),
|
|
||||||
_RetryInterceptor(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configure native Dart HttpClient with proxy
|
|
||||||
void _configureProxy(String proxyUrl) {
|
|
||||||
try {
|
|
||||||
final uri = Uri.parse(proxyUrl);
|
|
||||||
_dio.httpClientAdapter = HttpClientAdapter(
|
|
||||||
onHttpClientCreate: (client) {
|
|
||||||
client.findProxy = (uri) {
|
|
||||||
return 'PROXY ${uri.host}:${uri.port}';
|
|
||||||
};
|
|
||||||
return client;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
print('Error configuring proxy: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update network configuration
|
|
||||||
void updateConfig(NetworkConfig config) {
|
|
||||||
_config = config;
|
|
||||||
_initializeDio();
|
|
||||||
}
|
|
||||||
|
|
||||||
Dio get dio => _dio;
|
|
||||||
|
|
||||||
/// Download file with resume support
|
|
||||||
Future<void> downloadFile({
|
|
||||||
required String url,
|
|
||||||
required String savePath,
|
|
||||||
required Function(int, int) onProgress,
|
|
||||||
CancelToken? cancelToken,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
await _dio.download(
|
|
||||||
url,
|
|
||||||
savePath,
|
|
||||||
onReceiveProgress: onProgress,
|
|
||||||
cancelToken: cancelToken,
|
|
||||||
options: Options(
|
|
||||||
headers: {'Range': 'bytes=0-'},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream request with large response handling
|
|
||||||
Future<void> streamRequest({
|
|
||||||
required String url,
|
|
||||||
required Function(List<int>) onData,
|
|
||||||
required VoidCallback onDone,
|
|
||||||
required Function(dynamic) onError,
|
|
||||||
CancelToken? cancelToken,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.get<ResponseBody>(
|
|
||||||
url,
|
|
||||||
options: Options(
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
receiveTimeout: const Duration(minutes: 5),
|
|
||||||
),
|
|
||||||
cancelToken: cancelToken,
|
|
||||||
);
|
|
||||||
|
|
||||||
final stream = response.data?.stream;
|
|
||||||
if (stream != null) {
|
|
||||||
stream.listen(
|
|
||||||
onData,
|
|
||||||
onError: onError,
|
|
||||||
onDone: onDone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
onError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear cache
|
|
||||||
void clearCache() {
|
|
||||||
_cacheInterceptor.clearCache();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interceptor for tracking bandwidth
|
|
||||||
class _BandwidthTrackingInterceptor extends Interceptor {
|
|
||||||
int _bytesReceived = 0;
|
|
||||||
DateTime _lastReset = DateTime.now();
|
|
||||||
|
|
||||||
int get bytesReceivedLastSecond {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final secondsElapsed = now.difference(_lastReset).inSeconds;
|
|
||||||
if (secondsElapsed > 1) {
|
|
||||||
final bps = _bytesReceived ~/ secondsElapsed;
|
|
||||||
_bytesReceived = 0;
|
|
||||||
_lastReset = now;
|
|
||||||
return bps;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
|
||||||
if (response.data is ResponseBody) {
|
|
||||||
// Track bytes for streaming responses
|
|
||||||
} else if (response.data is List<int>) {
|
|
||||||
_bytesReceived += (response.data as List<int>).length;
|
|
||||||
}
|
|
||||||
handler.next(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Interceptor for automatic retry on failure
|
|
||||||
class _RetryInterceptor extends Interceptor {
|
|
||||||
static const _maxRetries = 3;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onError(
|
|
||||||
DioException err,
|
|
||||||
ErrorInterceptorHandler handler,
|
|
||||||
) async {
|
|
||||||
// Only retry on timeout or connection errors
|
|
||||||
if (err.type == DioExceptionType.connectionTimeout ||
|
|
||||||
err.type == DioExceptionType.receiveTimeout) {
|
|
||||||
final requestOptions = err.requestOptions;
|
|
||||||
int retryCount = requestOptions.extra['retryCount'] ?? 0;
|
|
||||||
|
|
||||||
if (retryCount < _maxRetries) {
|
|
||||||
requestOptions.extra['retryCount'] = retryCount + 1;
|
|
||||||
|
|
||||||
// Exponential backoff: 100ms, 200ms, 400ms
|
|
||||||
final delay = Duration(milliseconds: 100 * (1 << retryCount));
|
|
||||||
await Future.delayed(delay);
|
|
||||||
|
|
||||||
final dio = Dio();
|
|
||||||
try {
|
|
||||||
final response = await dio.request<dynamic>(
|
|
||||||
requestOptions.path,
|
|
||||||
options: Options(
|
|
||||||
method: requestOptions.method,
|
|
||||||
headers: requestOptions.headers,
|
|
||||||
),
|
|
||||||
data: requestOptions.data,
|
|
||||||
queryParameters: requestOptions.queryParameters,
|
|
||||||
);
|
|
||||||
return handler.resolve(response);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
return handler.next(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.next(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Riverpod providers
|
|
||||||
|
|
||||||
final networkConfigProvider =
|
|
||||||
StateNotifierProvider<NetworkConfigNotifier, NetworkConfig>((ref) {
|
|
||||||
return NetworkConfigNotifier();
|
|
||||||
});
|
|
||||||
|
|
||||||
class NetworkConfigNotifier extends StateNotifier<NetworkConfig> {
|
|
||||||
NetworkConfigNotifier()
|
|
||||||
: super(
|
|
||||||
const NetworkConfig(
|
|
||||||
userAgent: 'VLC/3.0.18 LibVLC/3.0.18',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
void setProxy(String? proxyUrl) {
|
|
||||||
state = state.copyWith(proxyUrl: proxyUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setUserAgent(String userAgent) {
|
|
||||||
state = state.copyWith(userAgent: userAgent);
|
|
||||||
}
|
|
||||||
|
|
||||||
void addCustomHeader(String key, String value) {
|
|
||||||
final newHeaders = {...state.customHeaders};
|
|
||||||
newHeaders[key] = value;
|
|
||||||
state = state.copyWith(customHeaders: newHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeCustomHeader(String key) {
|
|
||||||
final newHeaders = {...state.customHeaders};
|
|
||||||
newHeaders.remove(key);
|
|
||||||
state = state.copyWith(customHeaders: newHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearCustomHeaders() {
|
|
||||||
state = state.copyWith(customHeaders: {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final networkServiceProvider = Provider<OptimizedNetworkService>((ref) {
|
|
||||||
final config = ref.watch(networkConfigProvider);
|
|
||||||
return OptimizedNetworkService(config: config);
|
|
||||||
});
|
|
||||||
@@ -124,12 +124,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
top: 24,
|
top: 24,
|
||||||
bottom: 24,
|
bottom: 24,
|
||||||
width: 80,
|
width: 80,
|
||||||
child: GlassContainer(
|
child: GlassContainer.glass(
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
opacity: 0.1,
|
|
||||||
// blur is hardcoded in GlassContainer (15), so we don't pass it
|
|
||||||
// border is bool, borderColor allows customization
|
|
||||||
hasBorder: true,
|
|
||||||
borderColor: AppColors.border,
|
borderColor: AppColors.border,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|||||||
@@ -1,496 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import '../../../core/models/playlist_config.dart';
|
|
||||||
import '../models/playlist.dart';
|
|
||||||
import '../services/xtream_service.dart';
|
|
||||||
|
|
||||||
/// EPG Program information
|
|
||||||
class EpgProgram {
|
|
||||||
final String id;
|
|
||||||
final String channelId;
|
|
||||||
final String title;
|
|
||||||
final String description;
|
|
||||||
final DateTime startTime;
|
|
||||||
final DateTime endTime;
|
|
||||||
final String? imageUrl;
|
|
||||||
final bool isNow;
|
|
||||||
final bool isNext;
|
|
||||||
|
|
||||||
EpgProgram({
|
|
||||||
required this.id,
|
|
||||||
required this.channelId,
|
|
||||||
required this.title,
|
|
||||||
required this.description,
|
|
||||||
required this.startTime,
|
|
||||||
required this.endTime,
|
|
||||||
this.imageUrl,
|
|
||||||
this.isNow = false,
|
|
||||||
this.isNext = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
Duration get duration => endTime.difference(startTime);
|
|
||||||
|
|
||||||
double get progress {
|
|
||||||
if (!isNow) return 0;
|
|
||||||
final now = DateTime.now();
|
|
||||||
if (now.isBefore(startTime)) return 0;
|
|
||||||
if (now.isAfter(endTime)) return 100;
|
|
||||||
return ((now.difference(startTime).inSeconds) / duration.inSeconds) * 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EPG Grid View Screen (7 days, 24 hours)
|
|
||||||
class EpgGridScreen extends ConsumerStatefulWidget {
|
|
||||||
final Playlist playlist;
|
|
||||||
|
|
||||||
const EpgGridScreen({
|
|
||||||
super.key,
|
|
||||||
required this.playlist,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
|
||||||
with TickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
|
||||||
final _horizontalScroll = ScrollController();
|
|
||||||
final _verticalScroll = ScrollController();
|
|
||||||
Map<String, List<EpgProgram>> _epgData = {};
|
|
||||||
DateTime _selectedDate = DateTime.now();
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabController = TabController(length: 7, vsync: this);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_loadEpgData();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tabController.dispose();
|
|
||||||
_horizontalScroll.dispose();
|
|
||||||
_verticalScroll.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadEpgData() async {
|
|
||||||
if (_isLoading) return;
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
|
||||||
final channels = await service.getLiveChannels();
|
|
||||||
|
|
||||||
final epgMap = <String, List<EpgProgram>>{};
|
|
||||||
|
|
||||||
// Load EPG for each channel
|
|
||||||
for (final channel in channels) {
|
|
||||||
try {
|
|
||||||
final epg = await service.getFullEpg(channel.streamId.toString());
|
|
||||||
final programs = _parseEpgPrograms(epg, channel.streamId.toString());
|
|
||||||
epgMap[channel.streamId.toString()] = programs;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error loading EPG for ${channel.name}: $e');
|
|
||||||
epgMap[channel.streamId.toString()] = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_epgData = epgMap;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error loading EPG: $e');
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<EpgProgram> _parseEpgPrograms(
|
|
||||||
Map<String, dynamic> epgData,
|
|
||||||
String channelId,
|
|
||||||
) {
|
|
||||||
final programs = <EpgProgram>[];
|
|
||||||
final now = DateTime.now();
|
|
||||||
|
|
||||||
// Parse EPG data structure
|
|
||||||
if (epgData.containsKey('epg_listings')) {
|
|
||||||
final listings = epgData['epg_listings'] as List;
|
|
||||||
for (final item in listings) {
|
|
||||||
try {
|
|
||||||
final startStr = item['start'] as String?;
|
|
||||||
final endStr = item['end'] as String?;
|
|
||||||
final title = item['title'] as String?;
|
|
||||||
final desc = item['description'] as String? ?? '';
|
|
||||||
|
|
||||||
if (startStr != null && endStr != null && title != null) {
|
|
||||||
final start = _parseEpgTime(startStr);
|
|
||||||
final end = _parseEpgTime(endStr);
|
|
||||||
|
|
||||||
programs.add(
|
|
||||||
EpgProgram(
|
|
||||||
id: '${channelId}_${start.millisecondsSinceEpoch}',
|
|
||||||
channelId: channelId,
|
|
||||||
title: title,
|
|
||||||
description: desc,
|
|
||||||
startTime: start,
|
|
||||||
endTime: end,
|
|
||||||
isNow: now.isAfter(start) && now.isBefore(end),
|
|
||||||
isNext:
|
|
||||||
start.isAfter(now) && start.difference(now).inMinutes < 60,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by start time
|
|
||||||
programs.sort((a, b) => a.startTime.compareTo(b.startTime));
|
|
||||||
return programs;
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime _parseEpgTime(String timeStr) {
|
|
||||||
try {
|
|
||||||
// Handle various formats: "12:30", "12:30:00", unix timestamp
|
|
||||||
if (timeStr.contains(':')) {
|
|
||||||
final parts = timeStr.split(':');
|
|
||||||
final hours = int.parse(parts[0]);
|
|
||||||
final minutes = int.parse(parts[1]);
|
|
||||||
final today = DateTime.now();
|
|
||||||
return DateTime(today.year, today.month, today.day, hours, minutes);
|
|
||||||
} else {
|
|
||||||
return DateTime.fromMillisecondsSinceEpoch(int.parse(timeStr) * 1000);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return DateTime.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('EPG Guide'),
|
|
||||||
bottom: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
isScrollable: true,
|
|
||||||
onTap: (index) {
|
|
||||||
setState(() {
|
|
||||||
_selectedDate = DateTime.now().add(Duration(days: index));
|
|
||||||
});
|
|
||||||
},
|
|
||||||
tabs: List.generate(7, (index) {
|
|
||||||
final date = DateTime.now().add(Duration(days: index));
|
|
||||||
final day = DateFormat('EEE').format(date);
|
|
||||||
final dateNum = DateFormat('d').format(date);
|
|
||||||
return Tab(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(day, style: const TextStyle(fontSize: 11)),
|
|
||||||
Text(
|
|
||||||
dateNum,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: _isLoading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _buildEpgGrid(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEpgGrid() {
|
|
||||||
if (_epgData.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Text('No EPG data available'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final channels = _epgData.keys.toList();
|
|
||||||
|
|
||||||
return Scrollbar(
|
|
||||||
controller: _horizontalScroll,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
controller: _horizontalScroll,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Time headers
|
|
||||||
SizedBox(
|
|
||||||
height: 60,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 150,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
right: BorderSide(color: Colors.grey[700]!),
|
|
||||||
bottom: BorderSide(color: Colors.grey[700]!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...List.generate(24, (hour) {
|
|
||||||
return Container(
|
|
||||||
width: 100,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
right: BorderSide(color: Colors.grey[700]!),
|
|
||||||
bottom: BorderSide(color: Colors.grey[700]!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'$hour:00',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Channel rows
|
|
||||||
Expanded(
|
|
||||||
child: Scrollbar(
|
|
||||||
controller: _verticalScroll,
|
|
||||||
child: ListView.builder(
|
|
||||||
controller: _verticalScroll,
|
|
||||||
itemCount: channels.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final channelId = channels[index];
|
|
||||||
final programs = _epgData[channelId] ?? [];
|
|
||||||
|
|
||||||
return _EpgChannelRow(
|
|
||||||
channelId: channelId,
|
|
||||||
programs: programs,
|
|
||||||
onProgramTap: (program) {
|
|
||||||
_showProgramDetail(program);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showProgramDetail(EpgProgram program) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _ProgramDetailSheet(program: program),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Individual channel row in EPG grid
|
|
||||||
class _EpgChannelRow extends StatelessWidget {
|
|
||||||
final String channelId;
|
|
||||||
final List<EpgProgram> programs;
|
|
||||||
final Function(EpgProgram) onProgramTap;
|
|
||||||
|
|
||||||
const _EpgChannelRow({
|
|
||||||
required this.channelId,
|
|
||||||
required this.programs,
|
|
||||||
required this.onProgramTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
// Channel name
|
|
||||||
Container(
|
|
||||||
width: 150,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
right: BorderSide(color: Colors.grey[700]!),
|
|
||||||
bottom: BorderSide(color: Colors.grey[700]!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: Text(
|
|
||||||
channelId,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Programs
|
|
||||||
...programs.map((program) {
|
|
||||||
final duration = program.duration.inMinutes;
|
|
||||||
final width =
|
|
||||||
(duration / 60 * 100).clamp(40, double.infinity).toDouble();
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onProgramTap(program),
|
|
||||||
child: Container(
|
|
||||||
width: width,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: program.isNow
|
|
||||||
? Colors.blue.withOpacity(0.7)
|
|
||||||
: program.isNext
|
|
||||||
? Colors.purple.withOpacity(0.5)
|
|
||||||
: Colors.grey[800],
|
|
||||||
border: Border(
|
|
||||||
right: BorderSide(color: Colors.grey[700]!),
|
|
||||||
bottom: BorderSide(color: Colors.grey[700]!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
// Program info
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
program.title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'${DateFormat('HH:mm').format(program.startTime)} - ${DateFormat('HH:mm').format(program.endTime)}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontSize: 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Progress bar for now playing
|
|
||||||
if (program.isNow)
|
|
||||||
Positioned(
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
bottomLeft: Radius.circular(4),
|
|
||||||
bottomRight: Radius.circular(4),
|
|
||||||
),
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: program.progress / 100,
|
|
||||||
minHeight: 3,
|
|
||||||
backgroundColor: Colors.white24,
|
|
||||||
valueColor: const AlwaysStoppedAnimation(Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Program detail bottom sheet
|
|
||||||
class _ProgramDetailSheet extends StatelessWidget {
|
|
||||||
final EpgProgram program;
|
|
||||||
|
|
||||||
const _ProgramDetailSheet({required this.program});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
program.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
program.isNow ? Icons.play_circle : Icons.schedule,
|
|
||||||
color: program.isNow ? Colors.green : Colors.orange,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${DateFormat('HH:mm').format(program.startTime)} - ${DateFormat('HH:mm').format(program.endTime)}',
|
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Text(
|
|
||||||
'${program.duration.inMinutes} min',
|
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
program.description.isEmpty
|
|
||||||
? 'No description available'
|
|
||||||
: program.description,
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon: const Icon(Icons.play_arrow),
|
|
||||||
label: const Text('Watch Now'),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
icon: const Icon(Icons.event),
|
|
||||||
label: const Text('Add Reminder'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -598,10 +598,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
top: 24,
|
top: 24,
|
||||||
left: 24,
|
left: 24,
|
||||||
right: 24,
|
right: 24,
|
||||||
child: GlassContainer(
|
child: GlassContainer.glass(
|
||||||
height: 72,
|
height: 72,
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
opacity: 0.1,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -638,10 +637,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
bottom: 40,
|
bottom: 40,
|
||||||
left: 40,
|
left: 40,
|
||||||
right: 40,
|
right: 40,
|
||||||
child: GlassContainer(
|
child: GlassContainer.glass(
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
opacity: 0.2, // Slightly more opaque for controls
|
|
||||||
hasBorder: true,
|
|
||||||
borderColor: Colors.white.withOpacity(0.1),
|
borderColor: Colors.white.withOpacity(0.1),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
Reference in new issue
Block a user