feat: add core server with REST API, Xtream proxy, streaming, and watch history provider

This commit is contained in:
Michael committed 2025-12-06 13:40:48 +01:00
1 parent e32ccd6999
commit 68a03fb58e
4 files changed
+328 -87

No files matched your search

@@ -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 '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/watch_history_provider.dart';
import 'player_screen.dart';
/// Series detail screen showing seasons and episodes
@@ -241,11 +242,22 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
}
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(
color: Colors.grey.shade800,
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
// Mark as watched when playing
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
Navigator.push(
context,
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),
child: Padding(
padding: const EdgeInsets.all(12),
@@ -269,18 +285,20 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.blue.shade700,
color: isWatched ? Colors.green : Colors.blue.shade700,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'${episode.episodeNum}',
style: GoogleFonts.roboto(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
child: isWatched
? const Icon(Icons.check, color: Colors.white, size: 20)
: Text(
'${episode.episodeNum}',
style: GoogleFonts.roboto(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
const SizedBox(width: 12),
@@ -295,7 +313,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
style: GoogleFonts.roboto(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
color: isWatched ? Colors.white54 : Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -313,7 +331,11 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
),
// 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,
),
],
),
),
+91 -59
View File
@@ -6,6 +6,7 @@ import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart';
import '../providers/watch_history_provider.dart';
import '../screens/player_screen.dart';
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 PlaylistConfig playlist;
@@ -146,12 +147,17 @@ class _MovieCard extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final watchHistory = ref.watch(watchHistoryProvider);
final isWatched = watchHistory.isMovieWatched(movie.streamId);
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
// Use PlayerScreen for VOD (video on demand)
// Mark as watched when opening
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
context,
MaterialPageRoute(
@@ -165,67 +171,93 @@ class _MovieCard extends StatelessWidget {
),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
onLongPress: () {
// Toggle watched status on long press
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
},
child: Stack(
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,
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,
),
),
],
),
],
),
],
],
),
],
),
),
],
),
// 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,
),
),
),
],
),
),