diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index 5198b76..b42f0e6 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -136,6 +136,14 @@ class BackendApi { }); } + Future setMovieResolution({required int movieId, required String resolution}) async { + await _post({ + 'action': 'set_movie_resolution', + 'movie_id': movieId, + 'resolution': resolution, + }); + } + Future>> getEpisodes({ String? status, String? q, diff --git a/lib/features/movies/presentation/movie_detail_screen.dart b/lib/features/movies/presentation/movie_detail_screen.dart new file mode 100644 index 0000000..e727712 --- /dev/null +++ b/lib/features/movies/presentation/movie_detail_screen.dart @@ -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 createState() => _MovieDetailScreenState(); +} + +class _MovieDetailScreenState extends ConsumerState { + late ItemStatus _status; + Map? _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 _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 _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 _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) { + _setResolution(selected); + } + } + + Widget _castAndCrew() { + final credits = _tmdb?['credits'] as Map?; + if (credits == null) return const SizedBox.shrink(); + final cast = (credits['cast'] as List? ?? const []) + .cast() + .take(12) + .toList(); + final crew = (credits['crew'] as List? ?? const []) + .cast() + .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; + 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; + 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)), + ], + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index 4712976..a46950a 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -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), + ), + ); }, ); }, diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index e632072..ba7fd28 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -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;