Fix video controls, tag input and collection deletion

This commit is contained in:
2026-05-24 19:02:34 +03:00
parent 2868fdf314
commit 1adfc1fa0c
13 changed files with 327 additions and 216 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
const appDisplayVersion = '0.4.2';
const appBuildNumber = 6;
const appDisplayVersion = '0.4.3';
const appBuildNumber = 7;
@@ -34,7 +34,7 @@ class CollectionRepository {
.filter()
.collectionIdEqualTo(id)
.deleteAll();
await isar.collectionEntitys.filter().collectionIdEqualTo(id).deleteAll();
await isar.collectionEntitys.deleteByCollectionId(id);
});
}
@@ -347,6 +347,11 @@ class SettingsService {
const JsonEncoder.withIndent(' ').convert({
'schemaVersion': 2,
'settings': settings.toJson(),
'filters': {
'blacklistedTags': settings.blacklistedTags,
'whitelistedTags': settings.whitelistedTags,
'smartBlacklistRules': settings.smartBlacklistRules,
},
'providers': providers.map((provider) => provider.toJson()).toList(),
}),
);
@@ -357,6 +362,15 @@ class SettingsService {
final decoded = jsonDecode(json) as Map<String, dynamic>;
final settingsJson =
(decoded['settings'] as Map?)?.cast<String, dynamic>() ?? decoded;
final filtersJson =
(decoded['filters'] as Map?)?.cast<String, dynamic>() ?? const {};
for (final key in [
'blacklistedTags',
'whitelistedTags',
'smartBlacklistRules',
]) {
settingsJson.putIfAbsent(key, () => filtersJson[key]);
}
final settings = AppSettings.fromJson(settingsJson);
final result = await updateSettings(settings);
if (result is Error<void>) return result;
@@ -36,6 +36,7 @@ class CollectionsController extends AsyncNotifier<List<Collection>> {
Future<void> delete(String id) async {
await ref.read(collectionServiceProvider).deleteCollection(id);
ref.invalidate(collectionPostsProvider(id));
state = AsyncData(await _load());
}
@@ -63,6 +63,21 @@ class _FeedScreenState extends ConsumerState<FeedScreen> {
});
}
@override
void didUpdateWidget(covariant FeedScreen oldWidget) {
super.didUpdateWidget(oldWidget);
final nextQuery = widget.initialQuery;
if (nextQuery != null &&
nextQuery.isNotEmpty &&
nextQuery != oldWidget.initialQuery) {
_usedInitialQuery = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(feedControllerProvider.notifier).search(nextQuery);
});
}
}
@override
void dispose() {
_scrollSaveDebounce?.cancel();
@@ -279,7 +279,14 @@ class _PostMediaViewerState extends State<PostMediaViewer>
}
Future<void> _retryVideo() async {
_videoIndex = 0;
final player = _player;
if (player == null) return;
setState(() {
_videoError = null;
_videoIndex = 0;
_controlsVisible = true;
});
await player.stop();
await _openVideo(play: true);
_showControls();
}
@@ -994,7 +1001,7 @@ class _VideoSurface extends StatelessWidget {
},
),
if (errorMessage != null)
_VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
VideoErrorOverlay(message: errorMessage!, onRetry: onRetry),
if (errorMessage == null)
StreamBuilder<bool>(
stream: player.stream.playing,
@@ -1040,21 +1047,24 @@ class _VideoSurface extends StatelessWidget {
);
},
),
AnimatedOpacity(
opacity: controlsVisible || errorMessage != null ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: _VideoControls(
player: player,
muted: muted,
halfVolume: halfVolume,
loopVideo: loopVideo,
coverVideo: coverVideo,
fullscreen: fullscreen,
onToggleFit: onToggleFit,
onToggleMute: onToggleMute,
onToggleHalfVolume: onToggleHalfVolume,
onToggleLoop: onToggleLoop,
onFullscreen: onFullscreen,
IgnorePointer(
ignoring: !controlsVisible || errorMessage != null,
child: AnimatedOpacity(
opacity: controlsVisible || errorMessage != null ? 1 : 0,
duration: const Duration(milliseconds: 180),
child: _VideoControls(
player: player,
muted: muted,
halfVolume: halfVolume,
loopVideo: loopVideo,
coverVideo: coverVideo,
fullscreen: fullscreen,
onToggleFit: onToggleFit,
onToggleMute: onToggleMute,
onToggleHalfVolume: onToggleHalfVolume,
onToggleLoop: onToggleLoop,
onFullscreen: onFullscreen,
),
),
),
],
@@ -1080,10 +1090,11 @@ class _VideoSurface extends StatelessWidget {
}
}
class _VideoErrorOverlay extends StatelessWidget {
const _VideoErrorOverlay({
class VideoErrorOverlay extends StatelessWidget {
const VideoErrorOverlay({
required this.message,
required this.onRetry,
super.key,
});
final String message;
@@ -1210,186 +1221,184 @@ class _VideoControls extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.72),
Colors.black.withValues(alpha: 0.12),
Colors.transparent,
],
return Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
minimum: EdgeInsets.fromLTRB(
fullscreen ? 14 : 8,
0,
fullscreen ? 14 : 8,
fullscreen ? 10 : 8,
),
),
child: Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
minimum: EdgeInsets.fromLTRB(
fullscreen ? 14 : 8,
0,
fullscreen ? 14 : 8,
fullscreen ? 10 : 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.58),
borderRadius: BorderRadius.circular(fullscreen ? 18 : 14),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: StreamBuilder<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
builder: (context, playingSnapshot) {
final playing = playingSnapshot.data ?? false;
return StreamBuilder<Duration>(
stream: player.stream.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final duration = durationSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final maxMs =
duration.inMilliseconds.clamp(1, 1 << 31).toDouble();
final valueMs = position.inMilliseconds
.clamp(0, maxMs.toInt())
.toDouble();
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 560;
final tiny = constraints.maxWidth < 390;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: scheme.primary,
inactiveTrackColor:
Colors.white.withValues(alpha: 0.24),
trackHeight: 3,
thumbColor: scheme.primary,
overlayColor:
scheme.primary.withValues(alpha: 0.18),
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 5,
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 4, 10, 8),
child: StreamBuilder<bool>(
stream: player.stream.playing,
initialData: player.state.playing,
builder: (context, playingSnapshot) {
final playing = playingSnapshot.data ?? false;
return StreamBuilder<Duration>(
stream: player.stream.duration,
initialData: player.state.duration,
builder: (context, durationSnapshot) {
final duration = durationSnapshot.data ?? Duration.zero;
return StreamBuilder<Duration>(
stream: player.stream.position,
initialData: player.state.position,
builder: (context, positionSnapshot) {
final position = positionSnapshot.data ?? Duration.zero;
final maxMs = duration.inMilliseconds
.clamp(1, 1 << 31)
.toDouble();
final valueMs = position.inMilliseconds
.clamp(0, maxMs.toInt())
.toDouble();
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 560;
final tiny = constraints.maxWidth < 390;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: scheme.primary,
inactiveTrackColor:
Colors.white.withValues(alpha: 0.24),
trackHeight: 3,
thumbColor: scheme.primary,
overlayColor:
scheme.primary.withValues(alpha: 0.18),
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 5,
),
),
),
child: Slider(
value: valueMs,
max: maxMs,
onChanged: duration == Duration.zero
? null
: (value) => player.seek(
Duration(
milliseconds: value.round(),
child: Slider(
value: valueMs,
max: maxMs,
onChanged: duration == Duration.zero
? null
: (value) => player.seek(
Duration(
milliseconds: value.round(),
),
),
),
),
),
),
Row(
children: [
_RoundControlButton(
tooltip: playing ? 'Pause' : 'Play',
emphasized: true,
onPressed: player.playOrPause,
icon: playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
const SizedBox(width: 8),
Text(
tiny
? _format(position)
: '${_format(position)} / ${_format(duration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
if (!compact) ...[
Row(
children: [
_RoundControlButton(
tooltip: 'Back 10s',
onPressed: () => player.seek(
_clampSeek(
position -
const Duration(seconds: 10),
duration,
),
tooltip: playing ? 'Pause' : 'Play',
emphasized: true,
onPressed: player.playOrPause,
icon: playing
? Icons.pause_rounded
: Icons.play_arrow_rounded,
),
const SizedBox(width: 8),
Text(
tiny
? _format(position)
: '${_format(position)} / ${_format(duration)}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
),
icon: Icons.replay_10_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: 'Forward 10s',
onPressed: () => player.seek(
_clampSeek(
position +
const Duration(seconds: 10),
duration,
const Spacer(),
if (!compact) ...[
_RoundControlButton(
tooltip: 'Back 10s',
onPressed: () => player.seek(
_clampSeek(
position -
const Duration(seconds: 10),
duration,
),
),
icon: Icons.replay_10_rounded,
),
icon: Icons.forward_10_rounded,
),
const SizedBox(width: 5),
],
_RoundControlButton(
tooltip: muted ? 'Unmute' : 'Mute',
onPressed: onToggleMute,
icon: muted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: halfVolume
? 'Normal app volume'
: 'Half app volume',
selected: halfVolume,
onPressed: onToggleHalfVolume,
icon: Icons.volume_down_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: loopVideo
? 'Disable repeat'
: 'Repeat video',
selected: loopVideo,
onPressed: onToggleLoop,
icon: loopVideo
? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded,
),
const SizedBox(width: 5),
if (!compact) ...[
const SizedBox(width: 5),
_RoundControlButton(
tooltip: 'Forward 10s',
onPressed: () => player.seek(
_clampSeek(
position +
const Duration(seconds: 10),
duration,
),
),
icon: Icons.forward_10_rounded,
),
const SizedBox(width: 5),
],
_RoundControlButton(
tooltip: coverVideo ? 'Fit' : 'Fill',
selected: coverVideo,
onPressed: onToggleFit,
icon: coverVideo
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
tooltip: muted ? 'Unmute' : 'Mute',
onPressed: onToggleMute,
icon: muted
? Icons.volume_off_rounded
: Icons.volume_up_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: halfVolume
? 'Normal app volume'
: 'Half app volume',
selected: halfVolume,
onPressed: onToggleHalfVolume,
icon: Icons.volume_down_rounded,
),
const SizedBox(width: 5),
_RoundControlButton(
tooltip: loopVideo
? 'Disable repeat'
: 'Repeat video',
selected: loopVideo,
onPressed: onToggleLoop,
icon: loopVideo
? Icons.repeat_one_on_rounded
: Icons.repeat_one_rounded,
),
const SizedBox(width: 5),
if (!compact) ...[
_RoundControlButton(
tooltip: coverVideo ? 'Fit' : 'Fill',
selected: coverVideo,
onPressed: onToggleFit,
icon: coverVideo
? Icons.fit_screen_rounded
: Icons.crop_free_rounded,
),
const SizedBox(width: 5),
],
_RoundControlButton(
tooltip: fullscreen
? 'Exit fullscreen'
: 'Fullscreen',
onPressed: onFullscreen,
icon: fullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
),
],
_RoundControlButton(
tooltip: fullscreen
? 'Exit fullscreen'
: 'Fullscreen',
onPressed: onFullscreen,
icon: fullscreen
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
),
],
),
],
);
},
);
},
);
},
);
},
),
],
);
},
);
},
);
},
);
},
),
),
),
),
+18 -23
View File
@@ -133,11 +133,8 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.search,
inputFormatters: [
_TagCommitFormatter(onCommit: _commitDraft),
],
onSubmitted: (_) => _submit(),
onChanged: (_) => _notifyChangedDebounced(),
onChanged: _handleDraftChanged,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
@@ -200,6 +197,12 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
_commitDraft(_controller.text);
_submit();
return KeyEventResult.handled;
}
if (event.logicalKey != LogicalKeyboardKey.backspace) {
return KeyEventResult.ignored;
}
@@ -216,6 +219,17 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
return KeyEventResult.handled;
}
void _handleDraftChanged(String value) {
final composing = _controller.value.composing;
if (!composing.isValid &&
value.isNotEmpty &&
RegExp(r'\s$').hasMatch(value)) {
_commitDraft(value);
return;
}
_notifyChangedDebounced();
}
void _setFromQuery(String query) {
_tags = _parseTags(query);
_controller.clear();
@@ -310,25 +324,6 @@ class _TagInputSearchBarState extends State<TagInputSearchBar> {
}
}
class _TagCommitFormatter extends TextInputFormatter {
_TagCommitFormatter({required this.onCommit});
final ValueChanged<String> onCommit;
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (newValue.text.endsWith(' ') || newValue.text.endsWith('\n')) {
final value = newValue.text.trim();
WidgetsBinding.instance.addPostFrameCallback((_) => onCommit(value));
return const TextEditingValue();
}
return newValue;
}
}
class _TagSuggestionDropdown extends StatelessWidget {
const _TagSuggestionDropdown({
required this.suggestions,
+7
View File
@@ -8,9 +8,16 @@ class TagChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return ActionChip(
onPressed: onTap,
label: Text(tag),
labelStyle: TextStyle(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.62),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.65)),
visualDensity: VisualDensity.compact,
);
}