From 5174bb3a6c611ae3e8cb7bb4994d338796eb03e9 Mon Sep 17 00:00:00 2001 From: PartyDonut Date: Fri, 3 Oct 2025 13:02:51 +0200 Subject: [PATCH] fix: Lots of navigation improvements --- android/.gitignore | 2 - android/app/build.gradle | 5 +- .../main/kotlin/nl/jknaapen/fladder/Theme.kt | 18 +- .../composables/controls/ItemHeader.kt | 7 +- .../composables/controls/ProgressBar.kt | 29 +- .../composables/controls/TrickplayOverlay.kt | 12 + .../controls/VideoPlayerControls.kt | 6 +- assets/gradient.png | Bin 0 -> 32587 bytes lib/l10n/app_en.arb | 3 +- lib/routes/auto_router.dart | 4 - lib/screens/dashboard/dashboard_screen.dart | 29 +- .../details_screens/book_detail_screen.dart | 8 +- .../components/overview_header.dart | 31 +- .../episode_detail_screen.dart | 28 +- .../details_screens/movie_detail_screen.dart | 36 +- .../details_screens/series_detail_screen.dart | 30 +- lib/screens/library/library_screen.dart | 2 +- .../library_search/library_search_screen.dart | 396 ++++++++-------- lib/screens/login/login_user_grid.dart | 2 +- lib/screens/login/widgets/login_icon.dart | 7 +- lib/screens/metadata/edit_item.dart | 2 +- lib/screens/metadata/refresh_metadata.dart | 2 +- lib/screens/settings/settings_screen.dart | 12 +- lib/screens/shared/media/carousel_banner.dart | 6 +- lib/screens/shared/media/chapter_row.dart | 56 +-- .../media/components/media_play_button.dart | 36 +- lib/screens/shared/media/detailed_banner.dart | 181 +++----- lib/screens/shared/media/episode_posters.dart | 10 +- lib/screens/shared/media/external_urls.dart | 4 + lib/screens/shared/media/media_banner.dart | 4 +- lib/screens/shared/media/people_row.dart | 2 +- .../shared/media/poster_list_item.dart | 2 + lib/screens/shared/media/poster_row.dart | 2 +- lib/screens/shared/media/poster_widget.dart | 56 +++ lib/screens/shared/media/season_row.dart | 5 +- lib/screens/shared/nested_scaffold.dart | 5 +- lib/screens/shared/user_icon.dart | 16 +- lib/screens/syncing/sync_list_item.dart | 2 + .../components/video_player_chapters.dart | 8 +- .../components/video_player_next_wrapper.dart | 2 +- .../video_player/video_player_controls.dart | 12 +- lib/theme.dart | 3 +- lib/util/adaptive_layout/adaptive_layout.dart | 4 +- lib/util/focus_provider.dart | 54 +-- lib/util/throttler.dart | 19 +- lib/widgets/media_query_scaler.dart | 2 +- .../components/drawer_list_button.dart | 4 +- .../components/navigation_button.dart | 2 +- lib/widgets/shared/button_group.dart | 25 +- lib/widgets/shared/clickable_text.dart | 6 + lib/widgets/shared/custom_shader_mask.dart | 60 +++ lib/widgets/shared/ensure_visible.dart | 2 +- lib/widgets/shared/grid_focus_traveler.dart | 149 +++---- lib/widgets/shared/horizontal_list.dart | 422 ++++++++++-------- .../shared/selectable_icon_button.dart | 19 +- 55 files changed, 1019 insertions(+), 832 deletions(-) create mode 100644 assets/gradient.png create mode 100644 lib/widgets/shared/custom_shader_mask.dart diff --git a/android/.gitignore b/android/.gitignore index 0050c9b..55afd91 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,5 +11,3 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks - -**/TestData.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 168a5aa..4ac562f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,7 +110,7 @@ flutter { } dependencies { - def composeBom = platform('androidx.compose:compose-bom:2025.09.00') + def composeBom = platform('androidx.compose:compose-bom:2025.09.01') implementation composeBom androidTestImplementation composeBom implementation('androidx.compose.material3:material3') @@ -130,7 +130,8 @@ dependencies { implementation("io.github.peerless2012:ass-media:0.3.0-rc03") //UI - implementation("io.github.rabehx:iconsax-compose:0.0.3") + implementation("io.github.rabehx:iconsax-compose:0.0.4") implementation("io.coil-kt.coil3:coil-compose:3.3.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0") + implementation("com.materialkolor:material-kolor:3.0.1") } diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt index 9a4fda2..16e22fd 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/Theme.kt @@ -1,22 +1,26 @@ package nl.jknaapen.fladder import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color - -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF3B82F6) - -) +import com.materialkolor.PaletteStyle +import com.materialkolor.dynamiccolor.ColorSpec +import com.materialkolor.rememberDynamicColorScheme @Composable fun VideoPlayerTheme( content: @Composable () -> Unit ) { + val colorScheme = rememberDynamicColorScheme( + seedColor = Color(0xFFFF9800), + isDark = true, + specVersion = ColorSpec.SpecVersion.SPEC_2025, + style = PaletteStyle.Expressive, + ) + MaterialTheme( - colorScheme = DarkColorScheme, + colorScheme = colorScheme, ) { CompositionLocalProvider { content() diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt index 5fc23f1..ca82efd 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ItemHeader.kt @@ -2,10 +2,9 @@ package nl.jknaapen.fladder.composables.controls import PlayableData import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,8 +32,8 @@ fun ItemHeader(state: PlayableData?) { contentDescription = title ?: "logo", alignment = Alignment.CenterStart, modifier = Modifier - .heightIn(max = 100.dp) - .widthIn(max = 200.dp) + .fillMaxHeight(0.25f) + .fillMaxWidth(0.5f) ) } else { title?.let { diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt index 174bcea..8e6a316 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/ProgressBar.kt @@ -13,12 +13,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -102,6 +102,7 @@ internal fun ProgressBar( modifier = Modifier .fillMaxWidth() .height(125.dp) + .padding(bottom = 32.dp) .align(alignment = Alignment.CenterHorizontally), currentPosition = tempPosition.milliseconds, trickPlayModel = playbackData?.trickPlayModel @@ -129,7 +130,7 @@ internal fun ProgressBar( Text( formatTime(currentPosition), color = Color.White, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.titleMedium ) SimpleProgressBar( player, @@ -152,7 +153,7 @@ internal fun ProgressBar( ) ), color = Color.White, - style = MaterialTheme.typography.labelMedium + style = MaterialTheme.typography.titleMedium ) } } @@ -240,9 +241,11 @@ internal fun RowScope.SimpleProgressBar( modifier = Modifier .focusable(enabled = false) .fillMaxWidth() - .height(12.dp) + .height(8.dp) .background( - color = Color.Black.copy(alpha = 0.15f), + color = Color.Black.copy( + alpha = 0.15f + ), shape = slideBarShape ), ) { @@ -251,9 +254,11 @@ internal fun RowScope.SimpleProgressBar( .focusable(enabled = false) .fillMaxHeight() .fillMaxWidth(progress) - .padding(end = 9.dp) + .padding(end = 8.dp) .background( - color = Color.White.copy(alpha = 0.75f), + color = if (thumbFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy( + alpha = 0.75f + ), shape = slideBarShape ) ) @@ -321,11 +326,13 @@ internal fun RowScope.SimpleProgressBar( .graphicsLayer { translationX = startPx } - .size(6.dp) + .padding(vertical = 0.5.dp) + .fillMaxHeight() + .aspectRatio(ratio = 1f) .background( - color = (if (isAfterCurrentPositon) Color.White else Color.Black).copy( - alpha = 0.45f - ), + color = if (isAfterCurrentPositon) Color.White.copy( + alpha = 0.5f + ) else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), shape = CircleShape ) ) diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt index a8544da..27cca73 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/TrickplayOverlay.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -25,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import coil3.compose.AsyncImagePainter import coil3.compose.rememberAsyncImagePainter +import coil3.imageLoader import coil3.request.ImageRequest import coil3.toBitmap import kotlin.time.Duration @@ -42,6 +44,16 @@ fun FilmstripTrickPlayOverlay( return } + val context = LocalContext.current + LaunchedEffect(trickPlayModel) { + trickPlayModel.images.forEach { imageUrl -> + val request = ImageRequest.Builder(context) + .data(imageUrl) + .build() + context.imageLoader.enqueue(request) + } + } + val uniqueThumbnails = remember(currentPosition, trickPlayModel, thumbnailsToShowOnEachSide) { val currentFrameIndex = (currentPosition.inWholeMilliseconds / trickPlayModel.interval) .toInt() diff --git a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt index 22a94e6..b41899f 100644 --- a/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt +++ b/android/app/src/main/kotlin/nl/jknaapen/fladder/composables/controls/VideoPlayerControls.kt @@ -59,8 +59,8 @@ import io.github.rabehx.iconsax.filled.AudioSquare import io.github.rabehx.iconsax.filled.Backward import io.github.rabehx.iconsax.filled.Check import io.github.rabehx.iconsax.filled.Forward -import io.github.rabehx.iconsax.filled.PauseCircle -import io.github.rabehx.iconsax.filled.PlayCircle +import io.github.rabehx.iconsax.filled.Pause +import io.github.rabehx.iconsax.filled.Play import io.github.rabehx.iconsax.filled.Subtitle import io.github.rabehx.iconsax.outline.CloseSquare import io.github.rabehx.iconsax.outline.Refresh @@ -349,7 +349,7 @@ fun PlaybackButtons( }, ) { Icon( - if (isPlaying) Iconsax.Filled.PauseCircle else Iconsax.Filled.PlayCircle, + if (isPlaying) Iconsax.Filled.Pause else Iconsax.Filled.Play, modifier = Modifier.size(55.dp), contentDescription = if (isPlaying) "Pause" else "Play", ) diff --git a/assets/gradient.png b/assets/gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..29df1c21107f5e835a8f314620affb7f8837df04 GIT binary patch literal 32587 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 c9R^Q$o1 homeRoutes = [ page: DashboardRoute.page, initial: true, path: 'dashboard', - maintainState: false, ), AutoRoute( page: FavouritesRoute.page, path: 'favourites', - maintainState: false, ), AutoRoute( page: SyncedRoute.page, path: 'synced', - maintainState: false, ), AutoRoute( page: LibraryRoute.page, path: 'libraries', - maintainState: false, ), ]; diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 75b4002..eb4d002 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -49,7 +49,7 @@ class _DashboardScreenState extends ConsumerState { final textController = TextEditingController(); - ItemBaseModel? selectedPoster; + final selectedPoster = ValueNotifier(null); @override void initState() { @@ -76,7 +76,9 @@ class _DashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { final padding = AdaptiveLayout.adaptivePadding(context); - final bannerType = ref.watch(homeSettingsProvider.select((value) => value.homeBanner)); + final bannerType = AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad + ? HomeBanner.detailedBanner + : ref.watch(homeSettingsProvider.select((value) => value.homeBanner)); final dashboardData = ref.watch(dashboardProvider); final views = ref.watch(viewsProvider); @@ -99,10 +101,14 @@ class _DashboardScreenState extends ConsumerState { return MediaQuery.removeViewInsets( context: context, child: NestedScaffold( - background: BackgroundImage( - items: selectedPoster != null - ? [selectedPoster!] - : [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]), + background: ValueListenableBuilder( + valueListenable: selectedPoster, + builder: (_, value, __) { + return BackgroundImage( + items: value != null ? [value] : [...homeCarouselItems, ...dashboardData.nextUp, ...allResume], + ); + }, + ), body: PullToRefresh( refreshKey: _refreshIndicatorKey, displacement: 80 + MediaQuery.of(context).viewPadding.top, @@ -128,13 +134,7 @@ class _DashboardScreenState extends ConsumerState { ), child: HomeBannerWidget( posters: homeCarouselItems, - onSelect: (selected) { - // if (selectedPoster != selected) { - // setState(() { - // selectedPoster = selected; - // }); - // } - }, + onSelect: (poster) => selectedPoster.value = poster, ), ), ), @@ -218,7 +218,8 @@ class _DashboardScreenState extends ConsumerState { .mapIndexed( (index, child) => SliverToBoxAdapter( child: FocusProvider( - autoFocus: bannerType != HomeBanner.detailedBanner ? index == 0 : false, + autoFocus: + bannerType != HomeBanner.detailedBanner || homeCarouselItems.isEmpty ? index == 0 : false, child: child, ), ), diff --git a/lib/screens/details_screens/book_detail_screen.dart b/lib/screens/details_screens/book_detail_screen.dart index 0547502..7e08a52 100644 --- a/lib/screens/details_screens/book_detail_screen.dart +++ b/lib/screens/details_screens/book_detail_screen.dart @@ -76,10 +76,7 @@ class _BookDetailScreenState extends ConsumerState { OverviewHeader( subTitle: details.book?.parentName ?? details.parentModel?.name, name: details.nextUp?.name ?? "", - image: ImagesData( - logo: details.book?.getPosters?.primary, - ), - centerButtons: Builder( + playButton: Builder( builder: (context) { //Wrapped so the correct context is used for refreshing the pages return MediaPlayButton( @@ -88,6 +85,9 @@ class _BookDetailScreenState extends ConsumerState { ); }, ), + image: ImagesData( + logo: details.book?.getPosters?.primary, + ), productionYear: details.nextUp!.overview.productionYear, runTime: details.nextUp!.overview.runTime, genres: details.nextUp!.overview.genreItems, diff --git a/lib/screens/details_screens/components/overview_header.dart b/lib/screens/details_screens/components/overview_header.dart index f27c7c8..6c9458e 100644 --- a/lib/screens/details_screens/components/overview_header.dart +++ b/lib/screens/details_screens/components/overview_header.dart @@ -7,6 +7,7 @@ import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/screens/shared/media/components/chip_button.dart'; import 'package:fladder/screens/shared/media/components/media_header.dart'; import 'package:fladder/screens/shared/media/components/small_detail_widgets.dart'; +import 'package:fladder/theme.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/humanize_duration.dart'; import 'package:fladder/util/list_padding.dart'; @@ -14,6 +15,7 @@ import 'package:fladder/util/list_padding.dart'; class OverviewHeader extends ConsumerWidget { final String name; final ImagesData? image; + final Widget? playButton; final Widget? centerButtons; final EdgeInsets? padding; final String? subTitle; @@ -30,6 +32,7 @@ class OverviewHeader extends ConsumerWidget { const OverviewHeader({ required this.name, this.image, + this.playButton, this.centerButtons, this.padding, this.subTitle, @@ -59,7 +62,7 @@ class OverviewHeader extends ConsumerWidget { (MediaQuery.sizeOf(context).height - (MediaQuery.paddingOf(context).top + 150)).clamp(50, 1250).toDouble(); final crossAlignment = - AdaptiveLayout.viewSizeOf(context) != ViewSize.phone ? CrossAxisAlignment.start : CrossAxisAlignment.center; + AdaptiveLayout.viewSizeOf(context) != ViewSize.phone ? CrossAxisAlignment.start : CrossAxisAlignment.stretch; return ConstrainedBox( constraints: BoxConstraints( @@ -156,7 +159,7 @@ class OverviewHeader extends ConsumerWidget { Flexible( child: Text( summary ?? "", - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleMedium, overflow: TextOverflow.ellipsis, maxLines: 3, ), @@ -168,7 +171,29 @@ class OverviewHeader extends ConsumerWidget { ].addInBetween(const SizedBox(height: 10)), ), ), - if (centerButtons != null) centerButtons!, + if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) ...[ + if (playButton != null) playButton!, + if (centerButtons != null) centerButtons!, + ] else + Flexible( + child: Row( + spacing: 16, + children: [ + if (playButton != null) ...[ + playButton!, + Container( + width: 2, + height: 12, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface.withAlpha(64), + borderRadius: FladderTheme.smallShape.borderRadius, + ), + ) + ], + if (centerButtons != null) centerButtons!, + ], + ), + ), ].addInBetween(const SizedBox(height: 21)), ), ), diff --git a/lib/screens/details_screens/episode_detail_screen.dart b/lib/screens/details_screens/episode_detail_screen.dart index c1a5803..009ac93 100644 --- a/lib/screens/details_screens/episode_detail_screen.dart +++ b/lib/screens/details_screens/episode_detail_screen.dart @@ -79,25 +79,25 @@ class _ItemDetailScreenState extends ConsumerState { OverviewHeader( name: details.series?.name ?? "", image: seasonDetails.images, + playButton: episodeDetails.playAble + ? MediaPlayButton( + item: episodeDetails, + onPressed: () async { + await details.episode.play(context, ref); + ref.read(providerInstance.notifier).fetchDetails(widget.item); + }, + onLongPressed: () async { + await details.episode.play(context, ref, showPlaybackOption: true); + ref.read(providerInstance.notifier).fetchDetails(widget.item); + }, + ) + : null, centerButtons: Wrap( spacing: 8, runSpacing: 8, alignment: wrapAlignment, crossAxisAlignment: WrapCrossAlignment.center, children: [ - episodeDetails.playAble - ? MediaPlayButton( - item: episodeDetails, - onPressed: () async { - await details.episode.play(context, ref); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - onLongPressed: () async { - await details.episode.play(context, ref, showPlaybackOption: true); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - ) - : null, SelectableIconButton( onPressed: () async { await ref @@ -133,7 +133,7 @@ class _ItemDetailScreenState extends ConsumerState { selected: false, icon: IconsaxPlusLinear.more, ), - ].nonNulls.toList().addPadding(const EdgeInsets.symmetric(horizontal: 6)), + ].nonNulls.toList(), ), padding: padding, subTitle: details.episode?.detailedName(context), diff --git a/lib/screens/details_screens/movie_detail_screen.dart b/lib/screens/details_screens/movie_detail_screen.dart index 45f20f1..26ce688 100644 --- a/lib/screens/details_screens/movie_detail_screen.dart +++ b/lib/screens/details_screens/movie_detail_screen.dart @@ -73,30 +73,30 @@ class _ItemDetailScreenState extends ConsumerState { name: details.name, image: details.images, padding: padding, + playButton: MediaPlayButton( + item: details, + onLongPressed: () async { + await details.play( + context, + ref, + showPlaybackOption: true, + ); + ref.read(providerInstance.notifier).fetchDetails(widget.item); + }, + onPressed: () async { + await details.play( + context, + ref, + ); + ref.read(providerInstance.notifier).fetchDetails(widget.item); + }, + ), centerButtons: Wrap( spacing: 8, runSpacing: 8, alignment: wrapAlignment, crossAxisAlignment: WrapCrossAlignment.center, children: [ - MediaPlayButton( - item: details, - onLongPressed: () async { - await details.play( - context, - ref, - showPlaybackOption: true, - ); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - onPressed: () async { - await details.play( - context, - ref, - ); - ref.read(providerInstance.notifier).fetchDetails(widget.item); - }, - ), SelectableIconButton( onPressed: () async { await ref diff --git a/lib/screens/details_screens/series_detail_screen.dart b/lib/screens/details_screens/series_detail_screen.dart index e042808..91f2275 100644 --- a/lib/screens/details_screens/series_detail_screen.dart +++ b/lib/screens/details_screens/series_detail_screen.dart @@ -76,27 +76,27 @@ class _SeriesDetailScreenState extends ConsumerState { OverviewHeader( name: details.name, image: details.images, + playButton: MediaPlayButton( + item: details.nextUp, + onPressed: details.nextUp != null + ? () async { + await details.nextUp.play(context, ref); + ref.read(providerId.notifier).fetchDetails(widget.item); + } + : null, + onLongPressed: details.nextUp != null + ? () async { + await details.nextUp.play(context, ref, showPlaybackOption: true); + ref.read(providerId.notifier).fetchDetails(widget.item); + } + : null, + ), centerButtons: Wrap( spacing: 8, runSpacing: 8, alignment: wrapAlignment, crossAxisAlignment: WrapCrossAlignment.center, children: [ - MediaPlayButton( - item: details.nextUp, - onPressed: details.nextUp != null - ? () async { - await details.nextUp.play(context, ref); - ref.read(providerId.notifier).fetchDetails(widget.item); - } - : null, - onLongPressed: details.nextUp != null - ? () async { - await details.nextUp.play(context, ref, showPlaybackOption: true); - ref.read(providerId.notifier).fetchDetails(widget.item); - } - : null, - ), SelectableIconButton( onPressed: () async { await ref diff --git a/lib/screens/library/library_screen.dart b/lib/screens/library/library_screen.dart index 97122d3..d6baa02 100644 --- a/lib/screens/library/library_screen.dart +++ b/lib/screens/library/library_screen.dart @@ -236,7 +236,7 @@ class LibraryRow extends ConsumerWidget { autoFocus: true, startIndex: selectedView != null ? views.indexOf(selectedView!) : null, contentPadding: padding, - itemBuilder: (context, index, selected) { + itemBuilder: (context, index) { final view = views[index]; final isSelected = selectedView == view; final List viewActions = [ diff --git a/lib/screens/library_search/library_search_screen.dart b/lib/screens/library_search/library_search_screen.dart index b76216b..737b45e 100644 --- a/lib/screens/library_search/library_search_screen.dart +++ b/lib/screens/library_search/library_search_screen.dart @@ -158,56 +158,61 @@ class _LibrarySearchScreenState extends ConsumerState { extendBody: true, backgroundColor: Colors.transparent, extendBodyBehindAppBar: true, - floatingActionButton: HideOnScroll( - controller: scrollController, - visibleBuilder: (visible) => librarySearchResults.activePosters.isNotEmpty - ? FloatingActionButtonAnimated( - key: Key(context.localized.playLabel), - isExtended: visible, - tooltip: context.localized.playVideos, - onPressed: () async { - if (librarySearchResults.showGalleryButtons && !librarySearchResults.showPlayButtons) { - libraryProvider.viewGallery(context); - return; - } else if (!librarySearchResults.showGalleryButtons && librarySearchResults.showPlayButtons) { - libraryProvider.playLibraryItems(context, ref); - return; - } + floatingActionButton: AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad + ? HideOnScroll( + controller: scrollController, + visibleBuilder: (visible) => librarySearchResults.activePosters.isNotEmpty + ? FloatingActionButtonAnimated( + key: Key(context.localized.playLabel), + isExtended: visible, + tooltip: context.localized.playVideos, + onPressed: () async { + if (librarySearchResults.showGalleryButtons && !librarySearchResults.showPlayButtons) { + libraryProvider.viewGallery(context); + return; + } else if (!librarySearchResults.showGalleryButtons && + librarySearchResults.showPlayButtons) { + libraryProvider.playLibraryItems(context, ref); + return; + } - await showLibraryPlayOptions( - context, - context.localized.libraryPlayItems, - playVideos: librarySearchResults.showPlayButtons - ? () => libraryProvider.playLibraryItems(context, ref) - : null, - viewGallery: librarySearchResults.showGalleryButtons - ? () => libraryProvider.viewGallery(context) - : null, - ); - }, - label: Text(context.localized.playLabel), - icon: const Icon(IconsaxPlusBold.play), - ) - : null, - ), - bottomNavigationBar: HideOnScroll( - controller: scrollController, - canHide: !floatingAppBar, - child: IgnorePointer( - ignoring: librarySearchResults.fetchingItems, - child: _LibrarySearchBottomBar( - uniqueKey: uniqueKey, - refreshKey: refreshKey, - scrollController: scrollController, - libraryProvider: libraryProvider, - postersList: postersList, - ), - ), - ), + await showLibraryPlayOptions( + context, + context.localized.libraryPlayItems, + playVideos: librarySearchResults.showPlayButtons + ? () => libraryProvider.playLibraryItems(context, ref) + : null, + viewGallery: librarySearchResults.showGalleryButtons + ? () => libraryProvider.viewGallery(context) + : null, + ); + }, + label: Text(context.localized.playLabel), + icon: const Icon(IconsaxPlusBold.play), + ) + : null, + ) + : null, + bottomNavigationBar: AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad + ? HideOnScroll( + controller: scrollController, + canHide: !floatingAppBar, + child: IgnorePointer( + ignoring: librarySearchResults.fetchingItems, + child: _LibrarySearchBottomBar( + uniqueKey: uniqueKey, + refreshKey: refreshKey, + scrollController: scrollController, + libraryProvider: libraryProvider, + postersList: postersList, + ), + ), + ) + : null, body: PinchPosterZoom( scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference), child: FladderScrollbar( - visible: AdaptiveLayout.of(context).inputDevice != InputDevice.pointer, + visible: AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer, controller: scrollController, child: PullToRefresh( refreshKey: refreshKey, @@ -427,7 +432,7 @@ class _LibrarySearchScreenState extends ConsumerState { ], ), bottom: PreferredSize( - preferredSize: const Size(0, 50), + preferredSize: Size(0, AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad ? 105 : 50), child: Transform.translate( offset: Offset(0, AdaptiveLayout.of(context).isDesktop ? -20 : -15), child: IgnorePointer( @@ -446,6 +451,15 @@ class _LibrarySearchScreenState extends ConsumerState { ), ), ), + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad) + _LibrarySearchBottomBar( + uniqueKey: uniqueKey, + refreshKey: refreshKey, + scrollController: scrollController, + libraryProvider: libraryProvider, + postersList: postersList, + isDPadBar: true, + ), ], ), ), @@ -496,12 +510,14 @@ class _LibrarySearchBottomBar extends ConsumerWidget { final LibrarySearchNotifier libraryProvider; final List postersList; final GlobalKey refreshKey; + final bool isDPadBar; const _LibrarySearchBottomBar({ required this.uniqueKey, required this.scrollController, required this.libraryProvider, required this.postersList, required this.refreshKey, + this.isDPadBar = false, }); @override @@ -586,155 +602,161 @@ class _LibrarySearchBottomBar extends ConsumerWidget { ]; final paddingOf = MediaQuery.paddingOf(context); - return Padding( - padding: EdgeInsets.only(left: paddingOf.left, right: paddingOf.right), - child: NestedBottomAppBar( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, + Widget content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: 6, children: [ - Row( - spacing: 6, - children: [ - ScrollStatePosition( - controller: scrollController, - positionBuilder: (state) => AnimatedFadeSize( - child: state != ScrollState.top - ? Tooltip( - message: context.localized.scrollToTop, - child: IconButton.filled( - onPressed: () => scrollController.animateTo(0, - duration: const Duration(milliseconds: 500), curve: Curves.easeInOutCubic), - icon: const Icon( - IconsaxPlusLinear.arrow_up, - ), + if (!isDPadBar) + ScrollStatePosition( + controller: scrollController, + positionBuilder: (state) => AnimatedFadeSize( + child: state != ScrollState.top + ? Tooltip( + message: context.localized.scrollToTop, + child: IconButton.filled( + onPressed: () => scrollController.animateTo(0, + duration: const Duration(milliseconds: 500), curve: Curves.easeInOutCubic), + icon: const Icon( + IconsaxPlusLinear.arrow_up, ), - ) - : const SizedBox(), - ), - ), - if (!librarySearchResults.selecteMode) ...{ - IconButton( - tooltip: context.localized.sortBy, - onPressed: () async { - final newOptions = await openSortByDialogue( - context, - libraryProvider: libraryProvider, - uniqueKey: uniqueKey, - options: (librarySearchResults.filters.sortingOption, librarySearchResults.filters.sortOrder), - ); - if (newOptions != null) { - if (newOptions.$1 != null) { - libraryProvider.setSortBy(newOptions.$1!); - } - if (newOptions.$2 != null) { - libraryProvider.setSortOrder(newOptions.$2!); - } - } - }, - icon: const Icon(IconsaxPlusLinear.sort), - ), - if (librarySearchResults.hasActiveFilters) ...{ - IconButton( - tooltip: context.localized.disableFilters, - onPressed: disableFilters(librarySearchResults, libraryProvider), - icon: const Icon(IconsaxPlusLinear.filter_remove), - ), - }, - }, - IconButton( - onPressed: () => libraryProvider.toggleSelectMode(), - color: librarySearchResults.selecteMode ? Theme.of(context).colorScheme.primary : null, - icon: const Icon(IconsaxPlusLinear.category_2), - ), - AnimatedFadeSize( - child: librarySearchResults.selecteMode - ? Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16)), - child: Row( - spacing: 6, - children: [ - Tooltip( - message: context.localized.selectAll, - child: IconButton( - onPressed: () => libraryProvider.selectAll(true), - icon: const Icon(IconsaxPlusLinear.box_add), - ), - ), - Tooltip( - message: context.localized.clearSelection, - child: IconButton( - onPressed: () => libraryProvider.selectAll(false), - icon: const Icon(IconsaxPlusLinear.box_remove), - ), - ), - if (librarySearchResults.selectedPosters.isNotEmpty) ...{ - if (AdaptiveLayout.of(context).isDesktop) - PopupMenuButton( - itemBuilder: (context) => actions.popupMenuItems(useIcons: true), - ) - else - IconButton( - onPressed: () { - showBottomSheetPill( - context: context, - content: (context, scrollController) => ListView( - shrinkWrap: true, - controller: scrollController, - children: actions.listTileItems(context, useIcons: true), - ), - ); - }, - icon: const Icon(IconsaxPlusLinear.more)) - }, - ], ), ) : const SizedBox(), ), - const Spacer(), - if (librarySearchResults.activePosters.isNotEmpty) - IconButton.filledTonal( - tooltip: context.localized.random, - onPressed: () => libraryProvider.openRandom(context), - icon: const Icon( - IconsaxPlusBold.arrow_up_1, - ), - ), - if (librarySearchResults.activePosters.isNotEmpty) - IconButton( - tooltip: context.localized.shuffleVideos, - onPressed: () async { - if (librarySearchResults.showGalleryButtons && !librarySearchResults.showPlayButtons) { - libraryProvider.viewGallery(context, shuffle: true); - return; - } else if (!librarySearchResults.showGalleryButtons && librarySearchResults.showPlayButtons) { - libraryProvider.playLibraryItems(context, ref, shuffle: true); - return; - } - - await showLibraryPlayOptions( - context, - context.localized.libraryShuffleAndPlayItems, - playVideos: librarySearchResults.showPlayButtons - ? () => libraryProvider.playLibraryItems(context, ref, shuffle: true) - : null, - viewGallery: librarySearchResults.showGalleryButtons - ? () => libraryProvider.viewGallery(context, shuffle: true) - : null, - ); - }, - icon: const Icon(IconsaxPlusLinear.shuffle), - ), - ], + ), + if (!librarySearchResults.selecteMode) ...{ + IconButton( + tooltip: context.localized.sortBy, + onPressed: () async { + final newOptions = await openSortByDialogue( + context, + libraryProvider: libraryProvider, + uniqueKey: uniqueKey, + options: (librarySearchResults.filters.sortingOption, librarySearchResults.filters.sortOrder), + ); + if (newOptions != null) { + if (newOptions.$1 != null) { + libraryProvider.setSortBy(newOptions.$1!); + } + if (newOptions.$2 != null) { + libraryProvider.setSortOrder(newOptions.$2!); + } + } + }, + icon: const Icon(IconsaxPlusLinear.sort), + ), + if (librarySearchResults.hasActiveFilters) ...{ + IconButton( + tooltip: context.localized.disableFilters, + onPressed: disableFilters(librarySearchResults, libraryProvider), + icon: const Icon(IconsaxPlusLinear.filter_remove), + ), + }, + }, + IconButton( + onPressed: () => libraryProvider.toggleSelectMode(), + color: librarySearchResults.selecteMode ? Theme.of(context).colorScheme.primary : null, + icon: const Icon(IconsaxPlusLinear.category_2), ), + AnimatedFadeSize( + child: librarySearchResults.selecteMode + ? Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16)), + child: Row( + spacing: 6, + children: [ + Tooltip( + message: context.localized.selectAll, + child: IconButton( + onPressed: () => libraryProvider.selectAll(true), + icon: const Icon(IconsaxPlusLinear.box_add), + ), + ), + Tooltip( + message: context.localized.clearSelection, + child: IconButton( + onPressed: () => libraryProvider.selectAll(false), + icon: const Icon(IconsaxPlusLinear.box_remove), + ), + ), + if (librarySearchResults.selectedPosters.isNotEmpty) ...{ + if (AdaptiveLayout.of(context).isDesktop) + PopupMenuButton( + itemBuilder: (context) => actions.popupMenuItems(useIcons: true), + ) + else + IconButton( + onPressed: () { + showBottomSheetPill( + context: context, + content: (context, scrollController) => ListView( + shrinkWrap: true, + controller: scrollController, + children: actions.listTileItems(context, useIcons: true), + ), + ); + }, + icon: const Icon(IconsaxPlusLinear.more)) + }, + ], + ), + ) + : const SizedBox(), + ), + if (!isDPadBar) const Spacer(), + if (librarySearchResults.activePosters.isNotEmpty) + IconButton( + tooltip: context.localized.random, + onPressed: () => libraryProvider.openRandom(context), + icon: const Icon( + IconsaxPlusBold.slider_vertical, + ), + ), + if (librarySearchResults.activePosters.isNotEmpty) + IconButton( + tooltip: context.localized.shuffleVideos, + onPressed: () async { + if (librarySearchResults.showGalleryButtons && !librarySearchResults.showPlayButtons) { + libraryProvider.viewGallery(context, shuffle: true); + return; + } else if (!librarySearchResults.showGalleryButtons && librarySearchResults.showPlayButtons) { + libraryProvider.playLibraryItems(context, ref, shuffle: true); + return; + } + + await showLibraryPlayOptions( + context, + context.localized.libraryShuffleAndPlayItems, + playVideos: librarySearchResults.showPlayButtons + ? () => libraryProvider.playLibraryItems(context, ref, shuffle: true) + : null, + viewGallery: librarySearchResults.showGalleryButtons + ? () => libraryProvider.viewGallery(context, shuffle: true) + : null, + ); + }, + icon: const Icon(IconsaxPlusLinear.shuffle), + ), ], ), - ), + ], + ), + ); + + if (isDPadBar) { + return content; + } + return Padding( + padding: EdgeInsets.only(left: paddingOf.left, right: paddingOf.right), + child: NestedBottomAppBar( + child: content, ), ); } diff --git a/lib/screens/login/login_user_grid.dart b/lib/screens/login/login_user_grid.dart index d3a64d3..405199a 100644 --- a/lib/screens/login/login_user_grid.dart +++ b/lib/screens/login/login_user_grid.dart @@ -50,7 +50,7 @@ class LoginUserGrid extends ConsumerWidget { child: FocusButton( onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user), onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) { - InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user), + InputDevice.dPad || InputDevice.pointer => () => onLongPress?.call(user), InputDevice.touch => null, }, darkOverlay: false, diff --git a/lib/screens/login/widgets/login_icon.dart b/lib/screens/login/widgets/login_icon.dart index 30114f2..fe8eb02 100644 --- a/lib/screens/login/widgets/login_icon.dart +++ b/lib/screens/login/widgets/login_icon.dart @@ -1,9 +1,11 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/models/account_model.dart'; import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/user_icon.dart'; import 'package:fladder/util/list_padding.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; class LoginIcon extends ConsumerWidget { final AccountModel user; @@ -24,7 +26,6 @@ class LoginIcon extends ConsumerWidget { aspectRatio: 1.0, child: Card( elevation: 1, - clipBehavior: Clip.hardEdge, margin: EdgeInsets.zero, child: Stack( children: [ diff --git a/lib/screens/metadata/edit_item.dart b/lib/screens/metadata/edit_item.dart index c7f8566..8060c4d 100644 --- a/lib/screens/metadata/edit_item.dart +++ b/lib/screens/metadata/edit_item.dart @@ -26,7 +26,7 @@ Future showEditItemPopup( itemUpdated: (newItem) => updatedItem = newItem, refreshOnClose: (refresh) => shouldRefresh = refresh, ); - return AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + return AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? Dialog( insetPadding: const EdgeInsets.all(64), child: editWidget(), diff --git a/lib/screens/metadata/refresh_metadata.dart b/lib/screens/metadata/refresh_metadata.dart index 6d78a96..1345284 100644 --- a/lib/screens/metadata/refresh_metadata.dart +++ b/lib/screens/metadata/refresh_metadata.dart @@ -42,7 +42,7 @@ class _RefreshPopupDialogState extends ConsumerState { color: Theme.of(context).colorScheme.surface, child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 700 : double.infinity), + maxWidth: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? 700 : double.infinity), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 27c7f05..2335149 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -138,12 +138,6 @@ class _SettingsScreenState extends ConsumerState { icon: deviceIcon, onTap: () => navigateTo(const ClientSettingsRoute()), ), - if (quickConnectAvailable) - SettingsListTile( - label: Text(context.localized.settingsQuickConnectTitle), - icon: IconsaxPlusLinear.password_check, - onTap: () => openQuickConnectDialog(context), - ), SettingsListTile( label: Text(context.localized.settingsProfileTitle), subLabel: Text(context.localized.settingsProfileDesc), @@ -203,6 +197,12 @@ class _SettingsScreenState extends ConsumerState { widthFactor: 0.25, child: Divider(), ), + if (quickConnectAvailable) + SettingsListTile( + label: Text(context.localized.settingsQuickConnectTitle), + icon: IconsaxPlusLinear.password_check, + onTap: () => openQuickConnectDialog(context), + ), SettingsListTile( label: Text(context.localized.switchUser), icon: IconsaxPlusLinear.arrow_swap_horizontal, diff --git a/lib/screens/shared/media/carousel_banner.dart b/lib/screens/shared/media/carousel_banner.dart index 117d64c..ed07259 100644 --- a/lib/screens/shared/media/carousel_banner.dart +++ b/lib/screens/shared/media/carousel_banner.dart @@ -125,7 +125,7 @@ class _CarouselBannerState extends ConsumerState { ), FlatButton( onTap: () => widget.items[index].navigateTo(context), - onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? null : () { final poster = widget.items[index]; @@ -141,7 +141,7 @@ class _CarouselBannerState extends ConsumerState { ), ); }, - onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + onSecondaryTapDown: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch ? null : (details) async { Offset localPosition = details.globalPosition; @@ -175,7 +175,7 @@ class _CarouselBannerState extends ConsumerState { ) ], ), - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) AnimatedOpacity( duration: const Duration(milliseconds: 250), opacity: showControls ? 1 : 0, diff --git a/lib/screens/shared/media/chapter_row.dart b/lib/screens/shared/media/chapter_row.dart index c58db58..39920dd 100644 --- a/lib/screens/shared/media/chapter_row.dart +++ b/lib/screens/shared/media/chapter_row.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; @@ -24,7 +25,7 @@ class ChapterRow extends ConsumerWidget { label: context.localized.chapter(chapters.length), height: AdaptiveLayout.poster(context).size / 1.75, items: chapters, - itemBuilder: (context, index, selected) { + itemBuilder: (context, index) { final chapter = chapters[index]; List generateActions() { return [ @@ -58,35 +59,38 @@ class ChapterRow extends ConsumerWidget { }, ); }, - child: AspectRatio( - aspectRatio: 1.75, - child: Stack( - fit: StackFit.expand, - children: [ - CachedNetworkImage( - imageUrl: chapter.imageUrl, - fit: BoxFit.cover, - ), - Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(5), - child: Card( - elevation: 0, - shadowColor: Colors.transparent, - color: Theme.of(context).cardColor.withValues(alpha: 0.4), - child: Padding( - padding: const EdgeInsets.all(5), - child: Text( - "${chapter.name} \n${chapter.startPosition.humanize ?? context.localized.start}", - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + child: Card( + child: AspectRatio( + aspectRatio: 1.75, + child: Stack( + fit: StackFit.expand, + children: [ + CachedNetworkImage( + imageUrl: chapter.imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => const Icon(IconsaxPlusBold.image), + ), + Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.all(5), + child: Card( + elevation: 0, + shadowColor: Colors.transparent, + color: Theme.of(context).cardColor.withValues(alpha: 0.4), + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + "${chapter.name} \n${chapter.startPosition.humanize ?? context.localized.start}", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), + ), ), ), ), ), - ), - ], + ], + ), ), ), overlays: [ diff --git a/lib/screens/shared/media/components/media_play_button.dart b/lib/screens/shared/media/components/media_play_button.dart index 5da0a68..37d1859 100644 --- a/lib/screens/shared/media/components/media_play_button.dart +++ b/lib/screens/shared/media/components/media_play_button.dart @@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/item_base_model.dart'; -import 'package:fladder/providers/arguments_provider.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/widgets/shared/ensure_visible.dart'; class MediaPlayButton extends ConsumerWidget { @@ -24,7 +24,19 @@ class MediaPlayButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final progress = (item?.progress ?? 0) / 100.0; - final radius = FladderTheme.defaultShape.borderRadius; + final padding = 3.0; + final radius = FladderTheme.smallShape.borderRadius.subtract(BorderRadius.circular(padding)); + final buttonState = WidgetStateProperty.resolveWith( + (states) { + return BorderSide( + width: 2, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer + .withValues(alpha: states.contains(WidgetState.focused) ? 0.9 : 0.0), + ); + }, + ); Widget buttonTitle(Color contentColor) { return Padding( @@ -61,9 +73,10 @@ class MediaPlayButton extends ConsumerWidget { : TextButton( onPressed: onPressed, onLongPress: onLongPressed, - autofocus: ref.read(argumentsStateProvider).htpcMode, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, + autofocus: AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad, + style: ButtonStyle( + side: buttonState, + padding: const WidgetStatePropertyAll(EdgeInsets.zero), ), onFocusChange: (value) { if (value) { @@ -73,7 +86,7 @@ class MediaPlayButton extends ConsumerWidget { } }, child: Padding( - padding: const EdgeInsets.all(2.0), + padding: EdgeInsets.all(padding), child: Stack( alignment: Alignment.center, children: [ @@ -81,20 +94,13 @@ class MediaPlayButton extends ConsumerWidget { Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - boxShadow: [ - BoxShadow( - blurRadius: 8.0, - offset: const Offset(0, 2), - color: Colors.black.withValues(alpha: 0.3), - ) - ], + color: Theme.of(context).colorScheme.primaryContainer, borderRadius: radius, ), ), ), // Button content - buttonTitle(Theme.of(context).colorScheme.primary), + buttonTitle(Theme.of(context).colorScheme.onPrimaryContainer), Positioned.fill( child: ClipRect( clipper: _ProgressClipper( diff --git a/lib/screens/shared/media/detailed_banner.dart b/lib/screens/shared/media/detailed_banner.dart index 98e88fe..c2880b3 100644 --- a/lib/screens/shared/media/detailed_banner.dart +++ b/lib/screens/shared/media/detailed_banner.dart @@ -5,9 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/screens/details_screens/components/overview_header.dart'; import 'package:fladder/screens/shared/media/poster_row.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/shared/custom_shader_mask.dart'; import 'package:fladder/widgets/shared/ensure_visible.dart'; class DetailedBanner extends ConsumerStatefulWidget { @@ -29,122 +31,81 @@ class _DetailedBannerState extends ConsumerState { @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); - final color = Theme.of(context).colorScheme.surface; - final stops = [0.05, 0.35, 0.65, 0.95]; - return Column( + final phoneOffsetHeight = + AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? MediaQuery.paddingOf(context).top + 80 : 0.0; + return Stack( children: [ - SizedBox( - width: double.infinity, - height: size.height * 0.50, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - color.withValues(alpha: 0.85), - color.withValues(alpha: 0.75), - color.withValues(alpha: 0), - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + ExcludeFocus( + child: Align( + alignment: Alignment.topRight, + child: Transform.translate( + offset: Offset(0, -phoneOffsetHeight), + child: FractionallySizedBox( + widthFactor: 0.85, + child: AspectRatio( + aspectRatio: 1.8, + child: CustomShaderMask( + child: FladderImage( + image: selectedPoster.images?.primary, + ), + ), + ), ), ), - child: Stack( - children: [ - ExcludeFocus( - child: Align( - alignment: Alignment.topRight, - child: AspectRatio( - aspectRatio: 1.7, - child: ShaderMask( - shaderCallback: (Rect bounds) { - return LinearGradient( - colors: [ - Colors.white, - Colors.white, - Colors.white, - Colors.white.withAlpha(0), - ], - stops: stops, - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ).createShader(bounds); - }, - child: ShaderMask( - shaderCallback: (Rect bounds) { - return LinearGradient( - colors: [ - Colors.white.withAlpha(0), - Colors.white, - Colors.white, - Colors.white, - ], - stops: stops, - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ).createShader(bounds); - }, - child: FladderImage( - image: selectedPoster.images?.primary, - ), - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: FractionallySizedBox( - widthFactor: 0.5, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - spacing: 16, - children: [ - Flexible( - child: OverviewHeader( - name: selectedPoster.parentBaseModel.name, - subTitle: selectedPoster.label(context), - image: selectedPoster.getPosters, - logoAlignment: Alignment.centerLeft, - summary: selectedPoster.overview.summary, - productionYear: selectedPoster.overview.productionYear, - runTime: selectedPoster.overview.runTime, - genres: selectedPoster.overview.genreItems, - studios: selectedPoster.overview.studios, - officialRating: selectedPoster.overview.parentalRating, - communityRating: selectedPoster.overview.communityRating, - ), - ), - SizedBox( - height: size.height * 0.05, - ) - ], - ), - ), - ), - ], - ), ), ), - FocusProvider( - autoFocus: true, - child: PosterRow( - key: const Key("detailed-banner-row"), - primaryPosters: true, - label: context.localized.nextUp, - posters: widget.posters, - onFocused: (poster) { - context.ensureVisible( - alignment: 1.0, - ); - setState(() { - selectedPoster = poster; - }); - widget.onSelect(poster); - }, + SizedBox( + width: double.infinity, + height: size.height * 0.85, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16).copyWith(bottom: 4), + child: FractionallySizedBox( + widthFactor: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? 1.0 : 0.55, + child: OverviewHeader( + name: selectedPoster.parentBaseModel.name, + subTitle: selectedPoster.label(context), + image: selectedPoster.getPosters, + logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone + ? Alignment.center + : Alignment.centerLeft, + summary: selectedPoster.overview.summary, + productionYear: selectedPoster.overview.productionYear, + runTime: selectedPoster.overview.runTime, + genres: selectedPoster.overview.genreItems, + studios: selectedPoster.overview.studios, + officialRating: selectedPoster.overview.parentalRating, + communityRating: selectedPoster.overview.communityRating, + ), + ), + ), + ), + FocusProvider( + autoFocus: true, + child: PosterRow( + primaryPosters: true, + label: context.localized.nextUp, + posters: widget.posters, + onFocused: (poster) { + context.ensureVisible( + alignment: 1.0, + ); + setState(() { + selectedPoster = poster; + }); + widget.onSelect(poster); + }, + ), + ), + const SizedBox(height: 16) + ], ), - ) + ), ], ); } diff --git a/lib/screens/shared/media/episode_posters.dart b/lib/screens/shared/media/episode_posters.dart index 8178fa2..b98e345 100644 --- a/lib/screens/shared/media/episode_posters.dart +++ b/lib/screens/shared/media/episode_posters.dart @@ -12,6 +12,7 @@ import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/util/refresh_state.dart'; import 'package:fladder/widgets/shared/clickable_text.dart'; import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/horizontal_list.dart'; @@ -83,7 +84,7 @@ class _EpisodePosterState extends ConsumerState { contentPadding: widget.contentPadding, startIndex: indexOfCurrent, items: episodes, - itemBuilder: (context, index, selected) { + itemBuilder: (context, index) { final episode = episodes[index]; final isCurrentEpisode = index == indexOfCurrent; return EpisodePoster( @@ -101,8 +102,8 @@ class _EpisodePosterState extends ConsumerState { : () { episode.navigateTo(context); }, - onLongPress: () { - showBottomSheetPill( + onLongPress: () async { + await showBottomSheetPill( context: context, item: episode, content: (context, scrollController) { @@ -115,6 +116,7 @@ class _EpisodePosterState extends ConsumerState { ); }, ); + context.refreshData(); }, actions: episode.generateActions(context, ref), isCurrentEpisode: isCurrentEpisode, @@ -185,7 +187,7 @@ class EpisodePoster extends ConsumerWidget { decodeHeight: 250, ), overlays: [ - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty) + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer && actions.isNotEmpty) ExcludeFocus( child: Align( alignment: Alignment.bottomRight, diff --git a/lib/screens/shared/media/external_urls.dart b/lib/screens/shared/media/external_urls.dart index f74e534..51d825b 100644 --- a/lib/screens/shared/media/external_urls.dart +++ b/lib/screens/shared/media/external_urls.dart @@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart' as urilauncher; import 'package:url_launcher/url_launcher_string.dart'; import 'package:fladder/models/items/item_shared_models.dart'; +import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/sticky_header_text.dart'; @@ -19,6 +20,9 @@ class ExternalUrlsRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(argumentsStateProvider).htpcMode) { + return const SizedBox.shrink(); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/shared/media/media_banner.dart b/lib/screens/shared/media/media_banner.dart index 7d77c1f..c5be026 100644 --- a/lib/screens/shared/media/media_banner.dart +++ b/lib/screens/shared/media/media_banner.dart @@ -146,7 +146,7 @@ class _MediaBannerState extends ConsumerState { ), child: FocusButton( onTap: () => currentItem.navigateTo(context), - onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch ? () async { interacting = true; final poster = currentItem; @@ -165,7 +165,7 @@ class _MediaBannerState extends ConsumerState { timer.reset(); } : null, - onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + onSecondaryTapDown: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch ? null : (details) async { Offset localPosition = details.globalPosition; diff --git a/lib/screens/shared/media/people_row.dart b/lib/screens/shared/media/people_row.dart index b4e6a9b..caec02a 100644 --- a/lib/screens/shared/media/people_row.dart +++ b/lib/screens/shared/media/people_row.dart @@ -47,7 +47,7 @@ class PeopleRow extends ConsumerWidget { height: AdaptiveLayout.poster(context).size * 0.9, contentPadding: contentPadding, items: people, - itemBuilder: (context, index, selected) { + itemBuilder: (context, index) { final person = people[index]; return AspectRatio( aspectRatio: 0.6, diff --git a/lib/screens/shared/media/poster_list_item.dart b/lib/screens/shared/media/poster_list_item.dart index 26a99e6..1f609fc 100644 --- a/lib/screens/shared/media/poster_list_item.dart +++ b/lib/screens/shared/media/poster_list_item.dart @@ -69,6 +69,8 @@ class PosterListItem extends ConsumerWidget { ), child: FocusButton( onTap: () => pressedWidget(context), + autoFocus: + FocusProvider.autoFocusOf(context) && AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad, onFocusChanged: (focus) { if (focus) { context.ensureVisible(); diff --git a/lib/screens/shared/media/poster_row.dart b/lib/screens/shared/media/poster_row.dart index 7a2b4e0..5a5bea0 100644 --- a/lib/screens/shared/media/poster_row.dart +++ b/lib/screens/shared/media/poster_row.dart @@ -46,7 +46,7 @@ class PosterRow extends ConsumerWidget { context.ensureVisible(); } }, - itemBuilder: (context, index, selected) { + itemBuilder: (context, index) { final poster = posters[index]; return PosterWidget( key: Key(poster.id), diff --git a/lib/screens/shared/media/poster_widget.dart b/lib/screens/shared/media/poster_widget.dart index 2f2d86a..536fd24 100644 --- a/lib/screens/shared/media/poster_widget.dart +++ b/lib/screens/shared/media/poster_widget.dart @@ -1,13 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/screens/shared/media/components/poster_image.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; +import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/item_base_model/play_item_helpers.dart'; +import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/widgets/shared/clickable_text.dart'; import 'package:fladder/widgets/shared/item_actions.dart'; @@ -138,3 +141,56 @@ class PosterWidget extends ConsumerWidget { ); } } + +class PosterPlaceHolder extends StatelessWidget { + final Function() onTap; + final double aspectRatio; + const PosterPlaceHolder({ + required this.onTap, + required this.aspectRatio, + super.key, + }); + + @override + Widget build(BuildContext context) { + return AspectRatio( + aspectRatio: aspectRatio, + child: FractionallySizedBox( + alignment: Alignment.topCenter, + heightFactor: 0.85, + child: Padding( + padding: const EdgeInsets.all(4), + child: FocusButton( + onTap: onTap, + child: Card( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.2), + elevation: 0, + shadowColor: Colors.transparent, + child: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + const Icon( + IconsaxPlusLinear.more_square, + size: 46, + ), + Text( + context.localized.showMore, + style: Theme.of(context).textTheme.labelMedium, + ) + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/shared/media/season_row.dart b/lib/screens/shared/media/season_row.dart index 212cba7..62c3dd4 100644 --- a/lib/screens/shared/media/season_row.dart +++ b/lib/screens/shared/media/season_row.dart @@ -38,7 +38,6 @@ class SeasonsRow extends ConsumerWidget { itemBuilder: ( context, index, - selected, ) { final season = (seasons ?? [])[index]; return SeasonPoster( @@ -153,7 +152,7 @@ class SeasonPoster extends ConsumerWidget { items: season.generateActions(context, ref).popupMenuItems(useIcons: true)); }, onTap: () => onSeasonPressed?.call(season), - onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch + onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch ? () { showBottomSheetPill( context: context, @@ -166,7 +165,7 @@ class SeasonPoster extends ConsumerWidget { } : null, overlays: [ - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) ExcludeFocus( child: Align( alignment: Alignment.bottomRight, diff --git a/lib/screens/shared/nested_scaffold.dart b/lib/screens/shared/nested_scaffold.dart index 334163c..c4abfc6 100644 --- a/lib/screens/shared/nested_scaffold.dart +++ b/lib/screens/shared/nested_scaffold.dart @@ -31,10 +31,7 @@ class NestedScaffold extends ConsumerWidget { ], ), ), - child: Scaffold( - backgroundColor: Colors.transparent, - body: body, - ), + child: body, ), ], ); diff --git a/lib/screens/shared/user_icon.dart b/lib/screens/shared/user_icon.dart index 476411d..3e2c06a 100644 --- a/lib/screens/shared/user_icon.dart +++ b/lib/screens/shared/user_icon.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/account_model.dart'; import 'package:fladder/screens/shared/flat_button.dart'; +import 'package:fladder/theme.dart'; import 'package:fladder/util/string_extensions.dart'; class UserIcon extends ConsumerWidget { @@ -55,12 +56,15 @@ class UserIcon extends ConsumerWidget { child: Stack( alignment: Alignment.center, children: [ - CachedNetworkImage( - imageUrl: user?.avatar ?? "", - progressIndicatorBuilder: (context, url, progress) => placeHolder(), - errorWidget: (context, url, error) => placeHolder(), - memCacheHeight: 128, - fit: BoxFit.cover, + ClipRRect( + borderRadius: FladderTheme.defaultShape.borderRadius, + child: CachedNetworkImage( + imageUrl: user?.avatar ?? "", + progressIndicatorBuilder: (context, url, progress) => placeHolder(), + errorWidget: (context, url, error) => placeHolder(), + memCacheHeight: 128, + fit: BoxFit.cover, + ), ), FlatButton( onTap: onTap, diff --git a/lib/screens/syncing/sync_list_item.dart b/lib/screens/syncing/sync_list_item.dart index 94b7c3e..1561a34 100644 --- a/lib/screens/syncing/sync_list_item.dart +++ b/lib/screens/syncing/sync_list_item.dart @@ -11,6 +11,7 @@ import 'package:fladder/screens/shared/default_alert_dialog.dart'; import 'package:fladder/screens/syncing/sync_item_details.dart'; import 'package:fladder/screens/syncing/sync_widgets.dart'; import 'package:fladder/screens/syncing/widgets/sync_progress_builder.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/fladder_image.dart'; import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/localization_helper.dart'; @@ -65,6 +66,7 @@ class SyncListItem extends ConsumerWidget { child: FocusButton( onTap: () => baseItem?.navigateTo(context), onLongPress: () => showSyncItemDetails(context, syncedItem, ref), + autoFocus: FocusProvider.autoFocusOf(context) && AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad, child: ExcludeFocus( child: Padding( padding: const EdgeInsets.all(8.0), diff --git a/lib/screens/video_player/components/video_player_chapters.dart b/lib/screens/video_player/components/video_player_chapters.dart index f5064d8..5bdabb2 100644 --- a/lib/screens/video_player/components/video_player_chapters.dart +++ b/lib/screens/video_player/components/video_player_chapters.dart @@ -1,9 +1,11 @@ +import 'package:flutter/material.dart'; + import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/widgets/shared/horizontal_list.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; void showPlayerChapterDialogue( BuildContext context, { @@ -45,7 +47,7 @@ class VideoPlayerChapters extends ConsumerWidget { startIndex: chapters.indexOf(currentChapter ?? chapters.first), contentPadding: const EdgeInsets.symmetric(horizontal: 32), items: chapters.toList(), - itemBuilder: (context, index, selected) { + itemBuilder: (context, index) { final chapter = chapters[index]; final isCurrent = chapter == currentChapter; return Card( diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart index 8c863db..b93fa53 100644 --- a/lib/screens/video_player/components/video_player_next_wrapper.dart +++ b/lib/screens/video_player/components/video_player_next_wrapper.dart @@ -128,7 +128,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState } Future clearOverlaySettings() async { - if (AdaptiveLayout.of(context).inputDevice != InputDevice.pointer) { + if (AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer) { ScreenBrightness().resetApplicationScreenBrightness(); } else { fullScreenHelper.closeFullScreen(ref); diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 62523a5..6b940d0 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -95,10 +95,10 @@ class _DesktopControlsState extends ConsumerState { children: [ Positioned.fill( child: GestureDetector( - onTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + onTap: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => player.playOrPause() : () => toggleOverlay(), - onDoubleTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + onDoubleTap: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? () => fullScreenHelper.toggleFullScreen(ref) : null, ), @@ -245,7 +245,7 @@ class _DesktopControlsState extends ConsumerState { ], ), ), - if (AdaptiveLayout.of(context).inputDevice == InputDevice.touch) + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch) Align( alignment: Alignment.centerRight, child: Tooltip( @@ -362,7 +362,7 @@ class _DesktopControlsState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer) Tooltip( message: context.localized.stop, child: IconButton( @@ -379,7 +379,7 @@ class _DesktopControlsState extends ConsumerState { ), ), }, - if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer && AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[ VideoVolumeSlider( onChanged: () => resetTimer(), @@ -651,7 +651,7 @@ class _DesktopControlsState extends ConsumerState { Future clearOverlaySettings() async { toggleOverlay(value: true); - if (AdaptiveLayout.of(context).inputDevice != InputDevice.pointer) { + if (AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer) { ScreenBrightness().resetApplicationScreenBrightness(); } else { disableFullScreen(); diff --git a/lib/theme.dart b/lib/theme.dart index c410eea..9fefb65 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -41,7 +41,8 @@ class FladderTheme { (states) { return BorderSide( width: 2, - color: states.contains(WidgetState.focused) ? Colors.white.withValues(alpha: 0.65) : Colors.transparent, + color: scheme?.onPrimaryContainer.withValues(alpha: states.contains(WidgetState.focused) ? 0.9 : 0.0) ?? + Colors.transparent, ); }, ); diff --git a/lib/util/adaptive_layout/adaptive_layout.dart b/lib/util/adaptive_layout/adaptive_layout.dart index 91ba889..8962d1d 100644 --- a/lib/util/adaptive_layout/adaptive_layout.dart +++ b/lib/util/adaptive_layout/adaptive_layout.dart @@ -16,7 +16,7 @@ import 'package:fladder/util/resolution_checker.dart'; enum InputDevice { touch, pointer, - dpad, + dPad, } enum ViewSize { @@ -188,7 +188,7 @@ class _AdaptiveLayoutBuilderState extends ConsumerState { final selectedViewSize = selectAvailableOrSmaller(viewSize, acceptedViewSizes, ViewSize.values); final selectedLayoutMode = selectAvailableOrSmaller(layoutMode, acceptedLayouts, LayoutMode.values); final input = htpcMode - ? InputDevice.dpad + ? InputDevice.dPad : (isDesktop || kIsWeb) ? InputDevice.pointer : InputDevice.touch; diff --git a/lib/util/focus_provider.dart b/lib/util/focus_provider.dart index 399b53e..91c74ef 100644 --- a/lib/util/focus_provider.dart +++ b/lib/util/focus_provider.dart @@ -18,13 +18,11 @@ final acceptKeys = { class FocusProvider extends InheritedWidget { final bool hasFocus; final bool autoFocus; - final FocusNode? focusNode; const FocusProvider({ super.key, this.hasFocus = false, this.autoFocus = false, - this.focusNode, required super.child, }); @@ -38,11 +36,6 @@ class FocusProvider extends InheritedWidget { return widget?.autoFocus ?? false; } - static FocusNode? focusNodeOf(BuildContext context) { - final widget = context.dependOnInheritedWidgetOfExactType(); - return widget?.focusNode; - } - @override bool updateShouldNotify(FocusProvider oldWidget) { return oldWidget.hasFocus != hasFocus; @@ -51,6 +44,7 @@ class FocusProvider extends InheritedWidget { class FocusButton extends StatefulWidget { final Widget? child; + final bool autoFocus; final List overlays; final Function()? onTap; final Function()? onLongPress; @@ -60,6 +54,7 @@ class FocusButton extends StatefulWidget { const FocusButton({ this.child, + this.autoFocus = false, this.overlays = const [], this.onTap, this.onLongPress, @@ -74,7 +69,8 @@ class FocusButton extends StatefulWidget { } class FocusButtonState extends State { - bool onHover = false; + FocusNode focusNode = FocusNode(); + ValueNotifier onHover = ValueNotifier(false); Timer? _longPressTimer; bool _longPressTriggered = false; bool _keyDownActive = false; @@ -128,27 +124,29 @@ class FocusButtonState extends State { @override void dispose() { _resetKeyState(); + if (lastMainFocus == focusNode) { + lastMainFocus = null; + } + focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final focusNode = FocusProvider.focusNodeOf(context); return MouseRegion( cursor: SystemMouseCursors.click, - onEnter: (event) => setState(() => onHover = true), - onExit: (event) => setState(() => onHover = false), + onEnter: (event) => onHover.value = true, + onExit: (event) => onHover.value = false, hitTestBehavior: HitTestBehavior.translucent, child: Focus( focusNode: focusNode, + autofocus: widget.autoFocus, onFocusChange: (value) { widget.onFocusChanged?.call(value); if (value) { lastMainFocus = focusNode; } - setState(() { - onHover = value; - }); + onHover.value = value; }, onKeyEvent: _handleKey, child: ExcludeFocus( @@ -163,17 +161,23 @@ class FocusButtonState extends State { child: widget.child, ), Positioned.fill( - child: AnimatedOpacity( - opacity: onHover ? 1 : 0, - duration: const Duration(milliseconds: 125), - child: Container( - decoration: BoxDecoration( - color: widget.darkOverlay ? Colors.black.withValues(alpha: 0.35) : Colors.transparent, - border: Border.all(width: 3, color: Theme.of(context).colorScheme.primaryFixed), - borderRadius: FladderTheme.smallShape.borderRadius, - ), - child: Stack( - children: widget.overlays, + child: ValueListenableBuilder( + valueListenable: onHover, + builder: (context, value, child) => AnimatedOpacity( + opacity: value ? 1 : 0, + duration: const Duration(milliseconds: 125), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: widget.darkOverlay ? 0.1 : 0), + border: Border.all(width: 4, color: Theme.of(context).colorScheme.onPrimaryContainer), + borderRadius: FladderTheme.smallShape.borderRadius, + ), + child: Stack( + children: widget.overlays, + ), ), ), ), diff --git a/lib/util/throttler.dart b/lib/util/throttler.dart index de83fbf..4ad5505 100644 --- a/lib/util/throttler.dart +++ b/lib/util/throttler.dart @@ -2,19 +2,22 @@ import 'package:flutter/material.dart'; class Throttler { final Duration duration; - int? lastActionTime; + int? _lastActionTime; Throttler({required this.duration}); + bool canRun() { + final now = DateTime.now().millisecondsSinceEpoch; + if (_lastActionTime == null || now - _lastActionTime! >= duration.inMilliseconds) { + _lastActionTime = now; + return true; + } + return false; + } + void run(VoidCallback action) { - if (lastActionTime == null) { - lastActionTime = DateTime.now().millisecondsSinceEpoch; + if (canRun()) { action(); - } else { - if (DateTime.now().millisecondsSinceEpoch - lastActionTime! > (duration.inMilliseconds)) { - lastActionTime = DateTime.now().millisecondsSinceEpoch; - action(); - } } } } diff --git a/lib/widgets/media_query_scaler.dart b/lib/widgets/media_query_scaler.dart index ca7d0f7..1acd266 100644 --- a/lib/widgets/media_query_scaler.dart +++ b/lib/widgets/media_query_scaler.dart @@ -8,7 +8,7 @@ class MediaQueryScaler extends StatelessWidget { const MediaQueryScaler({ required this.child, required this.enable, - this.scale = 1.35, + this.scale = 1.4, super.key, }); diff --git a/lib/widgets/navigation_scaffold/components/drawer_list_button.dart b/lib/widgets/navigation_scaffold/components/drawer_list_button.dart index 93df459..baab4ef 100644 --- a/lib/widgets/navigation_scaffold/components/drawer_list_button.dart +++ b/lib/widgets/navigation_scaffold/components/drawer_list_button.dart @@ -45,7 +45,7 @@ class _DrawerListButtonState extends ConsumerState { selected: widget.selected, selectedTileColor: Theme.of(context).colorScheme.primary, selectedColor: Theme.of(context).colorScheme.onPrimary, - onLongPress: widget.actions.isNotEmpty && AdaptiveLayout.of(context).inputDevice == InputDevice.touch + onLongPress: widget.actions.isNotEmpty && AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch ? () => showBottomSheetPill( context: context, content: (context, scrollController) => ListView( @@ -61,7 +61,7 @@ class _DrawerListButtonState extends ConsumerState { child: AnimatedFadeSize(duration: widget.duration, child: widget.selected ? widget.selectedIcon : widget.icon), ), - trailing: widget.actions.isNotEmpty && AdaptiveLayout.of(context).inputDevice == InputDevice.pointer + trailing: widget.actions.isNotEmpty && AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? AnimatedOpacity( duration: const Duration(milliseconds: 125), opacity: showPopupButton ? 1 : 0, diff --git a/lib/widgets/navigation_scaffold/components/navigation_button.dart b/lib/widgets/navigation_scaffold/components/navigation_button.dart index 92c83a3..6f993d8 100644 --- a/lib/widgets/navigation_scaffold/components/navigation_button.dart +++ b/lib/widgets/navigation_scaffold/components/navigation_button.dart @@ -50,7 +50,7 @@ class _NavigationButtonState extends ConsumerState { : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45); return Padding( padding: const EdgeInsets.symmetric(horizontal: 6), - child: ElevatedButton( + child: TextButton( focusNode: widget.navFocusNode ? navBarNode : null, onHover: (value) => setState(() => showPopupButton = value), style: ButtonStyle( diff --git a/lib/widgets/shared/button_group.dart b/lib/widgets/shared/button_group.dart index 2b69881..effa703 100644 --- a/lib/widgets/shared/button_group.dart +++ b/lib/widgets/shared/button_group.dart @@ -79,16 +79,23 @@ class ExpressiveButton extends StatelessWidget { right: isSelected || position == PositionContext.last ? const Radius.circular(16) : const Radius.circular(4), ); return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: borderRadius), - elevation: isSelected ? 4 : 0, - backgroundColor: - isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, - foregroundColor: - isSelected ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant, - textStyle: Theme.of(context).textTheme.labelLarge, + style: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: borderRadius)), + elevation: WidgetStatePropertyAll(isSelected ? 4 : 0), + backgroundColor: WidgetStatePropertyAll( + isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest), + foregroundColor: WidgetStatePropertyAll( + isSelected ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant), + textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.labelLarge), visualDensity: VisualDensity.comfortable, - padding: const EdgeInsets.all(12), + side: WidgetStateProperty.resolveWith((states) => BorderSide( + width: 2, + color: (isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onPrimaryContainer) + .withValues(alpha: states.contains(WidgetState.focused) ? 1.0 : 0), + )), + padding: const WidgetStatePropertyAll(EdgeInsets.all(12)), ), onPressed: onPressed, label: label, diff --git a/lib/widgets/shared/clickable_text.dart b/lib/widgets/shared/clickable_text.dart index 25f4585..2662bd0 100644 --- a/lib/widgets/shared/clickable_text.dart +++ b/lib/widgets/shared/clickable_text.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; + class ClickableText extends ConsumerStatefulWidget { final String text; final double opacity; @@ -56,6 +59,9 @@ class _ClickableTextState extends ConsumerState { @override Widget build(BuildContext context) { + if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad) { + return _textWidget(false); + } return widget.onTap != null ? _buildClickable() : _textWidget(false); } } diff --git a/lib/widgets/shared/custom_shader_mask.dart b/lib/widgets/shared/custom_shader_mask.dart new file mode 100644 index 0000000..a69f5ce --- /dev/null +++ b/lib/widgets/shared/custom_shader_mask.dart @@ -0,0 +1,60 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CustomShaderMask extends StatefulWidget { + final Widget child; + const CustomShaderMask({required this.child, super.key}); + + @override + CustomShaderMaskState createState() => CustomShaderMaskState(); +} + +class CustomShaderMaskState extends State { + ui.Image? gradientImage; + + @override + void initState() { + super.initState(); + _loadImage('assets/gradient.png'); + } + + Future _loadImage(String assetPath) async { + final data = await rootBundle.load(assetPath); + final bytes = data.buffer.asUint8List(); + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + setState(() { + gradientImage = frame.image; + }); + } + + @override + Widget build(BuildContext context) { + if (gradientImage == null) { + return const SizedBox.shrink(); + } + + return ShaderMask( + shaderCallback: (Rect bounds) { + final imageWidth = gradientImage!.width.toDouble(); + final imageHeight = gradientImage!.height.toDouble(); + + final scaleX = bounds.width / imageWidth; + final scaleY = bounds.height / imageHeight; + + final matrix = Matrix4.diagonal3Values(scaleX, scaleY, 1); + + return ImageShader( + gradientImage!, + TileMode.clamp, + TileMode.clamp, + matrix.storage, + ); + }, + blendMode: BlendMode.dstIn, + child: widget.child, + ); + } +} diff --git a/lib/widgets/shared/ensure_visible.dart b/lib/widgets/shared/ensure_visible.dart index 9ce7c56..1f449cf 100644 --- a/lib/widgets/shared/ensure_visible.dart +++ b/lib/widgets/shared/ensure_visible.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; extension EnsureVisibleHelper on BuildContext { Future ensureVisible({ - Duration duration = const Duration(milliseconds: 300), + Duration duration = const Duration(milliseconds: 225), double? alignment, Curve curve = Curves.fastOutSlowIn, }) { diff --git a/lib/widgets/shared/grid_focus_traveler.dart b/lib/widgets/shared/grid_focus_traveler.dart index 4fbd2ec..3134aab 100644 --- a/lib/widgets/shared/grid_focus_traveler.dart +++ b/lib/widgets/shared/grid_focus_traveler.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/focus_provider.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart'; class GridFocusTraveler extends ConsumerStatefulWidget { @@ -28,80 +29,44 @@ class GridFocusTraveler extends ConsumerStatefulWidget { class _GridFocusTravelerState extends ConsumerState { late int selectedIndex = widget.currentIndex; - - late final List _focusNodes; - - @override - void initState() { - super.initState(); - _focusNodes = List.generate(widget.itemCount, (index) => FocusNode()); - _focusNodes.mapIndexed( - (index, element) { - element.addListener(() { - if (element.hasFocus) { - setState(() { - selectedIndex = index; - }); - } - }); - }, - ); - - if (!FocusProvider.autoFocusOf(context)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNodes.firstOrNull?.requestFocus(); - }); - } - } - - @override - void didUpdateWidget(GridFocusTraveler oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.itemCount != oldWidget.itemCount) { - for (var node in _focusNodes) { - node.dispose(); - } - _focusNodes = List.generate(widget.itemCount, (index) => FocusNode()); - if (selectedIndex >= widget.itemCount) { - selectedIndex = widget.itemCount - 1; - if (selectedIndex >= 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNodes[selectedIndex].requestFocus(); - }); - } - } - } - } - - @override - void dispose() { - for (var node in _focusNodes) { - node.dispose(); - } - super.dispose(); - } + bool _initializedFocus = false; @override Widget build(BuildContext context) { return FocusTraversalGroup( policy: GridFocusTravelerPolicy( navBarNode: navBarNode, - nodes: _focusNodes, crossAxisCount: widget.crossAxisCount, onChanged: (value) { selectedIndex = value; - _focusNodes[value].requestFocus(); }, ), - child: SliverGrid.builder( - gridDelegate: widget.gridDelegate, - itemCount: widget.itemCount, - itemBuilder: (context, index) { - return FocusProvider( - focusNode: _focusNodes[index], - child: Builder( - builder: (context) => widget.itemBuilder(context, selectedIndex, index), - ), + child: Builder( + builder: (context) { + if (!_initializedFocus && AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final parent = Focus.of(context); + final nodes = _childNodes(parent); + if (nodes.isNotEmpty) { + nodes.first.requestFocus(); + setState(() { + selectedIndex = 0; + _initializedFocus = true; + }); + } + }); + } + + return SliverGrid.builder( + gridDelegate: widget.gridDelegate, + itemCount: widget.itemCount, + itemBuilder: (context, index) { + return FocusProvider( + child: Builder( + builder: (context) => widget.itemBuilder(context, selectedIndex, index), + ), + ); + }, ); }, ), @@ -109,21 +74,20 @@ class _GridFocusTravelerState extends ConsumerState { } } -class GridFocusTravelerPolicy extends ReadingOrderTraversalPolicy { - /// The complete list of FocusNodes for the grid. - final List nodes; +List _childNodes(FocusNode node) { + return node.descendants.where((n) => n.canRequestFocus && n.context != null).toList() + ..sort((a, b) { + final dy = a.rect.top.compareTo(b.rect.top); + return dy != 0 ? dy : a.rect.left.compareTo(b.rect.left); + }); +} - /// The number of items in each row. +class GridFocusTravelerPolicy extends WidgetOrderTraversalPolicy { final int crossAxisCount; - - /// Callback to notify the parent which node index should be focused next. final Function(int value) onChanged; - - /// The navigation bar node to focus when navigating left from the first column. final FocusNode navBarNode; GridFocusTravelerPolicy({ - required this.nodes, required this.crossAxisCount, required this.onChanged, required this.navBarNode, @@ -131,52 +95,53 @@ class GridFocusTravelerPolicy extends ReadingOrderTraversalPolicy { @override bool inDirection(FocusNode currentNode, TraversalDirection direction) { - final int current = nodes.indexOf(currentNode); + final parent = currentNode.parent; + if (parent == null) { + return super.inDirection(currentNode, direction); + } + + final nodes = _childNodes(parent); + + final current = nodes.indexOf(currentNode); if (current == -1) { return super.inDirection(currentNode, direction); } - final int itemCount = nodes.length; - final int row = current ~/ crossAxisCount; - final int col = current % crossAxisCount; - final int rowCount = (itemCount / crossAxisCount).ceil(); - int? next; + final itemCount = nodes.length; + final row = current ~/ crossAxisCount; + final col = current % crossAxisCount; + final rowCount = (itemCount / crossAxisCount).ceil(); + int? next; switch (direction) { case TraversalDirection.left: - if (col > 0) { - next = current - 1; - } + if (col > 0) next = current - 1; break; - case TraversalDirection.right: if (col < crossAxisCount - 1 && current + 1 < itemCount) { next = current + 1; } break; - case TraversalDirection.up: - if (row > 0) { - next = current - crossAxisCount; - } + if (row > 0) next = current - crossAxisCount; break; - case TraversalDirection.down: if (row < rowCount - 1) { - final int candidate = current + crossAxisCount; - if (candidate < itemCount) { - next = candidate; - } + final candidate = current + crossAxisCount; + if (candidate < itemCount) next = candidate; } break; } if (next != null) { + final target = nodes[next]; + target.requestFocus(); onChanged(next); return true; } if (direction == TraversalDirection.left && col == 0) { + lastMainFocus = currentNode; navBarNode.requestFocus(); return true; } diff --git a/lib/widgets/shared/horizontal_list.dart b/lib/widgets/shared/horizontal_list.dart index 3a49d74..68408db 100644 --- a/lib/widgets/shared/horizontal_list.dart +++ b/lib/widgets/shared/horizontal_list.dart @@ -6,10 +6,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/screens/shared/media/poster_widget.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/sticky_header_text.dart'; +import 'package:fladder/util/throttler.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/navigation_body.dart'; import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart'; import 'package:fladder/widgets/shared/ensure_visible.dart'; @@ -21,7 +24,7 @@ class HorizontalList extends ConsumerStatefulWidget { final String? subtext; final List items; final int? startIndex; - final Widget Function(BuildContext context, int index, int selected) itemBuilder; + final Widget Function(BuildContext context, int index) itemBuilder; final Function(int index)? onFocused; final bool scrollToEnd; final EdgeInsets contentPadding; @@ -52,7 +55,7 @@ class HorizontalList extends ConsumerStatefulWidget { class _HorizontalListState extends ConsumerState { final FocusNode parentNode = FocusNode(); - late int currentIndex = 0; + FocusNode? lastFocused; final GlobalKey _firstItemKey = GlobalKey(); final ScrollController _scrollController = ScrollController(); final contentPadding = 8.0; @@ -60,81 +63,30 @@ class _HorizontalListState extends ConsumerState { double? _firstItemWidth; bool hasFocus = false; - late List _focusNodes; - @override void initState() { super.initState(); - _initFocusNodes(); _measureFirstItem(); } void _measureFirstItem() { if (_firstItemWidth != null) return; WidgetsBinding.instance.addPostFrameCallback((_) { - final context = _firstItemKey.currentContext; - if (context != null) { - final box = context.findRenderObject() as RenderBox; + final itemContext = _firstItemKey.currentContext; + if (itemContext != null) { + final box = itemContext.findRenderObject() as RenderBox; _firstItemWidth = box.size.width; _scrollToPosition(widget.startIndex ?? 0); } - }); - } - void _initFocusNodes() { - _focusNodes = List.generate(widget.items.length, (i) { - final node = FocusNode(); - node.addListener(() { - if (node.hasFocus) { - _scrollToPosition(i); - if (widget.onFocused != null) { - widget.onFocused?.call(i); - } else { - context.ensureVisible(); - } - } - }); - return node; - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.autoFocus) { - _focusNodes[currentIndex].requestFocus(); - context.ensureVisible(); + if ((FocusProvider.autoFocusOf(context) || widget.autoFocus) && + AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad) { + final nodesOnSameRow = _nodesInRow(parentNode); + nodesOnSameRow[widget.startIndex ?? 0].requestFocus(); } }); } - @override - void dispose() { - for (var node in _focusNodes) { - node.dispose(); - } - parentNode.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(HorizontalList oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.items.length != oldWidget.items.length) { - for (var node in _focusNodes) { - node.dispose(); - } - _initFocusNodes(); - - if (currentIndex >= widget.items.length) { - currentIndex = widget.items.isEmpty ? 0 : widget.items.length - 1; - } - - if (widget.items.isNotEmpty && parentNode.hasFocus) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNodes[currentIndex].requestFocus(); - }); - } - } - } - Future _scrollToPosition(int index) async { if (_firstItemWidth == null) return; @@ -167,110 +119,119 @@ class _HorizontalListState extends ConsumerState { @override Widget build(BuildContext context) { - final hasPointer = AdaptiveLayout.of(context).inputDevice == InputDevice.pointer; - return Focus( - focusNode: parentNode, - onFocusChange: (value) { - if (value) { - _focusNodes[currentIndex].requestFocus(); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: widget.contentPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (widget.label != null) - Flexible( - child: ExcludeFocus( - child: StickyHeaderText( - label: widget.label ?? "", - onClick: widget.onLabelClick, + final hasPointer = AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer; + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: widget.contentPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (widget.label != null) + Flexible( + child: ExcludeFocus( + child: StickyHeaderText( + label: widget.label ?? "", + onClick: + AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad ? null : widget.onLabelClick, + ), + ), + ), + if (widget.subtext != null) + Flexible( + child: ExcludeFocus( + child: Opacity( + opacity: 0.5, + child: Text( + widget.subtext!, + style: Theme.of(context).textTheme.titleMedium, ), ), ), - if (widget.subtext != null) - Flexible( - child: ExcludeFocus( - child: Opacity( - opacity: 0.5, - child: Text( - widget.subtext!, - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ), - ), - ...widget.titleActions - ], - ), + ), + ...widget.titleActions + ], ), - if (widget.items.length > 1) - ExcludeFocus( - child: Card( - elevation: 5, - color: Theme.of(context).colorScheme.surface, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (hasPointer) - GestureDetector( - onLongPress: () => _scrollToStart(), - child: IconButton( - onPressed: () { - _scrollController.animateTo( - _scrollController.offset + -(MediaQuery.of(context).size.width / 1.75), - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut); - }, - icon: const Icon( - IconsaxPlusLinear.arrow_left_1, - size: 20, - )), - ), - if (widget.startIndex != null) - IconButton( - tooltip: "Scroll to current", + ), + if (widget.items.length > 1) + ExcludeFocus( + child: Card( + elevation: 5, + color: Theme.of(context).colorScheme.surface, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (hasPointer) + GestureDetector( + onLongPress: () => _scrollToStart(), + child: IconButton( onPressed: () { - _scrollToPosition(widget.startIndex!); + _scrollController.animateTo( + _scrollController.offset + -(MediaQuery.of(context).size.width / 1.75), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut); }, icon: const Icon( - Icons.circle, - size: 16, + IconsaxPlusLinear.arrow_left_1, + size: 20, )), - if (hasPointer) - GestureDetector( - onLongPress: () => _scrollToEnd(), - child: IconButton( - onPressed: () { - _scrollController.animateTo( - _scrollController.offset + (MediaQuery.of(context).size.width / 1.75), - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut); - }, - icon: const Icon( - IconsaxPlusLinear.arrow_right_3, - size: 20, - )), - ), - ], - ), + ), + if (widget.startIndex != null) + IconButton( + tooltip: "Scroll to current", + onPressed: () => _scrollToPosition(widget.startIndex!), + icon: const Icon( + Icons.circle, + size: 16, + )), + if (hasPointer) + GestureDetector( + onLongPress: () => _scrollToEnd(), + child: IconButton( + onPressed: () { + _scrollController.animateTo( + _scrollController.offset + (MediaQuery.of(context).size.width / 1.75), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut); + }, + icon: const Icon( + IconsaxPlusLinear.arrow_right_3, + size: 20, + )), + ), + ], ), ), - ].addPadding(const EdgeInsets.symmetric(horizontal: 6)), - ), + ), + ].addPadding(const EdgeInsets.symmetric(horizontal: 6)), ), - const SizedBox(height: 8), - SizedBox( + ), + const SizedBox(height: 8), + Focus( + focusNode: parentNode, + onFocusChange: (value) { + if (value) { + final nodesOnSameRow = _nodesInRow(parentNode); + final focusNode = lastFocused ?? _firstFullyVisibleNode(context, nodesOnSameRow); + + if (focusNode != null) { + if (widget.onFocused != null) { + widget.onFocused!(nodesOnSameRow.indexOf(focusNode)); + } else { + context.ensureVisible(); + } + focusNode.requestFocus(); + } + } + }, + child: SizedBox( height: widget.height ?? ((AdaptiveLayout.poster(context).size * ref.watch(clientSettingsProvider.select((value) => value.posterSize))) / @@ -278,72 +239,137 @@ class _HorizontalListState extends ConsumerState { 0.72, child: FocusTraversalGroup( policy: HorizontalRailFocus( - parentNode: parentNode, - nodes: _focusNodes, - onChanged: (value) { - currentIndex = value; - _focusNodes[value].requestFocus(); - }), - child: ExcludeFocusTraversal( - child: ListView.separated( - controller: _scrollController, - scrollDirection: Axis.horizontal, - padding: widget.contentPadding, - itemBuilder: (context, index) { - return FocusProvider( - focusNode: _focusNodes[index], - hasFocus: hasFocus && index == currentIndex, - key: index == 0 ? _firstItemKey : null, - child: widget.itemBuilder(context, index, hasFocus ? currentIndex : -1), + parentNode: parentNode, + throttle: Throttler(duration: const Duration(milliseconds: 125)), + onFocused: (node) { + lastFocused = node; + final nodesOnSameRow = _nodesInRow(parentNode); + if (widget.onFocused != null) { + widget.onFocused?.call(nodesOnSameRow.indexOf(node)); + } + final nodeContext = node.context!; + final renderObject = nodeContext.findRenderObject(); + if (renderObject != null) { + final position = _scrollController.position; + position.ensureVisible( + renderObject, + alignment: _calcAlignmentWithPadding(nodeContext), + duration: const Duration(milliseconds: 200), + curve: Curves.fastOutSlowIn, ); - }, - separatorBuilder: (context, index) => SizedBox(width: contentPadding), - itemCount: widget.items.length, - ), + } + }, + ), + child: ListView.separated( + controller: _scrollController, + scrollDirection: Axis.horizontal, + padding: widget.contentPadding, + itemBuilder: (context, index) => index == widget.items.length + ? PosterPlaceHolder( + onTap: widget.onLabelClick ?? () {}, + aspectRatio: widget.dominantRatio ?? AdaptiveLayout.poster(context).ratio, + ) + : Container( + key: index == 0 ? _firstItemKey : null, + child: widget.itemBuilder(context, index), + ), + separatorBuilder: (context, index) => SizedBox(width: contentPadding), + itemCount: widget.onLabelClick != null && AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad + ? widget.items.length + 1 + : widget.items.length, ), ), ), - ], - ), + ), + ], ); } + + double _calcAlignmentWithPadding(BuildContext context) { + final viewportWidth = _scrollController.position.viewportDimension; + final double leftPadding = widget.contentPadding.left + (contentPadding * 2); + return leftPadding / viewportWidth; + } +} + +FocusNode? _firstFullyVisibleNode( + BuildContext context, + List nodes, +) { + if (nodes.isEmpty) return null; + + final scrollable = Scrollable.of(context); + + final viewportBox = scrollable.context.findRenderObject() as RenderBox; + final viewportSize = viewportBox.size; + + for (final node in nodes) { + final renderObj = node.context?.findRenderObject(); + if (renderObj is RenderBox) { + final topLeft = renderObj.localToGlobal(Offset.zero, ancestor: viewportBox); + final bottomRight = renderObj.localToGlobal(renderObj.size.bottomRight(Offset.zero), ancestor: viewportBox); + + final nodeRect = Rect.fromPoints(topLeft, bottomRight); + + final fullyVisible = nodeRect.left >= 0 && + nodeRect.right <= viewportSize.width && + nodeRect.top >= 0 && + nodeRect.bottom <= viewportSize.height; + + if (fullyVisible) { + return node; + } + } + } + + return nodes.firstOrNull; +} + +List _nodesInRow(FocusNode parentNode) { + return parentNode.descendants.where((n) => n.canRequestFocus && n.context != null).toList() + ..sort((a, b) => a.rect.left.compareTo(b.rect.left)); } class HorizontalRailFocus extends WidgetOrderTraversalPolicy { final FocusNode parentNode; - final List nodes; - final Function(int value) onChanged; + final void Function(FocusNode node) onFocused; + final Throttler? throttle; + HorizontalRailFocus({ required this.parentNode, - required this.nodes, - required this.onChanged, + required this.onFocused, + this.throttle, }); @override bool inDirection(FocusNode currentNode, TraversalDirection direction) { - // Find the index of the currently focused node - final int current = nodes.indexWhere((node) => node.hasFocus); - // If nothing is focused, default to 0 - final int currentIndex = current == -1 ? 0 : current; + if (throttle?.canRun() == false) return true; + + final rowNodes = _nodesInRow(parentNode); + final index = rowNodes.indexOf(currentNode); if (direction == TraversalDirection.left) { - if (currentIndex <= 0) { + if (index > 0) { + final target = rowNodes[index - 1]; + target.requestFocus(); + onFocused(target); + } else { + lastMainFocus = currentNode; navBarNode.requestFocus(); - return true; - } else { - onChanged(math.max(currentIndex - 1, 0)); - return true; - } - } else if (direction == TraversalDirection.right) { - if (currentIndex >= nodes.length - 1) { - // Corrected boundary check - return super.inDirection(parentNode, direction); - } else { - onChanged(math.min(currentIndex + 1, nodes.length - 1)); - return true; } + return true; } + + if (direction == TraversalDirection.right) { + if (index < rowNodes.length - 1) { + final target = rowNodes[index + 1]; + target.requestFocus(); + onFocused(target); + } + return true; + } + parentNode.requestFocus(); - return super.inDirection(parentNode, direction); + return super.inDirection(currentNode, direction); } } diff --git a/lib/widgets/shared/selectable_icon_button.dart b/lib/widgets/shared/selectable_icon_button.dart index 8e3bdba..725bcf8 100644 --- a/lib/widgets/shared/selectable_icon_button.dart +++ b/lib/widgets/shared/selectable_icon_button.dart @@ -39,18 +39,27 @@ class _SelectableIconButtonState extends ConsumerState { Widget build(BuildContext context) { const duration = Duration(milliseconds: 250); const iconSize = 24.0; + final theme = Theme.of(context).colorScheme; + final buttonState = WidgetStateProperty.resolveWith( + (states) { + return BorderSide( + width: 2, + color: theme.onPrimaryContainer.withValues(alpha: states.contains(WidgetState.focused) ? 0.9 : 0.0), + ); + }, + ); return Tooltip( message: widget.label ?? "", child: ElevatedButton( style: ButtonStyle( + side: buttonState, elevation: WidgetStatePropertyAll( widget.backgroundColor != null ? (widget.backgroundColor!.a < 1 ? 0 : null) : null), backgroundColor: WidgetStatePropertyAll( - widget.backgroundColor ?? (widget.selected ? Theme.of(context).colorScheme.primary : null)), - iconColor: WidgetStatePropertyAll( - widget.iconColor ?? (widget.selected ? Theme.of(context).colorScheme.onPrimary : null)), - foregroundColor: WidgetStatePropertyAll( - widget.iconColor ?? (widget.selected ? Theme.of(context).colorScheme.onPrimary : null)), + widget.backgroundColor ?? (widget.selected ? theme.primaryContainer : theme.surfaceContainerHigh)), + iconColor: WidgetStatePropertyAll(widget.iconColor ?? (widget.selected ? theme.onPrimaryContainer : null)), + foregroundColor: + WidgetStatePropertyAll(widget.iconColor ?? (widget.selected ? theme.onPrimaryContainer : null)), padding: const WidgetStatePropertyAll(EdgeInsets.zero), ), onFocusChange: (value) {