Beschreibung bei Suche, Hover

main
Herwig Birke 2 months ago
parent 731dfc6b54
commit 0066143712

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'features/movies/presentation/movie_list_screen.dart';
import 'features/series/presentation/series_list_screen.dart';
import 'features/import/import_screen.dart';
import 'features/ping/ping_test_screen.dart';
class MultimediaApp extends StatelessWidget {
const MultimediaApp({super.key});
@ -32,7 +31,7 @@ class _HomeTabsState extends State<_HomeTabs>
@override
void initState() {
super.initState();
_controller = TabController(length: 4, vsync: this);
_controller = TabController(length: 3, vsync: this);
}
@override
@ -52,7 +51,6 @@ class _HomeTabsState extends State<_HomeTabs>
Tab(text: 'Filme'),
Tab(text: 'Serien'),
Tab(text: 'Import'),
Tab(text: 'Ping'),
],
),
),
@ -62,7 +60,6 @@ class _HomeTabsState extends State<_HomeTabs>
const MovieListScreen(),
SeriesListScreen(),
const ImportScreen(),
const PingTestScreen(),
],
),
);

@ -145,6 +145,7 @@ class _MovieAddScreenState extends ConsumerState<MovieAddScreen> {
final year = release.length >= 4 ? release.substring(0, 4) : '';
final poster = m['poster_path'] as String?;
final sel = _selected.contains(id);
final overview = (m['overview'] as String?) ?? '';
return ListTile(
onTap: () {
setState(() {
@ -167,6 +168,14 @@ class _MovieAddScreenState extends ConsumerState<MovieAddScreen> {
)
: const SizedBox(width: 50, height: 75),
title: Text(year.isNotEmpty ? '$title ($year)' : title),
subtitle: overview.isNotEmpty
? Text(
overview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: null,
isThreeLine: overview.isNotEmpty,
trailing: Checkbox(
value: sel,
onChanged: (v) {

@ -8,7 +8,6 @@ final episodeFilterProvider = StateProvider<ItemStatus?>((_) => null);
final seriesGroupedProvider =
FutureProvider.autoDispose<SeriesGroupedData>((ref) async {
final backend = ref.watch(backendApiProvider);
final st = ref.watch(episodeFilterProvider);
int pageSize = 1000;
var offset = 0;
final items = <EpisodeItem>[];
@ -18,8 +17,8 @@ final seriesGroupedProvider =
while (true) {
List<Map<String, dynamic>> page;
try {
// Always fetch all episodes; apply filtering at series-level in UI.
page = await backend.getEpisodes(
status: st?.name,
offset: offset,
limit: pageSize,
);

@ -89,9 +89,11 @@ class _SeriesAddScreenState extends ConsumerState<SeriesAddScreen> {
if (mounted) setState(() {});
} catch (_) {}
}
// Refresh series list/table
// Refresh series list/table (both grouped and summary views)
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
// ignore: unused_result
ref.invalidate(seriesSummaryProvider);
messenger.showSnackBar(
SnackBar(content: Text('$ok Serie(n) hinzugefügt')));
if (mounted) Navigator.of(context).pop();
@ -158,6 +160,7 @@ class _SeriesAddScreenState extends ConsumerState<SeriesAddScreen> {
final year = firstAir.length >= 4 ? firstAir.substring(0, 4) : '';
final poster = m['poster_path'] as String?;
final sel = _selected.contains(id);
final overview = (m['overview'] as String?) ?? '';
return ListTile(
onTap: () {
setState(() {
@ -179,8 +182,15 @@ class _SeriesAddScreenState extends ConsumerState<SeriesAddScreen> {
),
)
: const SizedBox(width: 50, height: 75),
title:
Text(year.isNotEmpty ? '$title ($year)' : title),
title: Text(year.isNotEmpty ? '$title ($year)' : title),
subtitle: overview.isNotEmpty
? Text(
overview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: null,
isThreeLine: overview.isNotEmpty,
trailing: Checkbox(
value: sel,
onChanged: (v) {

@ -507,11 +507,19 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
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'),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_statusMatrix(episodesSorted, ItemStatus.Init, 'Init'),
const SizedBox(height: 8),
_statusMatrix(episodesSorted, ItemStatus.Progress, 'Progress'),
const SizedBox(height: 8),
_statusMatrix(episodesSorted, ItemStatus.Done, 'Done'),
],
),
),
],
),
),
@ -519,9 +527,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
}
Widget _statusMatrix(List<EpisodeItem> eps, ItemStatus rowStatus, String label) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
@ -555,8 +561,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
),
],
],
),
);
);
}
Future<void> _chooseResolution() async {

@ -175,123 +175,6 @@ class SeriesListScreen extends ConsumerWidget {
],
),
),
const widgets.SizedBox(height: 6),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.build),
label: const Text('TMDB: Serie reparieren (IDs)'),
onPressed: () async {
final ctrl = TextEditingController();
final ids = await showDialog<List<int>>(
context: context,
builder: (ctx) => AlertDialog(
title:
const Text('TMDB IDs (Komma/Leerzeichen getrennt)'),
content: TextField(
controller: ctrl,
decoration:
const InputDecoration(hintText: 'z.B. 1396, 1399'),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Abbrechen')),
FilledButton(
onPressed: () {
final parts = ctrl.text.split(RegExp(r'[\s,;]+'));
final list = <int>[];
for (final p in parts) {
final v = int.tryParse(p.trim());
if (v != null) list.add(v);
}
Navigator.of(ctx).pop(list);
},
child: const Text('Start'),
),
],
),
);
if (ids == null || ids.isEmpty) return;
if (!context.mounted) return;
await showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
String current = '';
int idx = 0;
final int total = ids.length;
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
return StatefulBuilder(builder: (ctx, setState) {
Future<void> run() async {
try {
for (final tmdbId in ids) {
idx++;
current = 'tmdb:$tmdbId';
setState(() {});
try {
final showJson = await tmdb.getShow(tmdbId);
await backend.upsertShow(showJson);
final seasons =
(showJson['seasons'] as List? ?? const [])
.where((s) =>
(s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)[
'season_number'] as int)
.toList();
final dbShowId =
await backend.getShowDbIdByTmdbId(tmdbId);
if (dbShowId == null) continue;
for (final sNo in seasons) {
final seasonJson =
await tmdb.getSeason(tmdbId, sNo);
final seasonId = await backend.upsertSeason(
dbShowId, seasonJson);
final eps =
(seasonJson['episodes'] as List? ??
const [])
.cast<Map<String, dynamic>>();
for (final e in eps) {
await backend.upsertEpisode(seasonId, e);
}
}
} catch (_) {}
}
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
} finally {
if (ctx.mounted) Navigator.of(ctx).pop();
}
}
if (idx == 0) {
// ignore: discarded_futures
run();
}
return AlertDialog(
title: const Text('TMDB Reparatur'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Aktualisiere: $idx/$total'),
const widgets.SizedBox(height: 8),
Text(current,
maxLines: 2, overflow: TextOverflow.ellipsis),
const widgets.SizedBox(height: 12),
LinearProgressIndicator(
value: total == 0 ? null : idx / total),
],
),
);
});
},
);
},
),
),
const widgets.SizedBox(height: 12),
Expanded(
child: Builder(builder: (_) {
@ -419,6 +302,23 @@ class SeriesListScreen extends ConsumerWidget {
} else {
bg = Colors.green;
}
// Apply series-level filter rules
final f = filter;
final include = () {
if (f == null) return true;
switch (f) {
case ItemStatus.Progress:
return anyProgress;
case ItemStatus.Init:
return anyInit && !anyProgress;
case ItemStatus.Done:
return !anyInit && !anyProgress;
}
}();
if (!include) {
continue; // skip this series row
}
// Context menu handled by MenuAnchor (no manual showMenu)
final cells = <DataCell>[
@ -733,19 +633,25 @@ class SeriesListScreen extends ConsumerWidget {
onPressed: () async { final messenger = ScaffoldMessenger.of(context); if (downloadPath != null && downloadPath.isNotEmpty) { final ok = await openFolder(downloadPath); if (!ok) { messenger.showSnackBar(const SnackBar(content: Text('Pfad kann nicht geöffnet werden')));} } else { messenger.showSnackBar(const SnackBar(content: Text('Kein Download Path vorhanden')));} },
),
],
builder: (ctx, controller, child) => GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (_) => controller.open(),
onSecondaryTapUp: (_) => controller.open(),
onLongPress: () => controller.open(),
child: EpisodeStatusStrip(
episodes: eps,
barWidth: 7,
barHeight: 25,
spacing: 0,
builder: (ctx, controller, child) => GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (_) => controller.open(),
onSecondaryTapUp: (_) => controller.open(),
onLongPress: () => controller.open(),
child: Tooltip(
message: _tooltipForEpisodes(eps),
child: Align(
alignment: Alignment.centerLeft,
child: EpisodeStatusStrip(
episodes: eps,
barWidth: 5,
barHeight: 25,
spacing: 0,
),
),
),
),
),
));
}
}
@ -795,6 +701,72 @@ class SeriesListScreen extends ConsumerWidget {
Widget _buildFromSummary(
BuildContext context, WidgetRef ref, List<Map<String, dynamic>> items) {
final filter = ref.watch(episodeFilterProvider);
bool _includeByFilter(Map<String, dynamic> m) {
int initTotal = 0;
int progTotal = 0;
int doneTotal = 0;
List<Map<String, dynamic>> seasons;
if (m['seasons'] is List) {
seasons = (m['seasons'] as List).cast<Map<String, dynamic>>();
for (final s in seasons) {
initTotal += (s['init'] as num?)?.toInt() ?? 0;
progTotal += (s['progress'] as num?)?.toInt() ?? 0;
doneTotal += (s['done'] as num?)?.toInt() ?? 0;
}
} 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(':');
if (seg.length < 2) continue;
final counts = seg[1].split(',');
initTotal += counts.isNotEmpty ? int.tryParse(counts[0]) ?? 0 : 0;
progTotal += counts.length > 1 ? int.tryParse(counts[1]) ?? 0 : 0;
doneTotal += counts.length > 2 ? int.tryParse(counts[2]) ?? 0 : 0;
}
} else if (m['seasons_eps'] is String) {
// Fallback: derive counts from compact episode list
final se = (m['seasons_eps'] as String);
for (final part in se.split(';')) {
if (part.isEmpty) continue;
final seg = part.split(':');
if (seg.length < 2) continue;
final list = seg[1];
for (final eSeg in list.split(',')) {
if (eSeg.isEmpty) continue;
final kv = eSeg.split('|');
final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0;
if (stCode == 2) {
doneTotal++;
} else if (stCode == 1) {
progTotal++;
} else {
initTotal++;
}
}
}
}
final anyInit = initTotal > 0;
final anyProgress = progTotal > 0;
final anyDone = doneTotal > 0;
if (filter == null) return true;
switch (filter) {
case ItemStatus.Progress:
return anyProgress;
case ItemStatus.Init:
return anyInit && !anyProgress;
case ItemStatus.Done:
return !anyInit && !anyProgress && anyDone;
}
}
// Apply series-level filter
items = items.where(_includeByFilter).toList();
// Build and sort keys
final keys = <String>[];
final byKey = <String, Map<String, dynamic>>{};
@ -831,7 +803,7 @@ class SeriesListScreen extends ConsumerWidget {
final seasonCols = allSeasons.toList()..sort();
// Compute per-season column widths based on widest row (episode count)
const double barW = 7;
const double barW = 5;
const int cap = 120;
final maxSquares = <int, int>{};
for (final m in items) {
@ -1112,11 +1084,17 @@ class SeriesListScreen extends ConsumerWidget {
onSecondaryTapDown: (_) => controller.open(),
onSecondaryTapUp: (_) => controller.open(),
onLongPress: () => controller.open(),
child: EpisodeStatusStrip(
episodes: eps,
barWidth: 7,
barHeight: 25,
spacing: 0,
child: Tooltip(
message: _tooltipForEpisodes(eps),
child: Align(
alignment: Alignment.centerLeft,
child: EpisodeStatusStrip(
episodes: eps,
barWidth: 5,
barHeight: 25,
spacing: 0,
),
),
),
),
);
@ -1162,14 +1140,20 @@ class SeriesListScreen extends ConsumerWidget {
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,
return Tooltip(
message: 'Init: $init\nProgress: $progress\nDone: $done',
child: Align(
alignment: Alignment.centerLeft,
child: SeasonStatusBar(
seasonNumber: s,
init: init,
progress: progress,
done: done,
barWidth: 5,
barHeight: 25,
cap: 120,
),
),
);
}),
),
@ -1236,7 +1220,10 @@ class SeriesListScreen extends ConsumerWidget {
child: ListView.separated(
itemCount: keys.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) => buildRow(keys[i]),
itemBuilder: (_, i) => widgets.SizedBox(
height: 88,
child: buildRow(keys[i]),
),
),
),
],
@ -1321,6 +1308,30 @@ class SeriesListScreen extends ConsumerWidget {
}
}
String _tooltipForEpisodes(List<EpisodeItem> eps) {
final inits = <int>[];
final progs = <int>[];
final dones = <int>[];
for (final e in eps) {
switch (e.status) {
case ItemStatus.Init:
inits.add(e.episodeNumber);
break;
case ItemStatus.Progress:
progs.add(e.episodeNumber);
break;
case ItemStatus.Done:
dones.add(e.episodeNumber);
break;
}
}
inits.sort();
progs.sort();
dones.sort();
String fmt(List<int> l) => l.isEmpty ? '-' : l.join(', ');
return 'Init: ${fmt(inits)}\nProgress: ${fmt(progs)}\nDone: ${fmt(dones)}';
}
extension _SeriesContextMenu on SeriesListScreen {
List<Widget> _seriesMenuItems(
BuildContext context,
@ -1456,3 +1467,4 @@ extension _SeriesContextMenu on SeriesListScreen {

Loading…
Cancel
Save