diff --git a/lib/pages/member_audio/widgets/item.dart b/lib/pages/member_audio/widgets/item.dart index 39c92e3d0..6d2094310 100644 --- a/lib/pages/member_audio/widgets/item.dart +++ b/lib/pages/member_audio/widgets/item.dart @@ -25,7 +25,7 @@ class MemberAudioItem extends StatelessWidget { onTap: () async { // TODO music play final aid = item.aid; - if (aid != null) { + if (aid != null && aid != 0) { final cid = await SearchHttp.ab2c(aid: aid); if (cid != null) { PageUtils.toVideoPage(cid: cid, aid: aid); diff --git a/lib/pages/setting/models/video_settings.dart b/lib/pages/setting/models/video_settings.dart index e8bf4546a..62f42f354 100644 --- a/lib/pages/setting/models/video_settings.dart +++ b/lib/pages/setting/models/video_settings.dart @@ -7,7 +7,7 @@ import 'package:PiliPlus/models/common/video/live_quality.dart'; import 'package:PiliPlus/models/common/video/video_decode_type.dart'; import 'package:PiliPlus/models/common/video/video_quality.dart'; import 'package:PiliPlus/pages/setting/models/model.dart'; -import 'package:PiliPlus/pages/setting/widgets/multi_select_dialog.dart'; +import 'package:PiliPlus/pages/setting/widgets/ordered_multi_select_dialog.dart'; import 'package:PiliPlus/pages/setting/widgets/select_dialog.dart'; import 'package:PiliPlus/plugin/pl_player/models/hwdec_type.dart'; import 'package:PiliPlus/utils/storage.dart'; @@ -345,10 +345,10 @@ List get videoSettings => [ leading: const Icon(Icons.memory_outlined), getSubtitle: () => '当前:${Pref.hardwareDecoding}(此项即mpv的--hwdec)', onTap: (setState) async { - final result = await showDialog>( + final result = await showDialog>( context: Get.context!, builder: (context) { - return MultiSelectDialog( + return OrderedMultiSelectDialog( title: '硬解模式', initValues: Pref.hardwareDecoding.split(','), values: { diff --git a/lib/pages/setting/widgets/checkbox_num.dart b/lib/pages/setting/widgets/checkbox_num.dart new file mode 100644 index 000000000..c57c03aad --- /dev/null +++ b/lib/pages/setting/widgets/checkbox_num.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class OrderedCheckbox extends StatelessWidget { + const OrderedCheckbox({ + super.key, + required this.value, + required this.onChanged, + }) : assert(value == null || value < 100); + + final int? value; + final ValueChanged? onChanged; + bool get selected => value != null; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final child = DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(1.5)), + border: Border.all( + color: selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + width: 1.6, + strokeAlign: BorderSide.strokeAlignCenter, + ), + color: selected ? theme.colorScheme.primary : null, + ), + child: selected + ? SizedBox.square( + dimension: 16.5, + child: Center( + child: Text( + value.toString(), + style: TextStyle( + inherit: false, + color: theme.colorScheme.onPrimary, + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: theme.iconTheme.shadows, + height: 1.0, + leadingDistribution: TextLeadingDistribution.even, + ), + ), + ), + ) + : const SizedBox.square(dimension: 16.5), + ); + if (onChanged != null) { + return InkWell( + onTap: () => onChanged!(value), + child: child, + ); + } + return child; + } +} diff --git a/lib/pages/setting/widgets/checkbox_num_list_tile.dart b/lib/pages/setting/widgets/checkbox_num_list_tile.dart new file mode 100644 index 000000000..1bf5113aa --- /dev/null +++ b/lib/pages/setting/widgets/checkbox_num_list_tile.dart @@ -0,0 +1,214 @@ +import 'package:PiliPlus/pages/setting/widgets/checkbox_num.dart'; +import 'package:flutter/material.dart'; + +class OrderedCheckboxListTile extends StatelessWidget { + /// Creates a combination of a list tile and a checkbox. + /// + /// The checkbox tile itself does not maintain any state. Instead, when the + /// state of the checkbox changes, the widget calls the [onChanged] callback. + /// Most widgets that use a checkbox will listen for the [onChanged] callback + /// and rebuild the checkbox tile with a new [value] to update the visual + /// appearance of the checkbox. + /// + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked. The [value] + /// can only be null if [tristate] is true. + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. + const OrderedCheckboxListTile({ + super.key, + required this.value, + required this.onChanged, + this.activeColor, + this.visualDensity, + this.focusNode, + this.autofocus = false, + this.shape, + this.tileColor, + this.title, + this.subtitle, + this.isThreeLine, + this.dense, + this.trailing, + this.contentPadding, + this.selectedTileColor, + this.onFocusChange, + this.enableFeedback, + this.checkboxScaleFactor = 1.0, + this.titleAlignment, + this.internalAddSemanticForOnTap = false, + }) : assert(isThreeLine != true || subtitle != null); + + /// Whether this checkbox is checked. + final int? value; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox tile with the + /// new value. + /// + /// If null, the checkbox will be displayed as disabled. + /// + /// {@tool snippet} + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// CheckboxListTile( + /// value: _throwShotAway, + /// onChanged: (bool? newValue) { + /// setState(() { + /// _throwShotAway = newValue; + /// }); + /// }, + /// title: const Text('Throw away your shot'), + /// ) + /// ``` + /// {@end-tool} + final ValueChanged? onChanged; + + /// The color to use when this checkbox is checked. + /// + /// Defaults to [ColorScheme.secondary] of the current [Theme]. + final Color? activeColor; + + /// Defines how compact the list tile's layout will be. + /// + /// {@macro flutter.material.themedata.visualDensity} + final VisualDensity? visualDensity; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.material.ListTile.shape} + final ShapeBorder? shape; + + /// {@macro flutter.material.ListTile.tileColor} + final Color? tileColor; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget? subtitle; + + /// A widget to display on the opposite side of the tile from the checkbox. + /// + /// Typically an [Icon] widget. + final Widget? trailing; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If null, the value from [ListTileThemeData.isThreeLine] is used. + /// If that is also null, the value from [ThemeData.listTileTheme] is used. + /// If still null, the default value is `false`. + final bool? isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileThemeData.dense]. + final bool? dense; + + /// Defines insets surrounding the tile's contents. + /// + /// This value will surround the [Checkbox], [title], [subtitle], and [trailing] + /// widgets in [OrderedCheckboxListTile]. + /// + /// When the value is null, the [contentPadding] is `EdgeInsets.symmetric(horizontal: 16.0)`. + final EdgeInsetsGeometry? contentPadding; + + /// If non-null, defines the background color when [OrderedCheckboxListTile.selected] is true. + final Color? selectedTileColor; + + /// {@macro flutter.material.inkwell.onFocusChange} + final ValueChanged? onFocusChange; + + /// {@macro flutter.material.ListTile.enableFeedback} + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool? enableFeedback; + + /// Defines how [ListTile.leading] and [ListTile.trailing] are + /// vertically aligned relative to the [ListTile]'s titles + /// ([ListTile.title] and [ListTile.subtitle]). + /// + /// If this property is null then [ListTileThemeData.titleAlignment] + /// is used. If that is also null then [ListTileTitleAlignment.threeLine] + /// is used. + /// + /// See also: + /// + /// * [ListTileTheme.of], which returns the nearest [ListTileTheme]'s + /// [ListTileThemeData]. + final ListTileTitleAlignment? titleAlignment; + + /// Whether to add button:true to the semantics if onTap is provided. + /// This is a temporary flag to help changing the behavior of ListTile onTap semantics. + /// + // TODO(hangyujin): Remove this flag after fixing related g3 tests and flipping + // the default value to true. + final bool internalAddSemanticForOnTap; + + /// Controls the scaling factor applied to the [Checkbox] within the [OrderedCheckboxListTile]. + /// + /// Defaults to 1.0. + final double checkboxScaleFactor; + + @override + Widget build(BuildContext context) { + Widget control; + + control = OrderedCheckbox(value: value, onChanged: null); + if (checkboxScaleFactor != 1.0) { + control = Transform.scale(scale: checkboxScaleFactor, child: control); + } + + final ThemeData theme = Theme.of(context); + final CheckboxThemeData checkboxTheme = CheckboxTheme.of(context); + final Set states = { + if (value != null) WidgetState.selected, + }; + final Color effectiveActiveColor = + activeColor ?? + checkboxTheme.fillColor?.resolve(states) ?? + theme.colorScheme.secondary; + return MergeSemantics( + child: ListTile( + selectedColor: effectiveActiveColor, + leading: control, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + enabled: onChanged != null, + onTap: onChanged != null ? () => onChanged!(value) : null, + selected: value != null, + autofocus: autofocus, + contentPadding: contentPadding, + shape: shape, + selectedTileColor: selectedTileColor, + tileColor: tileColor, + visualDensity: visualDensity, + focusNode: focusNode, + onFocusChange: onFocusChange, + enableFeedback: enableFeedback, + titleAlignment: titleAlignment, + internalAddSemanticForOnTap: internalAddSemanticForOnTap, + ), + ); + } +} diff --git a/lib/pages/setting/widgets/multi_select_dialog.dart b/lib/pages/setting/widgets/multi_select_dialog.dart index 482a85d50..d39550dd6 100644 --- a/lib/pages/setting/widgets/multi_select_dialog.dart +++ b/lib/pages/setting/widgets/multi_select_dialog.dart @@ -33,12 +33,12 @@ class _MultiSelectDialogState extends State> { clipBehavior: Clip.hardEdge, title: Text(widget.title), contentPadding: const EdgeInsets.only(top: 12), - content: StatefulBuilder( - builder: (context, StateSetter setState) { - return SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: widget.values.entries.map((i) { + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.values.entries.map((i) { + return Builder( + builder: (context) { bool isChecked = _tempValues.contains(i.key); return CheckboxListTile( dense: true, @@ -52,13 +52,13 @@ class _MultiSelectDialogState extends State> { isChecked ? _tempValues.remove(i.key) : _tempValues.add(i.key); - setState(() {}); + (context as Element).markNeedsBuild(); }, ); - }).toList(), - ), - ); - }, + }, + ); + }).toList(), + ), ), actionsPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 12), actions: [ diff --git a/lib/pages/setting/widgets/ordered_multi_select_dialog.dart b/lib/pages/setting/widgets/ordered_multi_select_dialog.dart new file mode 100644 index 000000000..a59467a8d --- /dev/null +++ b/lib/pages/setting/widgets/ordered_multi_select_dialog.dart @@ -0,0 +1,96 @@ +import 'package:PiliPlus/pages/setting/widgets/checkbox_num_list_tile.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class OrderedMultiSelectDialog extends StatefulWidget { + final Iterable initValues; + final String title; + final Map values; + + const OrderedMultiSelectDialog({ + super.key, + required this.initValues, + required this.values, + required this.title, + }); + + @override + State> createState() => + _OrderedMultiSelectDialogState(); +} + +class _OrderedMultiSelectDialogState + extends State> { + late Map _tempValues; + + @override + void initState() { + super.initState(); + _tempValues = {for (var (i, j) in widget.initValues.indexed) j: i + 1}; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + clipBehavior: Clip.hardEdge, + title: Text(widget.title), + contentPadding: const EdgeInsets.only(top: 12), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.values.entries.map((i) { + return Builder( + builder: (context) { + return OrderedCheckboxListTile( + dense: true, + value: _tempValues[i.key], + title: Text( + i.value, + style: theme.textTheme.titleMedium!, + ), + onChanged: (value) { + if (value == null) { + _tempValues[i.key] = _tempValues.length + 1; + (context as Element).markNeedsBuild(); + } else { + final pos = _tempValues.remove(i.key)!; + if (pos == _tempValues.length + 1) { + (context as Element).markNeedsBuild(); + } else { + _tempValues.updateAll( + (key, value) => value > pos ? value - 1 : value, + ); + setState(() {}); + } + } + }, + ); + }, + ); + }).toList(), + ), + ), + actionsPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 12), + actions: [ + TextButton( + onPressed: Get.back, + child: Text( + '取消', + style: TextStyle( + color: theme.colorScheme.outline, + ), + ), + ), + TextButton( + onPressed: () { + assert(_tempValues.values.isSorted((a, b) => a.compareTo(b))); + Get.back(result: _tempValues.keys.toList()); + }, + child: const Text('确定'), + ), + ], + ); + } +} diff --git a/lib/pages/settings_search/view.dart b/lib/pages/settings_search/view.dart index 0cc5c8281..c36853b30 100644 --- a/lib/pages/settings_search/view.dart +++ b/lib/pages/settings_search/view.dart @@ -47,7 +47,10 @@ class _SettingsSearchPageState (item.title ?? item.getTitle!()).toLowerCase().contains( value, ) || - item.subtitle?.toLowerCase().contains(value) == true, + (item.subtitle ?? item.getSubtitle?.call()) + ?.toLowerCase() + .contains(value) == + true, ) .toList(); } diff --git a/lib/pages/video/widgets/header_control.dart b/lib/pages/video/widgets/header_control.dart index ef31075f6..5fabf7385 100644 --- a/lib/pages/video/widgets/header_control.dart +++ b/lib/pages/video/widgets/header_control.dart @@ -648,71 +648,77 @@ class HeaderControlState extends TripleState { clipBehavior: Clip.hardEdge, color: theme.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(12)), - child: ListView( - padding: EdgeInsets.zero, - children: [ - SizedBox( - height: 45, - child: GestureDetector( - onTap: () => SmartDialog.showToast( - '标灰画质需要bilibili会员(已是会员?请关闭无痕模式);4k和杜比视界播放效果可能不佳', - ), - child: Row( - spacing: 8, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('选择画质', style: titleStyle), - Icon( - Icons.info_outline, - size: 16, - color: theme.colorScheme.outline, - ), - ], + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: SizedBox( + height: 45, + child: GestureDetector( + onTap: () => SmartDialog.showToast( + '标灰画质需要bilibili会员(已是会员?请关闭无痕模式);4k和杜比视界播放效果可能不佳', + ), + child: Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('选择画质', style: titleStyle), + Icon( + Icons.info_outline, + size: 16, + color: theme.colorScheme.outline, + ), + ], + ), ), ), ), - ...List.generate(totalQaSam, (index) { - final item = videoFormat[index]; - return ListTile( - dense: true, - onTap: () async { - if (currentVideoQa.code == item.quality) { - return; - } - Get.back(); - final int quality = item.quality!; - final newQa = VideoQuality.fromCode(quality); - videoDetailCtr - ..currentVideoQa.value = newQa - ..updatePlayer(); + SliverList.builder( + itemCount: totalQaSam, + itemBuilder: (context, index) { + final item = videoFormat[index]; + return ListTile( + dense: true, + onTap: () async { + if (currentVideoQa.code == item.quality) { + return; + } + Get.back(); + final int quality = item.quality!; + final newQa = VideoQuality.fromCode(quality); + videoDetailCtr + ..currentVideoQa.value = newQa + ..updatePlayer(); - SmartDialog.showToast("画质已变为:${newQa.desc}"); + SmartDialog.showToast("画质已变为:${newQa.desc}"); - // update - if (!plPlayerController.tempPlayerConf) { - setting.put( - await Utils.isWiFi - ? SettingBoxKey.defaultVideoQa - : SettingBoxKey.defaultVideoQaCellular, - quality, - ); - } - }, - // 可能包含会员解锁画质 - enabled: index >= totalQaSam - userfulQaSam, - contentPadding: const EdgeInsets.only(left: 20, right: 20), - title: Text(item.newDesc!), - trailing: currentVideoQa.code == item.quality - ? Icon( - Icons.done, - color: theme.colorScheme.primary, - ) - : Text( - item.format!, - style: subTitleStyle, - ), - ); - }), + // update + if (!plPlayerController.tempPlayerConf) { + setting.put( + await Utils.isWiFi + ? SettingBoxKey.defaultVideoQa + : SettingBoxKey.defaultVideoQaCellular, + quality, + ); + } + }, + // 可能包含会员解锁画质 + enabled: index >= totalQaSam - userfulQaSam, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + title: Text(item.newDesc!), + trailing: currentVideoQa.code == item.quality + ? Icon( + Icons.done, + color: theme.colorScheme.primary, + ) + : Text( + item.format!, + style: subTitleStyle, + ), + ); + }, + ), ], ), ), @@ -734,55 +740,62 @@ class HeaderControlState extends TripleState { clipBehavior: Clip.hardEdge, color: theme.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(12)), - child: ListView( - padding: EdgeInsets.zero, - children: [ - const SizedBox( - height: 45, - child: Center( - child: Text('选择音质', style: titleStyle), + child: CustomScrollView( + slivers: [ + const SliverToBoxAdapter( + child: SizedBox( + height: 45, + child: Center( + child: Text('选择音质', style: titleStyle), + ), ), ), - for (final AudioItem i in audio) ...[ - ListTile( - dense: true, - onTap: () async { - if (currentAudioQa.code == i.id) { - return; - } - Get.back(); - final int quality = i.id!; - final newQa = AudioQuality.fromCode(quality); - videoDetailCtr - ..currentAudioQa = newQa - ..updatePlayer(); + SliverList.builder( + itemCount: audio.length, + itemBuilder: (context, index) { + final i = audio[index]; + return ListTile( + dense: true, + onTap: () async { + if (currentAudioQa.code == i.id) { + return; + } + Get.back(); + final int quality = i.id!; + final newQa = AudioQuality.fromCode(quality); + videoDetailCtr + ..currentAudioQa = newQa + ..updatePlayer(); - SmartDialog.showToast("音质已变为:${newQa.desc}"); + SmartDialog.showToast("音质已变为:${newQa.desc}"); - // update - if (!plPlayerController.tempPlayerConf) { - setting.put( - await Utils.isWiFi - ? SettingBoxKey.defaultAudioQa - : SettingBoxKey.defaultAudioQaCellular, - quality, - ); - } - }, - contentPadding: const EdgeInsets.only(left: 20, right: 20), - title: Text(i.quality), - subtitle: Text( - i.codecs!, - style: subTitleStyle, - ), - trailing: currentAudioQa.code == i.id - ? Icon( - Icons.done, - color: theme.colorScheme.primary, - ) - : null, - ), - ], + // update + if (!plPlayerController.tempPlayerConf) { + setting.put( + await Utils.isWiFi + ? SettingBoxKey.defaultAudioQa + : SettingBoxKey.defaultAudioQaCellular, + quality, + ); + } + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + title: Text(i.quality), + subtitle: Text( + i.codecs!, + style: subTitleStyle, + ), + trailing: currentAudioQa.code == i.id + ? Icon( + Icons.done, + color: theme.colorScheme.primary, + ) + : null, + ); + }, + ), ], ), ), @@ -825,41 +838,41 @@ class HeaderControlState extends TripleState { ), ), Expanded( - child: ListView( - padding: EdgeInsets.zero, - children: [ - for (var i in list) ...[ - ListTile( - dense: true, - onTap: () { - if (currentDecodeFormats.codes.any(i.startsWith)) { - return; - } - videoDetailCtr - ..currentDecodeFormats = - VideoDecodeFormatType.fromString(i) - ..updatePlayer(); - Get.back(); - }, - contentPadding: const EdgeInsets.only( - left: 20, - right: 20, - ), - title: Text( - VideoDecodeFormatType.fromString(i).description, - ), - subtitle: Text( - i, - style: subTitleStyle, - ), - trailing: currentDecodeFormats.codes.any(i.startsWith) - ? Icon( - Icons.done, - color: theme.colorScheme.primary, - ) - : null, - ), - ], + child: CustomScrollView( + slivers: [ + SliverList.builder( + itemCount: list.length, + itemBuilder: (context, index) { + final i = list[index]; + final format = VideoDecodeFormatType.fromString(i); + return ListTile( + dense: true, + onTap: () { + if (currentDecodeFormats.codes.any( + i.startsWith, + )) { + return; + } + videoDetailCtr + ..currentDecodeFormats = format + ..updatePlayer(); + Get.back(); + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + title: Text(format.description), + subtitle: Text(i, style: subTitleStyle), + trailing: + currentDecodeFormats.codes.any(i.startsWith) + ? Icon( + Icons.done, + color: theme.colorScheme.primary, + ) + : null, + ); + }, + ), ], ), ), @@ -1845,32 +1858,39 @@ class HeaderControlState extends TripleState { clipBehavior: Clip.hardEdge, color: theme.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(12)), - child: ListView( - padding: EdgeInsets.zero, - children: [ - const SizedBox( - height: 45, - child: Center( - child: Text('选择播放顺序', style: titleStyle), + child: CustomScrollView( + slivers: [ + const SliverToBoxAdapter( + child: SizedBox( + height: 45, + child: Center( + child: Text('选择播放顺序', style: titleStyle), + ), ), ), - for (final PlayRepeat i in PlayRepeat.values) ...[ - ListTile( - dense: true, - onTap: () { - plPlayerController.setPlayRepeat(i); - Get.back(); - }, - contentPadding: const EdgeInsets.only(left: 20, right: 20), - title: Text(i.desc), - trailing: plPlayerController.playRepeat == i - ? Icon( - Icons.done, - color: theme.colorScheme.primary, - ) - : null, - ), - ], + SliverList.builder( + itemCount: PlayRepeat.values.length, + itemBuilder: (context, index) { + final i = PlayRepeat.values[index]; + return ListTile( + dense: true, + onTap: () { + plPlayerController.setPlayRepeat(i); + Get.back(); + }, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + title: Text(i.desc), + trailing: plPlayerController.playRepeat == i + ? Icon( + Icons.done, + color: theme.colorScheme.primary, + ) + : null, + ); + }, + ), ], ), ),