mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 21:48:06 +02:00
Merge pull request #36 from R0m1k3/antigravity
feat: add core server with REST API, Xtream proxy, streaming, and wat…
This commit is contained in:
4 files changed
+328
-87
No files matched your search
+47
-17
@@ -406,47 +406,77 @@ Handler _createStreamHandler() {
|
|||||||
// Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome)
|
// Transcode to H.264/AAC for browser compatibility (HEVC not supported in Chrome)
|
||||||
// Using fast preset for lower CPU usage
|
// Using fast preset for lower CPU usage
|
||||||
// Added resilience flags for corrupted/discontinuous streams
|
// Added resilience flags for corrupted/discontinuous streams
|
||||||
final process = await Process.start('ffmpeg', [
|
//
|
||||||
|
// Check if this is a VOD (file) or live stream
|
||||||
|
final isVod = streamId.startsWith('vod_');
|
||||||
|
|
||||||
|
final ffmpegArgs = <String>[
|
||||||
'-y', // Overwrite output files
|
'-y', // Overwrite output files
|
||||||
'-loglevel', 'warning', // Reduce verbose output
|
'-loglevel', 'warning', // Reduce verbose output
|
||||||
'-err_detect', 'ignore_err', // Ignore decoding errors
|
'-err_detect', 'ignore_err', // Ignore decoding errors
|
||||||
'-fflags', '+genpts+discardcorrupt+igndts', // Generate PTS, discard corrupt frames, ignore DTS
|
'-fflags', '+genpts+discardcorrupt', // Generate PTS, discard corrupt frames
|
||||||
'-flags', 'low_delay', // Low delay mode
|
'-analyzeduration', isVod ? '10000000' : '2000000', // 10s for VOD, 2s for live
|
||||||
'-analyzeduration', '2000000', // Limit analysis time (2 seconds)
|
'-probesize', isVod ? '5000000' : '1000000', // 5MB for VOD, 1MB for live
|
||||||
'-probesize', '1000000', // Limit probe size (1MB)
|
|
||||||
'-user_agent', 'VLC/3.0.18 LibVLC/3.0.18',
|
'-user_agent', 'VLC/3.0.18 LibVLC/3.0.18',
|
||||||
'-headers', 'Accept: */*\r\nConnection: keep-alive\r\n',
|
'-headers', 'Accept: */*\r\nConnection: keep-alive\r\n',
|
||||||
'-reconnect', '1',
|
];
|
||||||
'-reconnect_streamed', '1',
|
|
||||||
'-reconnect_delay_max', '5',
|
// Add reconnect flags only for live streams (not VOD)
|
||||||
'-reconnect_on_network_error', '1', // Reconnect on network errors
|
if (!isVod) {
|
||||||
'-reconnect_on_http_error', '4xx,5xx', // Reconnect on HTTP errors
|
ffmpegArgs.addAll([
|
||||||
|
'-reconnect', '1',
|
||||||
|
'-reconnect_streamed', '1',
|
||||||
|
'-reconnect_delay_max', '5',
|
||||||
|
'-reconnect_on_network_error', '1',
|
||||||
|
'-reconnect_on_http_error', '4xx,5xx',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegArgs.addAll([
|
||||||
'-i', iptvUrl,
|
'-i', iptvUrl,
|
||||||
// Video: transcode to H.264 for browser compatibility
|
// Video: transcode to H.264 for browser compatibility
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
'-preset', 'ultrafast', // Fastest encoding (less CPU)
|
'-preset', 'ultrafast', // Fastest encoding (less CPU)
|
||||||
'-tune', 'zerolatency', // Low latency for live streaming
|
|
||||||
'-profile:v', 'baseline', // Most compatible H.264 profile
|
'-profile:v', 'baseline', // Most compatible H.264 profile
|
||||||
'-level', '3.0',
|
'-level', '3.1',
|
||||||
|
'-pix_fmt', 'yuv420p', // Required for browser compatibility
|
||||||
'-b:v', '2500k', // 2.5 Mbps video bitrate
|
'-b:v', '2500k', // 2.5 Mbps video bitrate
|
||||||
'-maxrate', '3000k',
|
'-maxrate', '3000k',
|
||||||
'-bufsize', '6000k',
|
'-bufsize', '6000k',
|
||||||
'-vsync', 'passthrough', // Pass through timestamps as-is (fixes discontinuity)
|
]);
|
||||||
|
|
||||||
|
// Add latency flags only for live streams
|
||||||
|
if (!isVod) {
|
||||||
|
ffmpegArgs.addAll([
|
||||||
|
'-tune', 'zerolatency', // Low latency for live streaming
|
||||||
|
'-vsync', 'passthrough', // Pass through timestamps as-is
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
ffmpegArgs.addAll([
|
||||||
|
'-vsync', 'cfr', // Constant frame rate for VOD
|
||||||
|
'-g', '48', // Keyframe every 2 seconds at 24fps
|
||||||
|
'-keyint_min', '48',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegArgs.addAll([
|
||||||
'-avoid_negative_ts', 'make_zero', // Handle negative timestamps
|
'-avoid_negative_ts', 'make_zero', // Handle negative timestamps
|
||||||
// Audio: transcode to AAC
|
// Audio: transcode to AAC
|
||||||
'-c:a', 'aac',
|
'-c:a', 'aac',
|
||||||
'-b:a', '128k',
|
'-b:a', '128k',
|
||||||
'-ar', '44100',
|
'-ar', '44100',
|
||||||
'-async', '1', // Audio sync (stretch/compression allowed)
|
'-ac', '2', // Force stereo
|
||||||
// HLS output settings
|
// HLS output settings
|
||||||
'-f', 'hls',
|
'-f', 'hls',
|
||||||
'-hls_time', '2', // 2 second segments
|
'-hls_time', isVod ? '4' : '2', // 4 second segments for VOD, 2 for live
|
||||||
'-hls_list_size', '5', // Keep only last 5 segments
|
'-hls_list_size', isVod ? '0' : '5', // Keep all segments for VOD, last 5 for live
|
||||||
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
'-hls_flags', isVod ? 'independent_segments' : 'delete_segments+append_list+omit_endlist',
|
||||||
'-hls_segment_filename', '${outputDir.path}/segment_%d.ts',
|
'-hls_segment_filename', '${outputDir.path}/segment_%d.ts',
|
||||||
outputPath,
|
outputPath,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
final process = await Process.start('ffmpeg', ffmpegArgs);
|
||||||
|
|
||||||
_activeStreams[streamId] = process;
|
_activeStreams[streamId] = process;
|
||||||
|
|
||||||
// Collect FFmpeg stderr for error reporting
|
// Collect FFmpeg stderr for error reporting
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Watch history state - tracks which content has been watched
|
||||||
|
class WatchHistory {
|
||||||
|
/// Set of watched movie IDs
|
||||||
|
final Set<String> watchedMovies;
|
||||||
|
|
||||||
|
/// Set of watched episode IDs (format: seriesId_seasonNum_episodeNum)
|
||||||
|
final Set<String> watchedEpisodes;
|
||||||
|
|
||||||
|
const WatchHistory({
|
||||||
|
this.watchedMovies = const {},
|
||||||
|
this.watchedEpisodes = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
WatchHistory copyWith({
|
||||||
|
Set<String>? watchedMovies,
|
||||||
|
Set<String>? watchedEpisodes,
|
||||||
|
}) {
|
||||||
|
return WatchHistory(
|
||||||
|
watchedMovies: watchedMovies ?? this.watchedMovies,
|
||||||
|
watchedEpisodes: watchedEpisodes ?? this.watchedEpisodes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a movie has been watched
|
||||||
|
bool isMovieWatched(String movieId) => watchedMovies.contains(movieId);
|
||||||
|
|
||||||
|
/// Check if an episode has been watched
|
||||||
|
bool isEpisodeWatched(String episodeId) => watchedEpisodes.contains(episodeId);
|
||||||
|
|
||||||
|
/// Generate episode key
|
||||||
|
static String episodeKey(int seriesId, int seasonNum, int episodeNum) {
|
||||||
|
return '${seriesId}_${seasonNum}_$episodeNum';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch history notifier with persistence
|
||||||
|
class WatchHistoryNotifier extends StateNotifier<WatchHistory> {
|
||||||
|
static const String _moviesKey = 'watch_history_movies';
|
||||||
|
static const String _episodesKey = 'watch_history_episodes';
|
||||||
|
|
||||||
|
SharedPreferences? _prefs;
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
WatchHistoryNotifier() : super(const WatchHistory()) {
|
||||||
|
_loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHistory() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
final moviesJson = _prefs?.getString(_moviesKey);
|
||||||
|
final episodesJson = _prefs?.getString(_episodesKey);
|
||||||
|
|
||||||
|
final movies = moviesJson != null
|
||||||
|
? Set<String>.from(jsonDecode(moviesJson) as List)
|
||||||
|
: <String>{};
|
||||||
|
|
||||||
|
final episodes = episodesJson != null
|
||||||
|
? Set<String>.from(jsonDecode(episodesJson) as List)
|
||||||
|
: <String>{};
|
||||||
|
|
||||||
|
state = WatchHistory(
|
||||||
|
watchedMovies: movies,
|
||||||
|
watchedEpisodes: episodes,
|
||||||
|
);
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
print('Watch history loaded: ${movies.length} movies, ${episodes.length} episodes');
|
||||||
|
} catch (e) {
|
||||||
|
print('Error loading watch history: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMovies() async {
|
||||||
|
try {
|
||||||
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
await _prefs?.setString(_moviesKey, jsonEncode(state.watchedMovies.toList()));
|
||||||
|
} catch (e) {
|
||||||
|
print('Error saving movie watch history: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEpisodes() async {
|
||||||
|
try {
|
||||||
|
_prefs ??= await SharedPreferences.getInstance();
|
||||||
|
await _prefs?.setString(_episodesKey, jsonEncode(state.watchedEpisodes.toList()));
|
||||||
|
} catch (e) {
|
||||||
|
print('Error saving episode watch history: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a movie as watched
|
||||||
|
void markMovieWatched(String movieId) {
|
||||||
|
final newSet = Set<String>.from(state.watchedMovies)..add(movieId);
|
||||||
|
state = state.copyWith(watchedMovies: newSet);
|
||||||
|
_saveMovies();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a movie as unwatched
|
||||||
|
void markMovieUnwatched(String movieId) {
|
||||||
|
final newSet = Set<String>.from(state.watchedMovies)..remove(movieId);
|
||||||
|
state = state.copyWith(watchedMovies: newSet);
|
||||||
|
_saveMovies();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle movie watched status
|
||||||
|
void toggleMovieWatched(String movieId) {
|
||||||
|
if (state.isMovieWatched(movieId)) {
|
||||||
|
markMovieUnwatched(movieId);
|
||||||
|
} else {
|
||||||
|
markMovieWatched(movieId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an episode as watched
|
||||||
|
void markEpisodeWatched(String episodeKey) {
|
||||||
|
final newSet = Set<String>.from(state.watchedEpisodes)..add(episodeKey);
|
||||||
|
state = state.copyWith(watchedEpisodes: newSet);
|
||||||
|
_saveEpisodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark an episode as unwatched
|
||||||
|
void markEpisodeUnwatched(String episodeKey) {
|
||||||
|
final newSet = Set<String>.from(state.watchedEpisodes)..remove(episodeKey);
|
||||||
|
state = state.copyWith(watchedEpisodes: newSet);
|
||||||
|
_saveEpisodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle episode watched status
|
||||||
|
void toggleEpisodeWatched(String episodeKey) {
|
||||||
|
if (state.isEpisodeWatched(episodeKey)) {
|
||||||
|
markEpisodeUnwatched(episodeKey);
|
||||||
|
} else {
|
||||||
|
markEpisodeWatched(episodeKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all watch history
|
||||||
|
void clearAll() {
|
||||||
|
state = const WatchHistory();
|
||||||
|
_saveMovies();
|
||||||
|
_saveEpisodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider for watch history
|
||||||
|
final watchHistoryProvider =
|
||||||
|
StateNotifierProvider<WatchHistoryNotifier, WatchHistory>((ref) {
|
||||||
|
return WatchHistoryNotifier();
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import '../../../core/models/playlist_config.dart';
|
import '../../../core/models/playlist_config.dart';
|
||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
|
import '../providers/watch_history_provider.dart';
|
||||||
import 'player_screen.dart';
|
import 'player_screen.dart';
|
||||||
|
|
||||||
/// Series detail screen showing seasons and episodes
|
/// Series detail screen showing seasons and episodes
|
||||||
@@ -241,11 +242,22 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEpisodeTile(Episode episode) {
|
Widget _buildEpisodeTile(Episode episode) {
|
||||||
|
final watchHistory = ref.watch(watchHistoryProvider);
|
||||||
|
final episodeKey = WatchHistory.episodeKey(
|
||||||
|
widget.series.seriesId,
|
||||||
|
_selectedSeason,
|
||||||
|
episode.episodeNum,
|
||||||
|
);
|
||||||
|
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: Colors.grey.shade800,
|
color: Colors.grey.shade800,
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// Mark as watched when playing
|
||||||
|
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -259,6 +271,10 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
// Toggle watched on long press
|
||||||
|
ref.read(watchHistoryProvider.notifier).toggleEpisodeWatched(episodeKey);
|
||||||
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -269,18 +285,20 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.shade700,
|
color: isWatched ? Colors.green : Colors.blue.shade700,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: isWatched
|
||||||
'${episode.episodeNum}',
|
? const Icon(Icons.check, color: Colors.white, size: 20)
|
||||||
style: GoogleFonts.roboto(
|
: Text(
|
||||||
fontSize: 16,
|
'${episode.episodeNum}',
|
||||||
fontWeight: FontWeight.bold,
|
style: GoogleFonts.roboto(
|
||||||
color: Colors.white,
|
fontSize: 16,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -295,7 +313,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
style: GoogleFonts.roboto(
|
style: GoogleFonts.roboto(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.white,
|
color: isWatched ? Colors.white54 : Colors.white,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -313,7 +331,11 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Play icon
|
// Play icon
|
||||||
const Icon(Icons.play_circle_outline, color: Colors.white70, size: 28),
|
Icon(
|
||||||
|
isWatched ? Icons.replay : Icons.play_circle_outline,
|
||||||
|
color: Colors.white70,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/models/playlist_config.dart';
|
|||||||
import '../models/xtream_models.dart';
|
import '../models/xtream_models.dart';
|
||||||
import '../providers/xtream_provider.dart';
|
import '../providers/xtream_provider.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../providers/watch_history_provider.dart';
|
||||||
import '../screens/player_screen.dart';
|
import '../screens/player_screen.dart';
|
||||||
|
|
||||||
class MoviesTab extends ConsumerStatefulWidget {
|
class MoviesTab extends ConsumerStatefulWidget {
|
||||||
@@ -136,7 +137,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MovieCard extends StatelessWidget {
|
class _MovieCard extends ConsumerWidget {
|
||||||
final Movie movie;
|
final Movie movie;
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
|
|
||||||
@@ -146,12 +147,17 @@ class _MovieCard extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final watchHistory = ref.watch(watchHistoryProvider);
|
||||||
|
final isWatched = watchHistory.isMovieWatched(movie.streamId);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Use PlayerScreen for VOD (video on demand)
|
// Mark as watched when opening
|
||||||
|
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -165,67 +171,93 @@ class _MovieCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Column(
|
onLongPress: () {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
// Toggle watched status on long press
|
||||||
|
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Column(
|
||||||
child: movie.streamIcon != null
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
? CachedNetworkImage(
|
children: [
|
||||||
imageUrl: movie.streamIcon!,
|
Expanded(
|
||||||
fit: BoxFit.cover,
|
child: movie.streamIcon != null
|
||||||
placeholder: (context, url) => Container(
|
? CachedNetworkImage(
|
||||||
color: Colors.grey.shade800,
|
imageUrl: movie.streamIcon!,
|
||||||
child: const Center(
|
fit: BoxFit.cover,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
placeholder: (context, url) => Container(
|
||||||
),
|
color: Colors.grey.shade800,
|
||||||
),
|
child: const Center(
|
||||||
errorWidget: (context, url, error) => Container(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
// Watched indicator
|
||||||
|
if (isWatched)
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in new issue
Block a user