added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,957 @@
// Copyright 2020 Ben Hills and the project contributors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/podcast/episode_details.dart';
import 'package:pinepods_mobile/ui/podcast/transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
/// This class builds a tile for each episode in the podcast feed.
class EpisodeTile extends StatelessWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const EpisodeTile({
super.key,
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
Widget build(BuildContext context) {
final mediaQueryData = MediaQuery.of(context);
if (mediaQueryData.accessibleNavigation) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return _CupertinoAccessibleEpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
} else {
return _AccessibleEpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
}
} else {
return ExpandableEpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
}
}
}
/// An EpisodeTitle is built with an [ExpansionTile] widget and displays the episode's
/// basic details, thumbnail and play button.
///
/// It can then be expanded to present addition information about the episode and further
/// controls.
///
/// TODO: Replace [Opacity] with [Container] with a transparent colour.
class ExpandableEpisodeTile extends StatefulWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const ExpandableEpisodeTile({
super.key,
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
State<ExpandableEpisodeTile> createState() => _ExpandableEpisodeTileState();
}
class _ExpandableEpisodeTileState extends State<ExpandableEpisodeTile> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = Theme.of(context).textTheme;
final episodeBloc = Provider.of<EpisodeBloc>(context);
final queueBloc = Provider.of<QueueBloc>(context);
return ExpansionTile(
tilePadding: const EdgeInsets.fromLTRB(16.0, 0.0, 8.0, 0.0),
key: Key('PT${widget.episode.guid}'),
onExpansionChanged: (isExpanded) {
setState(() {
expanded = isExpanded;
});
},
trailing: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeTransportControls(
episode: widget.episode,
download: widget.download,
play: widget.play,
),
),
leading: ExcludeSemantics(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.passthrough,
children: <Widget>[
Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: TileImage(
url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!,
size: 56.0,
highlight: widget.episode.highlight,
),
),
SizedBox(
height: 5.0,
width: 56.0 * (widget.episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
),
subtitle: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeSubtitle(widget.episode),
),
title: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: Text(
widget.episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
),
children: <Widget>[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 4.0,
),
child: Text(
widget.episode.descriptionText!,
overflow: TextOverflow.ellipsis,
softWrap: false,
maxLines: 5,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)),
),
onPressed: widget.episode.downloaded
? () {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(
L.of(context)!.delete_episode_title,
),
content: Text(L.of(context)!.delete_episode_confirmation),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.cancel_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.delete_button_label,
),
iosIsDefaultAction: true,
iosIsDestructiveAction: true,
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context);
},
),
],
),
);
}
: null,
child: Column(
children: <Widget>[
Icon(
Icons.delete_outline,
semanticLabel: L.of(context)!.delete_episode_button_label,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
ExcludeSemantics(
child: Text(
L.of(context)!.delete_label,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
),
],
),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
),
onPressed: widget.playing
? null
: () {
if (widget.queued) {
queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode));
} else {
queueBloc.queueEvent(QueueAddEvent(episode: widget.episode));
}
},
child: Column(
children: <Widget>[
Icon(
widget.queued ? Icons.playlist_add_check_outlined : Icons.playlist_add_outlined,
semanticLabel: widget.queued
? L.of(context)!.semantics_remove_from_queue
: L.of(context)!.semantics_add_to_queue,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
ExcludeSemantics(
child: Text(
widget.queued ? 'Remove' : 'Add',
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
),
],
),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
),
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
widget.episode.played ? Icons.unpublished_outlined : Icons.check_circle_outline,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
Text(
widget.episode.played ? L.of(context)!.mark_unplayed_label : L.of(context)!.mark_played_label,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
],
),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
),
onPressed: () {
showModalBottomSheet<void>(
barrierLabel: L.of(context)!.scrim_episode_details_selector,
context: context,
backgroundColor: theme.bottomAppBarTheme.color,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
),
builder: (context) {
return EpisodeDetails(
episode: widget.episode,
);
});
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Icon(
Icons.unfold_more_outlined,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
Text(
L.of(context)!.more_label,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
],
),
),
),
],
),
),
],
);
}
}
/// This is an accessible version of the episode tile that uses Apple theming.
/// When the tile is tapped, an iOS menu will appear with the relevant options.
class _CupertinoAccessibleEpisodeTile extends StatefulWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const _CupertinoAccessibleEpisodeTile({
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
State<_CupertinoAccessibleEpisodeTile> createState() => _CupertinoAccessibleEpisodeTileState();
}
class _CupertinoAccessibleEpisodeTileState extends State<_CupertinoAccessibleEpisodeTile> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final episodeBloc = Provider.of<EpisodeBloc>(context);
final podcastBloc = Provider.of<PodcastBloc>(context);
final queueBloc = Provider.of<QueueBloc>(context);
return StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
final currentlyPlaying = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.playing;
final currentlyPaused = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.pausing;
return Semantics(
button: true,
child: ListTile(
key: Key('PT${widget.episode.guid}'),
leading: ExcludeSemantics(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.passthrough,
children: <Widget>[
Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: TileImage(
url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!,
size: 56.0,
highlight: widget.episode.highlight,
),
),
SizedBox(
height: 5.0,
width: 56.0 * (widget.episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
),
subtitle: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeSubtitle(widget.episode),
),
title: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: Text(
widget.episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
),
onTap: () {
showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
if (currentlyPlaying)
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
audioBloc.transitionState(TransitionState.pause);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.pause_button_label),
),
if (currentlyPaused)
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
audioBloc.transitionState(TransitionState.play);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.resume_button_label),
),
if (!currentlyPlaying && !currentlyPaused)
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(widget.episode);
optionalShowNowPlaying(context, settings);
Navigator.pop(context, 'Cancel');
},
child: widget.episode.downloaded
? Text(L.of(context)!.play_download_button_label)
: Text(L.of(context)!.play_button_label),
),
if (widget.episode.downloadState == DownloadState.queued ||
widget.episode.downloadState == DownloadState.downloading)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_download_button_label),
),
if (widget.episode.downloadState != DownloadState.downloading)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
if (widget.episode.downloaded) {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, 'Cancel');
} else {
podcastBloc.downloadEpisode(widget.episode);
Navigator.pop(context, 'Cancel');
}
},
child: widget.episode.downloaded
? Text(L.of(context)!.delete_episode_button_label)
: Text(L.of(context)!.download_episode_button_label),
),
if (!currentlyPlaying && !widget.queued)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
queueBloc.queueEvent(QueueAddEvent(episode: widget.episode));
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_add_to_queue),
),
if (!currentlyPlaying && widget.queued)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode));
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_remove_from_queue),
),
if (widget.episode.played)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_mark_episode_unplayed),
),
if (!widget.episode.played)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_mark_episode_played),
),
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
Navigator.pop(context, 'Cancel');
showModalBottomSheet<void>(
context: context,
barrierLabel: L.of(context)!.scrim_episode_details_selector,
backgroundColor: theme.bottomAppBarTheme.color,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
),
builder: (context) {
return EpisodeDetails(
episode: widget.episode,
);
});
},
child: Text(L.of(context)!.episode_details_button_label),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
Navigator.pop(context, 'Close');
},
child: Text(L.of(context)!.close_button_label),
),
);
},
);
},
),
);
});
}
}
/// This is an accessible version of the episode tile that uses Android theming.
/// When the tile is tapped, an Android dialog menu will appear with the relevant
/// options.
class _AccessibleEpisodeTile extends StatefulWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const _AccessibleEpisodeTile({
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
State<_AccessibleEpisodeTile> createState() => _AccessibleEpisodeTileState();
}
class _AccessibleEpisodeTileState extends State<_AccessibleEpisodeTile> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final episodeBloc = Provider.of<EpisodeBloc>(context);
final podcastBloc = Provider.of<PodcastBloc>(context);
final queueBloc = Provider.of<QueueBloc>(context);
return StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
final currentlyPlaying = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.playing;
final currentlyPaused = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.pausing;
return ListTile(
key: Key('PT${widget.episode.guid}'),
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return Semantics(
header: true,
child: SimpleDialog(
//TODO: Fix this - should not be hardcoded text
title: const Text('Episode Actions'),
children: <Widget>[
if (currentlyPlaying)
SimpleDialogOption(
onPressed: () {
audioBloc.transitionState(TransitionState.pause);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.pause_button_label),
),
if (currentlyPaused)
SimpleDialogOption(
onPressed: () {
audioBloc.transitionState(TransitionState.play);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.resume_button_label),
),
if (!currentlyPlaying && !currentlyPaused && widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(widget.episode);
optionalShowNowPlaying(context, settings);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.play_download_button_label),
),
if (!currentlyPlaying && !currentlyPaused && !widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(widget.episode);
optionalShowNowPlaying(context, settings);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.play_button_label),
),
if (widget.episode.downloadState == DownloadState.queued ||
widget.episode.downloadState == DownloadState.downloading)
SimpleDialogOption(
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.cancel_download_button_label),
),
if (widget.episode.downloadState != DownloadState.downloading && widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.delete_episode_button_label),
),
if (widget.episode.downloadState != DownloadState.downloading && !widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
podcastBloc.downloadEpisode(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.download_episode_button_label),
),
if (!currentlyPlaying && !widget.queued)
SimpleDialogOption(
onPressed: () {
queueBloc.queueEvent(QueueAddEvent(episode: widget.episode));
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_add_to_queue),
),
if (!currentlyPlaying && widget.queued)
SimpleDialogOption(
onPressed: () {
queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode));
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_remove_from_queue),
),
if (widget.episode.played)
SimpleDialogOption(
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_mark_episode_unplayed),
),
if (!widget.episode.played)
SimpleDialogOption(
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_mark_episode_played),
),
SimpleDialogOption(
onPressed: () {
Navigator.pop(context, '');
showModalBottomSheet<void>(
context: context,
barrierLabel: L.of(context)!.scrim_episode_details_selector,
backgroundColor: theme.bottomAppBarTheme.color,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
),
builder: (context) {
return EpisodeDetails(
episode: widget.episode,
);
});
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.episode_details_button_label),
),
SimpleDialogOption(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
// child: Text(L.of(context)!.close_button_label),
child: Align(
alignment: Alignment.centerRight,
child: TextButton(
child: ActionText(L.of(context)!.close_button_label),
onPressed: () {
Navigator.pop(context, '');
},
),
),
),
],
),
);
},
);
},
leading: ExcludeSemantics(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.passthrough,
children: <Widget>[
Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: TileImage(
url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!,
size: 56.0,
highlight: widget.episode.highlight,
),
),
SizedBox(
height: 5.0,
width: 56.0 * (widget.episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
),
subtitle: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeSubtitle(widget.episode),
),
title: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: Text(
widget.episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
),
);
});
}
}
class EpisodeTransportControls extends StatelessWidget {
final Episode episode;
final bool download;
final bool play;
const EpisodeTransportControls({
super.key,
required this.episode,
required this.download,
required this.play,
});
@override
Widget build(BuildContext context) {
final buttons = <Widget>[];
if (download) {
buttons.add(Semantics(
container: true,
child: DownloadControl(
episode: episode,
),
));
}
if (play) {
buttons.add(Semantics(
container: true,
child: PlayControl(
episode: episode,
),
));
}
return SizedBox(
width: (buttons.length * 48.0),
child: Row(
children: <Widget>[...buttons],
),
);
}
}
class EpisodeSubtitle extends StatelessWidget {
final Episode episode;
final String date;
final Duration length;
EpisodeSubtitle(this.episode, {super.key})
: date = episode.publicationDate == null
? ''
: DateFormat(episode.publicationDate!.year == DateTime.now().year ? 'd MMM' : 'd MMM yyyy')
.format(episode.publicationDate!),
length = Duration(seconds: episode.duration);
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
var timeRemaining = episode.timeRemaining;
String title;
if (length.inSeconds > 0) {
if (length.inSeconds < 60) {
title = '$date${length.inSeconds} sec';
} else {
title = '$date${length.inMinutes} min';
}
} else {
title = date;
}
if (timeRemaining.inSeconds > 0) {
if (timeRemaining.inSeconds < 60) {
title = '$title / ${timeRemaining.inSeconds} sec left';
} else {
title = '$title / ${timeRemaining.inMinutes} min left';
}
}
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
title,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: textTheme.bodySmall,
),
);
}
}
/// This class acts as a wrapper between the current audio state and
/// downloadables. Saves all that nesting of StreamBuilders.
class _PlayerControlState {
final AudioState audioState;
final Episode? episode;
_PlayerControlState(this.audioState, this.episode);
}