diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index b42f0e6..fa5c003 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -186,5 +186,21 @@ class BackendApi { final v = map['id']; return v == null ? null : (v as num).toInt(); } + + Future setShowMeta({ + required int showId, + String? resolution, + String? downloadPath, + bool? cliffhanger, + }) async { + final payload = { + 'action': 'set_show_meta', + 'show_id': showId, + if (resolution != null) 'resolution': resolution, + if (downloadPath != null) 'download_path': downloadPath, + if (cliffhanger != null) 'cliffhanger': cliffhanger, + }; + await _post(payload); + } } diff --git a/lib/features/series/data/episode_model.dart b/lib/features/series/data/episode_model.dart index b6adaf9..a5218b3 100644 --- a/lib/features/series/data/episode_model.dart +++ b/lib/features/series/data/episode_model.dart @@ -13,6 +13,8 @@ class EpisodeItem { final String? showStatus; final bool? showCliffhanger; final String? posterPath; + final int? showId; + final String? downloadPath; EpisodeItem({ required this.id, @@ -27,6 +29,8 @@ class EpisodeItem { this.showStatus, this.showCliffhanger, this.posterPath, + this.showId, + this.downloadPath, }); factory EpisodeItem.fromJson(Map j) => EpisodeItem( @@ -42,6 +46,8 @@ class EpisodeItem { showStatus: j['show_status'] as String?, showCliffhanger: _parseBool(j['show_cliffhanger']), posterPath: j['poster_path'] as String?, + showId: (j['show_id'] as num?)?.toInt(), + downloadPath: j['download_path'] as String?, ); static bool? _parseBool(dynamic v) { diff --git a/lib/features/series/presentation/series_detail_screen.dart b/lib/features/series/presentation/series_detail_screen.dart new file mode 100644 index 0000000..1c12e92 --- /dev/null +++ b/lib/features/series/presentation/series_detail_screen.dart @@ -0,0 +1,474 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/status.dart'; +import '../../shared/providers.dart'; +import '../data/series_repository.dart'; +import '../data/episode_model.dart'; + +class SeriesDetailScreen extends ConsumerStatefulWidget { + final String showName; + final int? year; + final String? resolution; + final String? posterPath; + const SeriesDetailScreen({ + super.key, + required this.showName, + this.year, + this.resolution, + this.posterPath, + }); + + @override + ConsumerState createState() => _SeriesDetailScreenState(); +} + +class _SeriesDetailScreenState extends ConsumerState { + Map>? _seasons; // season -> episodes + String? _resolution; + String? _downloadPath; + bool? _cliffhanger; + int? _showId; + final Map _pending = {}; // epId -> new status + bool _saving = false; + String? _origResolution; + String? _origDownloadPath; + bool? _origCliffhanger; + late final TextEditingController _downloadCtrl; + + @override + void initState() { + super.initState(); + _resolution = widget.resolution; + _downloadCtrl = TextEditingController(text: _downloadPath ?? ''); + _downloadCtrl.addListener(() { + final v = _downloadCtrl.text; + if (v != _downloadPath) { + setState(() => _downloadPath = v); + } + }); + _load(); + } + + Future _load() async { + 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 ?? ''; + } + } + } + }); + } + + Future _save() async { + if (_saving) return; + setState(() => _saving = true); + try { + final api = ref.read(backendApiProvider); + // ensure we have a showId from current data if missing + if (_showId == null && _seasons != null && _seasons!.isNotEmpty) { + final flat = _seasons!.values.expand((e) => e); + final first = flat.isEmpty ? null : flat.first; + if (first != null) { + _showId = first.showId; + } + } + // apply episode changes + final futures = []; + _pending.forEach((epId, st) { + futures.add(api.setStatus(type: 'episode', refId: epId, status: st.name)); + }); + // show meta + if (_showId != null && (_resolution != _origResolution || _downloadPath != _origDownloadPath || _cliffhanger != _origCliffhanger)) { + futures.add(api.setShowMeta( + showId: _showId!, + resolution: _resolution, + downloadPath: _downloadPath, + cliffhanger: _cliffhanger, + )); + } + await Future.wait(futures); + // refresh list/table + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + if (mounted) { + setState(() { + _pending.clear(); + _origResolution = _resolution; + _origDownloadPath = _downloadPath; + _origCliffhanger = _cliffhanger; + }); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gespeichert'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Speichern fehlgeschlagen: $e'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _setEpisodeStatus(EpisodeItem ep, ItemStatus s) { + setState(() { + _pending[ep.id] = s; + // reflect change in local list so radios don't jump back after save + _seasons = { + for (final e in _seasons!.entries) + e.key: e.value + .map((it) => it.id == ep.id + ? EpisodeItem( + id: it.id, + episodeNumber: it.episodeNumber, + seasonNumber: it.seasonNumber, + showName: it.showName, + name: it.name, + status: s, + resolution: it.resolution, + firstAirYear: it.firstAirYear, + showJson: it.showJson, + showStatus: it.showStatus, + showCliffhanger: it.showCliffhanger, + posterPath: it.posterPath, + showId: it.showId, + downloadPath: it.downloadPath, + ) + : it) + .toList(), + }; + }); + } + + Future _setSeasonAll(int season, ItemStatus s) async { + final eps = _seasons?[season] ?? const []; + if (eps.isEmpty) return; + setState(() { + _seasons = { + for (final e in _seasons!.entries) + e.key: e.key == season + ? e.value + .map((it) => EpisodeItem( + id: it.id, + episodeNumber: it.episodeNumber, + seasonNumber: it.seasonNumber, + showName: it.showName, + name: it.name, + status: s, + resolution: it.resolution, + firstAirYear: it.firstAirYear, + showJson: it.showJson, + showStatus: it.showStatus, + showCliffhanger: it.showCliffhanger, + posterPath: it.posterPath, + showId: it.showId, + downloadPath: it.downloadPath, + )) + .toList() + : e.value, + }; + for (final ep in eps) { + _pending[ep.id] = s; + } + }); + } + + @override + Widget build(BuildContext context) { + final seasons = _seasons; + final bgPoster = widget.posterPath; + final dirty = _pending.isNotEmpty || + _resolution != _origResolution || + _downloadPath != _origDownloadPath || + _cliffhanger != _origCliffhanger; + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text(widget.showName), + actions: [ + if (dirty) + IconButton( + icon: const Icon(Icons.save), + onPressed: _saving ? null : _save, + tooltip: 'Speichern', + ), + ], + ), + floatingActionButton: dirty + ? Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom > 0 + ? MediaQuery.of(context).viewInsets.bottom + 8 + : 0), + child: FloatingActionButton.extended( + onPressed: _saving ? null : _save, + icon: const Icon(Icons.save), + label: + _saving ? const Text('Speichern...') : const Text('Speichern'), + ), + ) + : null, + body: Container( + decoration: bgPoster != null + ? BoxDecoration( + image: DecorationImage( + image: NetworkImage('https://image.tmdb.org/t/p/w342$bgPoster'), + fit: BoxFit.cover, + ), + ) + : null, + foregroundDecoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.2), + Colors.black.withOpacity(0.6), + ], + ), + ), + child: SingleChildScrollView( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + kToolbarHeight + 12, + left: 12, + right: 12, + bottom: 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _header(context), + const SizedBox(height: 16), + if (seasons == null) + const Center(child: CircularProgressIndicator()) + else ...[ + for (final sNo in (seasons.keys.toList()..sort())) + _seasonBox(context, sNo, seasons[sNo]!) + ], + ], + ), + ), + ), + ); + } + + Widget _header(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.35), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.posterPath != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: 'https://image.tmdb.org/t/p/w342${widget.posterPath}', + width: 120, + height: 180, + fit: BoxFit.cover, + ), + ), + if (widget.posterPath != null) const SizedBox(width: 12), + Expanded( + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.year != null ? '${widget.showName} (${widget.year})' : widget.showName, + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white), + ), + const SizedBox(height: 8), + Row( + children: [ + GestureDetector( + onTap: _chooseResolution, + child: _resolutionBadge(_resolution, context), + ), + ], + ), + const SizedBox(height: 8), + TextField( + controller: _downloadCtrl, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Download Path', + labelStyle: TextStyle(color: Colors.white70), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Colors.white54)), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: Colors.white)), + ), + ), + Row( + children: [ + Checkbox( + value: _cliffhanger ?? false, + onChanged: (v) => setState(() => _cliffhanger = v ?? false), + checkColor: Colors.white, + side: const BorderSide(color: Colors.white70), + ), + const Text('Cliffhanger', style: TextStyle(color: Colors.white)), + ], + ), + ], + ), + ), + ) + ], + ), + ); + } + + Widget _seasonBox(BuildContext context, int seasonNo, List episodes) { + final episodesSorted = [...episodes]..sort((a, b) => a.episodeNumber.compareTo(b.episodeNumber)); + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.35), + borderRadius: BorderRadius.circular(12), + ), + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Staffel $seasonNo', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + const SizedBox(height: 8), + _statusMatrix(episodesSorted, ItemStatus.Init, 'Init'), + const SizedBox(height: 8), + _statusMatrix(episodesSorted, ItemStatus.Progress, 'Progress'), + const SizedBox(height: 8), + _statusMatrix(episodesSorted, ItemStatus.Done, 'Done'), + ], + ), + ), + ); + } + + Widget _statusMatrix(List eps, ItemStatus rowStatus, String label) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 110, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white70), + visualDensity: VisualDensity.compact, + ), + onPressed: () => _setSeasonAll(eps.first.seasonNumber, rowStatus), + child: Text('Alle $label'), + )), + const SizedBox(width: 12), + for (final ep in eps) ...[ + SizedBox( + width: 36, + child: Column( + children: [ + Radio( + value: rowStatus, + groupValue: (_pending[ep.id] ?? ep.status), + fillColor: const MaterialStatePropertyAll(Colors.white), + onChanged: (v) { + setState(() => _pending[ep.id] = rowStatus); + }, + ), + Text('${ep.episodeNumber}', style: const TextStyle(fontSize: 10, color: Colors.white70)), + ], + ), + ), + ], + ], + ), + ); + } + + Future _chooseResolution() async { + const options = ['320p', '576p', '720p', '1080p', '2160p']; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + backgroundColor: Colors.black.withOpacity(0.85), + title: const Text('Resolution wählen', style: TextStyle(color: Colors.white)), + children: [ + for (final opt in options) + SimpleDialogOption( + onPressed: () => Navigator.of(ctx).pop(opt), + child: Text(opt, style: const TextStyle(color: Colors.white)), + ), + ], + ), + ); + if (selected != null) { + setState(() => _resolution = selected); + } + } + + Widget _resolutionBadge(String? res, BuildContext context) { + if (res == null || res.isEmpty) return const SizedBox.shrink(); + final m = RegExp(r"\d+").firstMatch(res); + final v = m != null ? int.tryParse(m.group(0)!) : null; + IconData? icon; + Color color = Theme.of(context).colorScheme.primary; + String label = res; + if (v != null) { + if (v >= 2000) { + icon = Icons.four_k; + color = Colors.deepOrange; + label = '${v}p'; + } else if (v >= 1000) { + icon = Icons.hd; + color = Colors.blue; + label = '${v}p'; + } else { + icon = Icons.sd_card; + color = Colors.grey; + label = '${v}p'; + } + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.45), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.white.withOpacity(0.6)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) Icon(icon, size: 16, color: color), + if (icon != null) const SizedBox(width: 6), + const Text('Auflösung:', style: TextStyle(color: Colors.white70, fontSize: 12)), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 13, color: Colors.white)), + ], + ), + ); + } +} diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index d46d840..25db305 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -6,6 +6,7 @@ import '../../../core/status.dart'; import '../../movies/presentation/widgets/status_chip.dart'; import '../data/series_repository.dart'; import 'widgets/episode_status_strip.dart'; +import 'series_detail_screen.dart'; class SeriesListScreen extends ConsumerWidget { const SeriesListScreen({super.key}); @@ -160,7 +161,21 @@ class SeriesListScreen extends ConsumerWidget { } } - rows.add(DataRow(cells: cells)); + rows.add(DataRow( + cells: cells, + onSelectChanged: (_) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SeriesDetailScreen( + showName: showName, + year: year, + resolution: resolution, + posterPath: posterPath, + ), + ), + ); + }, + )); } return rows; } @@ -172,6 +187,7 @@ class SeriesListScreen extends ConsumerWidget { dataRowMinHeight: 88, dataRowMaxHeight: 96, columnSpacing: 16, + showCheckboxColumn: false, ); return SingleChildScrollView( diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index ba7fd28..1ec325c 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -247,6 +247,8 @@ try { 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, @@ -256,6 +258,8 @@ try { 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, @@ -266,6 +270,8 @@ try { 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; @@ -274,6 +280,8 @@ try { 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; @@ -357,6 +365,22 @@ try { resp(['ok' => true, 'id' => $id ? (int)$id : null]); } + case 'set_show_meta': { + $showId = (int)($in['show_id'] ?? 0); + if (!$showId) fail('bad params'); + $fields = []; + $params = []; + if (isset($in['resolution'])) { $fields[] = 'resolution = ?'; $params[] = $in['resolution']; } + if (array_key_exists('download_path', $in)) { $fields[] = 'download_path = ?'; $params[] = $in['download_path']; } + if (isset($in['cliffhanger'])) { $fields[] = 'cliffhanger = ?'; $params[] = (int)!!$in['cliffhanger']; } + if (empty($fields)) fail('no fields'); + $sql = 'UPDATE shows SET '.implode(', ', $fields).' WHERE id = ?'; + $params[] = $showId; + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + resp(['ok'=>true]); + } + case 'ping': { resp([ 'ok' => true,