587 lines
21 KiB
Dart
587 lines
21 KiB
Dart
// lib/widgets/viewer/visual/entry_page_view.dart
|
||
import 'dart:async';
|
||
|
||
import 'package:aves/app_mode.dart';
|
||
import 'package:aves/model/entry/entry.dart';
|
||
import 'package:aves/model/entry/extensions/props.dart';
|
||
import 'package:aves/model/settings/enums/widget_outline.dart';
|
||
import 'package:aves/model/settings/settings.dart';
|
||
import 'package:aves/model/viewer/view_state.dart';
|
||
import 'package:aves/services/common/services.dart';
|
||
import 'package:aves/services/media/media_session_service.dart';
|
||
import 'package:aves/theme/icons.dart';
|
||
import 'package:aves/view/view.dart';
|
||
import 'package:aves/widgets/aves_app.dart';
|
||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||
import 'package:aves/widgets/common/basic/insets.dart';
|
||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||
import 'package:aves/widgets/home_widget.dart';
|
||
import 'package:aves/widgets/viewer/controls/controller.dart';
|
||
import 'package:aves/widgets/viewer/controls/notifications.dart';
|
||
import 'package:aves/widgets/viewer/hero.dart';
|
||
import 'package:aves/widgets/viewer/video/conductor.dart';
|
||
import 'package:aves/widgets/viewer/view/conductor.dart';
|
||
import 'package:aves/widgets/viewer/visual/error.dart';
|
||
import 'package:aves/widgets/viewer/visual/raster.dart';
|
||
import 'package:aves/widgets/viewer/visual/vector.dart';
|
||
import 'package:aves/widgets/viewer/visual/video/cover.dart';
|
||
import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart';
|
||
import 'package:aves/widgets/viewer/visual/video/swipe_action.dart';
|
||
import 'package:aves/widgets/viewer/visual/video/video_view.dart';
|
||
import 'package:aves_magnifier/aves_magnifier.dart';
|
||
import 'package:aves_model/aves_model.dart';
|
||
import 'package:decorated_icon/decorated_icon.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
// Factory remota per controller video unificato (remoti + locali tramite Conductor)
|
||
import 'package:aves/widgets/viewer/video/remote_video_factory.dart';
|
||
|
||
class EntryPageView extends StatefulWidget {
|
||
final AvesEntry mainEntry, pageEntry;
|
||
final ViewerController viewerController;
|
||
final VoidCallback? onDisposed;
|
||
|
||
static const decorationCheckSize = 20.0;
|
||
static const rasterMaxScale = ScaleLevel(factor: 5);
|
||
static const vectorMaxScale = ScaleLevel(factor: 25);
|
||
|
||
const EntryPageView({
|
||
super.key,
|
||
required this.mainEntry,
|
||
required this.pageEntry,
|
||
required this.viewerController,
|
||
this.onDisposed,
|
||
});
|
||
|
||
@override
|
||
State<EntryPageView> createState() => _EntryPageViewState();
|
||
}
|
||
|
||
class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateMixin {
|
||
late ValueNotifier<ViewState> _viewStateNotifier;
|
||
late AvesMagnifierController _magnifierController;
|
||
final Set<StreamSubscription> _subscriptions = {};
|
||
final ValueNotifier<Widget?> _actionFeedbackChildNotifier = ValueNotifier(null);
|
||
OverlayEntry? _actionFeedbackOverlayEntry;
|
||
|
||
AvesEntry get mainEntry => widget.mainEntry;
|
||
AvesEntry get entry => widget.pageEntry;
|
||
ViewerController get viewerController => widget.viewerController;
|
||
|
||
bool get _isRemote => entry.origin == 1;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_registerWidget(widget);
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant EntryPageView oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.pageEntry != widget.pageEntry) {
|
||
_unregisterWidget(oldWidget);
|
||
_registerWidget(widget);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_unregisterWidget(widget);
|
||
widget.onDisposed?.call();
|
||
_actionFeedbackChildNotifier.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _registerWidget(EntryPageView widget) {
|
||
final entry = widget.pageEntry;
|
||
_viewStateNotifier = context.read<ViewStateConductor>().getOrCreateController(entry).viewStateNotifier;
|
||
_magnifierController = AvesMagnifierController();
|
||
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
|
||
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
|
||
if (entry.isVideo) {
|
||
_subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand));
|
||
}
|
||
|
||
// idempotente
|
||
context.read<VideoConductor>().registerRemoteFactory(RemoteVideoControllerFactory());
|
||
|
||
viewerController.startAutopilotAnimation(
|
||
vsync: this,
|
||
onUpdate: ({required scaleLevel}) {
|
||
final boundaries = _magnifierController.scaleBoundaries;
|
||
if (boundaries != null) {
|
||
final scale = boundaries.scaleForLevel(scaleLevel);
|
||
_magnifierController.update(scale: scale, source: ChangeSource.animation);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
void _unregisterWidget(EntryPageView oldWidget) {
|
||
viewerController.stopAutopilotAnimation(vsync: this);
|
||
_magnifierController.dispose();
|
||
_subscriptions
|
||
..forEach((sub) => sub.cancel())
|
||
..clear();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget child = AnimatedBuilder(
|
||
animation: entry.visualChangeNotifier,
|
||
builder: (context, child) {
|
||
Widget? child;
|
||
|
||
// Pipeline unificata:
|
||
// - SVG → _buildSvgView()
|
||
// - Video → _buildVideoView()
|
||
// - Raster (locali + remoti) → _buildRasterView()
|
||
if (entry.isSvg) {
|
||
child = _buildSvgView();
|
||
} else if (!entry.displaySize.isEmpty) {
|
||
if (entry.isVideo) {
|
||
child = _buildVideoView();
|
||
} else if (entry.isDecodingSupported || _isRemote) {
|
||
child = _buildRasterView();
|
||
}
|
||
}
|
||
|
||
child ??= ErrorView(
|
||
entry: entry,
|
||
onTap: _onTap,
|
||
);
|
||
return child;
|
||
},
|
||
);
|
||
|
||
if (!settings.viewerUseCutout) {
|
||
child = SafeCutoutArea(
|
||
child: ClipRect(child: child),
|
||
);
|
||
}
|
||
|
||
final animate = context.select<Settings, bool>((v) => v.animate);
|
||
if (animate) {
|
||
child = Consumer<EntryHeroInfo?>(
|
||
builder: (context, info, child) => Hero(
|
||
tag: info != null && info.entry == mainEntry ? info.tag : hashCode,
|
||
transitionOnUserGestures: true,
|
||
child: child!,
|
||
),
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
return child;
|
||
}
|
||
|
||
// RASTER (locali + remoti)
|
||
Widget _buildRasterView() {
|
||
return _buildMagnifier(
|
||
applyScale: false,
|
||
child: RasterImageView(
|
||
entry: entry,
|
||
viewStateNotifier: _viewStateNotifier,
|
||
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||
entry: entry,
|
||
onTap: _onTap,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSvgView() {
|
||
return _buildMagnifier(
|
||
maxScale: EntryPageView.vectorMaxScale,
|
||
scaleStateCycle: _vectorScaleStateCycle,
|
||
applyScale: false,
|
||
child: VectorImageView(
|
||
entry: entry,
|
||
viewStateNotifier: _viewStateNotifier,
|
||
errorBuilder: (context, error, stackTrace) => ErrorView(
|
||
entry: entry,
|
||
onTap: _onTap,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// === Helper: frame Magnifier per i VIDEO senza doppio SAR ===
|
||
// - Remoto: usa remoteWidth/remoteHeight, swap se remoteRotation=90/270, NO SAR.
|
||
// - Locale: usa il calcolo stock (applica SAR tramite entry.videoDisplaySize).
|
||
Size _videoFrameSize(double? sar) {
|
||
if (_isRemote) {
|
||
double w = (entry.remoteWidth ?? entry.width).toDouble();
|
||
double h = (entry.remoteHeight ?? entry.height).toDouble();
|
||
final rot90 = ((entry.remoteRotation ?? 0) % 180) == 90;
|
||
if (rot90) {
|
||
final t = w;
|
||
w = h;
|
||
h = t;
|
||
}
|
||
if (w <= 0 || h <= 0) {
|
||
final ds = entry.displaySize;
|
||
if (!ds.isEmpty) return ds;
|
||
return const Size(1920, 1080); // fallback prudente
|
||
}
|
||
return Size(w, h);
|
||
}
|
||
// Locali: logica stock Aves (SAR incluso)
|
||
return entry.videoDisplaySize(sar);
|
||
}
|
||
|
||
Widget _buildVideoView() {
|
||
final videoController = context.read<VideoConductor>().getController(entry);
|
||
if (videoController == null) return const SizedBox();
|
||
|
||
return ValueListenableBuilder<double?>(
|
||
valueListenable: videoController.sarNotifier,
|
||
builder: (context, sar, child) {
|
||
// REMOTE: evita doppio SAR nel frame; VideoView applicherà il SAR internamente
|
||
final videoDisplaySize = _videoFrameSize(sar);
|
||
final isPureVideo = entry.isPureVideo;
|
||
|
||
return Selector<Settings, (bool, bool, bool)>(
|
||
selector: (context, s) => (
|
||
isPureVideo && s.videoGestureDoubleTapTogglePlay,
|
||
isPureVideo && s.videoGestureSideDoubleTapSeek,
|
||
isPureVideo && s.videoGestureVerticalDragBrightnessVolume,
|
||
),
|
||
builder: (context, s, child) {
|
||
final (playGesture, seekGesture, useVerticalDragGesture) = s;
|
||
final useTapGesture = playGesture || seekGesture;
|
||
|
||
MagnifierDoubleTapCallback? onDoubleTap;
|
||
MagnifierGestureScaleStartCallback? onScaleStart;
|
||
MagnifierGestureScaleUpdateCallback? onScaleUpdate;
|
||
MagnifierGestureScaleEndCallback? onScaleEnd;
|
||
|
||
if (useTapGesture) {
|
||
void _applyAction(EntryAction action, {IconData? Function()? icon}) {
|
||
_actionFeedbackChildNotifier.value = DecoratedIcon(
|
||
icon?.call() ?? action.getIconData(),
|
||
size: 48,
|
||
color: Colors.white,
|
||
shadows: const [Shadow(color: Colors.black, blurRadius: 4)],
|
||
);
|
||
VideoActionNotification(
|
||
controller: videoController,
|
||
entry: entry,
|
||
action: action,
|
||
).dispatch(context);
|
||
}
|
||
|
||
onDoubleTap = (alignment) {
|
||
final x = alignment.x;
|
||
if (seekGesture) {
|
||
final sideRatio = _getSideRatio();
|
||
if (sideRatio != null) {
|
||
if (x < sideRatio) {
|
||
_applyAction(EntryAction.videoReplay10);
|
||
return true;
|
||
} else if (x > 1 - sideRatio) {
|
||
_applyAction(EntryAction.videoSkip10);
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
if (playGesture) {
|
||
_applyAction(
|
||
EntryAction.videoTogglePlay,
|
||
icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play,
|
||
);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
}
|
||
|
||
if (useVerticalDragGesture) {
|
||
SwipeAction? swipeAction;
|
||
var move = Offset.zero;
|
||
var dropped = false;
|
||
double? startValue;
|
||
ValueNotifier<double?>? valueNotifier;
|
||
|
||
onScaleStart = (details, doubleTap, boundaries) {
|
||
dropped = details.pointerCount > 1 || doubleTap;
|
||
if (dropped) return;
|
||
|
||
startValue = null;
|
||
valueNotifier = ValueNotifier<double?>(null);
|
||
final alignmentX = details.focalPoint.dx / boundaries.viewportSize.width;
|
||
final action = alignmentX > .5 ? SwipeAction.volume : SwipeAction.brightness;
|
||
action.get().then((v) => startValue = v);
|
||
swipeAction = action;
|
||
move = Offset.zero;
|
||
_actionFeedbackOverlayEntry = OverlayEntry(
|
||
builder: (context) => SwipeActionFeedback(
|
||
action: action,
|
||
valueNotifier: valueNotifier!,
|
||
),
|
||
);
|
||
Overlay.of(context).insert(_actionFeedbackOverlayEntry!);
|
||
};
|
||
onScaleUpdate = (details) {
|
||
if (valueNotifier == null) return false;
|
||
|
||
move += details.focalPointDelta;
|
||
dropped |= details.pointerCount > 1;
|
||
if (valueNotifier!.value == null) {
|
||
dropped |= MagnifierGestureRecognizer.isXPan(move);
|
||
}
|
||
if (dropped) return false;
|
||
|
||
final _startValue = startValue;
|
||
if (_startValue != null) {
|
||
final double value = (_startValue - move.dy / SwipeActionFeedback.height).clamp(0, 1);
|
||
valueNotifier!.value = value;
|
||
swipeAction?.set(value);
|
||
}
|
||
return true;
|
||
};
|
||
onScaleEnd = (details) {
|
||
valueNotifier?.dispose();
|
||
|
||
_actionFeedbackOverlayEntry
|
||
?..remove()
|
||
..dispose();
|
||
_actionFeedbackOverlayEntry = null;
|
||
};
|
||
}
|
||
|
||
Widget videoChild = Stack(
|
||
children: [
|
||
_buildMagnifier(
|
||
displaySize: videoDisplaySize,
|
||
onScaleStart: onScaleStart,
|
||
onScaleUpdate: onScaleUpdate,
|
||
onScaleEnd: onScaleEnd,
|
||
onDoubleTap: onDoubleTap,
|
||
child: VideoView(
|
||
entry: entry,
|
||
controller: videoController,
|
||
),
|
||
),
|
||
VideoSubtitles(
|
||
entry: entry,
|
||
controller: videoController,
|
||
viewStateNotifier: _viewStateNotifier,
|
||
),
|
||
if (useTapGesture)
|
||
ValueListenableBuilder<Widget?>(
|
||
valueListenable: _actionFeedbackChildNotifier,
|
||
builder: (context, feedbackChild, child) => ActionFeedback(
|
||
child: feedbackChild,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
if (useVerticalDragGesture) {
|
||
final scope = MagnifierGestureDetectorScope.maybeOf(context);
|
||
if (scope != null) {
|
||
videoChild = scope.copyWith(
|
||
acceptPointerEvent: MagnifierGestureRecognizer.isYPan,
|
||
child: videoChild,
|
||
);
|
||
}
|
||
}
|
||
return Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
videoChild,
|
||
VideoCover(
|
||
mainEntry: mainEntry,
|
||
pageEntry: entry,
|
||
magnifierController: _magnifierController,
|
||
videoController: videoController,
|
||
videoDisplaySize: videoDisplaySize,
|
||
onTap: _onTap,
|
||
magnifierBuilder: (coverController, coverSize, videoCoverUriImage) => _buildMagnifier(
|
||
controller: coverController,
|
||
displaySize: coverSize,
|
||
onDoubleTap: onDoubleTap,
|
||
child: Image(image: videoCoverUriImage),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Wrapper del Magnifier con bound **remote‑friendly**
|
||
Widget _buildMagnifier({
|
||
AvesMagnifierController? controller,
|
||
Size? displaySize,
|
||
ScaleLevel maxScale = EntryPageView.rasterMaxScale,
|
||
ScaleStateCycle scaleStateCycle = defaultScaleStateCycle,
|
||
bool applyScale = true,
|
||
MagnifierGestureScaleStartCallback? onScaleStart,
|
||
MagnifierGestureScaleUpdateCallback? onScaleUpdate,
|
||
MagnifierGestureScaleEndCallback? onScaleEnd,
|
||
MagnifierDoubleTapCallback? onDoubleTap,
|
||
required Widget child,
|
||
}) {
|
||
final isWallpaperMode = context.read<ValueNotifier<AppMode>>().value == AppMode.setWallpaper;
|
||
|
||
const contained = ScaleLevel(ref: ScaleReference.contained);
|
||
const covered = ScaleLevel(ref: ScaleReference.covered);
|
||
|
||
// REMOTO:
|
||
// - minScale sotto al fit per abilitare pinch‑out
|
||
// - initialScale al fit (poi RasterImageView lo “cappa” a <= 1.0)
|
||
final minScale = isWallpaperMode
|
||
? covered
|
||
: (_isRemote ? const ScaleLevel(ref: ScaleReference.contained, factor: .5) : contained);
|
||
final initialScale = _isRemote ? contained : viewerController.initialScale;
|
||
|
||
return ValueListenableBuilder<bool>(
|
||
valueListenable: AvesApp.canGestureToOtherApps,
|
||
builder: (context, canGestureToOtherApps, childWidget) {
|
||
return AvesMagnifier(
|
||
key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'),
|
||
controller: controller ?? _magnifierController,
|
||
contentSize: displaySize ?? entry.displaySize,
|
||
allowOriginalScaleBeyondRange: !isWallpaperMode,
|
||
allowDoubleTap: _allowDoubleTap,
|
||
minScale: minScale,
|
||
maxScale: maxScale,
|
||
initialScale: initialScale,
|
||
scaleStateCycle: scaleStateCycle,
|
||
applyScale: applyScale,
|
||
onScaleStart: onScaleStart,
|
||
onScaleUpdate: onScaleUpdate,
|
||
onScaleEnd: onScaleEnd,
|
||
onFling: _onFling,
|
||
onTap: (context, _, alignment, _) {
|
||
if (context.mounted) {
|
||
_onTap(alignment: alignment);
|
||
}
|
||
},
|
||
onLongPress: canGestureToOtherApps ? _startGlobalDrag : null,
|
||
onDoubleTap: onDoubleTap,
|
||
child: childWidget!,
|
||
);
|
||
},
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
Future<void> _startGlobalDrag() async {
|
||
const dragShadowSize = Size.square(128);
|
||
final cornerRadiusPx = await deviceService.getWidgetCornerRadiusPx();
|
||
|
||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||
final brightness = Theme.of(context).brightness;
|
||
final outline = await WidgetOutline.systemBlackAndWhite.color(brightness);
|
||
|
||
final dragShadowBytes = await HomeWidgetPainter(
|
||
entry: entry,
|
||
devicePixelRatio: devicePixelRatio,
|
||
).drawWidget(
|
||
sizeDip: dragShadowSize,
|
||
cornerRadiusPx: cornerRadiusPx,
|
||
outline: outline,
|
||
shape: WidgetShape.rrect,
|
||
);
|
||
|
||
await windowService.startGlobalDrag(entry.uri, entry.bestTitle, dragShadowSize, dragShadowBytes);
|
||
}
|
||
|
||
void _onFling(AxisDirection direction) {
|
||
const animate = true;
|
||
switch (direction) {
|
||
case AxisDirection.left:
|
||
(context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate)).dispatch(context);
|
||
case AxisDirection.right:
|
||
(context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate)).dispatch(context);
|
||
case AxisDirection.up:
|
||
PopVisualNotification().dispatch(context);
|
||
case AxisDirection.down:
|
||
ShowInfoPageNotification().dispatch(context);
|
||
}
|
||
}
|
||
|
||
Notification? _handleSideSingleTap(Alignment? alignment) {
|
||
if (settings.viewerGestureSideTapNext && alignment != null) {
|
||
final x = alignment.x;
|
||
final sideRatio = _getSideRatio();
|
||
if (sideRatio != null) {
|
||
const animate = false;
|
||
if (x < sideRatio) {
|
||
return context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate);
|
||
} else if (x > 1 - sideRatio) {
|
||
return context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate);
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
void _onTap({Alignment? alignment}) => (_handleSideSingleTap(alignment) ?? const ToggleOverlayNotification()).dispatch(context);
|
||
|
||
bool _allowDoubleTap(Alignment alignment) {
|
||
if (entry.isVideo && settings.videoGestureSideDoubleTapSeek) {
|
||
return true;
|
||
}
|
||
final actionNotification = _handleSideSingleTap(alignment);
|
||
return actionNotification == null;
|
||
}
|
||
|
||
void _onMediaCommand(MediaCommandEvent event) {
|
||
final videoController = context.read<VideoConductor>().getController(entry);
|
||
if (videoController == null) return;
|
||
|
||
switch (event.command) {
|
||
case MediaCommand.play:
|
||
videoController.play();
|
||
case MediaCommand.pause:
|
||
videoController.pause();
|
||
case MediaCommand.skipToNext:
|
||
ShowNextVideoNotification().dispatch(context);
|
||
case MediaCommand.skipToPrevious:
|
||
ShowPreviousVideoNotification().dispatch(context);
|
||
case MediaCommand.stop:
|
||
videoController.pause();
|
||
case MediaCommand.seek:
|
||
if (event is MediaSeekCommandEvent) {
|
||
videoController.seekTo(event.position);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _onViewStateChanged(MagnifierState v) {
|
||
if (!mounted) return;
|
||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||
position: v.position,
|
||
scale: v.scale,
|
||
);
|
||
}
|
||
|
||
void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
|
||
_viewStateNotifier.value = _viewStateNotifier.value.copyWith(
|
||
viewportSize: v.viewportSize,
|
||
contentSize: v.contentSize,
|
||
);
|
||
}
|
||
|
||
double? _getSideRatio() {
|
||
if (!mounted) return null;
|
||
final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait;
|
||
return isPortrait ? 1 / 6 : 1 / 8;
|
||
}
|
||
|
||
static ScaleState _vectorScaleStateCycle(ScaleState actual) {
|
||
switch (actual) {
|
||
case ScaleState.initial:
|
||
return ScaleState.covering;
|
||
default:
|
||
return ScaleState.initial;
|
||
}
|
||
}
|
||
}
|