jellyfin_genre_dialog.dart 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import '../../core/theme/app_colors.dart';
  4. import '../../core/widgets/app_icon.dart';
  5. import '../../data/models/jellyfin_library.dart';
  6. import '../../data/models/jellyfin_models.dart';
  7. import '../../data/services/jellyfin_library_service.dart';
  8. Future<List<JellyfinGenreSelection>?> showJellyfinGenreDialog(
  9. BuildContext context, {
  10. required JellyfinLibraryService library,
  11. required List<JellyfinGenreSelection> selected,
  12. }) {
  13. return showDialog<List<JellyfinGenreSelection>>(
  14. context: context,
  15. builder: (_) => JellyfinGenreDialog(library: library, selected: selected),
  16. );
  17. }
  18. /// Grouped multi-select for the Random row. At most five families; the server
  19. /// genre list is fetched each time so new tags appear after a library scan.
  20. class JellyfinGenreDialog extends StatefulWidget {
  21. const JellyfinGenreDialog({
  22. super.key,
  23. required this.library,
  24. required this.selected,
  25. });
  26. final JellyfinLibraryService library;
  27. final List<JellyfinGenreSelection> selected;
  28. @override
  29. State<JellyfinGenreDialog> createState() => _JellyfinGenreDialogState();
  30. }
  31. class _JellyfinGenreDialogState extends State<JellyfinGenreDialog> {
  32. static const _maxSelections = 5;
  33. final TextEditingController _search = TextEditingController();
  34. List<JellyfinGenreFamily>? _families;
  35. final Set<String> _selectedIds = {};
  36. bool _loading = true;
  37. String? _error;
  38. String _query = '';
  39. @override
  40. void initState() {
  41. super.initState();
  42. _selectedIds.addAll(widget.selected.map((selection) => selection.id));
  43. unawaited(_load());
  44. }
  45. @override
  46. void dispose() {
  47. _search.dispose();
  48. super.dispose();
  49. }
  50. Future<void> _load() async {
  51. setState(() {
  52. _loading = true;
  53. _error = null;
  54. });
  55. try {
  56. final families = await widget.library.genreFamilies(force: true);
  57. if (!mounted) return;
  58. final ids = families.map((family) => family.id).toSet();
  59. setState(() {
  60. _families = families;
  61. _selectedIds.removeWhere((id) => !ids.contains(id));
  62. _loading = false;
  63. });
  64. } on JellyfinException catch (exception) {
  65. if (!mounted) return;
  66. setState(() {
  67. _loading = false;
  68. _error = exception.message;
  69. });
  70. } catch (_) {
  71. if (!mounted) return;
  72. setState(() {
  73. _loading = false;
  74. _error = 'Could not load the music library.';
  75. });
  76. }
  77. }
  78. void _toggle(JellyfinGenreFamily family) {
  79. final selected = _selectedIds.contains(family.id);
  80. if (!selected && _selectedIds.length >= _maxSelections) return;
  81. setState(() {
  82. if (selected) {
  83. _selectedIds.remove(family.id);
  84. } else {
  85. _selectedIds.add(family.id);
  86. }
  87. });
  88. }
  89. void _save() {
  90. final families = _families;
  91. if (families == null) return;
  92. final selections = families
  93. .where((family) => _selectedIds.contains(family.id))
  94. .map(
  95. (family) =>
  96. JellyfinGenreSelection(id: family.id, label: family.label),
  97. )
  98. .toList(growable: false);
  99. Navigator.of(context).pop(selections);
  100. }
  101. @override
  102. Widget build(BuildContext context) {
  103. final colors = context.colors;
  104. final theme = Theme.of(context);
  105. final screen = MediaQuery.sizeOf(context);
  106. final width = (screen.width - 120).clamp(420.0, 680.0);
  107. final height = (screen.height - 160).clamp(320.0, 500.0);
  108. return AlertDialog(
  109. title: Row(
  110. children: [
  111. Text('Random genres', style: theme.textTheme.titleLarge),
  112. const Spacer(),
  113. Text(
  114. '${_selectedIds.length} / $_maxSelections',
  115. style: theme.textTheme.labelLarge?.copyWith(
  116. color: _selectedIds.length >= _maxSelections
  117. ? colors.accent
  118. : colors.inkMuted,
  119. ),
  120. ),
  121. ],
  122. ),
  123. content: SizedBox(
  124. width: width,
  125. height: height,
  126. child: _buildContent(context),
  127. ),
  128. actions: [
  129. TextButton(
  130. onPressed: () => Navigator.of(context).pop(),
  131. child: const Text('Cancel'),
  132. ),
  133. FilledButton(
  134. onPressed: _loading || _error != null ? null : _save,
  135. child: const Text('Save'),
  136. ),
  137. ],
  138. );
  139. }
  140. Widget _buildContent(BuildContext context) {
  141. final colors = context.colors;
  142. final theme = Theme.of(context);
  143. if (_loading) {
  144. return const Center(child: CircularProgressIndicator());
  145. }
  146. final error = _error;
  147. if (error != null) {
  148. return Center(
  149. child: Column(
  150. mainAxisSize: MainAxisSize.min,
  151. children: [
  152. Text(
  153. error,
  154. textAlign: TextAlign.center,
  155. style: theme.textTheme.bodyMedium?.copyWith(
  156. color: colors.inkMuted,
  157. ),
  158. ),
  159. const SizedBox(height: 10),
  160. TextButton(
  161. onPressed: _load,
  162. style: TextButton.styleFrom(foregroundColor: colors.accent),
  163. child: const Text('Retry'),
  164. ),
  165. ],
  166. ),
  167. );
  168. }
  169. final families = _families ?? const <JellyfinGenreFamily>[];
  170. if (families.isEmpty) {
  171. return Center(
  172. child: Text(
  173. 'No genres found in the music library.',
  174. style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
  175. ),
  176. );
  177. }
  178. final query = _query.trim().toLowerCase();
  179. final visible = query.isEmpty
  180. ? families
  181. : families
  182. .where(
  183. (family) =>
  184. family.label.toLowerCase().contains(query) ||
  185. family.rawGenres.any(
  186. (raw) => raw.toLowerCase().contains(query),
  187. ),
  188. )
  189. .toList(growable: false);
  190. final atLimit = _selectedIds.length >= _maxSelections;
  191. return Column(
  192. crossAxisAlignment: CrossAxisAlignment.stretch,
  193. children: [
  194. if (families.length > 12)
  195. Padding(
  196. padding: const EdgeInsets.only(bottom: 8),
  197. child: TextField(
  198. controller: _search,
  199. onChanged: (value) => setState(() => _query = value),
  200. style: theme.textTheme.bodyMedium,
  201. decoration: InputDecoration(
  202. hintText: 'Search genres',
  203. isDense: true,
  204. prefixIcon: Padding(
  205. padding: const EdgeInsets.only(left: 2),
  206. child: SizedBox(
  207. width: 18,
  208. height: 18,
  209. child: Center(
  210. child: AppIcon(
  211. 'assets/icons/search.svg',
  212. size: 15,
  213. color: colors.inkFaint,
  214. ),
  215. ),
  216. ),
  217. ),
  218. prefixIconConstraints: const BoxConstraints(
  219. minWidth: 34,
  220. minHeight: 34,
  221. ),
  222. ),
  223. ),
  224. ),
  225. if (atLimit)
  226. Padding(
  227. padding: const EdgeInsets.only(bottom: 4),
  228. child: Text(
  229. 'Deselect one to choose another.',
  230. style: theme.textTheme.bodySmall?.copyWith(
  231. color: colors.inkMuted,
  232. ),
  233. ),
  234. ),
  235. Expanded(
  236. child: visible.isEmpty
  237. ? Center(
  238. child: Text(
  239. 'No genres match that search.',
  240. style: theme.textTheme.bodySmall?.copyWith(
  241. color: colors.inkMuted,
  242. ),
  243. ),
  244. )
  245. : ListView.builder(
  246. itemCount: visible.length,
  247. itemBuilder: (context, index) {
  248. final family = visible[index];
  249. final selected = _selectedIds.contains(family.id);
  250. final enabled = selected || !atLimit;
  251. return _GenreFamilyRow(
  252. family: family,
  253. selected: selected,
  254. enabled: enabled,
  255. onTap: enabled ? () => _toggle(family) : null,
  256. );
  257. },
  258. ),
  259. ),
  260. ],
  261. );
  262. }
  263. }
  264. class _GenreFamilyRow extends StatelessWidget {
  265. const _GenreFamilyRow({
  266. required this.family,
  267. required this.selected,
  268. required this.enabled,
  269. required this.onTap,
  270. });
  271. final JellyfinGenreFamily family;
  272. final bool selected;
  273. final bool enabled;
  274. final VoidCallback? onTap;
  275. @override
  276. Widget build(BuildContext context) {
  277. final colors = context.colors;
  278. final theme = Theme.of(context);
  279. final count = family.rawCount;
  280. return InkWell(
  281. borderRadius: BorderRadius.circular(12),
  282. onTap: onTap,
  283. child: Padding(
  284. padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
  285. child: Row(
  286. children: [
  287. Checkbox(
  288. value: selected,
  289. onChanged: enabled ? (_) => onTap?.call() : null,
  290. activeColor: colors.accent,
  291. checkColor: colors.accentInk,
  292. side: BorderSide(color: colors.border),
  293. ),
  294. const SizedBox(width: 4),
  295. Expanded(
  296. child: Column(
  297. crossAxisAlignment: CrossAxisAlignment.start,
  298. children: [
  299. Text(
  300. family.label,
  301. style: theme.textTheme.bodyLarge?.copyWith(
  302. color: enabled ? colors.ink : colors.inkFaint,
  303. ),
  304. ),
  305. Text(
  306. '$count ${count == 1 ? 'genre' : 'genres'}',
  307. style: theme.textTheme.bodySmall?.copyWith(
  308. color: colors.inkMuted,
  309. ),
  310. ),
  311. ],
  312. ),
  313. ),
  314. ],
  315. ),
  316. ),
  317. );
  318. }
  319. }