Series Detail Screen

This commit is contained in:
2025-10-30 15:13:02 +01:00
parent 64354c6bd4
commit a77e36c6ec
5 changed files with 537 additions and 1 deletions
+16
View File
@@ -186,5 +186,21 @@ class BackendApi {
final v = map['id'];
return v == null ? null : (v as num).toInt();
}
Future<void> setShowMeta({
required int showId,
String? resolution,
String? downloadPath,
bool? cliffhanger,
}) async {
final payload = <String, dynamic>{
'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);
}
}
@@ -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<String, dynamic> 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) {
@@ -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<SeriesDetailScreen> createState() => _SeriesDetailScreenState();
}
class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
Map<int, List<EpisodeItem>>? _seasons; // season -> episodes
String? _resolution;
String? _downloadPath;
bool? _cliffhanger;
int? _showId;
final Map<int, ItemStatus> _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<void> _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<void> _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 = <Future>[];
_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<void> _setSeasonAll(int season, ItemStatus s) async {
final eps = _seasons?[season] ?? const <EpisodeItem>[];
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<EpisodeItem> 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<EpisodeItem> 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<ItemStatus>(
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<void> _chooseResolution() async {
const options = ['320p', '576p', '720p', '1080p', '2160p'];
final selected = await showDialog<String>(
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)),
],
),
);
}
}
@@ -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(
+24
View File
@@ -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,