feat: Sync offline/online playback when able (#431)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-08-03 13:35:56 +02:00 committed by GitHub
parent 15ac3566e2
commit 092836328f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1002 additions and 497 deletions

View file

@ -54,7 +54,7 @@ class _CurrentlyPlayingBarState extends ConsumerState<FloatingPlayerBar> {
}
}
if (context.mounted) {
context.refreshData();
await context.refreshData();
}
}

View file

@ -1,9 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/media_playback_model.dart';
import 'package:fladder/providers/connectivity_provider.dart';
import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/providers/views_provider.dart';
import 'package:fladder/routes/auto_router.dart';
@ -15,7 +16,9 @@ import 'package:fladder/widgets/navigation_scaffold/components/fladder_app_bar.d
import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart';
import 'package:fladder/widgets/navigation_scaffold/components/navigation_drawer.dart';
import 'package:fladder/widgets/shared/animated_visibility.dart';
import 'package:fladder/widgets/shared/hide_on_scroll.dart';
import 'package:fladder/widgets/shared/offline_banner.dart';
class NavigationScaffold extends ConsumerStatefulWidget {
final String? currentRouteName;
@ -55,17 +58,22 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state));
final showPlayerBar = playerState == VideoPlayerState.minimized;
final isDesktop = AdaptiveLayout.of(context).isDesktop;
final isDesktop = AdaptiveLayout.of(context).isDesktop || kIsWeb;
final mediaQuery = MediaQuery.of(context);
final theme = Theme.of(context);
final paddingOf = mediaQuery.padding;
final viewPaddingOf = mediaQuery.viewPadding;
final bottomPadding = isDesktop || kIsWeb ? 12.0 : paddingOf.bottom;
final bottomViewPadding = isDesktop || kIsWeb ? 12.0 : viewPaddingOf.bottom;
final bottomPadding = isDesktop ? 12.0 : paddingOf.bottom;
final bottomViewPadding = isDesktop ? 12.0 : viewPaddingOf.bottom;
final isHomeScreen = currentIndex != -1;
final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline));
final offlineMessageHeight = isOffline && !isDesktop ? 12 : 0;
return PopScope(
canPop: currentIndex == 0,
onPopInvokedWithResult: (didPop, result) {
@ -80,9 +88,13 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
child: MediaQuery(
data: mediaQuery.copyWith(
padding: paddingOf.copyWith(
bottom: showPlayerBar ? floatingPlayerHeight(context) + 12 + bottomPadding : bottomPadding),
top: mediaQuery.padding.top + offlineMessageHeight,
bottom: showPlayerBar ? floatingPlayerHeight(context) + 12 + bottomPadding : bottomPadding,
),
viewPadding: viewPaddingOf.copyWith(
bottom: showPlayerBar ? floatingPlayerHeight(context) + bottomViewPadding : bottomViewPadding),
top: mediaQuery.viewPadding.top,
bottom: showPlayerBar ? floatingPlayerHeight(context) + bottomViewPadding : bottomViewPadding,
),
),
//Builder to correctly apply new padding
child: Builder(builder: (context) {
@ -104,27 +116,29 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
currentLocation: currentLocation,
)
: null,
bottomNavigationBar: isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone
? HideOnScroll(
controller: AdaptiveLayout.scrollOf(context),
forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)),
child: NestedBottomAppBar(
child: SizedBox(
height: 65,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.destinations
.map(
(destination) => destination.toNavigationButton(
widget.currentRouteName == destination.route?.routeName, false, false),
)
.toList(),
),
),
bottomNavigationBar: AnimatedVisibility(
visible: (isHomeScreen && AdaptiveLayout.viewSizeOf(context) == ViewSize.phone),
duration: const Duration(milliseconds: 250),
child: HideOnScroll(
controller: AdaptiveLayout.scrollOf(context),
forceHide: !homeRoutes.any((element) => element.name.contains(currentLocation)),
child: NestedBottomAppBar(
child: SizedBox(
height: 65,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.destinations
.map(
(destination) => destination.toNavigationButton(
widget.currentRouteName == destination.route?.routeName, false, false),
)
.toList(),
),
)
: null,
),
),
),
),
body: widget.nestedChild != null
? NavigationBody(
child: widget.nestedChild!,
@ -147,7 +161,32 @@ class _NavigationScaffoldState extends ConsumerState<NavigationScaffold> {
child: showPlayerBar ? const FloatingPlayerBar() : const SizedBox.shrink(),
),
),
)
),
if (!AdaptiveLayout.of(context).isDesktop)
Align(
alignment: Alignment.topCenter,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: isOffline ? 1 : 0,
child: Container(
height: kToolbarHeight + offlineMessageHeight,
alignment: Alignment.bottomCenter,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.errorContainer.withValues(alpha: 0.8),
theme.colorScheme.errorContainer.withValues(alpha: 0.25),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)),
child: const Padding(
padding: EdgeInsets.only(bottom: 8),
child: OfflineBanner(),
),
),
),
),
],
),
);

View file

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class AnimatedVisibility extends StatelessWidget {
final Widget? child;
final bool visible;
final Duration duration;
const AnimatedVisibility(
{required this.child, required this.visible, this.duration = const Duration(milliseconds: 250), super.key});
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
duration: duration,
opacity: visible ? 1 : 0,
child: IgnorePointer(
ignoring: !visible,
child: SizedBox(
height: visible ? null : 16,
child: child,
),
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/providers/connectivity_provider.dart';
import 'package:fladder/util/localization_helper.dart';
class OfflineBanner extends ConsumerWidget {
const OfflineBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOffline = ref.watch(connectivityStatusProvider.select((value) => value == ConnectionState.offline));
final theme = Theme.of(context);
return AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: isOffline ? 1 : 0,
child: IgnorePointer(
child: Row(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
IconsaxPlusLinear.cloud_cross,
color: theme.colorScheme.onErrorContainer,
size: 20,
),
Text(
context.localized.offline,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
],
),
),
);
}
}

View file

@ -48,11 +48,11 @@ class _SelectableIconButtonState extends ConsumerState<SelectableIconButton> {
setState(() => loading = true);
try {
await widget.onPressed();
if (context.mounted) await context.refreshData();
} catch (e) {
log(e.toString());
} finally {
setState(() => loading = false);
if (context.mounted) await context.refreshData();
}
},
child: Padding(