Beschreibung bei Suche, Hover
This commit is contained in:
+1
-4
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user