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 0000000..29df1c2 Binary files /dev/null and b/assets/gradient.png differ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8ae9b96..eab896d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1341,5 +1341,6 @@ }, "quickConnectPostFailed": "Failed to get quick connect code", "quickConnectLoginUsingCode": "Using quick connect", - "quickConnectEnterCodeDescription": "Enter the code below to login" + "quickConnectEnterCodeDescription": "Enter the code below to login", + "showMore": "Show more" } \ No newline at end of file diff --git a/lib/routes/auto_router.dart b/lib/routes/auto_router.dart index 444d143..3e3f813 100644 --- a/lib/routes/auto_router.dart +++ b/lib/routes/auto_router.dart @@ -54,22 +54,18 @@ final List 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) {