feat: UI Improvements (#428)

This commit is contained in:
PartyDonut 2025-07-31 20:33:30 +02:00 committed by GitHub
commit 83c38d4042
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 947 additions and 521 deletions

View file

@ -1289,5 +1289,7 @@
"syncResumeAll": "Resume all", "syncResumeAll": "Resume all",
"syncStopAll": "Stop all", "syncStopAll": "Stop all",
"syncDeleteAll": "Delete all files", "syncDeleteAll": "Delete all files",
"syncAllFiles": "Sync all files" "syncAllFiles": "Sync all files",
"usePostersForLibraryIconsTitle": "Show posters for library icons",
"usePostersForLibraryIconsDesc": "Show posters instead of icons for libraries"
} }

View file

@ -181,7 +181,6 @@ class FladderCupertinoLocalizationsDelegate extends LocalizationsDelegate<Cupert
dayFormat = intl.DateFormat.d(correctedLocale); dayFormat = intl.DateFormat.d(correctedLocale);
weekdayFormat = intl.DateFormat.E(correctedLocale); weekdayFormat = intl.DateFormat.E(correctedLocale);
mediumDateFormat = intl.DateFormat.MMMEd(correctedLocale); mediumDateFormat = intl.DateFormat.MMMEd(correctedLocale);
// TODO(xster): fix when https://github.com/dart-lang/intl/issues/207 is resolved.
singleDigitHourFormat = intl.DateFormat('HH', correctedLocale); singleDigitHourFormat = intl.DateFormat('HH', correctedLocale);
singleDigitMinuteFormat = intl.DateFormat.m(correctedLocale); singleDigitMinuteFormat = intl.DateFormat.m(correctedLocale);
doubleDigitMinuteFormat = intl.DateFormat('mm', correctedLocale); doubleDigitMinuteFormat = intl.DateFormat('mm', correctedLocale);

View file

@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/items/trick_play_model.dart';
@ -115,6 +116,15 @@ class DirectPlaybackModel extends PlaybackModel {
return null; return null;
} }
@override
DirectPlaybackModel? updateUserData(UserData userData) {
return copyWith(
item: item.copyWith(
userData: userData,
),
);
}
@override @override
String toString() => 'DirectPlaybackModel(item: $item, playbackInfo: $playbackInfo)'; String toString() => 'DirectPlaybackModel(item: $item, playbackInfo: $playbackInfo)';

View file

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/items/trick_play_model.dart';
@ -96,6 +97,15 @@ class OfflinePlaybackModel extends PlaybackModel {
return false; return false;
} }
@override
OfflinePlaybackModel? updateUserData(UserData userData) {
return copyWith(
item: item.copyWith(
userData: userData,
),
);
}
@override @override
String toString() => 'OfflinePlaybackModel(item: $item, syncedItem: $syncedItem)'; String toString() => 'OfflinePlaybackModel(item: $item, syncedItem: $syncedItem)';

View file

@ -12,6 +12,7 @@ import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/episode_model.dart'; import 'package:fladder/models/items/episode_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/season_model.dart'; import 'package:fladder/models/items/season_model.dart';
@ -84,6 +85,8 @@ class PlaybackModel {
Future<Duration>? startDuration() async => item.userData.playBackPosition; Future<Duration>? startDuration() async => item.userData.playBackPosition;
PlaybackModel? updateUserData(UserData userData) => throw UnimplementedError();
Future<PlaybackModel>? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError(); Future<PlaybackModel>? setSubtitle(SubStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError();
Future<PlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError(); Future<PlaybackModel>? setAudio(AudioStreamModel? model, MediaControlsWrapper player) => throw UnimplementedError();
Future<PlaybackModel>? setQualityOption(Map<Bitrate, bool> map) => throw UnimplementedError(); Future<PlaybackModel>? setQualityOption(Map<Bitrate, bool> map) => throw UnimplementedError();

View file

@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_segments_model.dart';
import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/media_streams_model.dart';
import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/items/trick_play_model.dart';
@ -51,7 +52,7 @@ class TranscodePlaybackModel extends PlaybackModel {
} }
@override @override
Future<TranscodePlaybackModel>? setQualityOption(Map<Bitrate, bool> map) async => copyWith(bitRateOptions: map); Future<TranscodePlaybackModel>? setQualityOption(Map<Bitrate, bool> map) async => copyWith(bitRateOptions: map);
@override @override
Future<PlaybackModel?> playbackStarted(Duration position, Ref ref) async { Future<PlaybackModel?> playbackStarted(Duration position, Ref ref) async {
@ -114,6 +115,15 @@ class TranscodePlaybackModel extends PlaybackModel {
return this; return this;
} }
@override
TranscodePlaybackModel? updateUserData(UserData userData) {
return copyWith(
item: item.copyWith(
userData: userData,
),
);
}
@override @override
String toString() => 'TranscodePlaybackModel(item: $item, playbackInfo: $playbackInfo)'; String toString() => 'TranscodePlaybackModel(item: $item, playbackInfo: $playbackInfo)';

View file

@ -36,6 +36,7 @@ class ClientSettingsModel with _$ClientSettingsModel {
@Default(DynamicSchemeVariant.rainbow) DynamicSchemeVariant schemeVariant, @Default(DynamicSchemeVariant.rainbow) DynamicSchemeVariant schemeVariant,
@Default(true) bool backgroundPosters, @Default(true) bool backgroundPosters,
@Default(true) bool checkForUpdates, @Default(true) bool checkForUpdates,
@Default(false) bool usePosterForLibrary,
String? lastViewedUpdate, String? lastViewedUpdate,
int? libraryPageSize, int? libraryPageSize,
}) = _ClientSettingsModel; }) = _ClientSettingsModel;

View file

@ -42,6 +42,7 @@ mixin _$ClientSettingsModel {
DynamicSchemeVariant get schemeVariant => throw _privateConstructorUsedError; DynamicSchemeVariant get schemeVariant => throw _privateConstructorUsedError;
bool get backgroundPosters => throw _privateConstructorUsedError; bool get backgroundPosters => throw _privateConstructorUsedError;
bool get checkForUpdates => throw _privateConstructorUsedError; bool get checkForUpdates => throw _privateConstructorUsedError;
bool get usePosterForLibrary => throw _privateConstructorUsedError;
String? get lastViewedUpdate => throw _privateConstructorUsedError; String? get lastViewedUpdate => throw _privateConstructorUsedError;
int? get libraryPageSize => throw _privateConstructorUsedError; int? get libraryPageSize => throw _privateConstructorUsedError;
@ -83,6 +84,7 @@ abstract class $ClientSettingsModelCopyWith<$Res> {
DynamicSchemeVariant schemeVariant, DynamicSchemeVariant schemeVariant,
bool backgroundPosters, bool backgroundPosters,
bool checkForUpdates, bool checkForUpdates,
bool usePosterForLibrary,
String? lastViewedUpdate, String? lastViewedUpdate,
int? libraryPageSize}); int? libraryPageSize});
} }
@ -123,6 +125,7 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
Object? schemeVariant = null, Object? schemeVariant = null,
Object? backgroundPosters = null, Object? backgroundPosters = null,
Object? checkForUpdates = null, Object? checkForUpdates = null,
Object? usePosterForLibrary = null,
Object? lastViewedUpdate = freezed, Object? lastViewedUpdate = freezed,
Object? libraryPageSize = freezed, Object? libraryPageSize = freezed,
}) { }) {
@ -211,6 +214,10 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel>
? _value.checkForUpdates ? _value.checkForUpdates
: checkForUpdates // ignore: cast_nullable_to_non_nullable : checkForUpdates // ignore: cast_nullable_to_non_nullable
as bool, as bool,
usePosterForLibrary: null == usePosterForLibrary
? _value.usePosterForLibrary
: usePosterForLibrary // ignore: cast_nullable_to_non_nullable
as bool,
lastViewedUpdate: freezed == lastViewedUpdate lastViewedUpdate: freezed == lastViewedUpdate
? _value.lastViewedUpdate ? _value.lastViewedUpdate
: lastViewedUpdate // ignore: cast_nullable_to_non_nullable : lastViewedUpdate // ignore: cast_nullable_to_non_nullable
@ -253,6 +260,7 @@ abstract class _$$ClientSettingsModelImplCopyWith<$Res>
DynamicSchemeVariant schemeVariant, DynamicSchemeVariant schemeVariant,
bool backgroundPosters, bool backgroundPosters,
bool checkForUpdates, bool checkForUpdates,
bool usePosterForLibrary,
String? lastViewedUpdate, String? lastViewedUpdate,
int? libraryPageSize}); int? libraryPageSize});
} }
@ -291,6 +299,7 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
Object? schemeVariant = null, Object? schemeVariant = null,
Object? backgroundPosters = null, Object? backgroundPosters = null,
Object? checkForUpdates = null, Object? checkForUpdates = null,
Object? usePosterForLibrary = null,
Object? lastViewedUpdate = freezed, Object? lastViewedUpdate = freezed,
Object? libraryPageSize = freezed, Object? libraryPageSize = freezed,
}) { }) {
@ -379,6 +388,10 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res>
? _value.checkForUpdates ? _value.checkForUpdates
: checkForUpdates // ignore: cast_nullable_to_non_nullable : checkForUpdates // ignore: cast_nullable_to_non_nullable
as bool, as bool,
usePosterForLibrary: null == usePosterForLibrary
? _value.usePosterForLibrary
: usePosterForLibrary // ignore: cast_nullable_to_non_nullable
as bool,
lastViewedUpdate: freezed == lastViewedUpdate lastViewedUpdate: freezed == lastViewedUpdate
? _value.lastViewedUpdate ? _value.lastViewedUpdate
: lastViewedUpdate // ignore: cast_nullable_to_non_nullable : lastViewedUpdate // ignore: cast_nullable_to_non_nullable
@ -417,6 +430,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
this.schemeVariant = DynamicSchemeVariant.rainbow, this.schemeVariant = DynamicSchemeVariant.rainbow,
this.backgroundPosters = true, this.backgroundPosters = true,
this.checkForUpdates = true, this.checkForUpdates = true,
this.usePosterForLibrary = false,
this.lastViewedUpdate, this.lastViewedUpdate,
this.libraryPageSize}) this.libraryPageSize})
: super._(); : super._();
@ -485,13 +499,16 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
@JsonKey() @JsonKey()
final bool checkForUpdates; final bool checkForUpdates;
@override @override
@JsonKey()
final bool usePosterForLibrary;
@override
final String? lastViewedUpdate; final String? lastViewedUpdate;
@override @override
final int? libraryPageSize; final int? libraryPageSize;
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundPosters: $backgroundPosters, checkForUpdates: $checkForUpdates, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize)'; return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, backgroundPosters: $backgroundPosters, checkForUpdates: $checkForUpdates, usePosterForLibrary: $usePosterForLibrary, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize)';
} }
@override @override
@ -522,6 +539,7 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel
..add(DiagnosticsProperty('schemeVariant', schemeVariant)) ..add(DiagnosticsProperty('schemeVariant', schemeVariant))
..add(DiagnosticsProperty('backgroundPosters', backgroundPosters)) ..add(DiagnosticsProperty('backgroundPosters', backgroundPosters))
..add(DiagnosticsProperty('checkForUpdates', checkForUpdates)) ..add(DiagnosticsProperty('checkForUpdates', checkForUpdates))
..add(DiagnosticsProperty('usePosterForLibrary', usePosterForLibrary))
..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate))
..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)); ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize));
} }
@ -566,6 +584,7 @@ abstract class _ClientSettingsModel extends ClientSettingsModel {
final DynamicSchemeVariant schemeVariant, final DynamicSchemeVariant schemeVariant,
final bool backgroundPosters, final bool backgroundPosters,
final bool checkForUpdates, final bool checkForUpdates,
final bool usePosterForLibrary,
final String? lastViewedUpdate, final String? lastViewedUpdate,
final int? libraryPageSize}) = _$ClientSettingsModelImpl; final int? libraryPageSize}) = _$ClientSettingsModelImpl;
_ClientSettingsModel._() : super._(); _ClientSettingsModel._() : super._();
@ -617,6 +636,8 @@ abstract class _ClientSettingsModel extends ClientSettingsModel {
@override @override
bool get checkForUpdates; bool get checkForUpdates;
@override @override
bool get usePosterForLibrary;
@override
String? get lastViewedUpdate; String? get lastViewedUpdate;
@override @override
int? get libraryPageSize; int? get libraryPageSize;

View file

@ -43,6 +43,7 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson(
DynamicSchemeVariant.rainbow, DynamicSchemeVariant.rainbow,
backgroundPosters: json['backgroundPosters'] as bool? ?? true, backgroundPosters: json['backgroundPosters'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true,
usePosterForLibrary: json['usePosterForLibrary'] as bool? ?? false,
lastViewedUpdate: json['lastViewedUpdate'] as String?, lastViewedUpdate: json['lastViewedUpdate'] as String?,
libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(), libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(),
); );
@ -71,6 +72,7 @@ Map<String, dynamic> _$$ClientSettingsModelImplToJson(
'schemeVariant': _$DynamicSchemeVariantEnumMap[instance.schemeVariant]!, 'schemeVariant': _$DynamicSchemeVariantEnumMap[instance.schemeVariant]!,
'backgroundPosters': instance.backgroundPosters, 'backgroundPosters': instance.backgroundPosters,
'checkForUpdates': instance.checkForUpdates, 'checkForUpdates': instance.checkForUpdates,
'usePosterForLibrary': instance.usePosterForLibrary,
'lastViewedUpdate': instance.lastViewedUpdate, 'lastViewedUpdate': instance.lastViewedUpdate,
'libraryPageSize': instance.libraryPageSize, 'libraryPageSize': instance.libraryPageSize,
}; };

View file

@ -113,6 +113,7 @@ class ViewModel {
FutureOr Function() action, { FutureOr Function() action, {
FutureOr Function()? onLongPress, FutureOr Function()? onLongPress,
List<ItemAction>? trailing, List<ItemAction>? trailing,
Widget? customIcon,
}) { }) {
return NavigationButton( return NavigationButton(
label: name, label: name,
@ -121,6 +122,7 @@ class ViewModel {
onLongPress: onLongPress, onLongPress: onLongPress,
horizontal: horizontal, horizontal: horizontal,
expanded: expanded, expanded: expanded,
customIcon: customIcon,
trailing: trailing ?? [], trailing: trailing ?? [],
selectedIcon: Icon(collectionType.icon), selectedIcon: Icon(collectionType.icon),
icon: Icon(collectionType.iconOutlined), icon: Icon(collectionType.iconOutlined),

View file

@ -125,6 +125,10 @@ class AuthGuard extends AutoRouteGuard {
@override @override
Future<void> onNavigation(NavigationResolver resolver, StackRouter router) async { Future<void> onNavigation(NavigationResolver resolver, StackRouter router) async {
if (resolver.route == router.current.route) {
return;
}
if (ref.read(userProvider) != null || if (ref.read(userProvider) != null ||
resolver.routeName == const LoginRoute().routeName || resolver.routeName == const LoginRoute().routeName ||
resolver.routeName == SplashRoute().routeName) { resolver.routeName == SplashRoute().routeName) {

View file

@ -107,15 +107,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
if (homeBanner && homeCarouselItems.isNotEmpty) ...{ if (homeBanner && homeCarouselItems.isNotEmpty) ...{
SliverToBoxAdapter( SliverToBoxAdapter(
child: Transform.translate( child: Padding(
offset: Offset(0, AdaptiveLayout.layoutOf(context) == ViewSize.phone ? -14 : 0), padding: AdaptiveLayout.adaptivePadding(
child: Padding( context,
padding: AdaptiveLayout.adaptivePadding( horizontalPadding: 0,
context,
horizontalPadding: 0,
),
child: HomeBannerWidget(posters: homeCarouselItems),
), ),
child: HomeBannerWidget(posters: homeCarouselItems),
), ),
), ),
}, },

View file

@ -38,7 +38,6 @@ class FavouritesScreen extends ConsumerWidget {
slivers: [ slivers: [
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
NestedSliverAppBar( NestedSliverAppBar(
searchTitle: "${context.localized.search} ${context.localized.favorites.toLowerCase()}...",
parent: context, parent: context,
route: LibrarySearchRoute(favourites: true), route: LibrarySearchRoute(favourites: true),
) )

View file

@ -108,6 +108,13 @@ class HomeScreen extends ConsumerWidget {
selectedIcon: Icon(e.selectedIcon), selectedIcon: Icon(e.selectedIcon),
route: const LibraryRoute(), route: const LibraryRoute(),
action: () => e.navigate(context), action: () => e.navigate(context),
floatingActionButton: AdaptiveFab(
context: context,
title: context.localized.search,
key: Key(e.name.capitalize()),
onPressed: () => context.router.navigate(LibrarySearchRoute()),
child: const Icon(IconsaxPlusLinear.search_status),
),
); );
} }
}) })

View file

@ -99,6 +99,18 @@ List<Widget> buildClientSettingsVisual(
ref.read(clientSettingsProvider.notifier).update((cb) => cb.copyWith(backgroundPosters: value)), ref.read(clientSettingsProvider.notifier).update((cb) => cb.copyWith(backgroundPosters: value)),
), ),
), ),
SettingsListTile(
label: Text(context.localized.usePostersForLibraryIconsTitle),
subLabel: Text(context.localized.usePostersForLibraryIconsDesc),
onTap: () => ref
.read(clientSettingsProvider.notifier)
.update((cb) => cb.copyWith(usePosterForLibrary: !clientSettings.usePosterForLibrary)),
trailing: Switch(
value: clientSettings.usePosterForLibrary,
onChanged: (value) =>
ref.read(clientSettingsProvider.notifier).update((cb) => cb.copyWith(usePosterForLibrary: value)),
),
),
SettingsListTile( SettingsListTile(
label: Text(context.localized.settingsNextUpCutoffDays), label: Text(context.localized.settingsNextUpCutoffDays),
trailing: SizedBox( trailing: SizedBox(

View file

@ -249,6 +249,21 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
onChanged: (value) => provider.setUseLibass(value), onChanged: (value) => provider.setUseLibass(value),
), ),
), ),
if (!videoSettings.useLibass)
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: true,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
AnimatedFadeSize( AnimatedFadeSize(
child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid child: videoSettings.useLibass && videoSettings.hardwareAccel && Platform.isAndroid
? SettingsMessageBox( ? SettingsMessageBox(
@ -272,20 +287,6 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
}, },
)), )),
), ),
SettingsListTile(
label: Text(context.localized.settingsPlayerCustomSubtitlesTitle),
subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc),
onTap: videoSettings.useLibass
? null
: () {
showDialog(
context: context,
barrierDismissible: false,
useSafeArea: false,
builder: (context) => const SubtitleEditor(),
);
},
),
], ],
), ),
_ => SettingsMessageBox( _ => SettingsMessageBox(

View file

@ -7,9 +7,7 @@ import 'package:fladder/models/settings/subtitle_settings_model.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart';
import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart'; import 'package:fladder/screens/video_player/components/video_subtitle_controls.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
class SubtitleEditor extends ConsumerStatefulWidget { class SubtitleEditor extends ConsumerStatefulWidget {
const SubtitleEditor({super.key}); const SubtitleEditor({super.key});
@ -27,8 +25,9 @@ class _SubtitleEditorState extends ConsumerState<SubtitleEditor> {
final fakeText = context.localized.subtitleConfiguratorPlaceHolder; final fakeText = context.localized.subtitleConfiguratorPlaceHolder;
double lastScale = 0.0; double lastScale = 0.0;
return Scaffold( return Padding(
body: Dialog.fullscreen( padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top),
child: Dialog.fullscreen(
child: GestureDetector( child: GestureDetector(
onScaleUpdate: (details) { onScaleUpdate: (details) {
lastScale = details.scale; lastScale = details.scale;
@ -73,7 +72,6 @@ class _SubtitleEditorState extends ConsumerState<SubtitleEditor> {
padding: MediaQuery.paddingOf(context), padding: MediaQuery.paddingOf(context),
child: Column( child: Column(
children: [ children: [
if (AdaptiveLayout.of(context).isDesktop) const FladderAppBar(),
Row( Row(
children: [ children: [
const BackButton(), const BackButton(),

View file

@ -38,14 +38,21 @@ class _DefaultTitleBarState extends ConsumerState<DefaultTitleBar> with WindowLi
if (ref.watch(argumentsStateProvider.select((value) => value.htpcMode))) return const SizedBox.shrink(); if (ref.watch(argumentsStateProvider.select((value) => value.htpcMode))) return const SizedBox.shrink();
final brightness = widget.brightness ?? Theme.of(context).brightness; final brightness = widget.brightness ?? Theme.of(context).brightness;
final iconColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65); final iconColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65);
final surfaceColor = Theme.of(context).colorScheme.surface;
return MouseRegion( return MouseRegion(
onEnter: (event) => setState(() => hovering = true), onEnter: (event) => setState(() => hovering = true),
onExit: (event) => setState(() => hovering = false), onExit: (event) => setState(() => hovering = false),
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
decoration: BoxDecoration( decoration: BoxDecoration(
color: hovering ? Colors.black.withValues(alpha: 0.15) : Colors.transparent, gradient: LinearGradient(
), colors: [
surfaceColor.withValues(alpha: 0.7),
surfaceColor.withValues(alpha: hovering ? 0.7 : 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
height: widget.height, height: widget.height,
child: kIsWeb child: kIsWeb
? const SizedBox.shrink() ? const SizedBox.shrink()

View file

@ -17,8 +17,10 @@ class ItemLogo extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final logo = item.getPosters?.logo; final logo = item.getPosters?.logo;
final size = MediaQuery.sizeOf(context);
final maxHeight = size.height * 0.45;
final textWidget = Container( final textWidget = Container(
height: 512, height: maxHeight,
alignment: imageAlignment, alignment: imageAlignment,
child: Text( child: Text(
item.parentBaseModel.name, item.parentBaseModel.name,
@ -33,13 +35,14 @@ class ItemLogo extends StatelessWidget {
); );
return logo != null return logo != null
? ConstrainedBox( ? ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 500), constraints: BoxConstraints(maxWidth: size.width * 0.35, maxHeight: maxHeight),
child: FladderImage( child: FladderImage(
image: logo, image: logo,
disableBlur: true, disableBlur: true,
alignment: imageAlignment, stackFit: StackFit.passthrough,
alignment: Alignment.bottomLeft,
imageErrorBuilder: (context, object, stack) => textWidget, imageErrorBuilder: (context, object, stack) => textWidget,
placeHolder: const SizedBox(height: 0), placeHolder: textWidget,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
) )

View file

@ -23,8 +23,8 @@ class NestedScaffold extends ConsumerWidget {
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Theme.of(context).colorScheme.surface.withValues(alpha: 0.98), Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
], ],
), ),
), ),

View file

@ -4,77 +4,62 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart'; import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
import 'package:fladder/widgets/shared/shapes.dart';
class NestedSliverAppBar extends ConsumerWidget { class NestedSliverAppBar extends ConsumerWidget {
final BuildContext parent; final BuildContext parent;
final String? searchTitle;
final PageRouteInfo? route; final PageRouteInfo? route;
const NestedSliverAppBar({required this.parent, this.route, this.searchTitle, super.key}); const NestedSliverAppBar({required this.parent, this.route, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final surfaceColor = Theme.of(context).colorScheme.surface;
final buttonStyle = Theme.of(context).filledButtonTheme.style?.copyWith(
backgroundColor: WidgetStatePropertyAll(
surfaceColor.withValues(alpha: 0.8),
),
);
return SliverAppBar( return SliverAppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
elevation: 16, elevation: 0,
forceElevated: true, forceElevated: false,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Colors.transparent,
shape: AppBarShape(), flexibleSpace: Container(
title: SizedBox( decoration: BoxDecoration(
height: 65, gradient: LinearGradient(
colors: [
surfaceColor.withValues(alpha: 0.7),
surfaceColor.withValues(alpha: 0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 24), padding: MediaQuery.paddingOf(context).copyWith(bottom: 0),
child: Row( child: Padding(
crossAxisAlignment: CrossAxisAlignment.stretch, padding: const EdgeInsets.all(14),
spacing: 10, child: SizedBox(
children: [ height: 50,
SizedBox( child: Row(
width: 30, crossAxisAlignment: CrossAxisAlignment.stretch,
child: IconButton( spacing: 12,
onPressed: () => Scaffold.of(parent).openDrawer(), children: [
icon: const Icon(IconsaxPlusLinear.menu), AspectRatio(
padding: EdgeInsets.zero, aspectRatio: 1.0,
), child: IconButton.filledTonal(
), style: buttonStyle,
Expanded( onPressed: () => Scaffold.of(parent).openDrawer(),
child: Hero( icon: const Icon(IconsaxPlusLinear.menu),
tag: "PrimarySearch", padding: EdgeInsets.zero,
child: Card(
elevation: 3,
shadowColor: Colors.transparent,
child: InkWell(
onTap: route != null
? () {
route?.push(context);
}
: null,
child: Padding(
padding: const EdgeInsets.all(10),
child: Opacity(
opacity: 0.65,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(IconsaxPlusLinear.search_normal),
const SizedBox(width: 16),
Transform.translate(
offset: const Offset(0, 1.5),
child: Text(searchTitle ?? "${context.localized.search}..."),
),
],
),
),
),
), ),
), ),
), const Spacer(),
const SettingsUserIcon()
],
), ),
const SettingsUserIcon() ),
],
), ),
), ),
), ),

View file

@ -92,8 +92,7 @@ class _SyncItemDetailsState extends ConsumerState<SyncItemDetails> {
children: [ children: [
if (baseItem != null) ...{ if (baseItem != null) ...{
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 16, spacing: 16,
children: [ children: [
SizedBox( SizedBox(
@ -193,7 +192,10 @@ class _SyncItemDetailsState extends ConsumerState<SyncItemDetails> {
), ),
}, },
if (children?.isNotEmpty == true) ...[ if (children?.isNotEmpty == true) ...[
const Divider(), const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Divider(),
),
...children!.map( ...children!.map(
(e) => ChildSyncWidget(syncedChild: e), (e) => ChildSyncWidget(syncedChild: e),
), ),

View file

@ -47,7 +47,6 @@ class _SyncedScreenState extends ConsumerState<SyncedScreen> {
slivers: [ slivers: [
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone)
NestedSliverAppBar( NestedSliverAppBar(
searchTitle: "${context.localized.search} ...",
parent: context, parent: context,
route: LibrarySearchRoute(), route: LibrarySearchRoute(),
) )

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
@ -51,6 +52,19 @@ class SyncOptionsButton extends ConsumerWidget {
final enqueuedTasks = syncTasks.where((element) => element.status == TaskStatus.enqueued).toList(); final enqueuedTasks = syncTasks.where((element) => element.status == TaskStatus.enqueued).toList();
final pausedTasks = syncTasks.where((element) => element.status == TaskStatus.paused).toList(); final pausedTasks = syncTasks.where((element) => element.status == TaskStatus.paused).toList();
return <PopupMenuEntry>[ return <PopupMenuEntry>[
PopupMenuItem(
child: Row(
spacing: 12,
children: [
const Icon(IconsaxPlusLinear.arrow_right),
Text(context.localized.showDetails),
],
),
onTap: () {
syncedItem.itemModel?.navigateTo(context);
context.maybePop();
},
),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
spacing: 12, spacing: 12,

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/providers/settings/subtitle_settings_provider.dart'; import 'package:fladder/providers/settings/subtitle_settings_provider.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
@ -60,7 +62,8 @@ class _VideoSubtitleControlsState extends ConsumerState<VideoSubtitleControls> {
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
color: controlsHidden ? DialogTheme.of(context).backgroundColor?.withValues(alpha: 0.75) : Colors.transparent, color:
controlsHidden ? Theme.of(context).colorScheme.surfaceContainer.withValues(alpha: 0.8) : Colors.transparent,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -68,10 +71,19 @@ class _VideoSubtitleControlsState extends ConsumerState<VideoSubtitleControls> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.label?.isNotEmpty == true) if (widget.label?.isNotEmpty == true)
Text( Row(
widget.label!, mainAxisAlignment: MainAxisAlignment.spaceBetween,
style: Theme.of(context).textTheme.headlineMedium, children: [
), Text(
widget.label!,
style: Theme.of(context).textTheme.headlineMedium,
),
IconButton(
onPressed: () => context.maybePop(),
icon: const Icon(IconsaxPlusBold.close_circle),
)
],
).addVisiblity(activeKey == null ? controlsHidden : activeKey == const Key('title')),
IconButton.filledTonal( IconButton.filledTonal(
isSelected: !hideControls, isSelected: !hideControls,
onPressed: () => setState(() => hideControls = !hideControls), onPressed: () => setState(() => hideControls = !hideControls),

View file

@ -250,8 +250,9 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
spacing: 16,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
onPressed: () => minimizePlayer(context), onPressed: () => minimizePlayer(context),
@ -260,26 +261,26 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
size: 24, size: 24,
), ),
), ),
const SizedBox(width: 16),
if (currentItem != null) if (currentItem != null)
Expanded( ConstrainedBox(
child: ConstrainedBox( constraints: BoxConstraints(
constraints: BoxConstraints( maxHeight: 150.clamp(50, MediaQuery.sizeOf(context).height * 0.25).toDouble(),
maxHeight: 150.clamp(50, MediaQuery.sizeOf(context).height * 0.25).toDouble(), ),
), child: ItemLogo(
child: ItemLogo( item: currentItem,
item: currentItem, imageAlignment: Alignment.topLeft,
imageAlignment: Alignment.topLeft, textStyle: Theme.of(context).textTheme.headlineLarge,
textStyle: Theme.of(context).textTheme.headlineLarge,
),
), ),
), ),
const SizedBox(width: 16), const Spacer(),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.touch) if (AdaptiveLayout.of(context).inputDevice == InputDevice.touch)
Tooltip( Align(
message: context.localized.stop, alignment: Alignment.centerRight,
child: IconButton( child: Tooltip(
onPressed: () => closePlayer(), icon: const Icon(IconsaxPlusLinear.close_square))), message: context.localized.stop,
child: IconButton(
onPressed: () => closePlayer(), icon: const Icon(IconsaxPlusLinear.close_square))),
),
], ],
), ),
), ),

View file

@ -12,6 +12,7 @@ class FladderImage extends ConsumerWidget {
final Widget Function(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded)? frameBuilder; final Widget Function(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded)? frameBuilder;
final Widget Function(BuildContext context, Object object, StackTrace? stack)? imageErrorBuilder; final Widget Function(BuildContext context, Object object, StackTrace? stack)? imageErrorBuilder;
final Widget? placeHolder; final Widget? placeHolder;
final StackFit stackFit;
final BoxFit fit; final BoxFit fit;
final BoxFit? blurFit; final BoxFit? blurFit;
final AlignmentGeometry? alignment; final AlignmentGeometry? alignment;
@ -22,6 +23,7 @@ class FladderImage extends ConsumerWidget {
this.frameBuilder, this.frameBuilder,
this.imageErrorBuilder, this.imageErrorBuilder,
this.placeHolder, this.placeHolder,
this.stackFit = StackFit.expand,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.blurFit, this.blurFit,
this.alignment, this.alignment,
@ -39,7 +41,7 @@ class FladderImage extends ConsumerWidget {
} else { } else {
return Stack( return Stack(
key: Key(newImage.key), key: Key(newImage.key),
fit: StackFit.expand, fit: stackFit,
children: [ children: [
if (!disableBlur && useBluredPlaceHolder && newImage.hash.isNotEmpty || blurOnly) if (!disableBlur && useBluredPlaceHolder && newImage.hash.isNotEmpty || blurOnly)
BlurHash( BlurHash(

View file

@ -24,6 +24,6 @@ extension WidgetExtensions on Widget {
} }
Widget addVisiblity(bool visible) { Widget addVisiblity(bool visible) {
return AnimatedOpacity(duration: const Duration(milliseconds: 250), opacity: visible ? 1 : 0, child: this); return AnimatedOpacity(duration: const Duration(milliseconds: 250), opacity: visible ? 1 : 0.10, child: this);
} }
} }

View file

@ -81,13 +81,14 @@ class DestinationModel {
); );
} }
NavigationButton toNavigationButton(bool selected, bool horizontal, bool expanded) { NavigationButton toNavigationButton(bool selected, bool horizontal, bool expanded, {Widget? customIcon}) {
return NavigationButton( return NavigationButton(
label: label, label: label,
selected: selected, selected: selected,
onPressed: action, onPressed: action,
horizontal: horizontal, horizontal: horizontal,
expanded: expanded, expanded: expanded,
customIcon: customIcon,
selectedIcon: selectedIcon!, selectedIcon: selectedIcon!,
icon: icon!, icon: icon!,
); );

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class DrawerListButton extends ConsumerStatefulWidget { class DrawerListButton extends ConsumerStatefulWidget {
final String label; final String label;

View file

@ -3,10 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:overflow_view/overflow_view.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/screens/shared/fladder_snackbar.dart'; import 'package:fladder/screens/shared/fladder_snackbar.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
@ -15,10 +16,16 @@ import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/duration_extensions.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/shared/fladder_slider.dart';
import 'package:fladder/widgets/shared/item_actions.dart';
const videoPlayerHeroTag = "HeroPlayer"; const videoPlayerHeroTag = "HeroPlayer";
const floatingPlayerHeight = 70.0; double floatingPlayerHeight(BuildContext context) => switch (AdaptiveLayout.viewSizeOf(context)) {
ViewSize.phone => 75,
ViewSize.tablet => 85,
ViewSize.desktop => 95,
};
class FloatingPlayerBar extends ConsumerStatefulWidget { class FloatingPlayerBar extends ConsumerStatefulWidget {
const FloatingPlayerBar({super.key}); const FloatingPlayerBar({super.key});
@ -29,6 +36,8 @@ class FloatingPlayerBar extends ConsumerStatefulWidget {
class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> { class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
bool showExpandButton = false; bool showExpandButton = false;
bool changingSliderValue = false;
Duration lastPosition = Duration.zero;
Future<void> openFullScreenPlayer() async { Future<void> openFullScreenPlayer() async {
setState(() => showExpandButton = false); setState(() => showExpandButton = false);
@ -58,155 +67,239 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final playbackInfo = ref.watch(mediaPlaybackProvider); final playbackInfo = ref.watch(mediaPlaybackProvider);
final player = ref.watch(videoPlayerProvider); final player = ref.watch(videoPlayerProvider);
final playbackModel = ref.watch(playBackModel.select((value) => value?.item)); final item = ref.watch(playBackModel.select((value) => value?.item));
final progress = playbackInfo.position.inMilliseconds / playbackInfo.duration.inMilliseconds; if (!changingSliderValue) {
return Dismissible( lastPosition = playbackInfo.position;
key: const Key("CurrentlyPlayingBar"), }
confirmDismiss: (direction) async {
if (direction == DismissDirection.up) { var isFavourite = item?.userData.isFavourite == true;
await openFullScreenPlayer();
} else { final isDesktop = AdaptiveLayout.of(context).isDesktop;
await stopPlayer();
} final itemActions = [
return false; ItemActionButton(
}, label: Text(context.localized.audio),
direction: DismissDirection.vertical, icon: Consumer(
child: InkWell( builder: (context, ref, child) {
onLongPress: () => fladderSnackbar(context, title: "Swipe up/down to open/close the player"), var volume = (player.lastState?.volume ?? 0) <= 0;
child: Card( return Icon(
elevation: 5, volume ? IconsaxPlusBold.volume_cross : IconsaxPlusBold.volume_high,
color: Theme.of(context).colorScheme.primaryContainer, );
child: SizedBox( },
height: floatingPlayerHeight, ),
child: LayoutBuilder(builder: (context, constraints) { action: () {
return Row( final volume = player.lastState?.volume == 0 ? 100.0 : 0.0;
children: [ player.setVolume(volume);
Flexible( }),
child: Padding( ItemActionButton(
padding: MediaQuery.paddingOf(context).copyWith(top: 0, bottom: 0), label: Text(context.localized.stop),
child: Column( action: () async => stopPlayer(),
mainAxisSize: MainAxisSize.min, icon: const Icon(IconsaxPlusBold.stop),
children: [ ),
Expanded( ItemActionButton(
child: Padding( label: Text(isFavourite ? context.localized.removeAsFavorite : context.localized.addAsFavorite),
padding: const EdgeInsets.all(6), icon: Icon(
child: Row( color: isFavourite ? Colors.red : null,
spacing: 7, isFavourite ? IconsaxPlusBold.heart : IconsaxPlusLinear.heart,
children: [ ),
if (playbackInfo.state == VideoPlayerState.minimized) action: () async {
Card( final result = (await ref.read(userProvider.notifier).setAsFavorite(
child: AspectRatio( !isFavourite,
aspectRatio: 1.67, item?.id ?? "",
child: MouseRegion( ))
onEnter: (event) => setState(() => showExpandButton = true), ?.body;
onExit: (event) => setState(() => showExpandButton = false),
child: Stack( if (result != null) {
children: [ ref.read(playBackModel.notifier).update((state) => state?.updateUserData(result));
Hero( }
tag: videoPlayerHeroTag, },
child: player.videoWidget( ),
UniqueKey(), ];
BoxFit.fitHeight, return Padding(
) ?? padding:
const SizedBox.shrink(), MediaQuery.paddingOf(context).copyWith(top: 0, bottom: isDesktop ? 0 : MediaQuery.paddingOf(context).bottom),
), child: Dismissible(
Positioned.fill( key: const Key("CurrentlyPlayingBar"),
child: Tooltip( confirmDismiss: (direction) async {
message: "Expand player", if (direction == DismissDirection.up) {
waitDuration: const Duration(milliseconds: 500), await openFullScreenPlayer();
child: AnimatedOpacity( } else {
opacity: showExpandButton ? 1 : 0, await stopPlayer();
duration: const Duration(milliseconds: 125), }
child: Container( return false;
color: Colors.black.withValues(alpha: 0.6), },
child: FlatButton( direction: DismissDirection.vertical,
onTap: () async => openFullScreenPlayer(), child: InkWell(
child: const Icon(Icons.keyboard_arrow_up_rounded), onLongPress: () => fladderSnackbar(context, title: "Swipe up/down to open/close the player"),
), child: Card(
), elevation: 5,
), color: Theme.of(context).colorScheme.primaryContainer,
), child: SizedBox(
) height: floatingPlayerHeight(context),
], child: LayoutBuilder(builder: (context, constraints) {
), return Column(
), mainAxisSize: MainAxisSize.min,
), children: [
), Expanded(
Expanded( child: Padding(
child: Column( padding: const EdgeInsets.all(6),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
mainAxisSize: MainAxisSize.min, spacing: 12,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
children: [
if (playbackInfo.state == VideoPlayerState.minimized)
Card(
child: AspectRatio(
aspectRatio: 1.67,
child: MouseRegion(
onEnter: (event) => setState(() => showExpandButton = true),
onExit: (event) => setState(() => showExpandButton = false),
child: Stack(
children: [ children: [
Flexible( Hero(
child: Text( tag: videoPlayerHeroTag,
playbackModel?.title ?? "", child: player.videoWidget(
style: Theme.of(context).textTheme.titleMedium, UniqueKey(),
), BoxFit.fitHeight,
) ??
const SizedBox.shrink(),
), ),
if (playbackModel?.detailedName(context)?.isNotEmpty == true) Positioned.fill(
Flexible( child: Tooltip(
child: Text( message: "Expand player",
playbackModel?.detailedName(context) ?? "", waitDuration: const Duration(milliseconds: 500),
overflow: TextOverflow.ellipsis, child: AnimatedOpacity(
style: Theme.of(context).textTheme.bodyMedium?.copyWith( opacity: showExpandButton ? 1 : 0,
color: duration: const Duration(milliseconds: 125),
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65), child: Container(
), color: Colors.black.withValues(alpha: 0.6),
child: FlatButton(
onTap: () async => openFullScreenPlayer(),
child: const Icon(Icons.keyboard_arrow_up_rounded),
),
),
), ),
), ),
)
], ],
), ),
), ),
if (!progress.isNaN && constraints.maxWidth > 500) ),
Text( ),
"${playbackInfo.position.readAbleDuration} / ${playbackInfo.duration.readAbleDuration}"), Expanded(
Padding( child: InkWell(
padding: const EdgeInsets.symmetric(horizontal: 12), onTap: () => item?.navigateTo(context),
child: IconButton.filledTonal( child: Column(
onPressed: () => ref.read(videoPlayerProvider).playOrPause(), crossAxisAlignment: CrossAxisAlignment.start,
icon: playbackInfo.playing mainAxisSize: MainAxisSize.min,
? const Icon(Icons.pause_rounded) children: [
: const Icon(Icons.play_arrow_rounded), Flexible(
), child: Text(
), item?.title ?? "",
if (constraints.maxWidth > 500) ...[ style: Theme.of(context).textTheme.titleMedium,
IconButton( maxLines: 1,
onPressed: () {
final volume = player.lastState?.volume == 0 ? 100.0 : 0.0;
player.setVolume(volume);
},
icon: Icon(
ref.watch(videoPlayerSettingsProvider.select((value) => value.volume)) <= 0
? IconsaxPlusBold.volume_cross
: IconsaxPlusBold.volume_high,
), ),
), ),
Tooltip( if (item?.detailedName(context)?.isNotEmpty == true)
message: context.localized.stop, Flexible(
waitDuration: const Duration(milliseconds: 500), child: Text(
child: IconButton( item?.detailedName(context) ?? "",
onPressed: () async => stopPlayer(), overflow: TextOverflow.ellipsis,
icon: const Icon(IconsaxPlusBold.stop), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.65),
),
maxLines: 1,
),
), ),
),
], ],
], ),
), ),
), ),
), Expanded(
LinearProgressIndicator( child: Row(
minHeight: 6, mainAxisAlignment: MainAxisAlignment.end,
backgroundColor: Colors.black.withValues(alpha: 0.25), children: [
color: Theme.of(context).colorScheme.primary, if (constraints.maxWidth > 500)
value: progress.clamp(0, 1), Flexible(
), child: Text(
], "${lastPosition.readAbleDuration} / ${playbackInfo.duration.readAbleDuration}"),
),
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: IconButton.filledTonal(
onPressed: () => ref.read(videoPlayerProvider).playOrPause(),
icon: playbackInfo.playing
? const Icon(Icons.pause_rounded)
: const Icon(Icons.play_arrow_rounded),
),
),
),
Flexible(
child: OverflowView.flexible(
builder: (context, remainingItemCount) => PopupMenuButton(
iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45),
padding: EdgeInsets.zero,
itemBuilder: (context) => itemActions
.sublist(itemActions.length - remainingItemCount)
.map(
(e) => e.toPopupMenuItem(useIcons: true),
)
.toList(),
),
children: itemActions.map((e) => e.toButton()).toList(),
),
)
],
),
)
],
),
), ),
), ),
), AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer
], ? SizedBox(
); height: 8,
}), child: FladderSlider(
value: lastPosition.inMilliseconds.toDouble(),
min: 0.0,
max: playbackInfo.duration.inMilliseconds.toDouble(),
thumbWidth: 8,
onChangeStart: (value) {
setState(() {
changingSliderValue = true;
});
},
onChangeEnd: (value) async {
await player.seek(Duration(milliseconds: value ~/ 1));
await Future.delayed(const Duration(milliseconds: 250));
if (player.lastState?.playing == true) {
player.play();
}
setState(() {
lastPosition = Duration(milliseconds: value.toInt());
changingSliderValue = false;
});
},
onChanged: (value) {
setState(() {
lastPosition = Duration(milliseconds: value.toInt());
});
},
),
)
: LinearProgressIndicator(
minHeight: 8,
backgroundColor: Colors.black.withValues(alpha: 0.25),
color: Theme.of(context).colorScheme.primary,
value: (playbackInfo.position.inMilliseconds / playbackInfo.duration.inMilliseconds)
.clamp(0, 1),
)
],
);
}),
),
), ),
), ),
), ),

View file

@ -14,6 +14,7 @@ class NavigationButton extends ConsumerStatefulWidget {
final Function()? onPressed; final Function()? onPressed;
final Function()? onLongPress; final Function()? onLongPress;
final List<ItemAction> trailing; final List<ItemAction> trailing;
final Widget? customIcon;
final bool selected; final bool selected;
final Duration duration; final Duration duration;
const NavigationButton({ const NavigationButton({
@ -24,6 +25,7 @@ class NavigationButton extends ConsumerStatefulWidget {
this.expanded = false, this.expanded = false,
this.onPressed, this.onPressed,
this.onLongPress, this.onLongPress,
this.customIcon,
this.selected = false, this.selected = false,
this.trailing = const [], this.trailing = const [],
this.duration = const Duration(milliseconds: 125), this.duration = const Duration(milliseconds: 125),
@ -64,9 +66,11 @@ class _NavigationButtonState extends ConsumerState<NavigationButton> {
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
child: widget.horizontal child: widget.horizontal
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: widget.customIcon != null
? EdgeInsetsGeometry.zero
: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: SizedBox( child: SizedBox(
height: 35, height: widget.customIcon != null ? 60 : 35,
child: Row( child: Row(
spacing: 4, spacing: 4,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -85,10 +89,11 @@ class _NavigationButtonState extends ConsumerState<NavigationButton> {
.withValues(alpha: widget.selected && !widget.expanded ? 1 : 0), .withValues(alpha: widget.selected && !widget.expanded ? 1 : 0),
), ),
), ),
AnimatedSwitcher( widget.customIcon ??
duration: widget.duration, AnimatedSwitcher(
child: widget.selected ? widget.selectedIcon : widget.icon, duration: widget.duration,
), child: widget.selected ? widget.selectedIcon : widget.icon,
),
const SizedBox(width: 6), const SizedBox(width: 6),
if (widget.horizontal && widget.expanded) ...[ if (widget.horizontal && widget.expanded) ...[
if (widget.label != null) if (widget.label != null)
@ -119,17 +124,18 @@ class _NavigationButtonState extends ConsumerState<NavigationButton> {
), ),
) )
: Padding( : Padding(
padding: const EdgeInsets.all(8), padding: widget.customIcon != null ? EdgeInsetsGeometry.zero : const EdgeInsets.all(8),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Row( Row(
spacing: 8, spacing: 8,
children: [ children: [
AnimatedSwitcher( widget.customIcon ??
duration: widget.duration, AnimatedSwitcher(
child: widget.selected ? widget.selectedIcon : widget.icon, duration: widget.duration,
), child: widget.selected ? widget.selectedIcon : widget.icon,
),
if (widget.label != null && widget.horizontal && widget.expanded) if (widget.label != null && widget.horizontal && widget.expanded)
Flexible(child: Text(widget.label!)) Flexible(child: Text(widget.label!))
], ],

View file

@ -7,10 +7,13 @@ import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/collection_types.dart'; import 'package:fladder/models/collection_types.dart';
import 'package:fladder/models/view_model.dart'; import 'package:fladder/models/view_model.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/metadata/refresh_metadata.dart'; import 'package:fladder/screens/metadata/refresh_metadata.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart'; import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
@ -36,6 +39,7 @@ class NestedNavigationDrawer extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final useLibraryPosters = ref.watch(clientSettingsProvider.select((value) => value.usePosterForLibrary));
return NavigationDrawer( return NavigationDrawer(
key: const Key('navigation_drawer'), key: const Key('navigation_drawer'),
backgroundColor: isExpanded ? Colors.transparent : null, backgroundColor: isExpanded ? Colors.transparent : null,
@ -91,22 +95,41 @@ class NestedNavigationDrawer extends ConsumerWidget {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
...views.map((library) => DrawerListButton( ...views.map((library) {
label: library.name, var selected = context.router.currentUrl.contains(library.id);
selected: context.router.currentUrl.contains(library.id), final Widget? posterIcon = useLibraryPosters
actions: [ ? ClipRRect(
ItemActionButton( borderRadius: FladderTheme.smallShape.borderRadius,
label: Text(context.localized.scanLibrary), child: AspectRatio(
icon: const Icon(IconsaxPlusLinear.refresh), aspectRatio: 1.0,
action: () => showRefreshPopup(context, library.id, library.name), child: FladderImage(
), image: library.imageData?.primary,
], placeHolder: Card(
onPressed: () { child: Icon(
context.router.push(LibrarySearchRoute(viewModelId: library.id)); selected ? library.collectionType.icon : library.collectionType.iconOutlined,
Scaffold.of(context).closeDrawer(); ),
}, ),
selectedIcon: Icon(library.collectionType.icon), ),
icon: Icon(library.collectionType.iconOutlined))), ),
)
: null;
return DrawerListButton(
label: library.name,
selected: selected,
actions: [
ItemActionButton(
label: Text(context.localized.scanLibrary),
icon: const Icon(IconsaxPlusLinear.refresh),
action: () => showRefreshPopup(context, library.id, library.name),
),
],
onPressed: () {
context.router.push(LibrarySearchRoute(viewModelId: library.id));
Scaffold.of(context).closeDrawer();
},
selectedIcon: posterIcon ?? Icon(library.collectionType.icon),
icon: posterIcon ?? Icon(library.collectionType.iconOutlined));
}),
}, },
const Divider(indent: 28, endIndent: 28), const Divider(indent: 28, endIndent: 28),
if (isExpanded) if (isExpanded)

View file

@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
@ -9,16 +7,20 @@ import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:overflow_view/overflow_view.dart'; import 'package:overflow_view/overflow_view.dart';
import 'package:fladder/models/collection_types.dart'; import 'package:fladder/models/collection_types.dart';
import 'package:fladder/providers/settings/client_settings_provider.dart';
import 'package:fladder/providers/views_provider.dart'; import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/routes/auto_router.gr.dart';
import 'package:fladder/screens/metadata/refresh_metadata.dart'; import 'package:fladder/screens/metadata/refresh_metadata.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart'; import 'package:fladder/widgets/navigation_scaffold/components/adaptive_fab.dart';
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart'; import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_button.dart'; import 'package:fladder/widgets/navigation_scaffold/components/navigation_button.dart';
import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart'; import 'package:fladder/widgets/navigation_scaffold/components/settings_user_icon.dart';
import 'package:fladder/widgets/shared/custom_tooltip.dart';
import 'package:fladder/widgets/shared/item_actions.dart'; import 'package:fladder/widgets/shared/item_actions.dart';
import 'package:fladder/widgets/shared/modal_bottom_sheet.dart'; import 'package:fladder/widgets/shared/modal_bottom_sheet.dart';
@ -43,39 +45,21 @@ class SideNavigationBar extends ConsumerStatefulWidget {
class _SideNavigationBarState extends ConsumerState<SideNavigationBar> { class _SideNavigationBarState extends ConsumerState<SideNavigationBar> {
bool expandedSideBar = false; bool expandedSideBar = false;
bool showOnHover = false;
Timer? timer;
double currentWidth = 80;
void startTimer() {
timer?.cancel();
timer = Timer(const Duration(milliseconds: 650), () {
setState(() {
showOnHover = true;
});
});
}
void stopTimer() {
timer?.cancel();
timer = Timer(const Duration(milliseconds: 125), () {
setState(() {
showOnHover = false;
});
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final views = ref.watch(viewsProvider.select((value) => value.views)); final views = ref.watch(viewsProvider.select((value) => value.views));
final usePostersForLibrary = ref.watch(clientSettingsProvider.select((value) => value.usePosterForLibrary));
final expandedWidth = 250.0; final expandedWidth = 250.0;
final padding = MediaQuery.paddingOf(context); final padding = MediaQuery.paddingOf(context);
final collapsedWidth = 90.0 + padding.left; final collapsedWidth = 90 + padding.left;
final largeBar = AdaptiveLayout.layoutModeOf(context) != LayoutMode.single; final largeBar = AdaptiveLayout.layoutModeOf(context) != LayoutMode.single;
final fullyExpanded = largeBar ? expandedSideBar : false; final fullyExpanded = largeBar ? expandedSideBar : false;
final shouldExpand = showOnHover || fullyExpanded; final shouldExpand = fullyExpanded;
final isDesktop = AdaptiveLayout.of(context).isDesktop; final isDesktop = AdaptiveLayout.of(context).isDesktop;
return Stack( return Stack(
children: [ children: [
AdaptiveLayoutBuilder( AdaptiveLayoutBuilder(
@ -88,151 +72,229 @@ class _SideNavigationBarState extends ConsumerState<SideNavigationBar> {
color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85), color: Theme.of(context).colorScheme.surface.withValues(alpha: shouldExpand ? 0.95 : 0.85),
width: shouldExpand ? expandedWidth : collapsedWidth, width: shouldExpand ? expandedWidth : collapsedWidth,
child: MouseRegion( child: MouseRegion(
onEnter: (value) => startTimer(), child: Padding(
onExit: (event) => stopTimer(), key: const Key('navigation_rail'),
onHover: (value) => startTimer(), padding: padding.copyWith(right: 0, top: isDesktop ? padding.top : null),
child: Column( child: Column(
children: [ spacing: 2,
if (isDesktop && AdaptiveLayout.of(context).platform != TargetPlatform.macOS) ...{ children: [
const SizedBox(height: 4), Padding(
Text( padding: const EdgeInsets.symmetric(horizontal: 14),
"Fladder", child: Row(
style: Theme.of(context).textTheme.titleSmall, mainAxisAlignment: MainAxisAlignment.center,
),
},
if (AdaptiveLayout.of(context).platform == TargetPlatform.macOS) SizedBox(height: padding.top),
Expanded(
child: Padding(
key: const Key('navigation_rail'),
padding: padding.copyWith(right: 0, top: isDesktop ? 8 : null),
child: Column(
spacing: 2,
children: [ children: [
Align( if (expandedSideBar) ...[
alignment: largeBar && expandedSideBar ? Alignment.centerRight : Alignment.center, Expanded(child: Text(context.localized.navigation)),
child: Opacity( ],
opacity: largeBar && expandedSideBar ? 0.65 : 1.0, Opacity(
child: IconButton( opacity: largeBar && expandedSideBar ? 0.65 : 1.0,
onPressed: !largeBar child: IconButton(
? () => widget.scaffoldKey.currentState?.openDrawer() onPressed: !largeBar
: () => setState(() { ? () => widget.scaffoldKey.currentState?.openDrawer()
expandedSideBar = !expandedSideBar; : () => setState(() => expandedSideBar = !expandedSideBar),
if (!expandedSideBar) { icon: Icon(
showOnHover = false; largeBar && expandedSideBar ? IconsaxPlusLinear.sidebar_left : IconsaxPlusLinear.menu,
}
}),
icon: Icon(
largeBar && expandedSideBar ? IconsaxPlusLinear.sidebar_left : IconsaxPlusLinear.menu,
),
), ),
), ),
), )
const SizedBox(height: 8),
if (largeBar) ...[
AnimatedFadeSize(
duration: const Duration(milliseconds: 250),
child: shouldExpand ? actionButton(context).extended : actionButton(context).normal,
),
],
Expanded(
child: Column(
spacing: 2,
mainAxisAlignment: !largeBar ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
...widget.destinations.mapIndexed(
(index, destination) =>
destination.toNavigationButton(widget.currentIndex == index, true, shouldExpand),
),
if (views.isNotEmpty && largeBar) ...[
const Divider(
indent: 32,
endIndent: 32,
),
Flexible(
child: OverflowView.flexible(
direction: Axis.vertical,
spacing: 4,
children: views.map(
(view) {
final actions = [
ItemActionButton(
label: Text(context.localized.scanLibrary),
icon: const Icon(IconsaxPlusLinear.refresh),
action: () => showRefreshPopup(context, view.id, view.name),
)
];
return view.toNavigationButton(
context.router.currentUrl.contains(view.id),
true,
shouldExpand,
() => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
onLongPress: () => showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: actions.listTileItems(context, useIcons: true),
),
),
trailing: actions,
);
},
).toList(),
builder: (context, remaining) {
return PopupMenuButton(
iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45),
padding: EdgeInsets.zero,
icon: NavigationButton(
label: context.localized.other,
selectedIcon: const Icon(IconsaxPlusLinear.arrow_square_down),
icon: const Icon(IconsaxPlusLinear.arrow_square_down),
expanded: shouldExpand,
horizontal: true,
),
itemBuilder: (context) => views
.sublist(views.length - remaining)
.map(
(e) => PopupMenuItem(
onTap: () => context.pushRoute(LibrarySearchRoute(viewModelId: e.id)),
child: Row(
spacing: 8,
children: [
Icon(e.collectionType.iconOutlined),
Text(e.name),
],
),
),
)
.toList(),
);
},
),
),
],
],
),
),
NavigationButton(
label: context.localized.settings,
selected: widget.currentLocation.contains(const SettingsRoute().routeName),
selectedIcon: const Icon(IconsaxPlusBold.setting_3),
horizontal: true,
expanded: shouldExpand,
icon: const SettingsUserIcon(),
onPressed: () {
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) {
context.router.push(const SettingsRoute());
} else {
context.router.push(const ClientSettingsRoute());
}
},
),
], ],
), ),
), ),
), if (largeBar) ...[
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) const SizedBox(height: 16), AnimatedFadeSize(
], duration: const Duration(milliseconds: 250),
child: shouldExpand ? actionButton(context).extended : actionButton(context).normal,
),
],
Expanded(
child: Column(
mainAxisAlignment: !largeBar ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
...widget.destinations.mapIndexed(
(index, destination) => CustomTooltip(
tooltipContent: expandedSideBar
? null
: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
destination.label,
style: Theme.of(context).textTheme.titleSmall,
),
),
),
position: TooltipPosition.right,
child: destination.toNavigationButton(
widget.currentIndex == index,
true,
shouldExpand,
),
),
),
if (views.isNotEmpty && largeBar) ...[
const Divider(
indent: 32,
endIndent: 32,
),
Flexible(
child: OverflowView.flexible(
direction: Axis.vertical,
spacing: 4,
children: views.map(
(view) {
final selected = context.router.currentUrl.contains(view.id);
final actions = [
ItemActionButton(
label: Text(context.localized.scanLibrary),
icon: const Icon(IconsaxPlusLinear.refresh),
action: () => showRefreshPopup(context, view.id, view.name),
)
];
return CustomTooltip(
tooltipContent: expandedSideBar
? null
: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
view.name,
style: Theme.of(context).textTheme.titleSmall,
),
),
),
position: TooltipPosition.right,
child: view.toNavigationButton(
selected,
true,
shouldExpand,
() => context.pushRoute(LibrarySearchRoute(viewModelId: view.id)),
onLongPress: () => showBottomSheetPill(
context: context,
content: (context, scrollController) => ListView(
shrinkWrap: true,
controller: scrollController,
children: actions.listTileItems(context, useIcons: true),
),
),
customIcon: usePostersForLibrary
? ClipRRect(
borderRadius: FladderTheme.smallShape.borderRadius,
child: SizedBox.square(
dimension: 50,
child: FladderImage(
image: view.imageData?.primary,
placeHolder: Card(
child: Icon(
selected
? view.collectionType.icon
: view.collectionType.iconOutlined,
),
),
),
),
)
: null,
trailing: actions,
),
);
},
).toList(),
builder: (context, remaining) {
return CustomTooltip(
tooltipContent: expandedSideBar
? null
: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
context.localized.moreOptions,
style: Theme.of(context).textTheme.titleSmall,
),
),
),
position: TooltipPosition.right,
child: PopupMenuButton(
iconColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45),
padding: EdgeInsets.zero,
tooltip: "",
icon: NavigationButton(
label: context.localized.other,
selectedIcon: const Icon(IconsaxPlusLinear.arrow_square_down),
icon: const Icon(IconsaxPlusLinear.arrow_square_down),
expanded: shouldExpand,
customIcon: usePostersForLibrary
? ClipRRect(
borderRadius: FladderTheme.smallShape.borderRadius,
child: const SizedBox.square(
dimension: 50,
child: Card(
child: Icon(IconsaxPlusLinear.arrow_square_down),
),
),
)
: null,
horizontal: true,
),
itemBuilder: (context) => views
.sublist(views.length - remaining)
.map(
(e) => PopupMenuItem(
onTap: () => context.pushRoute(LibrarySearchRoute(viewModelId: e.id)),
child: Row(
spacing: 8,
children: [
usePostersForLibrary
? Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ClipRRect(
borderRadius: FladderTheme.smallShape.borderRadius,
child: SizedBox.square(
dimension: 45,
child: FladderImage(
image: e.imageData?.primary,
placeHolder: Card(
child: Icon(
e.collectionType.iconOutlined,
),
),
),
),
),
)
: Icon(e.collectionType.iconOutlined),
Text(e.name),
],
),
),
)
.toList(),
),
);
},
),
),
],
],
),
),
NavigationButton(
label: context.localized.settings,
selected: widget.currentLocation.contains(const SettingsRoute().routeName),
selectedIcon: const Icon(IconsaxPlusBold.setting_3),
horizontal: true,
expanded: shouldExpand,
icon: const SettingsUserIcon(),
onPressed: () {
if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) {
context.router.push(const SettingsRoute());
} else {
context.router.push(const ClientSettingsRoute());
}
},
),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) const SizedBox(height: 16),
],
),
), ),
), ),
), ),

View file

@ -10,7 +10,6 @@ import 'package:fladder/routes/auto_router.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart'; import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/screens/shared/nested_bottom_appbar.dart'; import 'package:fladder/screens/shared/nested_bottom_appbar.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/theme_extensions.dart';
import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart'; import 'package:fladder/widgets/navigation_scaffold/components/destination_model.dart';
import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart'; import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.dart';
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart'; import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
@ -58,9 +57,15 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
final isDesktop = AdaptiveLayout.of(context).isDesktop; final isDesktop = AdaptiveLayout.of(context).isDesktop;
final bottomPadding = isDesktop || kIsWeb ? 0.0 : MediaQuery.paddingOf(context).bottom; final mediaQuery = MediaQuery.of(context);
final paddingOf = mediaQuery.padding;
final viewPaddingOf = mediaQuery.viewPadding;
final bottomPadding = isDesktop || kIsWeb ? 0.0 : paddingOf.bottom;
final bottomViewPadding = isDesktop || kIsWeb ? 0.0 : viewPaddingOf.bottom;
final isHomeScreen = currentIndex != -1; final isHomeScreen = currentIndex != -1;
return PopScope( return PopScope(
canPop: currentIndex == 0, canPop: currentIndex == 0,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
@ -72,58 +77,66 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
children: [ children: [
Positioned.fill( Positioned.fill(
child: Padding( child: MediaQuery(
padding: EdgeInsets.only(bottom: showPlayerBar ? floatingPlayerHeight - 12 + bottomPadding : 0), data: mediaQuery.copyWith(
child: Scaffold( padding: paddingOf.copyWith(
key: _key, bottom: showPlayerBar ? floatingPlayerHeight(context) + 12 + bottomPadding : bottomPadding),
appBar: const FladderAppBar(), viewPadding: viewPaddingOf.copyWith(
extendBodyBehindAppBar: true, bottom: showPlayerBar ? floatingPlayerHeight(context) + bottomViewPadding : bottomViewPadding),
resizeToAvoidBottomInset: false, ),
extendBody: true, //Builder to correctly apply new padding
floatingActionButton: AdaptiveLayout.layoutModeOf(context) == LayoutMode.single && isHomeScreen child: Builder(builder: (context) {
? widget.destinations.elementAtOrNull(currentIndex)?.floatingActionButton?.normal return Scaffold(
: null, key: _key,
drawer: homeRoutes.any((element) => element.name.contains(currentLocation)) appBar: const FladderAppBar(),
? NestedNavigationDrawer( extendBodyBehindAppBar: true,
actionButton: null, resizeToAvoidBottomInset: false,
toggleExpanded: (value) => _key.currentState?.closeDrawer(), extendBody: true,
views: views, floatingActionButton: AdaptiveLayout.layoutModeOf(context) == LayoutMode.single && isHomeScreen
destinations: widget.destinations, ? widget.destinations.elementAtOrNull(currentIndex)?.floatingActionButton?.normal
currentLocation: currentLocation, : null,
) drawer: homeRoutes.any((element) => element.name.contains(currentLocation))
: null, ? NestedNavigationDrawer(
bottomNavigationBar: isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone actionButton: null,
? HideOnScroll( toggleExpanded: (value) => _key.currentState?.closeDrawer(),
controller: AdaptiveLayout.scrollOf(context), views: views,
forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)), destinations: widget.destinations,
child: NestedBottomAppBar( currentLocation: currentLocation,
child: SizedBox( )
height: 65, : null,
child: Row( bottomNavigationBar: isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone
mainAxisAlignment: MainAxisAlignment.spaceAround, ? HideOnScroll(
crossAxisAlignment: CrossAxisAlignment.stretch, controller: AdaptiveLayout.scrollOf(context),
children: widget.destinations forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)),
.map( child: NestedBottomAppBar(
(destination) => destination.toNavigationButton( child: SizedBox(
widget.currentRouteName == destination.route?.routeName, false, false), height: 65,
) child: Row(
.toList(), mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.destinations
.map(
(destination) => destination.toNavigationButton(
widget.currentRouteName == destination.route?.routeName, false, false),
)
.toList(),
),
), ),
), ),
), )
) : null,
: null, body: widget.nestedChild != null
body: widget.nestedChild != null ? NavigationBody(
? NavigationBody( child: widget.nestedChild!,
child: widget.nestedChild!, parentContext: context,
parentContext: context, currentIndex: currentIndex,
currentIndex: currentIndex, destinations: widget.destinations,
destinations: widget.destinations, currentLocation: currentLocation,
currentLocation: currentLocation, drawerKey: _key,
drawerKey: _key, )
) : null,
: null, );
), }),
), ),
), ),
Material( Material(
@ -131,19 +144,7 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
child: AnimatedFadeSize( child: AnimatedFadeSize(
child: Container( child: Container(
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( child: showPlayerBar ? const FloatingPlayerBar() : const SizedBox.shrink(),
color: context.colors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: showPlayerBar
? Padding(
padding: EdgeInsets.only(bottom: bottomPadding),
child: const FloatingPlayerBar(),
)
: const SizedBox.shrink(),
), ),
), ),
) )

View file

@ -0,0 +1,130 @@
import 'dart:async';
import 'package:flutter/material.dart';
class CustomTooltip extends StatefulWidget {
final Widget child;
final Widget? tooltipContent;
final double offset;
final TooltipPosition position;
final Duration showDelay;
const CustomTooltip({
required this.child,
required this.tooltipContent,
this.offset = 12,
this.position = TooltipPosition.top,
this.showDelay = const Duration(milliseconds: 125),
super.key,
});
@override
CustomTooltipState createState() => CustomTooltipState();
}
enum TooltipPosition { top, bottom, left, right }
class CustomTooltipState extends State<CustomTooltip> {
OverlayEntry? _overlayEntry;
Timer? _tooltipTimer;
final GlobalKey _tooltipKey = GlobalKey();
void _showTooltip() {
_tooltipTimer?.cancel();
_tooltipTimer = Timer(widget.showDelay, () {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry();
Overlay.of(context).insert(_overlayEntry!);
}
});
}
void _hideTooltip() {
_tooltipTimer?.cancel();
_overlayEntry?.remove();
_overlayEntry = null;
}
OverlayEntry _createOverlayEntry() {
RenderBox renderBox = context.findRenderObject() as RenderBox;
Offset targetPosition = renderBox.localToGlobal(Offset.zero);
Size targetSize = renderBox.size;
return OverlayEntry(
builder: (context) {
final tooltipRenderBox = _tooltipKey.currentContext?.findRenderObject() as RenderBox?;
if (tooltipRenderBox != null) {
Size tooltipSize = tooltipRenderBox.size;
Offset tooltipPosition;
switch (widget.position) {
case TooltipPosition.top:
tooltipPosition = Offset(
targetPosition.dx + (targetSize.width - tooltipSize.width) / 2,
targetPosition.dy - tooltipSize.height - widget.offset,
);
break;
case TooltipPosition.bottom:
tooltipPosition = Offset(
targetPosition.dx + (targetSize.width - tooltipSize.width) / 2,
targetPosition.dy + targetSize.height + widget.offset,
);
break;
case TooltipPosition.left:
tooltipPosition = Offset(
targetPosition.dx - tooltipSize.width - widget.offset,
targetPosition.dy + (targetSize.height - tooltipSize.height) / 2,
);
break;
case TooltipPosition.right:
tooltipPosition = Offset(
targetPosition.dx + targetSize.width + widget.offset,
targetPosition.dy + (targetSize.height - tooltipSize.height) / 2,
);
break;
}
return Positioned(
left: tooltipPosition.dx,
top: tooltipPosition.dy,
child: Material(
color: Colors.transparent,
child: widget.tooltipContent,
),
);
}
return const SizedBox.shrink();
},
);
}
@override
Widget build(BuildContext context) {
if (widget.tooltipContent == null) return widget.child;
return MouseRegion(
onEnter: (_) => _showTooltip(),
onExit: (_) => _hideTooltip(),
child: Stack(
children: [
widget.child,
Positioned(
left: -1000,
top: -1000,
child: Container(
key: _tooltipKey,
child: widget.tooltipContent,
),
),
],
),
);
}
@override
void dispose() {
_tooltipTimer?.cancel();
_hideTooltip(); // Ensure the tooltip is hidden on dispose
super.dispose();
}
}

View file

@ -10,6 +10,7 @@ abstract class ItemAction {
PopupMenuEntry toPopupMenuItem({bool useIcons = false}); PopupMenuEntry toPopupMenuItem({bool useIcons = false});
Widget toLabel(); Widget toLabel();
Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}); Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true});
Widget toButton();
} }
class ItemActionDivider extends ItemAction { class ItemActionDivider extends ItemAction {
@ -26,6 +27,9 @@ class ItemActionDivider extends ItemAction {
@override @override
Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) => const Divider(); Widget toListItem(BuildContext context, {bool useIcons = false, bool shouldPop = true}) => const Divider();
@override
Widget toButton() => Container();
} }
class ItemActionButton extends ItemAction { class ItemActionButton extends ItemAction {
@ -51,9 +55,10 @@ class ItemActionButton extends ItemAction {
} }
@override @override
MenuItemButton toMenuItemButton() { MenuItemButton toMenuItemButton() => MenuItemButton(leadingIcon: icon, onPressed: action, child: label);
return MenuItemButton(leadingIcon: icon, onPressed: action, child: label);
} @override
Widget toButton() => IconButton(onPressed: action, icon: icon ?? const SizedBox.shrink());
@override @override
PopupMenuItem toPopupMenuItem({bool useIcons = false}) { PopupMenuItem toPopupMenuItem({bool useIcons = false}) {