diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index 4c99f89..c4f7ad1 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -214,5 +214,15 @@ class BackendApi { }; await _post(payload); } + + Future>> getSeriesSummary() async { + final map = await _post({'action': 'get_series_summary'}); + return (map['items'] as List).cast>(); + } + + Future>> getShowEpisodes(int showId) async { + final map = await _post({'action': 'get_show_episodes', 'show_id': showId}); + return (map['items'] as List).cast>(); + } } diff --git a/lib/features/movies/data/movie_repository.dart b/lib/features/movies/data/movie_repository.dart index 4b187a7..37bc7f9 100644 --- a/lib/features/movies/data/movie_repository.dart +++ b/lib/features/movies/data/movie_repository.dart @@ -20,3 +20,24 @@ final moviesProvider = FutureProvider.autoDispose>((ref) async { } return all.map(Movie.fromJson).toList(); }); + +// Streaming variant for faster perceived load: yields pages as they arrive +final moviesStreamProvider = StreamProvider.autoDispose>((ref) async* { + final backend = ref.watch(backendApiProvider); + final st = ref.watch(movieFilterProvider); + const pageSize = 300; + var offset = 0; + var agg = []; + while (true) { + final page = await backend.getMovies(status: st?.name, offset: offset, limit: pageSize); + final mapped = page.map(Movie.fromJson).toList(); + if (mapped.isEmpty) { + if (agg.isEmpty) yield const []; + break; + } + agg = [...agg, ...mapped]; + yield agg; + if (mapped.length < pageSize) break; + offset += pageSize; + } +}); diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index 7686ac1..317c067 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -14,7 +14,7 @@ class MovieListScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final filter = ref.watch(movieFilterProvider); - final moviesAsync = ref.watch(moviesProvider); + final moviesAsync = ref.watch(moviesStreamProvider); return Padding( padding: const EdgeInsets.all(12.0), @@ -75,6 +75,7 @@ class MovieListScreen extends ConsumerWidget { } // Refresh list after update // ignore: unused_result + ref.invalidate(moviesStreamProvider); ref.invalidate(moviesProvider); messenger.showSnackBar(SnackBar(content: Text('TMDB Update fertig: $ok/$total Filme'))); } finally { @@ -114,7 +115,13 @@ class MovieListScreen extends ConsumerWidget { final itemsSorted = [...items] ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); return RefreshIndicator( - onRefresh: () async => ref.invalidate(moviesProvider), + onRefresh: () async { + // ignore: unused_result + ref.invalidate(moviesStreamProvider); + // also invalidate fallback provider used elsewhere + // ignore: unused_result + ref.invalidate(moviesProvider); + }, child: ListView.separated( itemCount: itemsSorted.length, separatorBuilder: (_, __) => const Divider(height: 1), diff --git a/lib/features/series/data/series_repository.dart b/lib/features/series/data/series_repository.dart index 3a65ead..e01e57e 100644 --- a/lib/features/series/data/series_repository.dart +++ b/lib/features/series/data/series_repository.dart @@ -9,14 +9,61 @@ final seriesGroupedProvider = FutureProvider.autoDispose((ref) async { final backend = ref.watch(backendApiProvider); final st = ref.watch(episodeFilterProvider); - final rows = await backend.getEpisodes(status: st?.name, limit: 5000); - final items = rows - .map(EpisodeItem.fromJson) - .where((e) => e.seasonNumber > 0) - .toList(); + int pageSize = 1000; + var offset = 0; + final items = []; + final seenIds = {}; + int safetyPages = 0; + int consecutiveFailures = 0; + while (true) { + List> page; + try { + page = await backend.getEpisodes( + status: st?.name, + offset: offset, + limit: pageSize, + ); + } catch (e) { + // Adaptive fallback: try smaller pages on server errors + if (pageSize > 200 && consecutiveFailures < 5) { + pageSize = (pageSize / 2).round().clamp(200, 2000); + consecutiveFailures++; + continue; // retry same offset with smaller page + } + // If we already have some data, return partial results to unblock UI + if (items.isNotEmpty) break; else rethrow; + } + consecutiveFailures = 0; + if (page.isEmpty) break; + var newCount = 0; + for (final j in page) { + final ep = EpisodeItem.fromJson(j); + if (ep.seasonNumber <= 0) continue; + if (seenIds.add(ep.id)) { + items.add(ep); + newCount++; + } + } + if (page.length < pageSize) break; + // Safety: if server ignores offset and repeats pages, stop when nothing new is added + if (newCount == 0) break; + offset += page.length; + if (++safetyPages > 500) break; // cap to avoid a runaway loop on huge libraries + } return SeriesGroupedData.fromEpisodes(items); }); +// Lightweight summary provider: uses backend aggregation instead of fetching all episodes. +final seriesSummaryProvider = FutureProvider.autoDispose>>((ref) async { + final backend = ref.watch(backendApiProvider); + try { + final items = await backend.getSeriesSummary(); + return items; + } catch (_) { + return const []; + } +}); + class SeriesGroupedData { final Map>> data; // groupKey -> season -> episodes (groupKey is unique per show) diff --git a/lib/features/series/presentation/series_detail_screen.dart b/lib/features/series/presentation/series_detail_screen.dart index 430ad8e..01bafb0 100644 --- a/lib/features/series/presentation/series_detail_screen.dart +++ b/lib/features/series/presentation/series_detail_screen.dart @@ -13,6 +13,8 @@ class SeriesDetailScreen extends ConsumerStatefulWidget { final String showName; // displayName is the human title used in UI. final String? displayName; + final int? showId; + final String? seasonsEps; // compact per-season episodes string from summary final int? year; final String? resolution; final String? posterPath; @@ -20,6 +22,8 @@ class SeriesDetailScreen extends ConsumerStatefulWidget { super.key, required this.showName, this.displayName, + this.showId, + this.seasonsEps, this.year, this.resolution, this.posterPath, @@ -57,62 +61,99 @@ class _SeriesDetailScreenState extends ConsumerState { setState(() => _downloadPath = v); } }); + // Quick-fill from seasonsEps for instant UI while fetching real data + if (widget.seasonsEps != null && widget.seasonsEps!.isNotEmpty) { + final map = >{}; + for (final part in widget.seasonsEps!.split(';')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + if (seg.length < 2) continue; + final sn = int.tryParse(seg[0]); + if (sn == null || sn <= 0) continue; + final eps = []; + int id = -1; + for (final eSeg in seg[1].split(',')) { + if (eSeg.isEmpty) continue; + final kv = eSeg.split('|'); + final epNo = kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0; + final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; + final st = stCode == 2 ? ItemStatus.Done : (stCode == 1 ? ItemStatus.Progress : ItemStatus.Init); + eps.add(EpisodeItem(id: id--, episodeNumber: epNo, seasonNumber: sn, showName: widget.displayName ?? widget.showName, status: st)); + } + if (eps.isNotEmpty) { + map[sn] = eps..sort((a,b)=>a.episodeNumber.compareTo(b.episodeNumber)); + } + } + if (map.isNotEmpty) { + _seasons = map; + } + } _load(); } Future _load() async { + try { + int? showId = widget.showId; + if (showId == null) { + // Try to parse from key: "name##id" + final parts = widget.showName.split('##'); + if (parts.length == 2) { + showId = int.tryParse(parts[1]); + } + } + if (showId != null) { + final rows = await ref.read(backendApiProvider).getShowEpisodes(showId); + final items = rows.map(EpisodeItem.fromJson).toList(); + // group by season number + final map = >{}; + for (final e in items) { + if (e.seasonNumber <= 0) continue; + map.putIfAbsent(e.seasonNumber, () => []).add(e); + } + if (!mounted) return; + setState(() { + _seasons = map.map((k, v) => MapEntry(k, (v..sort((a,b)=>a.episodeNumber.compareTo(b.episodeNumber))))); + final first = items.isNotEmpty ? items.first : null; + if (first != null) { + _resolution ??= first.resolution; + _downloadPath ??= first.downloadPath; + _cliffhanger ??= first.showCliffhanger; + _showId ??= first.showId ?? showId; + _origResolution ??= _resolution; + _origDownloadPath ??= _downloadPath; + _origCliffhanger ??= _cliffhanger; + if (_downloadCtrl.text != (_downloadPath ?? '')) { + _downloadCtrl.text = _downloadPath ?? ''; + } + if (first.showJson != null && first.showJson!.isNotEmpty) { + try { + final m = jsonDecode(first.showJson!) as Map; + _tmdbId = (m['id'] as num?)?.toInt() ?? _tmdbId; + _overview = (m['overview'] as String?) ?? _overview; + final credits = m['credits'] as Map?; + if (credits != null) { + final c = (credits['cast'] as List? ?? const []).cast().map((e)=>Map.from(e as Map)).toList(); + final allCrew = (credits['crew'] as List? ?? const []).cast().map((e)=>Map.from(e as Map)).toList(); + _cast = c.take(12).toList(); + const preferredJobs = {'Showrunner','Director','Writer','Screenplay','Story','Teleplay','Executive Producer','Producer'}; + final preferred = allCrew.where((m)=> preferredJobs.contains((m['job'] ?? '').toString()) || (m['department'] ?? '').toString()=='Directing' || (m['department'] ?? '').toString()=='Writing'); + final chosen = preferred.isNotEmpty ? preferred : allCrew; + _crew = chosen.take(12).toList(); + } + } catch (_) {} + } + } + }); + return; + } + } catch (_) { + // fall back to grouped provider + } final grouped = await ref.read(seriesGroupedProvider.future); final data = grouped.data[widget.showName]; if (!mounted) return; setState(() { _seasons = data; - if (data != null) { - // read show-level meta from first episode available - final flat = data.values.expand((e) => e); - final first = flat.isEmpty ? null : flat.first; - if (first != null) { - _resolution ??= first.resolution; - _downloadPath ??= first.downloadPath; - _cliffhanger ??= first.showCliffhanger; - _showId ??= first.showId; - _origResolution ??= _resolution; - _origDownloadPath ??= _downloadPath; - _origCliffhanger ??= _cliffhanger; - if (_downloadCtrl.text != (_downloadPath ?? '')) { - _downloadCtrl.text = _downloadPath ?? ''; - } - // parse overview/cast/crew (and tmdb id) from show_json - if (first.showJson != null && first.showJson!.isNotEmpty) { - try { - final m = jsonDecode(first.showJson!) as Map; - _tmdbId = (m['id'] as num?)?.toInt() ?? _tmdbId; - _overview = (m['overview'] as String?) ?? _overview; - final credits = m['credits'] as Map?; - if (credits != null) { - final c = (credits['cast'] as List? ?? const []) - .cast() - .map((e) => Map.from(e as Map)) - .toList(); - final allCrew = (credits['crew'] as List? ?? const []) - .cast() - .map((e) => Map.from(e as Map)) - .toList(); - _cast = c.take(12).toList(); - // Prefer key roles; if empty, fall back to first crew entries - const preferredJobs = { - 'Showrunner', 'Director', 'Writer', 'Screenplay', 'Story', 'Teleplay', 'Executive Producer', 'Producer' - }; - final preferred = allCrew.where((m) => - preferredJobs.contains((m['job'] ?? '').toString()) || - (m['department'] ?? '').toString() == 'Directing' || - (m['department'] ?? '').toString() == 'Writing'); - final chosen = preferred.isNotEmpty ? preferred : allCrew; - _crew = chosen.take(12).toList(); - } - } catch (_) {} - } - } - } }); } @@ -147,6 +188,12 @@ class _SeriesDetailScreenState extends ConsumerState { // refresh list/table // ignore: unused_result ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + // Reload episodes to reflect the saved statuses in this screen as well + await _load(); + // Mark that we have saved changes so list can refresh on back + _saved = true; if (mounted) { setState(() { _pending.clear(); @@ -165,6 +212,8 @@ class _SeriesDetailScreenState extends ConsumerState { } } + bool _saved = false; + void _setEpisodeStatus(EpisodeItem ep, ItemStatus s) { setState(() { _pending[ep.id] = s; @@ -236,7 +285,12 @@ class _SeriesDetailScreenState extends ConsumerState { _resolution != _origResolution || _downloadPath != _origDownloadPath || _cliffhanger != _origCliffhanger; - return Scaffold( + return WillPopScope( + onWillPop: () async { + Navigator.of(context).pop(_saved ? 'updated' : null); + return false; + }, + child: Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( backgroundColor: Colors.transparent, @@ -388,8 +442,9 @@ class _SeriesDetailScreenState extends ConsumerState { ), ), ), - ), - ); + ) + ), + ); } Widget _header(BuildContext context) { diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index 7f69ec2..ea72bbe 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -9,6 +9,8 @@ import 'widgets/episode_status_strip.dart'; import 'series_detail_screen.dart'; import 'series_add_screen.dart'; import '../../shared/providers.dart'; +import '../data/episode_model.dart'; +import 'widgets/season_status_bar.dart'; class SeriesListScreen extends ConsumerWidget { const SeriesListScreen({super.key}); @@ -17,6 +19,7 @@ class SeriesListScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final filter = ref.watch(episodeFilterProvider); final groupedAsync = ref.watch(seriesGroupedProvider); + final summaryAsync = ref.watch(seriesSummaryProvider); return Scaffold( body: Padding( @@ -86,7 +89,7 @@ class SeriesListScreen extends ConsumerWidget { } } total = showRows.length; - int ok = 0; + int success = 0; for (final row in showRows) { idx++; final tmdbId = (row['tmdb_id'] as num?)?.toInt(); @@ -110,13 +113,13 @@ class SeriesListScreen extends ConsumerWidget { await backend.upsertEpisode(seasonId, e); } } - ok++; + success++; } catch (_) {} } // ignore: unused_result ref.invalidate(seriesGroupedProvider); messenger.showSnackBar( - SnackBar(content: Text('TMDB Update fertig: $ok/$total Serien')), + SnackBar(content: Text('TMDB Update fertig: $success/$total Serien')), ); } finally { if (ctx.mounted) Navigator.of(ctx).pop(); @@ -253,10 +256,12 @@ class SeriesListScreen extends ConsumerWidget { ), const SizedBox(height: 12), Expanded( - child: groupedAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Fehler: $e')), - data: (data) { + child: (summaryAsync.hasValue && (summaryAsync.value?.isNotEmpty ?? false)) + ? _buildFromSummary(context, ref, summaryAsync.value!) + : groupedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Fehler: $e')), + data: (data) { final keys = data.data.keys.toList(); String displayOf(String key) { final bySeason = data.data[key]; @@ -311,7 +316,7 @@ class SeriesListScreen extends ConsumerWidget { } if ((year == null || showStatus == null || showCliffhanger == null) && showJson != null) { try { - final m = jsonDecode(showJson!); + final m = jsonDecode(showJson); if (m is Map && m['first_air_date'] is String) { final s = (m['first_air_date'] as String); if (s.length >= 4) year = int.tryParse(s.substring(0, 4)); @@ -404,6 +409,7 @@ class SeriesListScreen extends ConsumerWidget { builder: (_) => SeriesDetailScreen( showName: key, displayName: displayName, + showId: int.tryParse(key.split('##').last), year: year, resolution: resolution, posterPath: posterPath, @@ -439,6 +445,334 @@ class SeriesListScreen extends ConsumerWidget { ); } + Widget _buildFromSummary(BuildContext context, WidgetRef ref, List> items) { + // Build and sort keys + final keys = []; + final byKey = >{}; + for (final m in items) { + final showId = (m['show_id'] as num).toInt(); + final name = (m['name'] as String?) ?? ''; + final key = '${name}##${showId}'; + keys.add(key); + byKey[key] = m; + } + String displayOf(String key) => key.split('##').first; + keys.sort((a, b) => displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase())); + + // Determine all season columns to create a fixed horizontal grid + final allSeasons = {}; + for (final m in items) { + final seasonsArr = (m['seasons'] as List?); + if (seasonsArr != null) { + for (final s in seasonsArr) { + final sn = ((s as Map)['season_number'] as num?)?.toInt(); + if (sn != null && sn > 0) allSeasons.add(sn); + } + } else if (m['season_status'] is String) { + final str = m['season_status'] as String; + for (final part in str.split('|')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + final sn = int.tryParse(seg.first); + if (sn != null && sn > 0) allSeasons.add(sn); + } + } + } + final seasonCols = allSeasons.toList()..sort(); + + // Compute per-season column widths based on widest row (episode count) + const double barW = 7; + const int cap = 120; + final maxSquares = {}; + for (final m in items) { + if (m['seasons_eps'] is String) { + final se = (m['seasons_eps'] as String); + for (final part in se.split(';')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + final sn = int.tryParse(seg.first); + if (sn == null) continue; + final list = seg.length > 1 ? seg[1] : ''; + final cnt = list.isEmpty ? 0 : list.split(',').length; + final v = cnt > cap ? cap : cnt; + if (!maxSquares.containsKey(sn) || v > (maxSquares[sn] ?? 0)) { + maxSquares[sn] = v; + } + } + } else if (m['seasons'] is List) { + for (final s in (m['seasons'] as List)) { + final mm = s as Map; + final sn = (mm['season_number'] as num?)?.toInt(); + if (sn == null) continue; + final init = (mm['init'] as num?)?.toInt() ?? 0; + final prog = (mm['progress'] as num?)?.toInt() ?? 0; + final done = (mm['done'] as num?)?.toInt() ?? 0; + var cnt = init + prog + done; + if (cnt > cap) cnt = cap; + if (!maxSquares.containsKey(sn) || cnt > (maxSquares[sn] ?? 0)) { + maxSquares[sn] = cnt; + } + } + } + } + final seasonWidths = { + for (final s in seasonCols) s: ((maxSquares[s] ?? 0) * barW).toDouble().clamp(40.0, 2000.0) + }; + double tableWidth = 360; + for (final s in seasonCols) { + tableWidth += 16 + (seasonWidths[s] ?? 74); + } + + Widget buildRow(String key) { + final data = byKey[key]!; + final name = displayOf(key); + final jsonStr = data['json'] as String?; + int? year; + if (jsonStr != null && jsonStr.isNotEmpty) { + try { + final m = jsonDecode(jsonStr) as Map; + final s = (m['first_air_date'] as String?) ?? ''; + if (s.length >= 4) year = int.tryParse(s.substring(0, 4)); + } catch (_) {} + } + final posterPath = data['poster_path'] as String?; + final resolution = data['resolution'] as String?; + final showStatus = () { + if (jsonStr != null) { + try { final m = jsonDecode(jsonStr) as Map; return m['status'] as String?; } catch (_) {} + } + return null; + }(); + final cliff = () { + final v = data['cliffhanger']; + if (v is bool) return v; if (v is num) return v != 0; if (v is String) { final s=v.toLowerCase(); return s=='1'||s=='true'||s=='yes'; } + return null; + }(); + + // Build seasons list from either 'seasons' array or compact 'season_status' string + List> seasons; + if (data['seasons'] is List) { + seasons = (data['seasons'] as List).cast>(); + } else if (data['season_status'] is String) { + final str = data['season_status'] as String; + seasons = []; + for (final part in str.split('|')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + if (seg.length < 2) continue; + final sn = int.tryParse(seg[0]); + final counts = seg[1].split(','); + final init = counts.length > 0 ? int.tryParse(counts[0]) ?? 0 : 0; + final prog = counts.length > 1 ? int.tryParse(counts[1]) ?? 0 : 0; + final done = counts.length > 2 ? int.tryParse(counts[2]) ?? 0 : 0; + final tot = counts.length > 3 ? int.tryParse(counts[3]) ?? 0 : (init + prog + done); + if (sn != null) { + seasons.add({'season_number': sn, 'init': init, 'progress': prog, 'done': done, 'total': tot}); + } + } + } else { + seasons = const []; + } + // Use any_progress/any_init flags if provided + bool anyProgress = () { + final v = data['any_progress']; + if (v is num) return v.toInt() != 0; if (v is bool) return v; if (v is String) return v == '1' || v.toLowerCase() == 'true'; + for (final s in seasons) { if (((s['progress'] as num?)?.toInt() ?? 0) > 0) return true; } + return false; + }(); + bool anyInit = () { + final v = data['any_init']; + if (v is num) return v.toInt() != 0; if (v is bool) return v; if (v is String) return v == '1' || v.toLowerCase() == 'true'; + for (final s in seasons) { if (((s['init'] as num?)?.toInt() ?? 0) > 0) return true; } + return false; + }(); + Color? bg; if (anyProgress) bg = Colors.blue; else if (anyInit) bg = Colors.grey.shade200; else bg = Colors.green; + + return InkWell( + onTap: () async { + final res = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SeriesDetailScreen( + showName: key, + displayName: name, + showId: int.tryParse(key.split('##').last), + seasonsEps: data['seasons_eps'] as String?, + year: year, + resolution: resolution, + posterPath: posterPath, + ), + ), + ); + // After returning from detail, refresh summary to ensure latest values + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left + Container( + color: bg, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + width: 360, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterPath != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ), + if (posterPath != null) const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _seriesTitle(name, year, showStatus, cliff, context)), + const SizedBox(width: 6), + if ((showStatus ?? '').isNotEmpty) _statusBadge(showStatus!, context), + ], + ), + const SizedBox(height: 4), + _resolutionInline(resolution, context), + ], + ), + ), + ], + ), + ), + // Seasons columns + for (final s in seasonCols) ...[ + const SizedBox(width: 16), + SizedBox( + width: seasonWidths[s] ?? 74, + child: Builder(builder: (_) { + // Prefer full episode statuses if provided via seasons_eps; fallback to counts + final epsStrAll = data['seasons_eps'] as String?; + if (epsStrAll != null && epsStrAll.isNotEmpty) { + // Find this season + String? matchStr; + for (final part in epsStrAll.split(';')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + final sn = int.tryParse(seg.first); + if (sn == s) { matchStr = seg.length > 1 ? seg[1] : ''; break; } + } + if (matchStr != null && matchStr.isNotEmpty) { + final eps = []; + int id = -1; + for (final eSeg in matchStr.split(',')) { + if (eSeg.isEmpty) continue; + final kv = eSeg.split('|'); + final epNo = kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0; + final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; + final st = stCode == 2 ? ItemStatus.Done : (stCode == 1 ? ItemStatus.Progress : ItemStatus.Init); + eps.add(EpisodeItem(id: id--, episodeNumber: epNo, seasonNumber: s, showName: name, status: st)); + } + if (eps.isNotEmpty) { + return EpisodeStatusStrip(episodes: eps, barWidth: 7, barHeight: 25, spacing: 0); + } + } + } + // Fallback: counts only + final match = seasons.firstWhere( + (e) => (e['season_number'] as num?)?.toInt() == s, + orElse: () => const {}, + ); + if (match.isEmpty) { + return const Text('-', textAlign: TextAlign.center); + } + final init = (match['init'] as num?)?.toInt() ?? 0; + final progress = (match['progress'] as num?)?.toInt() ?? 0; + final done = (match['done'] as num?)?.toInt() ?? 0; + return SeasonStatusBar( + seasonNumber: s, + init: init, + progress: progress, + done: done, + barWidth: 7, + barHeight: 25, + cap: 120, + ); + }), + ), + ], + ], + ), + ), + ); + } + + Widget buildHeader() { + return Container( + color: Theme.of(context).colorScheme.surface, + padding: const EdgeInsets.only(bottom: 8, top: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(width: 360, child: Text('Serie', style: Theme.of(context).textTheme.labelMedium)), + for (final s in seasonCols) ...[ + const SizedBox(width: 16), + SizedBox( + width: seasonWidths[s] ?? 74, + child: Center( + child: Text('Season $s', style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600)), + ), + ), + ] + ], + ), + ); + } + + const headerHeight = 32.0; + return Scrollbar( + thumbVisibility: true, + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: tableWidth, + height: constraints.maxHeight, + child: Stack( + children: [ + // Sticky header at the top; scrolls horizontally with content + Positioned( + top: 0, + left: 0, + right: 0, + child: SizedBox(height: headerHeight, child: buildHeader()), + ), + // Vertical list below the header + Positioned.fill( + top: headerHeight, + child: ListView.separated( + itemCount: keys.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => buildRow(keys[i]), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + Widget _resolutionInline(String? res, BuildContext context) { if (res == null || res.isEmpty) return const SizedBox.shrink(); final m = RegExp(r"\d+").firstMatch(res); @@ -484,4 +818,28 @@ class SeriesListScreen extends ConsumerWidget { final text = year != null ? '$name ($year)' : name; return Text(text, overflow: TextOverflow.ellipsis, style: style); } + + Widget _statusBadge(String status, BuildContext context) { + final s = status.toLowerCase(); + Color color; + if (s.contains('canceled') || s.contains('cancelled')) { + color = Colors.redAccent; + } else if (s.contains('ended')) { + color = Colors.grey; + } else { + color = Colors.blueGrey; // running/returning + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.6)), + ), + child: Text( + status, + style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurface), + ), + ); + } } diff --git a/lib/features/series/presentation/widgets/season_status_bar.dart b/lib/features/series/presentation/widgets/season_status_bar.dart new file mode 100644 index 0000000..054e4d1 --- /dev/null +++ b/lib/features/series/presentation/widgets/season_status_bar.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import '../../../../core/status.dart'; + +class SeasonStatusBar extends StatelessWidget { + final int seasonNumber; + final int init; + final int progress; + final int done; + final double barWidth; + final double barHeight; + final int cap; // max number of squares to render + + const SeasonStatusBar({ + super.key, + required this.seasonNumber, + required this.init, + required this.progress, + required this.done, + this.barWidth = 7, + this.barHeight = 25, + this.cap = 120, + }); + + Color _fill(ItemStatus s) { + switch (s) { + case ItemStatus.Init: + return Colors.grey.shade200; + case ItemStatus.Progress: + return Colors.blue; + case ItemStatus.Done: + return Colors.green; + } + } + + @override + Widget build(BuildContext context) { + final total = init + progress + done; + if (total <= 0) return const Text('-', textAlign: TextAlign.center); + + // Compute how many squares to render (cap at max) + final n = total > cap ? cap : total; + // Distribute counts proportionally to keep the visual feel + int pInit = ((init / total) * n).round(); + int pProg = ((progress / total) * n).round(); + int pDone = n - pInit - pProg; + if (pInit < 0) pInit = 0; if (pProg < 0) pProg = 0; if (pDone < 0) pDone = 0; + + final children = []; + for (int i = 0; i < pInit; i++) { + children.add(_square(_fill(ItemStatus.Init), i == 0)); + } + for (int i = 0; i < pProg; i++) { + children.add(_square(_fill(ItemStatus.Progress), pInit == 0 && i == 0)); + } + for (int i = 0; i < pDone; i++) { + children.add(_square(_fill(ItemStatus.Done), pInit == 0 && pProg == 0 && i == 0)); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: children), + ); + } + + Widget _square(Color color, bool isFirst) { + return Container( + width: barWidth, + height: barHeight, + decoration: BoxDecoration( + color: color, + border: Border( + left: BorderSide(color: Colors.black, width: isFirst ? 1 : 0), + top: const BorderSide(color: Colors.black, width: 1), + right: const BorderSide(color: Colors.black, width: 1), + bottom: const BorderSide(color: Colors.black, width: 1), + ), + ), + ); + } +} + diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index 92e38bf..7e8d725 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -257,50 +257,27 @@ try { JOIN seasons se ON se.id = e.season_id JOIN shows sh ON sh.id = se.show_id WHERE 1=1"; - // Prefer extracting show status directly from JSON and include cliffhanger column if present - $selectWithYearJson = "SELECT e.*, - CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, - sh.resolution AS resolution, - sh.poster_path AS poster_path, - sh.first_air_year AS first_air_year, - sh.id AS show_id, - sh.download_path AS download_path, - JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status, - sh.cliffhanger AS show_cliffhanger, - sh.json AS show_json, - se.season_number, sh.name AS show_name ".$base; - $selectNoYearJson = "SELECT e.*, - CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, - sh.resolution AS resolution, - sh.poster_path AS poster_path, - NULL AS first_air_year, - sh.id AS show_id, - sh.download_path AS download_path, - JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status, - sh.cliffhanger AS show_cliffhanger, - sh.json AS show_json, - se.season_number, sh.name AS show_name ".$base; - // Fallback selects without JSON_EXTRACT (older MySQL) — frontend will parse from show_json - $selectWithYear = "SELECT e.*, - CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, - sh.resolution AS resolution, - sh.poster_path AS poster_path, - sh.first_air_year AS first_air_year, - sh.id AS show_id, - sh.download_path AS download_path, - sh.cliffhanger AS show_cliffhanger, - sh.json AS show_json, - se.season_number, sh.name AS show_name ".$base; - $selectNoYear = "SELECT e.*, - CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, - sh.resolution AS resolution, - sh.poster_path AS poster_path, - NULL AS first_air_year, - sh.id AS show_id, - sh.download_path AS download_path, - sh.cliffhanger AS show_cliffhanger, - sh.json AS show_json, - se.season_number, sh.name AS show_name ".$base; + // JSON-aware select (keine first_air_year-Spalte voraussetzen) + $selectJson = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + sh.id AS show_id, + sh.download_path AS download_path, + JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base; + // Fallback ohne JSON_EXTRACT (ältere MySQL-Versionen) + $selectNoJson = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + sh.id AS show_id, + sh.download_path AS download_path, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base; $run = function(string $sql) use ($pdo, $statusVal, $q, $offset, $limit) { $params = []; @@ -315,45 +292,18 @@ try { }; try { - $rows = $run($selectWithYearJson); + $rows = $run($selectJson); resp(['ok'=>true,'items'=>$rows]); } catch (Throwable $e) { $msg = $e->getMessage(); - if (strpos($msg, 'Unknown column') !== false) { - // Maybe first_air_year missing — try JSON version without year - try { - $rows = $run($selectNoYearJson); - resp(['ok'=>true,'items'=>$rows]); - } catch (Throwable $e2) { - $msg2 = $e2->getMessage(); - if (stripos($msg2, 'JSON_EXTRACT') !== false || stripos($msg2, 'Unknown function') !== false) { - // Fallback to non-JSON_EXTRACT selects - try { - $rows = $run($selectWithYear); - resp(['ok'=>true,'items'=>$rows]); - } catch (Throwable $e3) { - if (strpos($e3->getMessage(), 'Unknown column') !== false) { - $rows = $run($selectNoYear); - resp(['ok'=>true,'items'=>$rows]); - } else { throw $e3; } - } - } else { throw $e2; } - } - } elseif (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) { - // JSON functions not available - try { - $rows = $run($selectWithYear); - resp(['ok'=>true,'items'=>$rows]); - } catch (Throwable $e4) { - if (strpos($e4->getMessage(), 'Unknown column') !== false) { - $rows = $run($selectNoYear); - resp(['ok'=>true,'items'=>$rows]); - } else { throw $e4; } - } + if (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) { + // JSON-Funktionen nicht verfügbar -> Fallback ohne JSON + $rows = $run($selectNoJson); + resp(['ok'=>true,'items'=>$rows]); } else { throw $e; } } } - + // Movie list handling if ($type==='movie') { $statusVal = null; if ($status) { @@ -372,6 +322,134 @@ try { fail('unsupported type'); } + case 'get_series_summary': { + // Optional: increase GROUP_CONCAT limit for very large shows + try { $pdo->query('SET SESSION group_concat_max_len = 1048576'); } catch (Throwable $e) {} + $sql = " + SELECT + sh.id AS show_id, + sh.name, + sh.poster_path, + sh.resolution, + sh.download_path, + sh.cliffhanger, + sh.json, + sa.season_status, + sa.seasons_eps, + (af.progress_sum > 0) AS any_progress, + (af.init_sum > 0) AS any_init + FROM shows sh + LEFT JOIN ( + SELECT t.show_id, + GROUP_CONCAT(CONCAT(t.season_number, ':', t.init_cnt, ',', t.prog_cnt, ',', t.done_cnt, ',', t.total_cnt) + ORDER BY t.season_number SEPARATOR '|') AS season_status, + GROUP_CONCAT(CONCAT(t.season_number, ':', t.eps_list) + ORDER BY t.season_number SEPARATOR ';') AS seasons_eps + FROM ( + SELECT se.show_id, + se.season_number, + SUM(CASE WHEN e.status IS NULL OR e.status = 0 THEN 1 ELSE 0 END) AS init_cnt, + SUM(CASE WHEN e.status = 1 THEN 1 ELSE 0 END) AS prog_cnt, + SUM(CASE WHEN e.status = 2 THEN 1 ELSE 0 END) AS done_cnt, + COUNT(e.id) AS total_cnt, + GROUP_CONCAT(CONCAT(e.episode_number, '|', COALESCE(e.status,0)) + ORDER BY e.episode_number SEPARATOR ',') AS eps_list + FROM seasons se + LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.season_number > 0 + GROUP BY se.show_id, se.season_number + ) AS t + GROUP BY t.show_id + ) AS sa ON sa.show_id = sh.id + LEFT JOIN ( + SELECT se.show_id, + SUM(CASE WHEN e.status = 1 THEN 1 ELSE 0 END) AS progress_sum, + SUM(CASE WHEN e.status IS NULL OR e.status = 0 THEN 1 ELSE 0 END) AS init_sum + FROM seasons se + LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.season_number > 0 + GROUP BY se.show_id + ) AS af ON af.show_id = sh.id + ORDER BY sh.name ASC"; + try { + $stmt = $pdo->query($sql); + $rows = $stmt->fetchAll(); + resp(['ok' => true, 'items' => $rows]); + } catch (Throwable $e) { + // Fallback for legacy schemas using column name 'state' instead of 'status' + $msg = $e->getMessage(); + if (strpos($msg, 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $sql2 = str_replace(['e.status', 'COALESCE(e.status,0)'], ['e.state', 'COALESCE(e.state,0)'], $sql); + $stmt = $pdo->query($sql2); + $rows = $stmt->fetchAll(); + resp(['ok' => true, 'items' => $rows]); + } else { + throw $e; + } + } + } + + case 'get_show_episodes': { + $showId = (int)($in['show_id'] ?? 0); + if (!$showId) fail('bad params'); + $base = "FROM episodes e + JOIN seasons se ON se.id = e.season_id + JOIN shows sh ON sh.id = se.show_id + WHERE se.show_id = ? AND se.season_number > 0"; + $selectJson = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + sh.id AS show_id, + sh.download_path AS download_path, + JSON_UNQUOTE(JSON_EXTRACT(sh.json, '$.status')) AS show_status, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base. + " ORDER BY se.season_number ASC, e.episode_number ASC"; + $selectNoJson = "SELECT e.*, + CASE e.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, + sh.resolution AS resolution, + sh.poster_path AS poster_path, + sh.id AS show_id, + sh.download_path AS download_path, + sh.cliffhanger AS show_cliffhanger, + sh.json AS show_json, + se.season_number, sh.name AS show_name ".$base. + " ORDER BY se.season_number ASC, e.episode_number ASC"; + try { + $stmt = $pdo->prepare($selectJson); + $stmt->execute([$showId]); + $rows = $stmt->fetchAll(); + resp(['ok'=>true,'items'=>$rows]); + } catch (Throwable $e) { + $msg = $e->getMessage(); + if (stripos($msg, 'JSON_EXTRACT') !== false || stripos($msg, 'Unknown function') !== false) { + $stmt = $pdo->prepare($selectNoJson); + $stmt->execute([$showId]); + $rows = $stmt->fetchAll(); + resp(['ok'=>true,'items'=>$rows]); + } + // Fallback for legacy 'state' column instead of 'status' + if (strpos($msg, 'Unknown column') !== false || ($e instanceof PDOException && $e->getCode()==='42S22')) { + $selectJson2 = str_replace('e.status', 'e.state', $selectJson); + try { + $stmt = $pdo->prepare($selectJson2); + $stmt->execute([$showId]); + $rows = $stmt->fetchAll(); + resp(['ok'=>true,'items'=>$rows]); + } catch (Throwable $e2) { + $selectNoJson2 = str_replace('e.status', 'e.state', $selectNoJson); + $stmt = $pdo->prepare($selectNoJson2); + $stmt->execute([$showId]); + $rows = $stmt->fetchAll(); + resp(['ok'=>true,'items'=>$rows]); + } + } + throw $e; + } + } + case 'get_show_by_tmdb': { $tmdbId = (int)($in['tmdb_id'] ?? 0); if (!$tmdbId) fail('bad params');