Fix video controls, tag input and collection deletion
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user