Initial commit
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
|
||||
|
||||
class AndroidDetailBanner extends StatefulWidget {
|
||||
const AndroidDetailBanner({
|
||||
super.key,
|
||||
required this.scrollProgress,
|
||||
required this.backdropUrl,
|
||||
required this.fallbackUrls,
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
});
|
||||
|
||||
final ValueListenable<double> scrollProgress;
|
||||
final String backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
@override
|
||||
State<AndroidDetailBanner> createState() => _AndroidDetailBannerState();
|
||||
}
|
||||
|
||||
class _AndroidDetailBannerState extends State<AndroidDetailBanner> {
|
||||
bool _allImagesFailed = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AndroidDetailBanner oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.backdropUrl != widget.backdropUrl ||
|
||||
!listEquals(oldWidget.fallbackUrls, widget.fallbackUrls)) {
|
||||
_allImagesFailed = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (!_allImagesFailed)
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0.0, 0.68, 1.0],
|
||||
colors: [Colors.black, Colors.black, Colors.transparent],
|
||||
).createShader(bounds),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: SmartImage(
|
||||
url: widget.backdropUrl,
|
||||
fallbackUrls: widget.fallbackUrls,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.center,
|
||||
borderRadius: 0,
|
||||
placeholder: const SizedBox.expand(),
|
||||
onError: () {
|
||||
if (mounted && !_allImagesFailed) {
|
||||
setState(() => _allImagesFailed = true);
|
||||
}
|
||||
},
|
||||
resolveHttpHeaders: (url) =>
|
||||
widget.embyBaseUrl.isNotEmpty &&
|
||||
url.startsWith(widget.embyBaseUrl)
|
||||
? widget.imageHeaders
|
||||
: null,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 120,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.38),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: ValueListenableBuilder<double>(
|
||||
valueListenable: widget.scrollProgress,
|
||||
builder: (context, rawProgress, child) {
|
||||
final progress = rawProgress.clamp(0.0, 1.0);
|
||||
return IgnorePointer(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: progress * 0.5),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'dart:ui' show Color;
|
||||
|
||||
class AndroidDetailColors {
|
||||
AndroidDetailColors._();
|
||||
|
||||
static const background = Color(0xFF1A1A2E);
|
||||
static const navBarBg = Color(0xFF141428);
|
||||
static const accent = Color(0xFFE5A00D);
|
||||
static const cardBg = Color(0x0AFFFFFF);
|
||||
static const genrePillBg = Color(0x14FFFFFF);
|
||||
static const genrePillBorder = Color(0x14FFFFFF);
|
||||
static const divider = Color(0x1AFFFFFF);
|
||||
static const starEmpty = Color(0x4DFFFFFF);
|
||||
static const mediaTagBg = Color(0x1AFFFFFF);
|
||||
static const mediaTagBorder = Color(0x1AFFFFFF);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const double androidDetailDesktopTitleBarHeight = 38.0;
|
||||
const double androidDetailNavigationContentHeight = 30.0;
|
||||
|
||||
double computeAndroidDetailNavigationHeight(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
return math.max(topInset, androidDetailDesktopTitleBarHeight) +
|
||||
androidDetailNavigationContentHeight;
|
||||
}
|
||||
|
||||
double computeAndroidDetailBannerHeight(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final bodyHeight = mediaQuery.size.height - mediaQuery.padding.top;
|
||||
final preferredHeight = (bodyHeight * 0.46).clamp(300.0, 390.0);
|
||||
final landscapeSafeHeight = bodyHeight * 0.55;
|
||||
|
||||
return math.min(preferredHeight, landscapeSafeHeight);
|
||||
}
|
||||
|
||||
double computeAndroidDetailCollapseExtent(BuildContext context) {
|
||||
final bannerHeight = computeAndroidDetailBannerHeight(context);
|
||||
final navigationHeight = computeAndroidDetailNavigationHeight(context);
|
||||
return math.max(1.0, bannerHeight - navigationHeight);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
import '../widgets/detail_nav_bar.dart';
|
||||
import 'android_detail_banner.dart';
|
||||
import 'android_detail_colors.dart';
|
||||
import 'android_detail_geometry.dart';
|
||||
|
||||
|
||||
class AndroidDetailLayout extends StatelessWidget {
|
||||
const AndroidDetailLayout({
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.scrollProgress,
|
||||
this.backdropUrl,
|
||||
this.fallbackUrls = const [],
|
||||
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
this.backgroundColor,
|
||||
this.navigationColor,
|
||||
required this.title,
|
||||
required this.onBack,
|
||||
required this.heroInfo,
|
||||
this.heroActions,
|
||||
this.overview,
|
||||
this.belowOverview,
|
||||
this.sections = const [],
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final ValueNotifier<double> scrollProgress;
|
||||
|
||||
final String? backdropUrl;
|
||||
final List<String> fallbackUrls;
|
||||
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final Color? backgroundColor;
|
||||
final Color? navigationColor;
|
||||
|
||||
final String title;
|
||||
final VoidCallback onBack;
|
||||
|
||||
final Widget heroInfo;
|
||||
final Widget? heroActions;
|
||||
final Widget? overview;
|
||||
|
||||
|
||||
final Widget? belowOverview;
|
||||
|
||||
|
||||
final List<Widget> sections;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
final bannerHeight = computeAndroidDetailBannerHeight(context);
|
||||
final backdrop =
|
||||
backdropUrl ?? (fallbackUrls.isNotEmpty ? fallbackUrls.first : null);
|
||||
final effectiveFallbackUrls = backdropUrl != null
|
||||
? fallbackUrls
|
||||
: fallbackUrls.skip(1).toList(growable: false);
|
||||
|
||||
final targetBackgroundColor =
|
||||
backgroundColor ?? AndroidDetailColors.background;
|
||||
final animationDuration = MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 350);
|
||||
|
||||
return TweenAnimationBuilder<Color?>(
|
||||
tween: ColorTween(
|
||||
begin: AndroidDetailColors.background,
|
||||
end: targetBackgroundColor,
|
||||
),
|
||||
duration: animationDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, animatedBackgroundColor, child) {
|
||||
final effectiveBackgroundColor =
|
||||
animatedBackgroundColor ?? targetBackgroundColor;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: effectiveBackgroundColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _AndroidDetailBlurredBackdrop(
|
||||
imageUrl: backdrop,
|
||||
fallbackUrls: effectiveFallbackUrls,
|
||||
embyBaseUrl: embyBaseUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
),
|
||||
),
|
||||
CustomScrollView(
|
||||
controller: scrollController,
|
||||
physics: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
if (backdrop != null)
|
||||
SliverAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
primary: false,
|
||||
pinned: false,
|
||||
stretch: true,
|
||||
toolbarHeight: 0,
|
||||
collapsedHeight: 0,
|
||||
expandedHeight: bannerHeight,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.parallax,
|
||||
stretchModes: const [StretchMode.zoomBackground],
|
||||
background: AndroidDetailBanner(
|
||||
scrollProgress: scrollProgress,
|
||||
backdropUrl: backdrop,
|
||||
fallbackUrls: effectiveFallbackUrls,
|
||||
embyBaseUrl: embyBaseUrl,
|
||||
imageHeaders: imageHeaders,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverToBoxAdapter(child: SizedBox(height: topInset + 72)),
|
||||
SliverToBoxAdapter(child: heroInfo),
|
||||
if (heroActions != null)
|
||||
SliverToBoxAdapter(child: heroActions!),
|
||||
if (overview != null) SliverToBoxAdapter(child: overview!),
|
||||
if (belowOverview != null)
|
||||
SliverToBoxAdapter(child: belowOverview!),
|
||||
SliverList.list(
|
||||
children: [
|
||||
...sections,
|
||||
SizedBox(
|
||||
height: 32 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
DetailNavBar(
|
||||
scrollProgress: scrollProgress,
|
||||
title: title,
|
||||
onBack: onBack,
|
||||
chromeColor: navigationColor ?? AndroidDetailColors.navBarBg,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _AndroidDetailBlurredBackdrop extends StatelessWidget {
|
||||
const _AndroidDetailBlurredBackdrop({
|
||||
required this.imageUrl,
|
||||
required this.fallbackUrls,
|
||||
required this.embyBaseUrl,
|
||||
required this.imageHeaders,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final List<String> fallbackUrls;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
|
||||
static const double _blurSigma = 32;
|
||||
|
||||
|
||||
static const LinearGradient _scrim = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0.0, 0.4, 1.0],
|
||||
colors: [Color(0x59000000), Color(0x8C000000), Color(0xD9000000)],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = imageUrl;
|
||||
|
||||
final Widget child;
|
||||
if (url == null) {
|
||||
child = const SizedBox.expand(key: ValueKey<String>('__no_backdrop__'));
|
||||
} else {
|
||||
child = RepaintBoundary(
|
||||
key: ValueKey<String>(url),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: _blurSigma,
|
||||
sigmaY: _blurSigma,
|
||||
tileMode: TileMode.clamp,
|
||||
),
|
||||
child: SmartImage(
|
||||
url: url,
|
||||
fallbackUrls: fallbackUrls,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 0,
|
||||
memCacheWidth: 400,
|
||||
fallbackIcon: null,
|
||||
placeholder: const SizedBox.expand(),
|
||||
resolveHttpHeaders: (candidateUrl) =>
|
||||
embyBaseUrl.isNotEmpty &&
|
||||
candidateUrl.startsWith(embyBaseUrl)
|
||||
? imageHeaders
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const DecoratedBox(decoration: BoxDecoration(gradient: _scrim)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeOut,
|
||||
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [...previousChildren, if (currentChild != null) currentChild],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../widgets/section_container.dart';
|
||||
|
||||
class ExternalLinksSection extends StatelessWidget {
|
||||
final String? imdbId;
|
||||
final int? tmdbId;
|
||||
final String? tmdbMediaType;
|
||||
final int? traktId;
|
||||
final bool embedded;
|
||||
|
||||
|
||||
final Widget? leading;
|
||||
|
||||
const ExternalLinksSection({
|
||||
super.key,
|
||||
this.imdbId,
|
||||
this.tmdbId,
|
||||
this.tmdbMediaType,
|
||||
this.traktId,
|
||||
this.embedded = true,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
bool get _hasAnyLink => imdbId != null || tmdbId != null || traktId != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_hasAnyLink) return const SizedBox.shrink();
|
||||
return SectionContainer(
|
||||
title: '外部链接',
|
||||
embedded: embedded,
|
||||
leading: leading,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (imdbId != null)
|
||||
_LinkPill(
|
||||
label: 'IMDb',
|
||||
dotColor: const Color(0xFFF5C518),
|
||||
onTap: () =>
|
||||
launchUrl(Uri.parse('https://www.imdb.com/title/$imdbId')),
|
||||
),
|
||||
if (tmdbId != null)
|
||||
_LinkPill(
|
||||
label: 'TMDB',
|
||||
dotColor: const Color(0xFF01B4E4),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://www.themoviedb.org/${tmdbMediaType ?? 'movie'}/$tmdbId',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (traktId != null)
|
||||
_LinkPill(
|
||||
label: 'Trakt',
|
||||
dotColor: const Color(0xFFED1C24),
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse(
|
||||
'https://trakt.tv/${tmdbMediaType == 'tv' ? 'shows' : 'movies'}/$traktId',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkPill extends StatelessWidget {
|
||||
final String label;
|
||||
final Color dotColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _LinkPill({
|
||||
required this.label,
|
||||
required this.dotColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'android_detail_colors.dart';
|
||||
import '../../../shared/widgets/app_loading_ring.dart';
|
||||
|
||||
class AndroidHeroActions extends StatelessWidget {
|
||||
final String playLabel;
|
||||
final String? playTrailingLabel;
|
||||
final double? progressPercent;
|
||||
final bool isLoading;
|
||||
final String loadingLabel;
|
||||
final bool canPlay;
|
||||
final VoidCallback? onPlay;
|
||||
final bool showReplay;
|
||||
final VoidCallback? onReplay;
|
||||
final bool isPlayed;
|
||||
final VoidCallback onTogglePlayed;
|
||||
final bool isFavorite;
|
||||
final VoidCallback onToggleFavorite;
|
||||
|
||||
const AndroidHeroActions({
|
||||
super.key,
|
||||
required this.playLabel,
|
||||
this.playTrailingLabel,
|
||||
this.progressPercent,
|
||||
this.isLoading = false,
|
||||
this.loadingLabel = '启动中',
|
||||
this.canPlay = true,
|
||||
this.onPlay,
|
||||
this.showReplay = false,
|
||||
this.onReplay,
|
||||
required this.isPlayed,
|
||||
required this.onTogglePlayed,
|
||||
required this.isFavorite,
|
||||
required this.onToggleFavorite,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (canPlay)
|
||||
Expanded(
|
||||
child: _GoldPlayButton(
|
||||
label: playLabel,
|
||||
trailingLabel: playTrailingLabel,
|
||||
progressPercent: progressPercent,
|
||||
isLoading: isLoading,
|
||||
loadingLabel: loadingLabel,
|
||||
onPressed: onPlay,
|
||||
),
|
||||
),
|
||||
if (canPlay) const SizedBox(width: 8),
|
||||
if (!canPlay) const Spacer(),
|
||||
_SquareButton(
|
||||
icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline,
|
||||
active: isPlayed,
|
||||
onTap: onTogglePlayed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_SquareButton(
|
||||
icon: isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
active: isFavorite,
|
||||
onTap: onToggleFavorite,
|
||||
),
|
||||
if (showReplay) ...[
|
||||
const SizedBox(width: 8),
|
||||
_SquareButton(icon: Icons.replay, active: false, onTap: onReplay),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GoldPlayButton extends StatelessWidget {
|
||||
final String label;
|
||||
final String? trailingLabel;
|
||||
final bool isLoading;
|
||||
final String loadingLabel;
|
||||
final double? progressPercent;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _GoldPlayButton({
|
||||
required this.label,
|
||||
this.trailingLabel,
|
||||
this.isLoading = false,
|
||||
this.loadingLabel = '启动中',
|
||||
this.progressPercent,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = progressPercent;
|
||||
final normalizedProgress = progress != null && progress > 0
|
||||
? progress.clamp(0.0, 100.0) / 100.0
|
||||
: null;
|
||||
final enabled = !isLoading && onPressed != null;
|
||||
final radius = BorderRadius.circular(10);
|
||||
final backgroundColor = enabled
|
||||
? AndroidDetailColors.accent
|
||||
: Colors.white.withValues(alpha: 0.12);
|
||||
final foregroundColor = enabled
|
||||
? Colors.black
|
||||
: Colors.white.withValues(alpha: 0.38);
|
||||
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: backgroundColor,
|
||||
borderRadius: radius,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (normalizedProgress != null)
|
||||
FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: normalizedProgress,
|
||||
child: ColoredBox(
|
||||
color: AndroidDetailColors.accent.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLoading)
|
||||
AppLoadingRing(size: 16, color: foregroundColor)
|
||||
else
|
||||
Icon(Icons.play_arrow, color: foregroundColor, size: 20),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isLoading
|
||||
? loadingLabel
|
||||
: trailingLabel != null
|
||||
? '$label $trailingLabel'
|
||||
: label,
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SquareButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool active;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SquareButton({required this.icon, required this.active, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = active
|
||||
? AndroidDetailColors.accent
|
||||
: Colors.white.withValues(alpha: 0.8);
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTap,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Center(child: Icon(icon, size: 20, color: color)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/smart_image.dart';
|
||||
import 'android_detail_colors.dart';
|
||||
|
||||
class AndroidHeroInfo extends StatelessWidget {
|
||||
final String? posterUrl;
|
||||
final String embyBaseUrl;
|
||||
final Map<String, String>? imageHeaders;
|
||||
final String title;
|
||||
final double? rating;
|
||||
final List<String> metaParts;
|
||||
final List<String> genres;
|
||||
final List<String> mediaInfoTags;
|
||||
|
||||
const AndroidHeroInfo({
|
||||
super.key,
|
||||
this.posterUrl,
|
||||
this.embyBaseUrl = '',
|
||||
this.imageHeaders,
|
||||
required this.title,
|
||||
this.rating,
|
||||
this.metaParts = const [],
|
||||
this.genres = const [],
|
||||
this.mediaInfoTags = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x80000000),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 180,
|
||||
child: SmartImage(
|
||||
url: posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
borderRadius: 10,
|
||||
placeholder: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A2A4A),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
resolveHttpHeaders: (url) =>
|
||||
embyBaseUrl.isNotEmpty && url.startsWith(embyBaseUrl)
|
||||
? imageHeaders
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
height: 1.2,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0xCC000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (metaParts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
metaParts.join(' · '),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Color(0xCC000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (rating != null && rating! > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: _StarRating(rating: rating!),
|
||||
),
|
||||
if (genres.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: genres
|
||||
.map((g) => _GenrePill(label: g))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
if (mediaInfoTags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: mediaInfoTags
|
||||
.map((t) => _MediaInfoPill(label: t))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StarRating extends StatelessWidget {
|
||||
final double rating;
|
||||
const _StarRating({required this.rating});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stars = rating / 2.0;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < 5; i++)
|
||||
Icon(
|
||||
Icons.star_rounded,
|
||||
size: 16,
|
||||
color: i < stars.round()
|
||||
? AndroidDetailColors.accent
|
||||
: AndroidDetailColors.starEmpty,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
rating.toStringAsFixed(1),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Color(0xCC000000),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenrePill extends StatelessWidget {
|
||||
final String label;
|
||||
const _GenrePill({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AndroidDetailColors.genrePillBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AndroidDetailColors.genrePillBorder),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.65),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaInfoPill extends StatelessWidget {
|
||||
final String label;
|
||||
const _MediaInfoPill({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AndroidDetailColors.mediaTagBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AndroidDetailColors.mediaTagBorder),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
|
||||
class AndroidMediaInfoSection extends StatelessWidget {
|
||||
final EmbyRawMediaSource? source;
|
||||
final List<String> studioNames;
|
||||
final List<String> directors;
|
||||
final Widget? leading;
|
||||
|
||||
const AndroidMediaInfoSection({
|
||||
super.key,
|
||||
this.source,
|
||||
this.studioNames = const [],
|
||||
this.directors = const [],
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rows = _buildRows();
|
||||
if (rows.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) leading!,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: rows,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRows() {
|
||||
final src = source;
|
||||
final streams = src?.MediaStreams ?? [];
|
||||
final rows = <Widget>[];
|
||||
|
||||
final subCount = streams.where((s) => s.Type == 'Subtitle').length;
|
||||
if (subCount > 0) {
|
||||
_addRow(rows, '字幕', '$subCount 条字幕轨');
|
||||
}
|
||||
|
||||
if (directors.isNotEmpty) {
|
||||
_addRow(rows, '导演', directors.join(' · '));
|
||||
}
|
||||
|
||||
if (studioNames.isNotEmpty) {
|
||||
_addRow(rows, '工作室', studioNames.join(' · '));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _addRow(List<Widget> rows, String label, String value) {
|
||||
if (rows.isNotEmpty) {
|
||||
rows.add(Divider(color: Colors.white.withValues(alpha: 0.04), height: 1));
|
||||
}
|
||||
rows.add(_InfoRow(label: label, value: value));
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/widgets/app_dialog.dart';
|
||||
import 'android_detail_colors.dart';
|
||||
|
||||
class AndroidOverviewSection extends StatelessWidget {
|
||||
final String overview;
|
||||
|
||||
const AndroidOverviewSection({super.key, required this.overview});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
height: 1.65,
|
||||
);
|
||||
final textSpan = TextSpan(text: overview, style: textStyle);
|
||||
final tp = TextPainter(
|
||||
text: textSpan,
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final hasOverflow = tp.didExceedMaxLines;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: hasOverflow ? () => _showOverviewDialog(context) : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
overview,
|
||||
style: textStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasOverflow) ...[
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'阅读全部',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AndroidDetailColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOverviewDialog(BuildContext context) {
|
||||
showAppRawDialog<void>(
|
||||
context,
|
||||
builder: (context) => ColoredBox(
|
||||
color: const Color(0xFF1E1E2E),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'简介',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
overview,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.75),
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import 'android_detail_colors.dart';
|
||||
|
||||
|
||||
class AndroidSectionDivider extends StatelessWidget {
|
||||
const AndroidSectionDivider({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: FDivider(style: .delta(color: AndroidDetailColors.divider)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../shared/widgets/auto_dismiss_menu.dart';
|
||||
import '../widgets/stream_selector_actions.dart';
|
||||
|
||||
const _dropdownMenuMaxHeight = 320.0;
|
||||
|
||||
|
||||
class AndroidStreamSelectorRow extends StatelessWidget {
|
||||
|
||||
final List<EmbyRawMediaSource> sources;
|
||||
final int selectedSourceIdx;
|
||||
final ValueChanged<int>? onSelectSourceIndex;
|
||||
|
||||
final List<EmbyRawMediaStream> subtitles;
|
||||
final List<EmbyRawMediaStream> audios;
|
||||
final int? selectedSubtitleIdx;
|
||||
final int selectedAudioIdx;
|
||||
final ValueChanged<int?> onSelectSubtitleIndex;
|
||||
final ValueChanged<int> onSelectAudioIndex;
|
||||
|
||||
const AndroidStreamSelectorRow({
|
||||
super.key,
|
||||
this.sources = const [],
|
||||
this.selectedSourceIdx = 0,
|
||||
this.onSelectSourceIndex,
|
||||
required this.subtitles,
|
||||
required this.audios,
|
||||
required this.selectedSubtitleIdx,
|
||||
required this.selectedAudioIdx,
|
||||
required this.onSelectSubtitleIndex,
|
||||
required this.onSelectAudioIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final buttons = <Widget>[];
|
||||
|
||||
final onSelectSource = onSelectSourceIndex;
|
||||
if (sources.length > 1 && onSelectSource != null) {
|
||||
buttons.add(
|
||||
_SelectorButton(
|
||||
icon: Icons.video_file_outlined,
|
||||
label: versionSummaryLabel(sources, selectedSourceIdx),
|
||||
menuChildrenBuilder: () => buildVersionMenuButtons(
|
||||
sources: sources,
|
||||
selectedSourceIdx: selectedSourceIdx,
|
||||
onSelectSource: onSelectSource,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (subtitles.length > 1) {
|
||||
buttons.add(
|
||||
_SelectorButton(
|
||||
icon: Icons.subtitles_outlined,
|
||||
label: subtitleSummaryLabel(subtitles, selectedSubtitleIdx),
|
||||
menuChildrenBuilder: () => buildSubtitleMenuButtons(
|
||||
subtitles: subtitles,
|
||||
selectedSubtitleIdx: selectedSubtitleIdx,
|
||||
onSelectSubtitle: onSelectSubtitleIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (audios.length > 1) {
|
||||
buttons.add(
|
||||
_SelectorButton(
|
||||
icon: Icons.audiotrack_outlined,
|
||||
label: audioSummaryLabel(audios, selectedAudioIdx),
|
||||
menuChildrenBuilder: () => buildAudioMenuButtons(
|
||||
audios: audios,
|
||||
selectedAudioIdx: selectedAudioIdx,
|
||||
onSelectAudio: onSelectAudioIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (buttons.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: buttons.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, index) => buttons[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectorButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final List<FItem> Function() menuChildrenBuilder;
|
||||
|
||||
const _SelectorButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.menuChildrenBuilder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FPopoverMenu(
|
||||
menuBuilder: autoDismissMenuBuilder,
|
||||
menuAnchor: Alignment.topCenter,
|
||||
childAnchor: Alignment.bottomCenter,
|
||||
maxHeight: _dropdownMenuMaxHeight,
|
||||
menu: [FItemGroup(children: menuChildrenBuilder())],
|
||||
builder: (context, controller, child) => GestureDetector(
|
||||
onTap: controller.toggle,
|
||||
child: child,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 140),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 18,
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user