diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 6f6d1a6a31e88..37e98a7f7010e 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -19,6 +19,8 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); +const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); +const Color blackOpacity40 = Color.fromARGB((0.40 * 255) ~/ 1, 0, 0, 0); final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index c0cf60514f04d..42d338956ff8e 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -7,10 +7,10 @@ import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; class ImmichTheme { - ColorScheme light; - ColorScheme dark; + final ColorScheme light; + final ColorScheme dark; - ImmichTheme({required this.light, required this.dark}); + const ImmichTheme({required this.light, required this.dark}); } ImmichTheme? _immichDynamicTheme; @@ -151,7 +151,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) { return ThemeData( useMaterial3: true, - brightness: isDark ? Brightness.dark : Brightness.light, + brightness: colorScheme.brightness, colorScheme: colorScheme, primaryColor: primaryColor, hintColor: colorScheme.onSurfaceSecondary, diff --git a/mobile/lib/widgets/asset_viewer/formatted_duration.dart b/mobile/lib/widgets/asset_viewer/formatted_duration.dart new file mode 100644 index 0000000000000..bf64a0c0024f3 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/formatted_duration.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; + +@pragma('vm:prefer-inline') +String _formatDuration(Duration position) { + final seconds = position.inSeconds.remainder(60).toString().padLeft(2, "0"); + final minutes = position.inMinutes.remainder(60).toString().padLeft(2, "0"); + if (position.inHours == 0) { + return "$minutes:$seconds"; + } + final hours = position.inHours.toString().padLeft(2, '0'); + return "$hours:$minutes:$seconds"; +} + +class FormattedDuration extends StatelessWidget { + final Duration data; + const FormattedDuration(this.data, {super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: data.inHours > 0 ? 64 : 43, // use a fixed width to prevent jitter + child: Text( + _formatDuration(data), + style: const TextStyle( + fontSize: 14.0, + color: whiteOpacity75, + fontWeight: FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index a5f5f18ce87c1..c96d58d374ca3 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -1,125 +1,35 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/video_position.dart'; -/// The video controls for the [videPlayerControlsProvider] +/// The video controls for the [videoPlayerControlsProvider] class VideoControls extends ConsumerWidget { const VideoControls({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final duration = - ref.watch(videoPlaybackValueProvider.select((v) => v.duration)); - final position = - ref.watch(videoPlaybackValueProvider.select((v) => v.position)); - + final isPortrait = + MediaQuery.orientationOf(context) == Orientation.portrait; return AnimatedOpacity( opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, duration: const Duration(milliseconds: 100), - child: OrientationBuilder( - builder: (context, orientation) => Container( - padding: EdgeInsets.symmetric( - horizontal: orientation == Orientation.portrait ? 12.0 : 64.0, - ), - color: Colors.black.withOpacity(0.4), - child: Padding( - padding: MediaQuery.of(context).orientation == Orientation.portrait - ? const EdgeInsets.symmetric(horizontal: 12.0) - : const EdgeInsets.symmetric(horizontal: 64.0), - child: Row( - children: [ - Text( - _formatDuration(position), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, - ), - ), - Expanded( - child: Slider( - value: duration == Duration.zero - ? 0.0 - : min( - position.inMicroseconds / - duration.inMicroseconds * - 100, - 100, - ), - min: 0, - max: 100, - thumbColor: Colors.white, - activeColor: Colors.white, - inactiveColor: Colors.white.withOpacity(0.75), - onChanged: (position) { - ref.read(videoPlayerControlsProvider.notifier).position = - position; - }, - ), - ), - Text( - _formatDuration(duration), - style: TextStyle( - fontSize: 14.0, - color: Colors.white.withOpacity(.75), - fontWeight: FontWeight.normal, - ), - ), - IconButton( - icon: Icon( - ref.watch( - videoPlayerControlsProvider.select((value) => value.mute), - ) - ? Icons.volume_off - : Icons.volume_up, - ), - onPressed: () => ref - .read(videoPlayerControlsProvider.notifier) - .toggleMute(), - color: Colors.white, - ), - ], + child: isPortrait + ? const ColoredBox( + color: blackOpacity40, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24.0), + child: VideoPosition(), + ), + ) + : const ColoredBox( + color: blackOpacity40, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 128.0), + child: VideoPosition(), + ), ), - ), - ), - ), ); } - - String _formatDuration(Duration position) { - final ms = position.inMilliseconds; - - int seconds = ms ~/ 1000; - final int hours = seconds ~/ 3600; - seconds = seconds % 3600; - final minutes = seconds ~/ 60; - seconds = seconds % 60; - - final hoursString = hours >= 10 - ? '$hours' - : hours == 0 - ? '00' - : '0$hours'; - - final minutesString = minutes >= 10 - ? '$minutes' - : minutes == 0 - ? '00' - : '0$minutes'; - - final secondsString = seconds >= 10 - ? '$seconds' - : seconds == 0 - ? '00' - : '0$seconds'; - - final formattedTime = - '${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString'; - - return formattedTime; - } } diff --git a/mobile/lib/widgets/asset_viewer/video_mute_button.dart b/mobile/lib/widgets/asset_viewer/video_mute_button.dart new file mode 100644 index 0000000000000..da0f6f31749c7 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/video_mute_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; + +class VideoMuteButton extends ConsumerWidget { + const VideoMuteButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + icon: ref.watch( + videoPlayerControlsProvider.select((value) => value.mute), + ) + ? const Icon(Icons.volume_off) + : const Icon(Icons.volume_up), + onPressed: () => + ref.read(videoPlayerControlsProvider.notifier).toggleMute(), + color: Colors.white, + padding: const EdgeInsets.all(0), + alignment: Alignment.centerRight, + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart new file mode 100644 index 0000000000000..0512785782e18 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -0,0 +1,86 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart'; +import 'package:immich_mobile/widgets/asset_viewer/video_mute_button.dart'; + +class VideoPosition extends HookConsumerWidget { + const VideoPosition({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final (position, duration) = ref.watch( + videoPlaybackValueProvider.select((v) => (v.position, v.duration)), + ); + final wasPlaying = useRef(true); + return duration == Duration.zero + ? const _VideoPositionPlaceholder() + : Row( + children: [ + FormattedDuration(position), + Expanded( + child: Slider( + value: min( + position.inMicroseconds / duration.inMicroseconds * 100, + 100, + ), + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + onChangeStart: (value) { + final state = ref.read(videoPlaybackValueProvider).state; + wasPlaying.value = state != VideoPlaybackState.paused; + ref.read(videoPlayerControlsProvider.notifier).pause(); + }, + onChangeEnd: (value) { + if (wasPlaying.value) { + ref.read(videoPlayerControlsProvider.notifier).play(); + } + }, + onChanged: (position) { + ref.read(videoPlayerControlsProvider.notifier).position = + position; + }, + ), + ), + FormattedDuration(duration), + const VideoMuteButton(), + ], + ); + } +} + +class _VideoPositionPlaceholder extends StatelessWidget { + const _VideoPositionPlaceholder(); + + static void _onChangedDummy(_) {} + + @override + Widget build(BuildContext context) { + return const Row( + children: [ + FormattedDuration(Duration.zero), + Expanded( + child: Slider( + value: 0.0, + min: 0, + max: 100, + thumbColor: Colors.white, + activeColor: Colors.white, + inactiveColor: whiteOpacity75, + onChanged: _onChangedDummy, + ), + ), + FormattedDuration(Duration.zero), + VideoMuteButton(), + ], + ); + } +}