145 lines
5.1 KiB
Dart
145 lines
5.1 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../media/media_hub.dart';
|
|
import '../media/media_item.dart';
|
|
import '../media/media_server_client.dart';
|
|
import '../navigation/main_screen_scope.dart';
|
|
import '../services/settings_service.dart';
|
|
import '../utils/debouncer.dart';
|
|
import '../utils/layout_constants.dart';
|
|
import 'tv_browse_rail.dart';
|
|
import 'tv_spotlight_background.dart';
|
|
|
|
class TvSpotlightController extends ValueNotifier<MediaItem?> {
|
|
TvSpotlightController({Duration settleDelay = const Duration(milliseconds: 150)})
|
|
: _settleDelay = settleDelay,
|
|
_debouncer = Debouncer(settleDelay),
|
|
super(null);
|
|
|
|
final Duration _settleDelay;
|
|
final Debouncer _debouncer;
|
|
|
|
void select(MediaItem item) {
|
|
void apply() {
|
|
if (value?.globalKey == item.globalKey) return;
|
|
value = item;
|
|
}
|
|
|
|
if (_settleDelay == Duration.zero) {
|
|
apply();
|
|
} else {
|
|
_debouncer.run(apply);
|
|
}
|
|
}
|
|
|
|
MediaItem? resolve(Iterable<MediaHub> hubs) {
|
|
MediaItem? fallback;
|
|
final current = value;
|
|
for (final hub in hubs) {
|
|
if (hub.items.isEmpty) continue;
|
|
fallback ??= hub.items.first;
|
|
if (current == null) continue;
|
|
for (final item in hub.items) {
|
|
if (item.globalKey == current.globalKey) return item;
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debouncer.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
typedef TvSpotlightClientResolver = MediaServerClient? Function(MediaItem? item);
|
|
|
|
/// Shared full-screen TV backdrop and foreground stack used by hub rails.
|
|
class TvSpotlightScaffold extends StatelessWidget {
|
|
const TvSpotlightScaffold({
|
|
super.key,
|
|
required this.hubs,
|
|
required this.spotlightListenable,
|
|
required this.resolveSpotlight,
|
|
required this.resolveClient,
|
|
required this.foreground,
|
|
this.hideSpoilers,
|
|
});
|
|
|
|
final List<MediaHub> hubs;
|
|
final ValueListenable<MediaItem?> spotlightListenable;
|
|
final MediaItem? Function() resolveSpotlight;
|
|
final TvSpotlightClientResolver resolveClient;
|
|
final Widget foreground;
|
|
final bool? hideSpoilers;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final size = MediaQuery.sizeOf(context);
|
|
final settings = SettingsService.instance;
|
|
final scale = TvLayoutConstants.scaleForSize(size);
|
|
final railSize = MainScreenFocusScope.foregroundSizeOf(context);
|
|
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
|
final railHeight = hubs.isEmpty
|
|
? 0.0
|
|
: TvBrowseRailLayout.estimateHeight(
|
|
size: railSize,
|
|
hubs: hubs,
|
|
density: settings.read(SettingsService.libraryDensity),
|
|
episodePosterMode: settings.read(SettingsService.episodePosterMode),
|
|
fullCardLayout: settings.read(SettingsService.tvFullCardLayout),
|
|
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
|
);
|
|
final spotlightTop = (size.height * 0.075).clamp(64.0 * scale, 120.0 * scale).toDouble();
|
|
final minimumSpotlightBottom = railHeight + (8 * scale);
|
|
final baseSpotlightBottom = (size.height * 0.48).clamp(160.0, 820.0).toDouble();
|
|
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
|
|
? minimumSpotlightBottom
|
|
: baseSpotlightBottom;
|
|
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
|
|
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
|
|
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
|
|
|
|
return Material(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: SizedBox.expand(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Builder(
|
|
builder: (context) {
|
|
final foregroundLeft = MainScreenFocusScope.foregroundLeftOf(context);
|
|
return SideNavigationBleedBuilder(
|
|
targetBleed: foregroundLeft,
|
|
child: ValueListenableBuilder<MediaItem?>(
|
|
valueListenable: spotlightListenable,
|
|
builder: (context, _, _) {
|
|
final spotlight = resolveSpotlight();
|
|
return TvSpotlightBackground(
|
|
item: spotlight,
|
|
client: resolveClient(spotlight),
|
|
hideSpoilers: hideSpoilers ?? settings.read(SettingsService.hideSpoilers),
|
|
contentTop: spotlightTop,
|
|
contentBottom: spotlightBottom,
|
|
contentLeft: spotlightLeft + foregroundLeft,
|
|
compact: true,
|
|
showPrimaryAction: false,
|
|
);
|
|
},
|
|
),
|
|
builder: (context, animatedBleed, child) =>
|
|
Positioned(top: 0, bottom: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
|
);
|
|
},
|
|
),
|
|
foreground,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|