Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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),
),
],
),
),
),
),
);
}
}