File Details

This commit is contained in:
2025-10-30 12:23:37 +01:00
parent 65f1175ba7
commit 64354c6bd4
4 changed files with 435 additions and 1 deletions
+8
View File
@@ -136,6 +136,14 @@ class BackendApi {
});
}
Future<void> setMovieResolution({required int movieId, required String resolution}) async {
await _post({
'action': 'set_movie_resolution',
'movie_id': movieId,
'resolution': resolution,
});
}
Future<List<Map<String, dynamic>>> getEpisodes({
String? status,
String? q,
@@ -0,0 +1,412 @@
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/movie_repository.dart';
import '../data/movie_model.dart';
class MovieDetailScreen extends ConsumerStatefulWidget {
final Movie movie;
const MovieDetailScreen({super.key, required this.movie});
@override
ConsumerState<MovieDetailScreen> createState() => _MovieDetailScreenState();
}
class _MovieDetailScreenState extends ConsumerState<MovieDetailScreen> {
late ItemStatus _status;
Map<String, dynamic>? _tmdb;
bool _loading = false;
String? _resolution;
late ItemStatus _origStatus;
late String? _origResolution;
@override
void initState() {
super.initState();
_status = widget.movie.status;
_resolution = widget.movie.resolution;
_origStatus = widget.movie.status;
_origResolution = widget.movie.resolution;
_loadTmdb();
}
Future<void> _loadTmdb() async {
setState(() => _loading = true);
try {
final tmdb = ref.read(tmdbApiProvider);
final data = await tmdb.getMovie(widget.movie.tmdbId);
setState(() => _tmdb = data);
} catch (e) {
// ignore error, keep UI functional
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _setStatus(ItemStatus s) => setState(() => _status = s);
void _setResolution(String res) => setState(() => _resolution = res);
bool get _dirty => _status != _origStatus || _resolution != _origResolution;
Future<void> _save() async {
try {
final api = ref.read(backendApiProvider);
if (_status != _origStatus) {
await api.setStatus(type: 'movie', refId: widget.movie.id, status: _status.name);
}
if (_resolution != _origResolution && _resolution != null && _resolution!.isNotEmpty) {
await api.setMovieResolution(movieId: widget.movie.id, resolution: _resolution!);
}
if (mounted) {
// Refresh movie list so changes are visible when navigating back
// ignore: unused_result
ref.invalidate(moviesProvider);
// Update originals to hide FAB
setState(() {
_origStatus = _status;
_origResolution = _resolution;
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gespeichert')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Speichern fehlgeschlagen: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final m = widget.movie;
final backdrop = _tmdb?['backdrop_path'] as String?;
final poster = m.posterPath;
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(m.title),
),
floatingActionButton: _dirty
? FloatingActionButton.extended(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Speichern'),
)
: null,
body: Container(
decoration: (backdrop != null || poster != null)
? BoxDecoration(
image: DecorationImage(
image: NetworkImage(backdrop != null
? 'https://image.tmdb.org/t/p/w780$backdrop'
: 'https://image.tmdb.org/t/p/w342$poster'),
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 box
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 (m.posterPath != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: 'https://image.tmdb.org/t/p/w342${m.posterPath}',
width: 120,
height: 180,
fit: BoxFit.cover,
),
),
if (m.posterPath != null) const SizedBox(width: 12),
Expanded(
child: DefaultTextStyle(
style: const TextStyle(color: Colors.white),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
m.releaseYear != null ? '${m.title} (${m.releaseYear})' : m.title,
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: 12),
_statusSelector(_status),
],
),
),
),
],
),
),
const SizedBox(height: 16),
if ((m.overview ?? '').isNotEmpty)
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(12),
child: DefaultTextStyle(
style: const TextStyle(color: Colors.white),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Beschreibung', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
const SizedBox(height: 6),
Text(m.overview!),
],
),
),
),
const SizedBox(height: 16),
if (_tmdb != null)
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.35),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(12),
child: DefaultTextStyle(
style: const TextStyle(color: Colors.white),
child: _castAndCrew(),
),
),
],
),
),
),
);
}
Widget _statusSelector(ItemStatus current) {
final items = ItemStatus.values;
return Wrap(
spacing: 8,
children: [
for (final s in items)
ChoiceChip(
label: Text(s.name),
selected: current == s,
onSelected: (sel) {
if (sel) _setStatus(s);
},
),
],
);
}
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),
Text(label, style: const TextStyle(fontSize: 13, color: Colors.white)),
],
),
);
}
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) {
_setResolution(selected);
}
}
Widget _castAndCrew() {
final credits = _tmdb?['credits'] as Map<String, dynamic>?;
if (credits == null) return const SizedBox.shrink();
final cast = (credits['cast'] as List? ?? const [])
.cast<Map>()
.take(12)
.toList();
final crew = (credits['crew'] as List? ?? const [])
.cast<Map>()
.where((m) => (m['job'] == 'Director') || (m['job'] == 'Writer'))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Cast', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
const SizedBox(height: 8),
SizedBox(
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: cast.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final c = cast[i] as Map<String, dynamic>;
final name = c['name']?.toString() ?? '';
final character = c['character']?.toString() ?? '';
final profile = c['profile_path']?.toString();
return SizedBox(
width: 100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: profile != null
? Image.network('https://image.tmdb.org/t/p/w185$profile',
width: 100, height: 96, fit: BoxFit.cover)
: Container(
width: 100,
height: 96,
color: Colors.black12,
child: const Icon(Icons.person, size: 40),
),
),
const SizedBox(height: 6),
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white)),
Text(character,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
);
},
),
),
const SizedBox(height: 16),
Text('Crew', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
const SizedBox(height: 8),
SizedBox(
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: crew.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final cr = crew[i] as Map<String, dynamic>;
final name = cr['name']?.toString() ?? '';
final job = cr['job']?.toString() ?? '';
final profile = cr['profile_path']?.toString();
return SizedBox(
width: 100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: profile != null
? Image.network('https://image.tmdb.org/t/p/w185$profile',
width: 100, height: 96, fit: BoxFit.cover)
: Container(
width: 100,
height: 96,
color: Colors.black12,
child: const Icon(Icons.person, size: 40),
),
),
const SizedBox(height: 6),
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white)),
Text(job,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
);
},
),
),
],
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart';
import '../data/movie_repository.dart';
import 'widgets/status_chip.dart';
import 'movie_detail_screen.dart';
class MovieListScreen extends ConsumerWidget {
const MovieListScreen({super.key});
@@ -81,7 +82,11 @@ class MovieListScreen extends ConsumerWidget {
),
isThreeLine: true,
onTap: () {
// TODO: Detailseite / Status ändern
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => MovieDetailScreen(movie: m),
),
);
},
);
},
+9
View File
@@ -215,6 +215,15 @@ try {
resp(['ok'=>true]);
}
case 'set_movie_resolution': {
$movieId = (int)($in['movie_id'] ?? 0);
$res = $in['resolution'] ?? null;
if (!$movieId || !$res) fail('bad params');
$stmt = $pdo->prepare('UPDATE movies SET resolution = ? WHERE id = ?');
$stmt->execute([$res, $movieId]);
resp(['ok' => true]);
}
case 'get_list': {
$type=$in['type'] ?? 'movie';
$status=$in['status'] ?? null;