mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
feat: add mobile-optimized IPTV player with HLS and MpegTS support
This commit is contained in:
1 parent
3c9a9c7be7
commit
fa6ae90d8d
38 files changed
+1280
-895
No files matched your search
@@ -33,20 +33,23 @@ class RecommendationService {
|
||||
List<Playlist> allContent,
|
||||
) {
|
||||
final recommendations = <Recommendation>[];
|
||||
|
||||
|
||||
for (final item in allContent) {
|
||||
final historyKey = item.streamId.toString();
|
||||
if (watchHistory.containsKey(historyKey)) {
|
||||
final lastPosition = watchHistory[historyKey] as double? ?? 0.0;
|
||||
|
||||
|
||||
// Only show if watched more than 5 seconds ago and less than 95% complete
|
||||
if (lastPosition > 5 && lastPosition < 95) {
|
||||
recommendations.add(Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.continueWatching,
|
||||
score: 100.0 - lastPosition, // Score higher if recently started
|
||||
reason: 'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
|
||||
));
|
||||
recommendations.add(
|
||||
Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.continueWatching,
|
||||
score: 100.0 - lastPosition, // Score higher if recently started
|
||||
reason:
|
||||
'Continue watching from ${lastPosition.toStringAsFixed(0)}%',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,16 +65,18 @@ class RecommendationService {
|
||||
List<Playlist> allContent,
|
||||
) {
|
||||
final recommendations = <Recommendation>[];
|
||||
|
||||
|
||||
for (final item in allContent) {
|
||||
final views = viewCounts[item.streamId.toString()] ?? 0;
|
||||
if (views > 0) {
|
||||
recommendations.add(Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.trending,
|
||||
score: views.toDouble(),
|
||||
reason: '$views views - Trending now',
|
||||
));
|
||||
recommendations.add(
|
||||
Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.trending,
|
||||
score: views.toDouble(),
|
||||
reason: '$views views - Trending now',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ class RecommendationService {
|
||||
/// Get recently added content
|
||||
static List<Recommendation> getRecentlyAdded(List<Playlist> allContent) {
|
||||
final recommendations = <Recommendation>[];
|
||||
|
||||
|
||||
// Sort by added date (most recent first)
|
||||
final sorted = [...allContent];
|
||||
sorted.sort((a, b) {
|
||||
@@ -95,13 +100,15 @@ class RecommendationService {
|
||||
final daysAgo = DateTime.now()
|
||||
.difference(DateTime.tryParse(item.added ?? '') ?? DateTime(2000))
|
||||
.inDays;
|
||||
|
||||
recommendations.add(Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.recentlyAdded,
|
||||
score: (20 - daysAgo).toDouble().clamp(0, 100),
|
||||
reason: 'Added $daysAgo days ago',
|
||||
));
|
||||
|
||||
recommendations.add(
|
||||
Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.recentlyAdded,
|
||||
score: (20 - daysAgo).toDouble().clamp(0, 100),
|
||||
reason: 'Added $daysAgo days ago',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return recommendations.take(10).toList();
|
||||
@@ -113,7 +120,7 @@ class RecommendationService {
|
||||
List<Playlist> allContent,
|
||||
) {
|
||||
final recommendations = <Recommendation>[];
|
||||
|
||||
|
||||
// Collect watched categories
|
||||
final watchedCategories = <String>{};
|
||||
watchHistory.forEach((key, value) {
|
||||
@@ -125,12 +132,14 @@ class RecommendationService {
|
||||
// Recommend similar content
|
||||
for (final item in allContent) {
|
||||
if (watchedCategories.contains(item.categoryId)) {
|
||||
recommendations.add(Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.forYou,
|
||||
score: 75.0,
|
||||
reason: 'Based on your interests',
|
||||
));
|
||||
recommendations.add(
|
||||
Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.forYou,
|
||||
score: 75.0,
|
||||
reason: 'Based on your interests',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,12 +155,14 @@ class RecommendationService {
|
||||
final rating = double.tryParse(i.rating ?? '0') ?? 0.0;
|
||||
return rating >= 7.5;
|
||||
}).take(5)) {
|
||||
recommendations.add(Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.topRated,
|
||||
score: double.tryParse(item.rating ?? '0') ?? 0.0,
|
||||
reason: 'Highly rated - ${item.rating}/10',
|
||||
));
|
||||
recommendations.add(
|
||||
Recommendation(
|
||||
item: item,
|
||||
type: RecommendationType.topRated,
|
||||
score: double.tryParse(item.rating ?? '0') ?? 0.0,
|
||||
reason: 'Highly rated - ${item.rating}/10',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Remove duplicates and limit
|
||||
@@ -169,9 +180,8 @@ class RecommendationService {
|
||||
}
|
||||
|
||||
/// Provider for watch history
|
||||
final watchHistoryProvider = StateNotifierProvider<
|
||||
WatchHistoryNotifier,
|
||||
Map<String, dynamic>>((ref) {
|
||||
final watchHistoryProvider =
|
||||
StateNotifierProvider<WatchHistoryNotifier, Map<String, dynamic>>((ref) {
|
||||
return WatchHistoryNotifier();
|
||||
});
|
||||
|
||||
@@ -194,9 +204,8 @@ class WatchHistoryNotifier extends StateNotifier<Map<String, dynamic>> {
|
||||
}
|
||||
|
||||
/// Provider for view counts/trending
|
||||
final trendingProvider = StateNotifierProvider<
|
||||
TrendingNotifier,
|
||||
Map<String, int>>((ref) {
|
||||
final trendingProvider =
|
||||
StateNotifierProvider<TrendingNotifier, Map<String, int>>((ref) {
|
||||
return TrendingNotifier();
|
||||
});
|
||||
|
||||
@@ -213,11 +222,11 @@ class TrendingNotifier extends StateNotifier<Map<String, int>> {
|
||||
}
|
||||
|
||||
/// Provider for recommendations
|
||||
final recommendationsProvider =
|
||||
FutureProvider.family<List<Recommendation>, (Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
|
||||
final recommendationsProvider = FutureProvider.family<List<Recommendation>,
|
||||
(Playlist, Map<String, dynamic>, Map<String, int>, List<Playlist>)>(
|
||||
(ref, params) async {
|
||||
final (playlist, watchHistory, trending, allContent) = params;
|
||||
|
||||
|
||||
// Combine all recommendation types
|
||||
final allRecommendations = <Recommendation>[
|
||||
...RecommendationService.getContinueWatching(watchHistory, allContent),
|
||||
@@ -229,7 +238,7 @@ final recommendationsProvider =
|
||||
// Deduplicate and score
|
||||
final seen = <int>{};
|
||||
final scored = <Recommendation>[];
|
||||
|
||||
|
||||
for (final rec in allRecommendations) {
|
||||
if (!seen.contains(rec.item.streamId)) {
|
||||
scored.add(rec);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
@@ -25,7 +24,13 @@ class DashboardScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
int _selectedIndex = 0;
|
||||
final List<String> _tabs = ['Live TV', 'Movies', 'Series', 'Recordings', 'Settings'];
|
||||
final List<String> _tabs = [
|
||||
'Live TV',
|
||||
'Movies',
|
||||
'Series',
|
||||
'Recordings',
|
||||
'Settings'
|
||||
];
|
||||
final List<IconData> _icons = [
|
||||
Icons.live_tv_rounded,
|
||||
Icons.movie_rounded,
|
||||
|
||||
@@ -30,7 +30,7 @@ class EpgProgram {
|
||||
});
|
||||
|
||||
Duration get duration => endTime.difference(startTime);
|
||||
|
||||
|
||||
double get progress {
|
||||
if (!isNow) return 0;
|
||||
final now = DateTime.now();
|
||||
@@ -45,9 +45,9 @@ class EpgGridScreen extends ConsumerStatefulWidget {
|
||||
final Playlist playlist;
|
||||
|
||||
const EpgGridScreen({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.playlist,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EpgGridScreen> createState() => _EpgGridScreenState();
|
||||
@@ -112,7 +112,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
||||
}
|
||||
|
||||
List<EpgProgram> _parseEpgPrograms(
|
||||
Map<String, dynamic> epgData, String channelId) {
|
||||
Map<String, dynamic> epgData,
|
||||
String channelId,
|
||||
) {
|
||||
final programs = <EpgProgram>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
@@ -130,17 +132,19 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
||||
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,
|
||||
));
|
||||
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;
|
||||
@@ -192,7 +196,9 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(day, style: const TextStyle(fontSize: 11)),
|
||||
Text(dateNum, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
Text(dateNum,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -250,7 +256,8 @@ class _EpgGridScreenState extends ConsumerState<EpgGridScreen>
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$hour:00',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -335,7 +342,8 @@ class _EpgChannelRow extends StatelessWidget {
|
||||
// Programs
|
||||
...programs.map((program) {
|
||||
final duration = program.duration.inMinutes;
|
||||
final width = (duration / 60 * 100).clamp(40, double.infinity).toDouble();
|
||||
final width =
|
||||
(duration / 60 * 100).clamp(40, double.infinity).toDouble();
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onProgramTap(program),
|
||||
@@ -397,8 +405,7 @@ class _EpgChannelRow extends StatelessWidget {
|
||||
value: program.progress / 100,
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation(Colors.red),
|
||||
valueColor: const AlwaysStoppedAnimation(Colors.red),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -455,7 +462,9 @@ class _ProgramDetailSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
program.description.isEmpty ? 'No description available' : program.description,
|
||||
program.description.isEmpty
|
||||
? 'No description available'
|
||||
: program.description,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -11,11 +11,8 @@ import '../providers/playback_positions_provider.dart';
|
||||
// ... (existing imports)
|
||||
// ... (existing imports)
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/models/iptv_models.dart';
|
||||
import '../../../core/widgets/tv_focusable_card.dart';
|
||||
import '../../../core/widgets/glass_container.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../widgets/epg_overlay.dart';
|
||||
|
||||
enum StreamType { live, vod, series }
|
||||
|
||||
@@ -57,7 +54,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
StreamSubscription? _messageSubscription;
|
||||
final String _aspectRatio = 'contain';
|
||||
bool _isSeeking = false;
|
||||
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
late final String _viewIdPrefix =
|
||||
DateTime.now().millisecondsSinceEpoch.toString();
|
||||
String _viewId = 'iptv-player';
|
||||
String? _currentStreamUrl;
|
||||
String _statusMessage = 'Loading...';
|
||||
@@ -134,7 +132,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// Store URL for Lite Player
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
final isLiveTV = widget.streamType == StreamType.live;
|
||||
|
||||
|
||||
// TURBO-START: Use direct MPEG-TS for Live TV on Desktop/Android for instant zapping
|
||||
if (isLiveTV && !isMobile) {
|
||||
streamUrl = service.getLiveStreamUrlTs(currentStreamId);
|
||||
@@ -152,15 +150,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// Force player choice based on stream type:
|
||||
// - Live TV: Player Lite (simple TS playback with mpegts.js)
|
||||
// - VOD/Series: Player Standard (fuller controls, HLS support)
|
||||
|
||||
|
||||
// Cache buster to force reload of updated player.html
|
||||
final cacheBuster = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
final streamTypeParam = widget.streamType == StreamType.live ? 'live' : 'vod';
|
||||
final playerFile = isMobile ? 'player_mobile.html' : (isLiveTV ? 'player_lite.html' : 'player.html');
|
||||
|
||||
final streamTypeParam =
|
||||
widget.streamType == StreamType.live ? 'live' : 'vod';
|
||||
final playerFile = isMobile
|
||||
? 'player_mobile.html'
|
||||
: (isLiveTV ? 'player_lite.html' : 'player.html');
|
||||
|
||||
// Inject Turbo-Start flag
|
||||
var playerSrc = '$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
|
||||
var playerSrc =
|
||||
'$playerFile?url=$encodedUrl&type=$streamTypeParam&turbo=true&v=$cacheBuster';
|
||||
|
||||
if (startTimeOverride != null) {
|
||||
playerSrc += '&t=$startTimeOverride';
|
||||
@@ -300,31 +302,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
void _toggleFullscreen() {
|
||||
try {
|
||||
final doc = html.document;
|
||||
final isFullscreen = doc.fullscreenElement != null ||
|
||||
(doc as dynamic).webkitFullscreenElement != null ||
|
||||
(doc as dynamic).mozFullScreenElement != null;
|
||||
final isFullscreen = doc.fullscreenElement != null ||
|
||||
(doc as dynamic).webkitFullscreenElement != null ||
|
||||
(doc as dynamic).mozFullScreenElement != null;
|
||||
|
||||
if (isFullscreen) {
|
||||
if (doc.exitFullscreen != null) {
|
||||
doc.exitFullscreen();
|
||||
} else if ((doc as dynamic).webkitExitFullscreen != null) {
|
||||
(doc as dynamic).webkitExitFullscreen();
|
||||
} else if ((doc as dynamic).mozCancelFullScreen != null) {
|
||||
(doc as dynamic).mozCancelFullScreen();
|
||||
}
|
||||
doc.exitFullscreen();
|
||||
} else {
|
||||
final element = doc.documentElement;
|
||||
if (element != null) {
|
||||
if (element.requestFullscreen != null) {
|
||||
element.requestFullscreen();
|
||||
} else if ((element as dynamic).webkitRequestFullscreen != null) {
|
||||
(element as dynamic).webkitRequestFullscreen();
|
||||
} else if ((element as dynamic).mozRequestFullScreen != null) {
|
||||
(element as dynamic).mozRequestFullScreen();
|
||||
} else {
|
||||
// Fallback: Message the player if browser API fails
|
||||
_sendMessage({'type': 'request_fullscreen'});
|
||||
}
|
||||
element.requestFullscreen();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -442,9 +429,12 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
children: [
|
||||
Text(
|
||||
widget.channels != null
|
||||
? (widget.channels![_currentIndex]
|
||||
? (widget
|
||||
.channels![
|
||||
_currentIndex]
|
||||
.name ??
|
||||
widget.channels![
|
||||
widget
|
||||
.channels![
|
||||
_currentIndex]
|
||||
.title ??
|
||||
'Unknown')
|
||||
@@ -478,12 +468,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// Unified Bottom Bar - EPG + Controls in one bar
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
bottom: _showControls ? 24 : -150, // More offset for stacked layout
|
||||
bottom: _showControls
|
||||
? 24
|
||||
: -150, // More offset for stacked layout
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isSmallScreen = constraints.maxWidth < 600;
|
||||
final isSmallScreen =
|
||||
constraints.maxWidth < 600;
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isSmallScreen ? 16 : 20,
|
||||
@@ -494,13 +487,15 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.background.withOpacity(0.3),
|
||||
color: AppColors.background
|
||||
.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: AppColors.textPrimary.withOpacity(0.15),
|
||||
color: AppColors.textPrimary
|
||||
.withOpacity(0.15),
|
||||
),
|
||||
),
|
||||
child: isSmallScreen
|
||||
@@ -510,14 +505,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
// EPG Info (top in mobile)
|
||||
_buildInlineEpg(),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(color: Colors.white10),
|
||||
const Divider(
|
||||
color: Colors.white10),
|
||||
const SizedBox(height: 8),
|
||||
// Control Buttons
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceEvenly,
|
||||
children: _buildControlButtons(
|
||||
isSmallScreen),
|
||||
isSmallScreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -532,7 +529,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _buildControlButtons(
|
||||
isSmallScreen),
|
||||
isSmallScreen,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -851,7 +849,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
onTap: _toggleMute,
|
||||
size: buttonSize,
|
||||
),
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1059,5 +1056,4 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,8 +37,10 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
actions: [
|
||||
if (currentUser?.isAdmin ?? false)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.admin_panel_settings,
|
||||
color: AppColors.textSecondary),
|
||||
icon: const Icon(
|
||||
Icons.admin_panel_settings,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () => context.go('/admin'),
|
||||
tooltip: 'Admin Panel',
|
||||
),
|
||||
@@ -75,8 +77,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
!constraints.maxHeight.isFinite ||
|
||||
constraints.maxHeight <= 0) {
|
||||
return const Center(
|
||||
child:
|
||||
CircularProgressIndicator(color: AppColors.primary));
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
);
|
||||
}
|
||||
|
||||
return playlistsAsync.when(
|
||||
@@ -87,8 +89,11 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 64, color: AppColors.error),
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Error loading playlists',
|
||||
|
||||
@@ -120,12 +120,15 @@ class DownloadService {
|
||||
|
||||
/// Get download progress
|
||||
double getDownloadProgress(String id) {
|
||||
final task = _downloads.firstWhere((t) => t.id == id, orElse: () => DownloadTask(
|
||||
id: id,
|
||||
title: '',
|
||||
url: '',
|
||||
filePath: '',
|
||||
));
|
||||
final task = _downloads.firstWhere(
|
||||
(t) => t.id == id,
|
||||
orElse: () => DownloadTask(
|
||||
id: id,
|
||||
title: '',
|
||||
url: '',
|
||||
filePath: '',
|
||||
),
|
||||
);
|
||||
return task.progress;
|
||||
}
|
||||
|
||||
@@ -151,13 +154,12 @@ class DownloadService {
|
||||
if (available < 1024 * 1024 * 1024) {
|
||||
// Less than 1GB free
|
||||
final dir = Directory(_downloadDir);
|
||||
final files = dir.listSync()
|
||||
.whereType<File>()
|
||||
.toList();
|
||||
final files = dir.listSync().whereType<File>().toList();
|
||||
|
||||
// Sort by modification time, delete oldest first
|
||||
files.sort((a, b) =>
|
||||
a.statSync().modified.compareTo(b.statSync().modified));
|
||||
files.sort(
|
||||
(a, b) => a.statSync().modified.compareTo(b.statSync().modified),
|
||||
);
|
||||
|
||||
for (final file in files.take(files.length ~/ 3)) {
|
||||
try {
|
||||
@@ -216,7 +218,8 @@ class DownloadService {
|
||||
}
|
||||
|
||||
// Check if paused or cancelled
|
||||
final currentTask = _downloads.firstWhere((t) => t.id == task.id, orElse: () => task);
|
||||
final currentTask =
|
||||
_downloads.firstWhere((t) => t.id == task.id, orElse: () => task);
|
||||
if (currentTask.status == DownloadStatus.paused ||
|
||||
!_activeDownloads.contains(task.id)) {
|
||||
break;
|
||||
@@ -301,8 +304,6 @@ class DownloadsNotifier extends StateNotifier<List<DownloadTask>> {
|
||||
}
|
||||
|
||||
void clearCompleted() {
|
||||
state = state
|
||||
.where((d) => d.status != DownloadStatus.completed)
|
||||
.toList();
|
||||
state = state.where((d) => d.status != DownloadStatus.completed).toList();
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,12 @@ class SubtitleTrack {
|
||||
});
|
||||
|
||||
SubtitleTrack copyWith({bool? isEnabled}) => SubtitleTrack(
|
||||
id: id,
|
||||
name: name,
|
||||
language: language,
|
||||
url: url,
|
||||
isEnabled: isEnabled ?? this.isEnabled,
|
||||
);
|
||||
id: id,
|
||||
name: name,
|
||||
language: language,
|
||||
url: url,
|
||||
isEnabled: isEnabled ?? this.isEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
/// Subtitle content with timing
|
||||
@@ -42,16 +42,17 @@ class SubtitleEntry {
|
||||
|
||||
/// Service for handling subtitles
|
||||
class SubtitleService {
|
||||
static const _srtPattern = r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)';
|
||||
static const _srtPattern =
|
||||
r'(\d+)\n(\d{2}):(\d{2}):(\d{2}),(\d{3}) --> (\d{2}):(\d{2}):(\d{2}),(\d{3})\n([\s\S]*?)(?=\n\n|\Z)';
|
||||
|
||||
/// Parse SRT subtitle content
|
||||
static List<SubtitleEntry> parseSrt(String content) {
|
||||
final entries = <SubtitleEntry>[];
|
||||
final blocks = content.split('\n\n');
|
||||
|
||||
|
||||
for (final block in blocks) {
|
||||
if (block.trim().isEmpty) continue;
|
||||
|
||||
|
||||
final lines = block.trim().split('\n');
|
||||
if (lines.length < 3) continue;
|
||||
|
||||
@@ -67,12 +68,14 @@ class SubtitleService {
|
||||
final startTime = _parseTime(times[0].trim());
|
||||
final endTime = _parseTime(times[1].trim());
|
||||
|
||||
entries.add(SubtitleEntry(
|
||||
index: index,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
text: text,
|
||||
));
|
||||
entries.add(
|
||||
SubtitleEntry(
|
||||
index: index,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
@@ -85,10 +88,10 @@ class SubtitleService {
|
||||
static List<SubtitleEntry> parseWebVtt(String content) {
|
||||
final entries = <SubtitleEntry>[];
|
||||
final blocks = content.split('\n\n');
|
||||
|
||||
|
||||
for (final block in blocks) {
|
||||
if (block.trim().isEmpty || block.startsWith('WEBVTT')) continue;
|
||||
|
||||
|
||||
final lines = block.trim().split('\n');
|
||||
if (lines.length < 2) continue;
|
||||
|
||||
@@ -103,12 +106,14 @@ class SubtitleService {
|
||||
final startTime = _parseWebVttTime(times[0].trim());
|
||||
final endTime = _parseWebVttTime(times[1].trim());
|
||||
|
||||
entries.add(SubtitleEntry(
|
||||
index: entries.length,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
text: text,
|
||||
));
|
||||
entries.add(
|
||||
SubtitleEntry(
|
||||
index: entries.length,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
@@ -120,7 +125,8 @@ class SubtitleService {
|
||||
/// Get subtitle at specific time
|
||||
static String? getSubtitleAtTime(List<SubtitleEntry> entries, Duration time) {
|
||||
for (final entry in entries) {
|
||||
if (time.compareTo(entry.startTime) >= 0 && time.compareTo(entry.endTime) <= 0) {
|
||||
if (time.compareTo(entry.startTime) >= 0 &&
|
||||
time.compareTo(entry.endTime) <= 0) {
|
||||
return entry.text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +183,14 @@ class XtreamService {
|
||||
/// Get all live TV channels grouped by category
|
||||
///
|
||||
/// Returns a Map where key is category name and value is list of channels
|
||||
Future<Map<String, List<Channel>>> getLiveChannels({bool refresh = false}) async {
|
||||
Future<Map<String, List<Channel>>> getLiveChannels(
|
||||
{bool refresh = false}) async {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
|
||||
final options = refresh
|
||||
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
|
||||
final options = refresh
|
||||
? _cacheOptions
|
||||
.copyWith(policy: CachePolicy.refreshForceCache)
|
||||
.toExtra()
|
||||
: _cacheOptions.toExtra();
|
||||
|
||||
try {
|
||||
@@ -226,7 +229,8 @@ class XtreamService {
|
||||
return groupedChannels;
|
||||
} catch (e) {
|
||||
// Try to return from local cache if network fails
|
||||
final cached = _loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
|
||||
final cached =
|
||||
_loadFromLocalCache<Channel>('live_channels', Channel.fromJson);
|
||||
if (cached.isNotEmpty) return cached;
|
||||
throw Exception('Failed to fetch live channels: $e');
|
||||
}
|
||||
@@ -236,27 +240,30 @@ class XtreamService {
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
final jsonStr = jsonEncode(data);
|
||||
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] = jsonStr;
|
||||
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'] =
|
||||
jsonStr;
|
||||
} catch (e) {
|
||||
debugPrint('Error saving to local cache ($key): $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, List<T>> _loadFromLocalCache<T>(
|
||||
String key, T Function(Map<String, dynamic>) fromJson) {
|
||||
String key,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (!kIsWeb) return {};
|
||||
try {
|
||||
final jsonStr = html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
|
||||
final jsonStr =
|
||||
html.window.localStorage['xtream_cache_${_currentPlaylist?.id}_$key'];
|
||||
if (jsonStr == null) return {};
|
||||
|
||||
|
||||
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
final result = <String, List<T>>{};
|
||||
|
||||
|
||||
data.forEach((cat, items) {
|
||||
if (items is List) {
|
||||
result[cat] = items
|
||||
.map((i) => fromJson(i as Map<String, dynamic>))
|
||||
.toList();
|
||||
result[cat] =
|
||||
items.map((i) => fromJson(i as Map<String, dynamic>)).toList();
|
||||
}
|
||||
});
|
||||
return result;
|
||||
@@ -301,8 +308,10 @@ class XtreamService {
|
||||
Future<Map<String, List<VodItem>>> getVodItems({bool refresh = false}) async {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
|
||||
final options = refresh
|
||||
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
|
||||
final options = refresh
|
||||
? _cacheOptions
|
||||
.copyWith(policy: CachePolicy.refreshForceCache)
|
||||
.toExtra()
|
||||
: _cacheOptions.toExtra();
|
||||
|
||||
try {
|
||||
@@ -342,7 +351,8 @@ class XtreamService {
|
||||
return groupedVods;
|
||||
} catch (e) {
|
||||
// Try to return from local cache if network fails
|
||||
final cached = _loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
|
||||
final cached =
|
||||
_loadFromLocalCache<VodItem>('vod_items', VodItem.fromJson);
|
||||
if (cached.isNotEmpty) return cached;
|
||||
throw Exception('Failed to fetch VOD items: $e');
|
||||
}
|
||||
@@ -383,8 +393,10 @@ class XtreamService {
|
||||
Future<Map<String, List<Series>>> getSeries({bool refresh = false}) async {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
|
||||
final options = refresh
|
||||
? _cacheOptions.copyWith(policy: CachePolicy.refreshForceCache).toExtra()
|
||||
final options = refresh
|
||||
? _cacheOptions
|
||||
.copyWith(policy: CachePolicy.refreshForceCache)
|
||||
.toExtra()
|
||||
: _cacheOptions.toExtra();
|
||||
|
||||
try {
|
||||
@@ -424,15 +436,18 @@ class XtreamService {
|
||||
return groupedSeries;
|
||||
} catch (e) {
|
||||
// Try to return from local cache if network fails
|
||||
final cached = _loadFromLocalCache<Series>('series_items', Series.fromJson);
|
||||
final cached =
|
||||
_loadFromLocalCache<Series>('series_items', Series.fromJson);
|
||||
if (cached.isNotEmpty) return cached;
|
||||
throw Exception('Failed to fetch series: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get movies with pagination support
|
||||
Future<List<xm.Movie>> getMoviesPaginated(
|
||||
{int offset = 0, int limit = 100}) async {
|
||||
Future<List<xm.Movie>> getMoviesPaginated({
|
||||
int offset = 0,
|
||||
int limit = 100,
|
||||
}) async {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
|
||||
try {
|
||||
@@ -511,8 +526,10 @@ class XtreamService {
|
||||
}
|
||||
|
||||
/// Get series with pagination support (returns flat list)
|
||||
Future<List<xm.Series>> getSeriesPaginated(
|
||||
{int offset = 0, int limit = 100}) async {
|
||||
Future<List<xm.Series>> getSeriesPaginated({
|
||||
int offset = 0,
|
||||
int limit = 100,
|
||||
}) async {
|
||||
if (_currentPlaylist == null) throw Exception('No playlist configured');
|
||||
|
||||
try {
|
||||
|
||||
@@ -79,7 +79,9 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: ScaleTransition(
|
||||
scale: widget.isActive ? _pulseAnimation : AlwaysStoppedAnimation(1.0),
|
||||
scale: widget.isActive
|
||||
? _pulseAnimation
|
||||
: const AlwaysStoppedAnimation(1.0),
|
||||
child: AnimatedContainer(
|
||||
duration: AppTheme.durationMd,
|
||||
curve: AppTheme.curveDefault,
|
||||
@@ -90,7 +92,9 @@ class _ChannelCardState extends State<ChannelCard>
|
||||
border: Border.all(
|
||||
color: widget.isActive
|
||||
? AppColors.primary
|
||||
: (_isHovered ? AppColors.border : AppColors.border.withOpacity(0.5)),
|
||||
: (_isHovered
|
||||
? AppColors.border
|
||||
: AppColors.border.withOpacity(0.5)),
|
||||
width: widget.isActive ? 2.5 : 1.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
|
||||
@@ -12,11 +12,11 @@ class ContinueWatchingWidget extends ConsumerStatefulWidget {
|
||||
final VoidCallback? onItemTap;
|
||||
|
||||
const ContinueWatchingWidget({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.playlist,
|
||||
required this.content,
|
||||
this.onItemTap,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<ContinueWatchingWidget> createState() =>
|
||||
@@ -68,12 +68,12 @@ class _ContinueWatchingWidgetState
|
||||
const SizedBox(width: 16),
|
||||
...continueWatching.take(5).map((rec) {
|
||||
final item = rec.item;
|
||||
final percentage = watchHistory[item.streamId.toString()]
|
||||
is Map
|
||||
? (watchHistory[item.streamId.toString()]
|
||||
as Map)['percentage'] as double? ??
|
||||
0.0
|
||||
: 0.0;
|
||||
final percentage =
|
||||
watchHistory[item.streamId.toString()] is Map
|
||||
? (watchHistory[item.streamId.toString()]
|
||||
as Map)['percentage'] as double? ??
|
||||
0.0
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
@@ -132,9 +132,10 @@ class _ContinueWatchingCard extends StatelessWidget {
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
errorWidget: (context, url, error) =>
|
||||
Icon(Icons.image_not_supported,
|
||||
color: Colors.grey[600]),
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.image_not_supported,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -239,11 +240,11 @@ class TrendingWidget extends ConsumerWidget {
|
||||
final VoidCallback? onItemTap;
|
||||
|
||||
const TrendingWidget({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.playlist,
|
||||
required this.content,
|
||||
this.onItemTap,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -265,12 +266,15 @@ class TrendingWidget extends ConsumerWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
const Row(
|
||||
children: [
|
||||
const Icon(Icons.local_fire_department,
|
||||
color: Colors.red, size: 24),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
Icon(
|
||||
Icons.local_fire_department,
|
||||
color: Colors.red,
|
||||
size: 24,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Trending Now',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
@@ -354,9 +358,10 @@ class _TrendingCard extends StatelessWidget {
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
errorWidget: (context, url, error) =>
|
||||
Icon(Icons.image_not_supported,
|
||||
color: Colors.grey[600]),
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.image_not_supported,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -379,8 +384,11 @@ class _TrendingCard extends StatelessWidget {
|
||||
if (item.rating != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.star,
|
||||
color: Colors.amber, size: 12),
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
item.rating ?? '0',
|
||||
@@ -444,11 +452,11 @@ class RecentlyAddedWidget extends ConsumerWidget {
|
||||
final VoidCallback? onItemTap;
|
||||
|
||||
const RecentlyAddedWidget({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.playlist,
|
||||
required this.content,
|
||||
this.onItemTap,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -463,9 +471,9 @@ class RecentlyAddedWidget extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: const Text(
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Recently Added',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
|
||||
@@ -389,9 +389,11 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(
|
||||
0.05), // Subtle transparent background
|
||||
0.05,
|
||||
), // Subtle transparent background
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12)),
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
@@ -418,7 +420,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
// Footer
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
color: Colors.black54,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -464,8 +468,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
EpgEntry.parseDateTime(entry.start);
|
||||
final end =
|
||||
EpgEntry.parseDateTime(entry.end);
|
||||
if (start == null || end == null)
|
||||
if (start == null || end == null) {
|
||||
return false;
|
||||
}
|
||||
return now.isAfter(start) &&
|
||||
now.isBefore(end);
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,10 +8,10 @@ class QualitySelectorWidget extends ConsumerStatefulWidget {
|
||||
final VoidCallback onClose;
|
||||
|
||||
const QualitySelectorWidget({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.qualitySelector,
|
||||
required this.onClose,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<QualitySelectorWidget> createState() =>
|
||||
@@ -43,7 +43,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.white24),
|
||||
),
|
||||
@@ -109,13 +109,16 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
|
||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
leading: isSelected
|
||||
? const Icon(Icons.check_circle, color: Colors.blue)
|
||||
: const Icon(Icons.radio_button_unchecked,
|
||||
color: Colors.white30),
|
||||
: const Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
color: Colors.white30,
|
||||
),
|
||||
title: Text(
|
||||
quality.label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.blue : Colors.white,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
@@ -136,7 +139,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.white24),
|
||||
),
|
||||
@@ -177,10 +180,10 @@ class QualityIndicator extends ConsumerWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const QualityIndicator({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.qualitySelector,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -219,9 +222,9 @@ class BandwidthMonitor extends ConsumerWidget {
|
||||
final QualitySelector qualitySelector;
|
||||
|
||||
const BandwidthMonitor({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.qualitySelector,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -47,7 +47,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
|
||||
_isRecording = true;
|
||||
_status = '🔴 Recording for $minutes min';
|
||||
});
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('✅ Recording started!')),
|
||||
@@ -80,7 +80,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
setState(() => _status = '⏰ Scheduled for ${_startTime.toLocal()}');
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('✅ Recording scheduled!')),
|
||||
@@ -253,10 +253,12 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
|
||||
value: _durationMinutes,
|
||||
dropdownColor: Colors.grey[800],
|
||||
items: [30, 60, 120, 240, 480]
|
||||
.map((m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text('$m min'),
|
||||
))
|
||||
.map(
|
||||
(m) => DropdownMenuItem(
|
||||
value: m,
|
||||
child: Text('$m min'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
|
||||
Reference in new issue
Block a user