fix: Lots of navigation improvements

This commit is contained in:
PartyDonut 2025-10-03 13:02:51 +02:00
parent c299492d6d
commit 5174bb3a6c
55 changed files with 1019 additions and 832 deletions

2
android/.gitignore vendored
View file

@ -11,5 +11,3 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
**/TestData.kt

View file

@ -110,7 +110,7 @@ flutter {
} }
dependencies { dependencies {
def composeBom = platform('androidx.compose:compose-bom:2025.09.00') def composeBom = platform('androidx.compose:compose-bom:2025.09.01')
implementation composeBom implementation composeBom
androidTestImplementation composeBom androidTestImplementation composeBom
implementation('androidx.compose.material3:material3') implementation('androidx.compose.material3:material3')
@ -130,7 +130,8 @@ dependencies {
implementation("io.github.peerless2012:ass-media:0.3.0-rc03") implementation("io.github.peerless2012:ass-media:0.3.0-rc03")
//UI //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-compose:3.3.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0")
implementation("com.materialkolor:material-kolor:3.0.1")
} }

View file

@ -1,22 +1,26 @@
package nl.jknaapen.fladder package nl.jknaapen.fladder
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.materialkolor.PaletteStyle
private val DarkColorScheme = darkColorScheme( import com.materialkolor.dynamiccolor.ColorSpec
primary = Color(0xFF3B82F6) import com.materialkolor.rememberDynamicColorScheme
)
@Composable @Composable
fun VideoPlayerTheme( fun VideoPlayerTheme(
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = rememberDynamicColorScheme(
seedColor = Color(0xFFFF9800),
isDark = true,
specVersion = ColorSpec.SpecVersion.SPEC_2025,
style = PaletteStyle.Expressive,
)
MaterialTheme( MaterialTheme(
colorScheme = DarkColorScheme, colorScheme = colorScheme,
) { ) {
CompositionLocalProvider { CompositionLocalProvider {
content() content()

View file

@ -2,10 +2,9 @@ package nl.jknaapen.fladder.composables.controls
import PlayableData import PlayableData
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -33,8 +32,8 @@ fun ItemHeader(state: PlayableData?) {
contentDescription = title ?: "logo", contentDescription = title ?: "logo",
alignment = Alignment.CenterStart, alignment = Alignment.CenterStart,
modifier = Modifier modifier = Modifier
.heightIn(max = 100.dp) .fillMaxHeight(0.25f)
.widthIn(max = 200.dp) .fillMaxWidth(0.5f)
) )
} else { } else {
title?.let { title?.let {

View file

@ -13,12 +13,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -102,6 +102,7 @@ internal fun ProgressBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(125.dp) .height(125.dp)
.padding(bottom = 32.dp)
.align(alignment = Alignment.CenterHorizontally), .align(alignment = Alignment.CenterHorizontally),
currentPosition = tempPosition.milliseconds, currentPosition = tempPosition.milliseconds,
trickPlayModel = playbackData?.trickPlayModel trickPlayModel = playbackData?.trickPlayModel
@ -129,7 +130,7 @@ internal fun ProgressBar(
Text( Text(
formatTime(currentPosition), formatTime(currentPosition),
color = Color.White, color = Color.White,
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.titleMedium
) )
SimpleProgressBar( SimpleProgressBar(
player, player,
@ -152,7 +153,7 @@ internal fun ProgressBar(
) )
), ),
color = Color.White, color = Color.White,
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.titleMedium
) )
} }
} }
@ -240,9 +241,11 @@ internal fun RowScope.SimpleProgressBar(
modifier = Modifier modifier = Modifier
.focusable(enabled = false) .focusable(enabled = false)
.fillMaxWidth() .fillMaxWidth()
.height(12.dp) .height(8.dp)
.background( .background(
color = Color.Black.copy(alpha = 0.15f), color = Color.Black.copy(
alpha = 0.15f
),
shape = slideBarShape shape = slideBarShape
), ),
) { ) {
@ -251,9 +254,11 @@ internal fun RowScope.SimpleProgressBar(
.focusable(enabled = false) .focusable(enabled = false)
.fillMaxHeight() .fillMaxHeight()
.fillMaxWidth(progress) .fillMaxWidth(progress)
.padding(end = 9.dp) .padding(end = 8.dp)
.background( .background(
color = Color.White.copy(alpha = 0.75f), color = if (thumbFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(
alpha = 0.75f
),
shape = slideBarShape shape = slideBarShape
) )
) )
@ -321,11 +326,13 @@ internal fun RowScope.SimpleProgressBar(
.graphicsLayer { .graphicsLayer {
translationX = startPx translationX = startPx
} }
.size(6.dp) .padding(vertical = 0.5.dp)
.fillMaxHeight()
.aspectRatio(ratio = 1f)
.background( .background(
color = (if (isAfterCurrentPositon) Color.White else Color.Black).copy( color = if (isAfterCurrentPositon) Color.White.copy(
alpha = 0.45f alpha = 0.5f
), ) else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
shape = CircleShape shape = CircleShape
) )
) )

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -25,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.toBitmap import coil3.toBitmap
import kotlin.time.Duration import kotlin.time.Duration
@ -42,6 +44,16 @@ fun FilmstripTrickPlayOverlay(
return 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 uniqueThumbnails = remember(currentPosition, trickPlayModel, thumbnailsToShowOnEachSide) {
val currentFrameIndex = (currentPosition.inWholeMilliseconds / trickPlayModel.interval) val currentFrameIndex = (currentPosition.inWholeMilliseconds / trickPlayModel.interval)
.toInt() .toInt()

View file

@ -59,8 +59,8 @@ import io.github.rabehx.iconsax.filled.AudioSquare
import io.github.rabehx.iconsax.filled.Backward import io.github.rabehx.iconsax.filled.Backward
import io.github.rabehx.iconsax.filled.Check import io.github.rabehx.iconsax.filled.Check
import io.github.rabehx.iconsax.filled.Forward import io.github.rabehx.iconsax.filled.Forward
import io.github.rabehx.iconsax.filled.PauseCircle import io.github.rabehx.iconsax.filled.Pause
import io.github.rabehx.iconsax.filled.PlayCircle import io.github.rabehx.iconsax.filled.Play
import io.github.rabehx.iconsax.filled.Subtitle import io.github.rabehx.iconsax.filled.Subtitle
import io.github.rabehx.iconsax.outline.CloseSquare import io.github.rabehx.iconsax.outline.CloseSquare
import io.github.rabehx.iconsax.outline.Refresh import io.github.rabehx.iconsax.outline.Refresh
@ -349,7 +349,7 @@ fun PlaybackButtons(
}, },
) { ) {
Icon( Icon(
if (isPlaying) Iconsax.Filled.PauseCircle else Iconsax.Filled.PlayCircle, if (isPlaying) Iconsax.Filled.Pause else Iconsax.Filled.Play,
modifier = Modifier.size(55.dp), modifier = Modifier.size(55.dp),
contentDescription = if (isPlaying) "Pause" else "Play", contentDescription = if (isPlaying) "Pause" else "Play",
) )

BIN
assets/gradient.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -1341,5 +1341,6 @@
}, },
"quickConnectPostFailed": "Failed to get quick connect code", "quickConnectPostFailed": "Failed to get quick connect code",
"quickConnectLoginUsingCode": "Using quick connect", "quickConnectLoginUsingCode": "Using quick connect",
"quickConnectEnterCodeDescription": "Enter the code below to login" "quickConnectEnterCodeDescription": "Enter the code below to login",
"showMore": "Show more"
} }

View file

@ -54,22 +54,18 @@ final List<AutoRoute> homeRoutes = [
page: DashboardRoute.page, page: DashboardRoute.page,
initial: true, initial: true,
path: 'dashboard', path: 'dashboard',
maintainState: false,
), ),
AutoRoute( AutoRoute(
page: FavouritesRoute.page, page: FavouritesRoute.page,
path: 'favourites', path: 'favourites',
maintainState: false,
), ),
AutoRoute( AutoRoute(
page: SyncedRoute.page, page: SyncedRoute.page,
path: 'synced', path: 'synced',
maintainState: false,
), ),
AutoRoute( AutoRoute(
page: LibraryRoute.page, page: LibraryRoute.page,
path: 'libraries', path: 'libraries',
maintainState: false,
), ),
]; ];

View file

@ -49,7 +49,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final textController = TextEditingController(); final textController = TextEditingController();
ItemBaseModel? selectedPoster; final selectedPoster = ValueNotifier<ItemBaseModel?>(null);
@override @override
void initState() { void initState() {
@ -76,7 +76,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final padding = AdaptiveLayout.adaptivePadding(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 dashboardData = ref.watch(dashboardProvider);
final views = ref.watch(viewsProvider); final views = ref.watch(viewsProvider);
@ -99,10 +101,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return MediaQuery.removeViewInsets( return MediaQuery.removeViewInsets(
context: context, context: context,
child: NestedScaffold( child: NestedScaffold(
background: BackgroundImage( background: ValueListenableBuilder<ItemBaseModel?>(
items: selectedPoster != null valueListenable: selectedPoster,
? [selectedPoster!] builder: (_, value, __) {
: [...homeCarouselItems, ...dashboardData.nextUp, ...allResume]), return BackgroundImage(
items: value != null ? [value] : [...homeCarouselItems, ...dashboardData.nextUp, ...allResume],
);
},
),
body: PullToRefresh( body: PullToRefresh(
refreshKey: _refreshIndicatorKey, refreshKey: _refreshIndicatorKey,
displacement: 80 + MediaQuery.of(context).viewPadding.top, displacement: 80 + MediaQuery.of(context).viewPadding.top,
@ -128,13 +134,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
), ),
child: HomeBannerWidget( child: HomeBannerWidget(
posters: homeCarouselItems, posters: homeCarouselItems,
onSelect: (selected) { onSelect: (poster) => selectedPoster.value = poster,
// if (selectedPoster != selected) {
// setState(() {
// selectedPoster = selected;
// });
// }
},
), ),
), ),
), ),
@ -218,7 +218,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
.mapIndexed( .mapIndexed(
(index, child) => SliverToBoxAdapter( (index, child) => SliverToBoxAdapter(
child: FocusProvider( child: FocusProvider(
autoFocus: bannerType != HomeBanner.detailedBanner ? index == 0 : false, autoFocus:
bannerType != HomeBanner.detailedBanner || homeCarouselItems.isEmpty ? index == 0 : false,
child: child, child: child,
), ),
), ),

View file

@ -76,10 +76,7 @@ class _BookDetailScreenState extends ConsumerState<BookDetailScreen> {
OverviewHeader( OverviewHeader(
subTitle: details.book?.parentName ?? details.parentModel?.name, subTitle: details.book?.parentName ?? details.parentModel?.name,
name: details.nextUp?.name ?? "", name: details.nextUp?.name ?? "",
image: ImagesData( playButton: Builder(
logo: details.book?.getPosters?.primary,
),
centerButtons: Builder(
builder: (context) { builder: (context) {
//Wrapped so the correct context is used for refreshing the pages //Wrapped so the correct context is used for refreshing the pages
return MediaPlayButton( return MediaPlayButton(
@ -88,6 +85,9 @@ class _BookDetailScreenState extends ConsumerState<BookDetailScreen> {
); );
}, },
), ),
image: ImagesData(
logo: details.book?.getPosters?.primary,
),
productionYear: details.nextUp!.overview.productionYear, productionYear: details.nextUp!.overview.productionYear,
runTime: details.nextUp!.overview.runTime, runTime: details.nextUp!.overview.runTime,
genres: details.nextUp!.overview.genreItems, genres: details.nextUp!.overview.genreItems,

View file

@ -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/chip_button.dart';
import 'package:fladder/screens/shared/media/components/media_header.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/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/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/humanize_duration.dart'; import 'package:fladder/util/humanize_duration.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
@ -14,6 +15,7 @@ import 'package:fladder/util/list_padding.dart';
class OverviewHeader extends ConsumerWidget { class OverviewHeader extends ConsumerWidget {
final String name; final String name;
final ImagesData? image; final ImagesData? image;
final Widget? playButton;
final Widget? centerButtons; final Widget? centerButtons;
final EdgeInsets? padding; final EdgeInsets? padding;
final String? subTitle; final String? subTitle;
@ -30,6 +32,7 @@ class OverviewHeader extends ConsumerWidget {
const OverviewHeader({ const OverviewHeader({
required this.name, required this.name,
this.image, this.image,
this.playButton,
this.centerButtons, this.centerButtons,
this.padding, this.padding,
this.subTitle, this.subTitle,
@ -59,7 +62,7 @@ class OverviewHeader extends ConsumerWidget {
(MediaQuery.sizeOf(context).height - (MediaQuery.paddingOf(context).top + 150)).clamp(50, 1250).toDouble(); (MediaQuery.sizeOf(context).height - (MediaQuery.paddingOf(context).top + 150)).clamp(50, 1250).toDouble();
final crossAlignment = final crossAlignment =
AdaptiveLayout.viewSizeOf(context) != ViewSize.phone ? CrossAxisAlignment.start : CrossAxisAlignment.center; AdaptiveLayout.viewSizeOf(context) != ViewSize.phone ? CrossAxisAlignment.start : CrossAxisAlignment.stretch;
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
@ -156,7 +159,7 @@ class OverviewHeader extends ConsumerWidget {
Flexible( Flexible(
child: Text( child: Text(
summary ?? "", summary ?? "",
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 3, maxLines: 3,
), ),
@ -168,7 +171,29 @@ class OverviewHeader extends ConsumerWidget {
].addInBetween(const SizedBox(height: 10)), ].addInBetween(const SizedBox(height: 10)),
), ),
), ),
if (AdaptiveLayout.viewSizeOf(context) == ViewSize.phone) ...[
if (playButton != null) playButton!,
if (centerButtons != null) centerButtons!, 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)), ].addInBetween(const SizedBox(height: 21)),
), ),
), ),

View file

@ -79,13 +79,7 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
OverviewHeader( OverviewHeader(
name: details.series?.name ?? "", name: details.series?.name ?? "",
image: seasonDetails.images, image: seasonDetails.images,
centerButtons: Wrap( playButton: episodeDetails.playAble
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
episodeDetails.playAble
? MediaPlayButton( ? MediaPlayButton(
item: episodeDetails, item: episodeDetails,
onPressed: () async { onPressed: () async {
@ -98,6 +92,12 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
}, },
) )
: null, : null,
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton( SelectableIconButton(
onPressed: () async { onPressed: () async {
await ref await ref
@ -133,7 +133,7 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
selected: false, selected: false,
icon: IconsaxPlusLinear.more, icon: IconsaxPlusLinear.more,
), ),
].nonNulls.toList().addPadding(const EdgeInsets.symmetric(horizontal: 6)), ].nonNulls.toList(),
), ),
padding: padding, padding: padding,
subTitle: details.episode?.detailedName(context), subTitle: details.episode?.detailedName(context),

View file

@ -73,13 +73,7 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
name: details.name, name: details.name,
image: details.images, image: details.images,
padding: padding, padding: padding,
centerButtons: Wrap( playButton: MediaPlayButton(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
MediaPlayButton(
item: details, item: details,
onLongPressed: () async { onLongPressed: () async {
await details.play( await details.play(
@ -97,6 +91,12 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
ref.read(providerInstance.notifier).fetchDetails(widget.item); ref.read(providerInstance.notifier).fetchDetails(widget.item);
}, },
), ),
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton( SelectableIconButton(
onPressed: () async { onPressed: () async {
await ref await ref

View file

@ -76,13 +76,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
OverviewHeader( OverviewHeader(
name: details.name, name: details.name,
image: details.images, image: details.images,
centerButtons: Wrap( playButton: MediaPlayButton(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
MediaPlayButton(
item: details.nextUp, item: details.nextUp,
onPressed: details.nextUp != null onPressed: details.nextUp != null
? () async { ? () async {
@ -97,6 +91,12 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
} }
: null, : null,
), ),
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,
alignment: wrapAlignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SelectableIconButton( SelectableIconButton(
onPressed: () async { onPressed: () async {
await ref await ref

View file

@ -236,7 +236,7 @@ class LibraryRow extends ConsumerWidget {
autoFocus: true, autoFocus: true,
startIndex: selectedView != null ? views.indexOf(selectedView!) : null, startIndex: selectedView != null ? views.indexOf(selectedView!) : null,
contentPadding: padding, contentPadding: padding,
itemBuilder: (context, index, selected) { itemBuilder: (context, index) {
final view = views[index]; final view = views[index];
final isSelected = selectedView == view; final isSelected = selectedView == view;
final List<ItemActionButton> viewActions = [ final List<ItemActionButton> viewActions = [

View file

@ -158,7 +158,8 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
extendBody: true, extendBody: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
floatingActionButton: HideOnScroll( floatingActionButton: AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad
? HideOnScroll(
controller: scrollController, controller: scrollController,
visibleBuilder: (visible) => librarySearchResults.activePosters.isNotEmpty visibleBuilder: (visible) => librarySearchResults.activePosters.isNotEmpty
? FloatingActionButtonAnimated( ? FloatingActionButtonAnimated(
@ -169,7 +170,8 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
if (librarySearchResults.showGalleryButtons && !librarySearchResults.showPlayButtons) { if (librarySearchResults.showGalleryButtons && !librarySearchResults.showPlayButtons) {
libraryProvider.viewGallery(context); libraryProvider.viewGallery(context);
return; return;
} else if (!librarySearchResults.showGalleryButtons && librarySearchResults.showPlayButtons) { } else if (!librarySearchResults.showGalleryButtons &&
librarySearchResults.showPlayButtons) {
libraryProvider.playLibraryItems(context, ref); libraryProvider.playLibraryItems(context, ref);
return; return;
} }
@ -189,8 +191,10 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
icon: const Icon(IconsaxPlusBold.play), icon: const Icon(IconsaxPlusBold.play),
) )
: null, : null,
), )
bottomNavigationBar: HideOnScroll( : null,
bottomNavigationBar: AdaptiveLayout.inputDeviceOf(context) != InputDevice.dPad
? HideOnScroll(
controller: scrollController, controller: scrollController,
canHide: !floatingAppBar, canHide: !floatingAppBar,
child: IgnorePointer( child: IgnorePointer(
@ -203,11 +207,12 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
postersList: postersList, postersList: postersList,
), ),
), ),
), )
: null,
body: PinchPosterZoom( body: PinchPosterZoom(
scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference), scaleDifference: (difference) => ref.read(clientSettingsProvider.notifier).addPosterSize(difference),
child: FladderScrollbar( child: FladderScrollbar(
visible: AdaptiveLayout.of(context).inputDevice != InputDevice.pointer, visible: AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer,
controller: scrollController, controller: scrollController,
child: PullToRefresh( child: PullToRefresh(
refreshKey: refreshKey, refreshKey: refreshKey,
@ -427,7 +432,7 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
], ],
), ),
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: const Size(0, 50), preferredSize: Size(0, AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad ? 105 : 50),
child: Transform.translate( child: Transform.translate(
offset: Offset(0, AdaptiveLayout.of(context).isDesktop ? -20 : -15), offset: Offset(0, AdaptiveLayout.of(context).isDesktop ? -20 : -15),
child: IgnorePointer( child: IgnorePointer(
@ -446,6 +451,15 @@ class _LibrarySearchScreenState extends ConsumerState<LibrarySearchScreen> {
), ),
), ),
), ),
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 LibrarySearchNotifier libraryProvider;
final List<ItemBaseModel> postersList; final List<ItemBaseModel> postersList;
final GlobalKey<RefreshIndicatorState> refreshKey; final GlobalKey<RefreshIndicatorState> refreshKey;
final bool isDPadBar;
const _LibrarySearchBottomBar({ const _LibrarySearchBottomBar({
required this.uniqueKey, required this.uniqueKey,
required this.scrollController, required this.scrollController,
required this.libraryProvider, required this.libraryProvider,
required this.postersList, required this.postersList,
required this.refreshKey, required this.refreshKey,
this.isDPadBar = false,
}); });
@override @override
@ -586,10 +602,7 @@ class _LibrarySearchBottomBar extends ConsumerWidget {
]; ];
final paddingOf = MediaQuery.paddingOf(context); final paddingOf = MediaQuery.paddingOf(context);
return Padding( Widget content = Padding(
padding: EdgeInsets.only(left: paddingOf.left, right: paddingOf.right),
child: NestedBottomAppBar(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@ -598,6 +611,7 @@ class _LibrarySearchBottomBar extends ConsumerWidget {
Row( Row(
spacing: 6, spacing: 6,
children: [ children: [
if (!isDPadBar)
ScrollStatePosition( ScrollStatePosition(
controller: scrollController, controller: scrollController,
positionBuilder: (state) => AnimatedFadeSize( positionBuilder: (state) => AnimatedFadeSize(
@ -696,13 +710,13 @@ class _LibrarySearchBottomBar extends ConsumerWidget {
) )
: const SizedBox(), : const SizedBox(),
), ),
const Spacer(), if (!isDPadBar) const Spacer(),
if (librarySearchResults.activePosters.isNotEmpty) if (librarySearchResults.activePosters.isNotEmpty)
IconButton.filledTonal( IconButton(
tooltip: context.localized.random, tooltip: context.localized.random,
onPressed: () => libraryProvider.openRandom(context), onPressed: () => libraryProvider.openRandom(context),
icon: const Icon( icon: const Icon(
IconsaxPlusBold.arrow_up_1, IconsaxPlusBold.slider_vertical,
), ),
), ),
if (librarySearchResults.activePosters.isNotEmpty) if (librarySearchResults.activePosters.isNotEmpty)
@ -734,7 +748,15 @@ class _LibrarySearchBottomBar extends ConsumerWidget {
), ),
], ],
), ),
), );
if (isDPadBar) {
return content;
}
return Padding(
padding: EdgeInsets.only(left: paddingOf.left, right: paddingOf.right),
child: NestedBottomAppBar(
child: content,
), ),
); );
} }

View file

@ -50,7 +50,7 @@ class LoginUserGrid extends ConsumerWidget {
child: FocusButton( child: FocusButton(
onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user), onTap: () => editMode ? onLongPress?.call(user) : onPressed?.call(user),
onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) { onLongPress: switch (AdaptiveLayout.inputDeviceOf(context)) {
InputDevice.dpad || InputDevice.pointer => () => onLongPress?.call(user), InputDevice.dPad || InputDevice.pointer => () => onLongPress?.call(user),
InputDevice.touch => null, InputDevice.touch => null,
}, },
darkOverlay: false, darkOverlay: false,

View file

@ -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/models/account_model.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/screens/shared/user_icon.dart'; import 'package:fladder/screens/shared/user_icon.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginIcon extends ConsumerWidget { class LoginIcon extends ConsumerWidget {
final AccountModel user; final AccountModel user;
@ -24,7 +26,6 @@ class LoginIcon extends ConsumerWidget {
aspectRatio: 1.0, aspectRatio: 1.0,
child: Card( child: Card(
elevation: 1, elevation: 1,
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Stack( child: Stack(
children: [ children: [

View file

@ -26,7 +26,7 @@ Future<ItemBaseModel?> showEditItemPopup(
itemUpdated: (newItem) => updatedItem = newItem, itemUpdated: (newItem) => updatedItem = newItem,
refreshOnClose: (refresh) => shouldRefresh = refresh, refreshOnClose: (refresh) => shouldRefresh = refresh,
); );
return AdaptiveLayout.of(context).inputDevice == InputDevice.pointer return AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer
? Dialog( ? Dialog(
insetPadding: const EdgeInsets.all(64), insetPadding: const EdgeInsets.all(64),
child: editWidget(), child: editWidget(),

View file

@ -42,7 +42,7 @@ class _RefreshPopupDialogState extends ConsumerState<RefreshPopupDialog> {
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer ? 700 : double.infinity), maxWidth: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer ? 700 : double.infinity),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View file

@ -138,12 +138,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
icon: deviceIcon, icon: deviceIcon,
onTap: () => navigateTo(const ClientSettingsRoute()), onTap: () => navigateTo(const ClientSettingsRoute()),
), ),
if (quickConnectAvailable)
SettingsListTile(
label: Text(context.localized.settingsQuickConnectTitle),
icon: IconsaxPlusLinear.password_check,
onTap: () => openQuickConnectDialog(context),
),
SettingsListTile( SettingsListTile(
label: Text(context.localized.settingsProfileTitle), label: Text(context.localized.settingsProfileTitle),
subLabel: Text(context.localized.settingsProfileDesc), subLabel: Text(context.localized.settingsProfileDesc),
@ -203,6 +197,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
widthFactor: 0.25, widthFactor: 0.25,
child: Divider(), child: Divider(),
), ),
if (quickConnectAvailable)
SettingsListTile(
label: Text(context.localized.settingsQuickConnectTitle),
icon: IconsaxPlusLinear.password_check,
onTap: () => openQuickConnectDialog(context),
),
SettingsListTile( SettingsListTile(
label: Text(context.localized.switchUser), label: Text(context.localized.switchUser),
icon: IconsaxPlusLinear.arrow_swap_horizontal, icon: IconsaxPlusLinear.arrow_swap_horizontal,

View file

@ -125,7 +125,7 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
), ),
FlatButton( FlatButton(
onTap: () => widget.items[index].navigateTo(context), onTap: () => widget.items[index].navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer
? null ? null
: () { : () {
final poster = widget.items[index]; final poster = widget.items[index];
@ -141,7 +141,7 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
), ),
); );
}, },
onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch onSecondaryTapDown: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch
? null ? null
: (details) async { : (details) async {
Offset localPosition = details.globalPosition; Offset localPosition = details.globalPosition;
@ -175,7 +175,7 @@ class _CarouselBannerState extends ConsumerState<CarouselBanner> {
) )
], ],
), ),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer)
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
opacity: showControls ? 1 : 0, opacity: showControls ? 1 : 0,

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/items/chapters_model.dart'; import 'package:fladder/models/items/chapters_model.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
@ -24,7 +25,7 @@ class ChapterRow extends ConsumerWidget {
label: context.localized.chapter(chapters.length), label: context.localized.chapter(chapters.length),
height: AdaptiveLayout.poster(context).size / 1.75, height: AdaptiveLayout.poster(context).size / 1.75,
items: chapters, items: chapters,
itemBuilder: (context, index, selected) { itemBuilder: (context, index) {
final chapter = chapters[index]; final chapter = chapters[index];
List<ItemAction> generateActions() { List<ItemAction> generateActions() {
return [ return [
@ -58,6 +59,7 @@ class ChapterRow extends ConsumerWidget {
}, },
); );
}, },
child: Card(
child: AspectRatio( child: AspectRatio(
aspectRatio: 1.75, aspectRatio: 1.75,
child: Stack( child: Stack(
@ -66,6 +68,7 @@ class ChapterRow extends ConsumerWidget {
CachedNetworkImage( CachedNetworkImage(
imageUrl: chapter.imageUrl, imageUrl: chapter.imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => const Icon(IconsaxPlusBold.image),
), ),
Align( Align(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
@ -89,6 +92,7 @@ class ChapterRow extends ConsumerWidget {
], ],
), ),
), ),
),
overlays: [ overlays: [
if (AdaptiveLayout.of(context).isDesktop) if (AdaptiveLayout.of(context).isDesktop)
ExcludeFocus( ExcludeFocus(

View file

@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.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/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart'; import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart'; import 'package:fladder/widgets/shared/ensure_visible.dart';
class MediaPlayButton extends ConsumerWidget { class MediaPlayButton extends ConsumerWidget {
@ -24,7 +24,19 @@ class MediaPlayButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final progress = (item?.progress ?? 0) / 100.0; 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) { Widget buttonTitle(Color contentColor) {
return Padding( return Padding(
@ -61,9 +73,10 @@ class MediaPlayButton extends ConsumerWidget {
: TextButton( : TextButton(
onPressed: onPressed, onPressed: onPressed,
onLongPress: onLongPressed, onLongPress: onLongPressed,
autofocus: ref.read(argumentsStateProvider).htpcMode, autofocus: AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad,
style: TextButton.styleFrom( style: ButtonStyle(
padding: EdgeInsets.zero, side: buttonState,
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
), ),
onFocusChange: (value) { onFocusChange: (value) {
if (value) { if (value) {
@ -73,7 +86,7 @@ class MediaPlayButton extends ConsumerWidget {
} }
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(2.0), padding: EdgeInsets.all(padding),
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
@ -81,20 +94,13 @@ class MediaPlayButton extends ConsumerWidget {
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.primaryContainer,
boxShadow: [
BoxShadow(
blurRadius: 8.0,
offset: const Offset(0, 2),
color: Colors.black.withValues(alpha: 0.3),
)
],
borderRadius: radius, borderRadius: radius,
), ),
), ),
), ),
// Button content // Button content
buttonTitle(Theme.of(context).colorScheme.primary), buttonTitle(Theme.of(context).colorScheme.onPrimaryContainer),
Positioned.fill( Positioned.fill(
child: ClipRect( child: ClipRect(
clipper: _ProgressClipper( clipper: _ProgressClipper(

View file

@ -5,9 +5,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/details_screens/components/overview_header.dart'; import 'package:fladder/screens/details_screens/components/overview_header.dart';
import 'package:fladder/screens/shared/media/poster_row.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/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.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'; import 'package:fladder/widgets/shared/ensure_visible.dart';
class DetailedBanner extends ConsumerStatefulWidget { class DetailedBanner extends ConsumerStatefulWidget {
@ -29,60 +31,20 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
final color = Theme.of(context).colorScheme.surface; final phoneOffsetHeight =
final stops = [0.05, 0.35, 0.65, 0.95]; AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone ? MediaQuery.paddingOf(context).top + 80 : 0.0;
return Column( 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,
),
),
child: Stack(
children: [ children: [
ExcludeFocus( ExcludeFocus(
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: Transform.translate(
offset: Offset(0, -phoneOffsetHeight),
child: FractionallySizedBox(
widthFactor: 0.85,
child: AspectRatio( child: AspectRatio(
aspectRatio: 1.7, aspectRatio: 1.8,
child: ShaderMask( child: CustomShaderMask(
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( child: FladderImage(
image: selectedPoster.images?.primary, image: selectedPoster.images?.primary,
), ),
@ -91,22 +53,27 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
), ),
), ),
), ),
Padding( ),
padding: const EdgeInsets.all(16.0), SizedBox(
child: FractionallySizedBox( width: double.infinity,
widthFactor: 0.5, height: size.height * 0.85,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.min,
spacing: 16,
children: [ children: [
Flexible( 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( child: OverviewHeader(
name: selectedPoster.parentBaseModel.name, name: selectedPoster.parentBaseModel.name,
subTitle: selectedPoster.label(context), subTitle: selectedPoster.label(context),
image: selectedPoster.getPosters, image: selectedPoster.getPosters,
logoAlignment: Alignment.centerLeft, logoAlignment: AdaptiveLayout.viewSizeOf(context) <= ViewSize.phone
? Alignment.center
: Alignment.centerLeft,
summary: selectedPoster.overview.summary, summary: selectedPoster.overview.summary,
productionYear: selectedPoster.overview.productionYear, productionYear: selectedPoster.overview.productionYear,
runTime: selectedPoster.overview.runTime, runTime: selectedPoster.overview.runTime,
@ -116,21 +83,11 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
communityRating: selectedPoster.overview.communityRating, communityRating: selectedPoster.overview.communityRating,
), ),
), ),
SizedBox(
height: size.height * 0.05,
)
],
),
),
),
],
),
), ),
), ),
FocusProvider( FocusProvider(
autoFocus: true, autoFocus: true,
child: PosterRow( child: PosterRow(
key: const Key("detailed-banner-row"),
primaryPosters: true, primaryPosters: true,
label: context.localized.nextUp, label: context.localized.nextUp,
posters: widget.posters, posters: widget.posters,
@ -144,7 +101,11 @@ class _DetailedBannerState extends ConsumerState<DetailedBanner> {
widget.onSelect(poster); widget.onSelect(poster);
}, },
), ),
) ),
const SizedBox(height: 16)
],
),
),
], ],
); );
} }

View file

@ -12,6 +12,7 @@ import 'package:fladder/util/fladder_image.dart';
import 'package:fladder/util/focus_provider.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/item_base_model_extensions.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/refresh_state.dart';
import 'package:fladder/widgets/shared/clickable_text.dart'; import 'package:fladder/widgets/shared/clickable_text.dart';
import 'package:fladder/widgets/shared/enum_selection.dart'; import 'package:fladder/widgets/shared/enum_selection.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart'; import 'package:fladder/widgets/shared/horizontal_list.dart';
@ -83,7 +84,7 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
contentPadding: widget.contentPadding, contentPadding: widget.contentPadding,
startIndex: indexOfCurrent, startIndex: indexOfCurrent,
items: episodes, items: episodes,
itemBuilder: (context, index, selected) { itemBuilder: (context, index) {
final episode = episodes[index]; final episode = episodes[index];
final isCurrentEpisode = index == indexOfCurrent; final isCurrentEpisode = index == indexOfCurrent;
return EpisodePoster( return EpisodePoster(
@ -101,8 +102,8 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
: () { : () {
episode.navigateTo(context); episode.navigateTo(context);
}, },
onLongPress: () { onLongPress: () async {
showBottomSheetPill( await showBottomSheetPill(
context: context, context: context,
item: episode, item: episode,
content: (context, scrollController) { content: (context, scrollController) {
@ -115,6 +116,7 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
); );
}, },
); );
context.refreshData();
}, },
actions: episode.generateActions(context, ref), actions: episode.generateActions(context, ref),
isCurrentEpisode: isCurrentEpisode, isCurrentEpisode: isCurrentEpisode,
@ -185,7 +187,7 @@ class EpisodePoster extends ConsumerWidget {
decodeHeight: 250, decodeHeight: 250,
), ),
overlays: [ overlays: [
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && actions.isNotEmpty) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer && actions.isNotEmpty)
ExcludeFocus( ExcludeFocus(
child: Align( child: Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,

View file

@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart' as urilauncher;
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:fladder/models/items/item_shared_models.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/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/sticky_header_text.dart'; import 'package:fladder/util/sticky_header_text.dart';
@ -19,6 +20,9 @@ class ExternalUrlsRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
if (ref.watch(argumentsStateProvider).htpcMode) {
return const SizedBox.shrink();
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View file

@ -146,7 +146,7 @@ class _MediaBannerState extends ConsumerState<MediaBanner> {
), ),
child: FocusButton( child: FocusButton(
onTap: () => currentItem.navigateTo(context), onTap: () => currentItem.navigateTo(context),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch
? () async { ? () async {
interacting = true; interacting = true;
final poster = currentItem; final poster = currentItem;
@ -165,7 +165,7 @@ class _MediaBannerState extends ConsumerState<MediaBanner> {
timer.reset(); timer.reset();
} }
: null, : null,
onSecondaryTapDown: AdaptiveLayout.of(context).inputDevice == InputDevice.touch onSecondaryTapDown: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch
? null ? null
: (details) async { : (details) async {
Offset localPosition = details.globalPosition; Offset localPosition = details.globalPosition;

View file

@ -47,7 +47,7 @@ class PeopleRow extends ConsumerWidget {
height: AdaptiveLayout.poster(context).size * 0.9, height: AdaptiveLayout.poster(context).size * 0.9,
contentPadding: contentPadding, contentPadding: contentPadding,
items: people, items: people,
itemBuilder: (context, index, selected) { itemBuilder: (context, index) {
final person = people[index]; final person = people[index];
return AspectRatio( return AspectRatio(
aspectRatio: 0.6, aspectRatio: 0.6,

View file

@ -69,6 +69,8 @@ class PosterListItem extends ConsumerWidget {
), ),
child: FocusButton( child: FocusButton(
onTap: () => pressedWidget(context), onTap: () => pressedWidget(context),
autoFocus:
FocusProvider.autoFocusOf(context) && AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad,
onFocusChanged: (focus) { onFocusChanged: (focus) {
if (focus) { if (focus) {
context.ensureVisible(); context.ensureVisible();

View file

@ -46,7 +46,7 @@ class PosterRow extends ConsumerWidget {
context.ensureVisible(); context.ensureVisible();
} }
}, },
itemBuilder: (context, index, selected) { itemBuilder: (context, index) {
final poster = posters[index]; final poster = posters[index];
return PosterWidget( return PosterWidget(
key: Key(poster.id), key: Key(poster.id),

View file

@ -1,13 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/item_shared_models.dart';
import 'package:fladder/screens/shared/media/components/poster_image.dart'; import 'package:fladder/screens/shared/media/components/poster_image.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.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/item_base_model_extensions.dart';
import 'package:fladder/util/item_base_model/play_item_helpers.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/clickable_text.dart';
import 'package:fladder/widgets/shared/item_actions.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,
)
],
),
),
),
),
),
),
),
);
}
}

View file

@ -38,7 +38,6 @@ class SeasonsRow extends ConsumerWidget {
itemBuilder: ( itemBuilder: (
context, context,
index, index,
selected,
) { ) {
final season = (seasons ?? [])[index]; final season = (seasons ?? [])[index];
return SeasonPoster( return SeasonPoster(
@ -153,7 +152,7 @@ class SeasonPoster extends ConsumerWidget {
items: season.generateActions(context, ref).popupMenuItems(useIcons: true)); items: season.generateActions(context, ref).popupMenuItems(useIcons: true));
}, },
onTap: () => onSeasonPressed?.call(season), onTap: () => onSeasonPressed?.call(season),
onLongPress: AdaptiveLayout.of(context).inputDevice == InputDevice.touch onLongPress: AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch
? () { ? () {
showBottomSheetPill( showBottomSheetPill(
context: context, context: context,
@ -166,7 +165,7 @@ class SeasonPoster extends ConsumerWidget {
} }
: null, : null,
overlays: [ overlays: [
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer)
ExcludeFocus( ExcludeFocus(
child: Align( child: Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,

View file

@ -31,10 +31,7 @@ class NestedScaffold extends ConsumerWidget {
], ],
), ),
), ),
child: Scaffold( child: body,
backgroundColor: Colors.transparent,
body: body,
),
), ),
], ],
); );

View file

@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/account_model.dart'; import 'package:fladder/models/account_model.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/string_extensions.dart'; import 'package:fladder/util/string_extensions.dart';
class UserIcon extends ConsumerWidget { class UserIcon extends ConsumerWidget {
@ -55,13 +56,16 @@ class UserIcon extends ConsumerWidget {
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CachedNetworkImage( ClipRRect(
borderRadius: FladderTheme.defaultShape.borderRadius,
child: CachedNetworkImage(
imageUrl: user?.avatar ?? "", imageUrl: user?.avatar ?? "",
progressIndicatorBuilder: (context, url, progress) => placeHolder(), progressIndicatorBuilder: (context, url, progress) => placeHolder(),
errorWidget: (context, url, error) => placeHolder(), errorWidget: (context, url, error) => placeHolder(),
memCacheHeight: 128, memCacheHeight: 128,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
),
FlatButton( FlatButton(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,

View file

@ -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_item_details.dart';
import 'package:fladder/screens/syncing/sync_widgets.dart'; import 'package:fladder/screens/syncing/sync_widgets.dart';
import 'package:fladder/screens/syncing/widgets/sync_progress_builder.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/fladder_image.dart';
import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/localization_helper.dart';
@ -65,6 +66,7 @@ class SyncListItem extends ConsumerWidget {
child: FocusButton( child: FocusButton(
onTap: () => baseItem?.navigateTo(context), onTap: () => baseItem?.navigateTo(context),
onLongPress: () => showSyncItemDetails(context, syncedItem, ref), onLongPress: () => showSyncItemDetails(context, syncedItem, ref),
autoFocus: FocusProvider.autoFocusOf(context) && AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad,
child: ExcludeFocus( child: ExcludeFocus(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),

View file

@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.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/models/items/chapters_model.dart';
import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/flat_button.dart';
import 'package:fladder/widgets/shared/horizontal_list.dart'; import 'package:fladder/widgets/shared/horizontal_list.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void showPlayerChapterDialogue( void showPlayerChapterDialogue(
BuildContext context, { BuildContext context, {
@ -45,7 +47,7 @@ class VideoPlayerChapters extends ConsumerWidget {
startIndex: chapters.indexOf(currentChapter ?? chapters.first), startIndex: chapters.indexOf(currentChapter ?? chapters.first),
contentPadding: const EdgeInsets.symmetric(horizontal: 32), contentPadding: const EdgeInsets.symmetric(horizontal: 32),
items: chapters.toList(), items: chapters.toList(),
itemBuilder: (context, index, selected) { itemBuilder: (context, index) {
final chapter = chapters[index]; final chapter = chapters[index];
final isCurrent = chapter == currentChapter; final isCurrent = chapter == currentChapter;
return Card( return Card(

View file

@ -128,7 +128,7 @@ class _VideoPlayerNextWrapperState extends ConsumerState<VideoPlayerNextWrapper>
} }
Future<void> clearOverlaySettings() async { Future<void> clearOverlaySettings() async {
if (AdaptiveLayout.of(context).inputDevice != InputDevice.pointer) { if (AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer) {
ScreenBrightness().resetApplicationScreenBrightness(); ScreenBrightness().resetApplicationScreenBrightness();
} else { } else {
fullScreenHelper.closeFullScreen(ref); fullScreenHelper.closeFullScreen(ref);

View file

@ -95,10 +95,10 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
children: [ children: [
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer onTap: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer
? () => player.playOrPause() ? () => player.playOrPause()
: () => toggleOverlay(), : () => toggleOverlay(),
onDoubleTap: AdaptiveLayout.of(context).inputDevice == InputDevice.pointer onDoubleTap: AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer
? () => fullScreenHelper.toggleFullScreen(ref) ? () => fullScreenHelper.toggleFullScreen(ref)
: null, : null,
), ),
@ -245,7 +245,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
], ],
), ),
), ),
if (AdaptiveLayout.of(context).inputDevice == InputDevice.touch) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.touch)
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Tooltip( child: Tooltip(
@ -362,7 +362,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer) if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer)
Tooltip( Tooltip(
message: context.localized.stop, message: context.localized.stop,
child: IconButton( child: IconButton(
@ -379,7 +379,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
), ),
), ),
}, },
if (AdaptiveLayout.of(context).inputDevice == InputDevice.pointer && if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer &&
AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[ AdaptiveLayout.viewSizeOf(context) > ViewSize.phone) ...[
VideoVolumeSlider( VideoVolumeSlider(
onChanged: () => resetTimer(), onChanged: () => resetTimer(),
@ -651,7 +651,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
Future<void> clearOverlaySettings() async { Future<void> clearOverlaySettings() async {
toggleOverlay(value: true); toggleOverlay(value: true);
if (AdaptiveLayout.of(context).inputDevice != InputDevice.pointer) { if (AdaptiveLayout.inputDeviceOf(context) != InputDevice.pointer) {
ScreenBrightness().resetApplicationScreenBrightness(); ScreenBrightness().resetApplicationScreenBrightness();
} else { } else {
disableFullScreen(); disableFullScreen();

View file

@ -41,7 +41,8 @@ class FladderTheme {
(states) { (states) {
return BorderSide( return BorderSide(
width: 2, 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,
); );
}, },
); );

View file

@ -16,7 +16,7 @@ import 'package:fladder/util/resolution_checker.dart';
enum InputDevice { enum InputDevice {
touch, touch,
pointer, pointer,
dpad, dPad,
} }
enum ViewSize { enum ViewSize {
@ -188,7 +188,7 @@ class _AdaptiveLayoutBuilderState extends ConsumerState<AdaptiveLayoutBuilder> {
final selectedViewSize = selectAvailableOrSmaller<ViewSize>(viewSize, acceptedViewSizes, ViewSize.values); final selectedViewSize = selectAvailableOrSmaller<ViewSize>(viewSize, acceptedViewSizes, ViewSize.values);
final selectedLayoutMode = selectAvailableOrSmaller<LayoutMode>(layoutMode, acceptedLayouts, LayoutMode.values); final selectedLayoutMode = selectAvailableOrSmaller<LayoutMode>(layoutMode, acceptedLayouts, LayoutMode.values);
final input = htpcMode final input = htpcMode
? InputDevice.dpad ? InputDevice.dPad
: (isDesktop || kIsWeb) : (isDesktop || kIsWeb)
? InputDevice.pointer ? InputDevice.pointer
: InputDevice.touch; : InputDevice.touch;

View file

@ -18,13 +18,11 @@ final acceptKeys = {
class FocusProvider extends InheritedWidget { class FocusProvider extends InheritedWidget {
final bool hasFocus; final bool hasFocus;
final bool autoFocus; final bool autoFocus;
final FocusNode? focusNode;
const FocusProvider({ const FocusProvider({
super.key, super.key,
this.hasFocus = false, this.hasFocus = false,
this.autoFocus = false, this.autoFocus = false,
this.focusNode,
required super.child, required super.child,
}); });
@ -38,11 +36,6 @@ class FocusProvider extends InheritedWidget {
return widget?.autoFocus ?? false; return widget?.autoFocus ?? false;
} }
static FocusNode? focusNodeOf(BuildContext context) {
final widget = context.dependOnInheritedWidgetOfExactType<FocusProvider>();
return widget?.focusNode;
}
@override @override
bool updateShouldNotify(FocusProvider oldWidget) { bool updateShouldNotify(FocusProvider oldWidget) {
return oldWidget.hasFocus != hasFocus; return oldWidget.hasFocus != hasFocus;
@ -51,6 +44,7 @@ class FocusProvider extends InheritedWidget {
class FocusButton extends StatefulWidget { class FocusButton extends StatefulWidget {
final Widget? child; final Widget? child;
final bool autoFocus;
final List<Widget> overlays; final List<Widget> overlays;
final Function()? onTap; final Function()? onTap;
final Function()? onLongPress; final Function()? onLongPress;
@ -60,6 +54,7 @@ class FocusButton extends StatefulWidget {
const FocusButton({ const FocusButton({
this.child, this.child,
this.autoFocus = false,
this.overlays = const [], this.overlays = const [],
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
@ -74,7 +69,8 @@ class FocusButton extends StatefulWidget {
} }
class FocusButtonState extends State<FocusButton> { class FocusButtonState extends State<FocusButton> {
bool onHover = false; FocusNode focusNode = FocusNode();
ValueNotifier<bool> onHover = ValueNotifier<bool>(false);
Timer? _longPressTimer; Timer? _longPressTimer;
bool _longPressTriggered = false; bool _longPressTriggered = false;
bool _keyDownActive = false; bool _keyDownActive = false;
@ -128,27 +124,29 @@ class FocusButtonState extends State<FocusButton> {
@override @override
void dispose() { void dispose() {
_resetKeyState(); _resetKeyState();
if (lastMainFocus == focusNode) {
lastMainFocus = null;
}
focusNode.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final focusNode = FocusProvider.focusNodeOf(context);
return MouseRegion( return MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
onEnter: (event) => setState(() => onHover = true), onEnter: (event) => onHover.value = true,
onExit: (event) => setState(() => onHover = false), onExit: (event) => onHover.value = false,
hitTestBehavior: HitTestBehavior.translucent, hitTestBehavior: HitTestBehavior.translucent,
child: Focus( child: Focus(
focusNode: focusNode, focusNode: focusNode,
autofocus: widget.autoFocus,
onFocusChange: (value) { onFocusChange: (value) {
widget.onFocusChanged?.call(value); widget.onFocusChanged?.call(value);
if (value) { if (value) {
lastMainFocus = focusNode; lastMainFocus = focusNode;
} }
setState(() { onHover.value = value;
onHover = value;
});
}, },
onKeyEvent: _handleKey, onKeyEvent: _handleKey,
child: ExcludeFocus( child: ExcludeFocus(
@ -163,13 +161,18 @@ class FocusButtonState extends State<FocusButton> {
child: widget.child, child: widget.child,
), ),
Positioned.fill( Positioned.fill(
child: AnimatedOpacity( child: ValueListenableBuilder(
opacity: onHover ? 1 : 0, valueListenable: onHover,
builder: (context, value, child) => AnimatedOpacity(
opacity: value ? 1 : 0,
duration: const Duration(milliseconds: 125), duration: const Duration(milliseconds: 125),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.darkOverlay ? Colors.black.withValues(alpha: 0.35) : Colors.transparent, color: Theme.of(context)
border: Border.all(width: 3, color: Theme.of(context).colorScheme.primaryFixed), .colorScheme
.primaryContainer
.withValues(alpha: widget.darkOverlay ? 0.1 : 0),
border: Border.all(width: 4, color: Theme.of(context).colorScheme.onPrimaryContainer),
borderRadius: FladderTheme.smallShape.borderRadius, borderRadius: FladderTheme.smallShape.borderRadius,
), ),
child: Stack( child: Stack(
@ -178,6 +181,7 @@ class FocusButtonState extends State<FocusButton> {
), ),
), ),
), ),
),
], ],
), ),
), ),

View file

@ -2,19 +2,22 @@ import 'package:flutter/material.dart';
class Throttler { class Throttler {
final Duration duration; final Duration duration;
int? lastActionTime; int? _lastActionTime;
Throttler({required this.duration}); 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) { void run(VoidCallback action) {
if (lastActionTime == null) { if (canRun()) {
lastActionTime = DateTime.now().millisecondsSinceEpoch;
action();
} else {
if (DateTime.now().millisecondsSinceEpoch - lastActionTime! > (duration.inMilliseconds)) {
lastActionTime = DateTime.now().millisecondsSinceEpoch;
action(); action();
} }
} }
} }
}

View file

@ -8,7 +8,7 @@ class MediaQueryScaler extends StatelessWidget {
const MediaQueryScaler({ const MediaQueryScaler({
required this.child, required this.child,
required this.enable, required this.enable,
this.scale = 1.35, this.scale = 1.4,
super.key, super.key,
}); });

View file

@ -45,7 +45,7 @@ class _DrawerListButtonState extends ConsumerState<DrawerListButton> {
selected: widget.selected, selected: widget.selected,
selectedTileColor: Theme.of(context).colorScheme.primary, selectedTileColor: Theme.of(context).colorScheme.primary,
selectedColor: Theme.of(context).colorScheme.onPrimary, 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( ? () => showBottomSheetPill(
context: context, context: context,
content: (context, scrollController) => ListView( content: (context, scrollController) => ListView(
@ -61,7 +61,7 @@ class _DrawerListButtonState extends ConsumerState<DrawerListButton> {
child: child:
AnimatedFadeSize(duration: widget.duration, child: widget.selected ? widget.selectedIcon : widget.icon), 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( ? AnimatedOpacity(
duration: const Duration(milliseconds: 125), duration: const Duration(milliseconds: 125),
opacity: showPopupButton ? 1 : 0, opacity: showPopupButton ? 1 : 0,

View file

@ -50,7 +50,7 @@ class _NavigationButtonState extends ConsumerState<NavigationButton> {
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45); : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.symmetric(horizontal: 6),
child: ElevatedButton( child: TextButton(
focusNode: widget.navFocusNode ? navBarNode : null, focusNode: widget.navFocusNode ? navBarNode : null,
onHover: (value) => setState(() => showPopupButton = value), onHover: (value) => setState(() => showPopupButton = value),
style: ButtonStyle( style: ButtonStyle(

View file

@ -79,16 +79,23 @@ class ExpressiveButton extends StatelessWidget {
right: isSelected || position == PositionContext.last ? const Radius.circular(16) : const Radius.circular(4), right: isSelected || position == PositionContext.last ? const Radius.circular(16) : const Radius.circular(4),
); );
return ElevatedButton.icon( return ElevatedButton.icon(
style: ElevatedButton.styleFrom( style: ButtonStyle(
shape: RoundedRectangleBorder(borderRadius: borderRadius), shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: borderRadius)),
elevation: isSelected ? 4 : 0, elevation: WidgetStatePropertyAll(isSelected ? 4 : 0),
backgroundColor: backgroundColor: WidgetStatePropertyAll(
isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest, isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.surfaceContainerHighest),
foregroundColor: foregroundColor: WidgetStatePropertyAll(
isSelected ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant, isSelected ? Theme.of(context).colorScheme.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant),
textStyle: Theme.of(context).textTheme.labelLarge, textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.labelLarge),
visualDensity: VisualDensity.comfortable, 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, onPressed: onPressed,
label: label, label: label,

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
class ClickableText extends ConsumerStatefulWidget { class ClickableText extends ConsumerStatefulWidget {
final String text; final String text;
final double opacity; final double opacity;
@ -56,6 +59,9 @@ class _ClickableTextState extends ConsumerState<ClickableText> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad) {
return _textWidget(false);
}
return widget.onTap != null ? _buildClickable() : _textWidget(false); return widget.onTap != null ? _buildClickable() : _textWidget(false);
} }
} }

View file

@ -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<CustomShaderMask> {
ui.Image? gradientImage;
@override
void initState() {
super.initState();
_loadImage('assets/gradient.png');
}
Future<void> _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,
);
}
}

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
extension EnsureVisibleHelper on BuildContext { extension EnsureVisibleHelper on BuildContext {
Future<void> ensureVisible({ Future<void> ensureVisible({
Duration duration = const Duration(milliseconds: 300), Duration duration = const Duration(milliseconds: 225),
double? alignment, double? alignment,
Curve curve = Curves.fastOutSlowIn, Curve curve = Curves.fastOutSlowIn,
}) { }) {

View file

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/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'; import 'package:fladder/widgets/navigation_scaffold/components/side_navigation_bar.dart';
class GridFocusTraveler extends ConsumerStatefulWidget { class GridFocusTraveler extends ConsumerStatefulWidget {
@ -28,102 +29,65 @@ class GridFocusTraveler extends ConsumerStatefulWidget {
class _GridFocusTravelerState extends ConsumerState<GridFocusTraveler> { class _GridFocusTravelerState extends ConsumerState<GridFocusTraveler> {
late int selectedIndex = widget.currentIndex; late int selectedIndex = widget.currentIndex;
bool _initializedFocus = false;
late final List<FocusNode> _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();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FocusTraversalGroup( return FocusTraversalGroup(
policy: GridFocusTravelerPolicy( policy: GridFocusTravelerPolicy(
navBarNode: navBarNode, navBarNode: navBarNode,
nodes: _focusNodes,
crossAxisCount: widget.crossAxisCount, crossAxisCount: widget.crossAxisCount,
onChanged: (value) { onChanged: (value) {
selectedIndex = value; selectedIndex = value;
_focusNodes[value].requestFocus();
}, },
), ),
child: SliverGrid.builder( 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, gridDelegate: widget.gridDelegate,
itemCount: widget.itemCount, itemCount: widget.itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return FocusProvider( return FocusProvider(
focusNode: _focusNodes[index],
child: Builder( child: Builder(
builder: (context) => widget.itemBuilder(context, selectedIndex, index), builder: (context) => widget.itemBuilder(context, selectedIndex, index),
), ),
); );
}, },
);
},
), ),
); );
} }
} }
class GridFocusTravelerPolicy extends ReadingOrderTraversalPolicy { List<FocusNode> _childNodes(FocusNode node) {
/// The complete list of FocusNodes for the grid. return node.descendants.where((n) => n.canRequestFocus && n.context != null).toList()
final List<FocusNode> nodes; ..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; final int crossAxisCount;
/// Callback to notify the parent which node index should be focused next.
final Function(int value) onChanged; final Function(int value) onChanged;
/// The navigation bar node to focus when navigating left from the first column.
final FocusNode navBarNode; final FocusNode navBarNode;
GridFocusTravelerPolicy({ GridFocusTravelerPolicy({
required this.nodes,
required this.crossAxisCount, required this.crossAxisCount,
required this.onChanged, required this.onChanged,
required this.navBarNode, required this.navBarNode,
@ -131,52 +95,53 @@ class GridFocusTravelerPolicy extends ReadingOrderTraversalPolicy {
@override @override
bool inDirection(FocusNode currentNode, TraversalDirection direction) { 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) { if (current == -1) {
return super.inDirection(currentNode, direction); return super.inDirection(currentNode, direction);
} }
final int itemCount = nodes.length; final itemCount = nodes.length;
final int row = current ~/ crossAxisCount; final row = current ~/ crossAxisCount;
final int col = current % crossAxisCount; final col = current % crossAxisCount;
final int rowCount = (itemCount / crossAxisCount).ceil(); final rowCount = (itemCount / crossAxisCount).ceil();
int? next;
int? next;
switch (direction) { switch (direction) {
case TraversalDirection.left: case TraversalDirection.left:
if (col > 0) { if (col > 0) next = current - 1;
next = current - 1;
}
break; break;
case TraversalDirection.right: case TraversalDirection.right:
if (col < crossAxisCount - 1 && current + 1 < itemCount) { if (col < crossAxisCount - 1 && current + 1 < itemCount) {
next = current + 1; next = current + 1;
} }
break; break;
case TraversalDirection.up: case TraversalDirection.up:
if (row > 0) { if (row > 0) next = current - crossAxisCount;
next = current - crossAxisCount;
}
break; break;
case TraversalDirection.down: case TraversalDirection.down:
if (row < rowCount - 1) { if (row < rowCount - 1) {
final int candidate = current + crossAxisCount; final candidate = current + crossAxisCount;
if (candidate < itemCount) { if (candidate < itemCount) next = candidate;
next = candidate;
}
} }
break; break;
} }
if (next != null) { if (next != null) {
final target = nodes[next];
target.requestFocus();
onChanged(next); onChanged(next);
return true; return true;
} }
if (direction == TraversalDirection.left && col == 0) { if (direction == TraversalDirection.left && col == 0) {
lastMainFocus = currentNode;
navBarNode.requestFocus(); navBarNode.requestFocus();
return true; return true;
} }

View file

@ -6,10 +6,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:iconsax_plus/iconsax_plus.dart';
import 'package:fladder/providers/settings/client_settings_provider.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/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart'; import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
import 'package:fladder/util/sticky_header_text.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/navigation_scaffold/components/side_navigation_bar.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart'; import 'package:fladder/widgets/shared/ensure_visible.dart';
@ -21,7 +24,7 @@ class HorizontalList<T> extends ConsumerStatefulWidget {
final String? subtext; final String? subtext;
final List<T> items; final List<T> items;
final int? startIndex; 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 Function(int index)? onFocused;
final bool scrollToEnd; final bool scrollToEnd;
final EdgeInsets contentPadding; final EdgeInsets contentPadding;
@ -52,7 +55,7 @@ class HorizontalList<T> extends ConsumerStatefulWidget {
class _HorizontalListState extends ConsumerState<HorizontalList> { class _HorizontalListState extends ConsumerState<HorizontalList> {
final FocusNode parentNode = FocusNode(); final FocusNode parentNode = FocusNode();
late int currentIndex = 0; FocusNode? lastFocused;
final GlobalKey _firstItemKey = GlobalKey(); final GlobalKey _firstItemKey = GlobalKey();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final contentPadding = 8.0; final contentPadding = 8.0;
@ -60,79 +63,28 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
double? _firstItemWidth; double? _firstItemWidth;
bool hasFocus = false; bool hasFocus = false;
late List<FocusNode> _focusNodes;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initFocusNodes();
_measureFirstItem(); _measureFirstItem();
} }
void _measureFirstItem() { void _measureFirstItem() {
if (_firstItemWidth != null) return; if (_firstItemWidth != null) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final context = _firstItemKey.currentContext; final itemContext = _firstItemKey.currentContext;
if (context != null) { if (itemContext != null) {
final box = context.findRenderObject() as RenderBox; final box = itemContext.findRenderObject() as RenderBox;
_firstItemWidth = box.size.width; _firstItemWidth = box.size.width;
_scrollToPosition(widget.startIndex ?? 0); _scrollToPosition(widget.startIndex ?? 0);
} }
});
}
void _initFocusNodes() { if ((FocusProvider.autoFocusOf(context) || widget.autoFocus) &&
_focusNodes = List.generate(widget.items.length, (i) { AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad) {
final node = FocusNode(); final nodesOnSameRow = _nodesInRow(parentNode);
node.addListener(() { nodesOnSameRow[widget.startIndex ?? 0].requestFocus();
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();
}
});
}
@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<void> _scrollToPosition(int index) async { Future<void> _scrollToPosition(int index) async {
@ -167,15 +119,8 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasPointer = AdaptiveLayout.of(context).inputDevice == InputDevice.pointer; final hasPointer = AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer;
return Focus( return Column(
focusNode: parentNode,
onFocusChange: (value) {
if (value) {
_focusNodes[currentIndex].requestFocus();
}
},
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -194,7 +139,8 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
child: ExcludeFocus( child: ExcludeFocus(
child: StickyHeaderText( child: StickyHeaderText(
label: widget.label ?? "", label: widget.label ?? "",
onClick: widget.onLabelClick, onClick:
AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad ? null : widget.onLabelClick,
), ),
), ),
), ),
@ -240,9 +186,7 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
if (widget.startIndex != null) if (widget.startIndex != null)
IconButton( IconButton(
tooltip: "Scroll to current", tooltip: "Scroll to current",
onPressed: () { onPressed: () => _scrollToPosition(widget.startIndex!),
_scrollToPosition(widget.startIndex!);
},
icon: const Icon( icon: const Icon(
Icons.circle, Icons.circle,
size: 16, size: 16,
@ -270,7 +214,24 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( 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 ?? height: widget.height ??
((AdaptiveLayout.poster(context).size * ((AdaptiveLayout.poster(context).size *
ref.watch(clientSettingsProvider.select((value) => value.posterSize))) / ref.watch(clientSettingsProvider.select((value) => value.posterSize))) /
@ -279,71 +240,136 @@ class _HorizontalListState extends ConsumerState<HorizontalList> {
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: HorizontalRailFocus( policy: HorizontalRailFocus(
parentNode: parentNode, parentNode: parentNode,
nodes: _focusNodes, throttle: Throttler(duration: const Duration(milliseconds: 125)),
onChanged: (value) { onFocused: (node) {
currentIndex = value; lastFocused = node;
_focusNodes[value].requestFocus(); final nodesOnSameRow = _nodesInRow(parentNode);
}), if (widget.onFocused != null) {
child: ExcludeFocusTraversal( 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,
);
}
},
),
child: ListView.separated( child: ListView.separated(
controller: _scrollController, controller: _scrollController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: widget.contentPadding, padding: widget.contentPadding,
itemBuilder: (context, index) { itemBuilder: (context, index) => index == widget.items.length
return FocusProvider( ? PosterPlaceHolder(
focusNode: _focusNodes[index], onTap: widget.onLabelClick ?? () {},
hasFocus: hasFocus && index == currentIndex, aspectRatio: widget.dominantRatio ?? AdaptiveLayout.poster(context).ratio,
)
: Container(
key: index == 0 ? _firstItemKey : null, key: index == 0 ? _firstItemKey : null,
child: widget.itemBuilder(context, index, hasFocus ? currentIndex : -1), child: widget.itemBuilder(context, index),
); ),
},
separatorBuilder: (context, index) => SizedBox(width: contentPadding), separatorBuilder: (context, index) => SizedBox(width: contentPadding),
itemCount: widget.items.length, 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<FocusNode> 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<FocusNode> _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 { class HorizontalRailFocus extends WidgetOrderTraversalPolicy {
final FocusNode parentNode; final FocusNode parentNode;
final List<FocusNode> nodes; final void Function(FocusNode node) onFocused;
final Function(int value) onChanged; final Throttler? throttle;
HorizontalRailFocus({ HorizontalRailFocus({
required this.parentNode, required this.parentNode,
required this.nodes, required this.onFocused,
required this.onChanged, this.throttle,
}); });
@override @override
bool inDirection(FocusNode currentNode, TraversalDirection direction) { bool inDirection(FocusNode currentNode, TraversalDirection direction) {
// Find the index of the currently focused node if (throttle?.canRun() == false) return true;
final int current = nodes.indexWhere((node) => node.hasFocus);
// If nothing is focused, default to 0 final rowNodes = _nodesInRow(parentNode);
final int currentIndex = current == -1 ? 0 : current; final index = rowNodes.indexOf(currentNode);
if (direction == TraversalDirection.left) { if (direction == TraversalDirection.left) {
if (currentIndex <= 0) { if (index > 0) {
final target = rowNodes[index - 1];
target.requestFocus();
onFocused(target);
} else {
lastMainFocus = currentNode;
navBarNode.requestFocus(); navBarNode.requestFocus();
return true; }
} else {
onChanged(math.max(currentIndex - 1, 0));
return true; return true;
} }
} else if (direction == TraversalDirection.right) {
if (currentIndex >= nodes.length - 1) { if (direction == TraversalDirection.right) {
// Corrected boundary check if (index < rowNodes.length - 1) {
return super.inDirection(parentNode, direction); final target = rowNodes[index + 1];
} else { target.requestFocus();
onChanged(math.min(currentIndex + 1, nodes.length - 1)); onFocused(target);
}
return true; return true;
} }
}
parentNode.requestFocus(); parentNode.requestFocus();
return super.inDirection(parentNode, direction); return super.inDirection(currentNode, direction);
} }
} }

View file

@ -39,18 +39,27 @@ class _SelectableIconButtonState extends ConsumerState<SelectableIconButton> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
const duration = Duration(milliseconds: 250); const duration = Duration(milliseconds: 250);
const iconSize = 24.0; 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( return Tooltip(
message: widget.label ?? "", message: widget.label ?? "",
child: ElevatedButton( child: ElevatedButton(
style: ButtonStyle( style: ButtonStyle(
side: buttonState,
elevation: WidgetStatePropertyAll( elevation: WidgetStatePropertyAll(
widget.backgroundColor != null ? (widget.backgroundColor!.a < 1 ? 0 : null) : null), widget.backgroundColor != null ? (widget.backgroundColor!.a < 1 ? 0 : null) : null),
backgroundColor: WidgetStatePropertyAll( backgroundColor: WidgetStatePropertyAll(
widget.backgroundColor ?? (widget.selected ? Theme.of(context).colorScheme.primary : null)), widget.backgroundColor ?? (widget.selected ? theme.primaryContainer : theme.surfaceContainerHigh)),
iconColor: WidgetStatePropertyAll( iconColor: WidgetStatePropertyAll(widget.iconColor ?? (widget.selected ? theme.onPrimaryContainer : null)),
widget.iconColor ?? (widget.selected ? Theme.of(context).colorScheme.onPrimary : null)), foregroundColor:
foregroundColor: WidgetStatePropertyAll( WidgetStatePropertyAll(widget.iconColor ?? (widget.selected ? theme.onPrimaryContainer : null)),
widget.iconColor ?? (widget.selected ? Theme.of(context).colorScheme.onPrimary : null)),
padding: const WidgetStatePropertyAll(EdgeInsets.zero), padding: const WidgetStatePropertyAll(EdgeInsets.zero),
), ),
onFocusChange: (value) { onFocusChange: (value) {