// 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 createState() => _ExpandableEpisodeTileState(); } class _ExpandableEpisodeTileState extends State { bool expanded = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = Theme.of(context).textTheme; final episodeBloc = Provider.of(context); final queueBloc = Provider.of(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: [ 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: [ 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: [ Expanded( child: TextButton( style: TextButton.styleFrom( padding: EdgeInsets.zero, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)), ), onPressed: widget.episode.downloaded ? () { showPlatformDialog( context: context, useRootNavigator: false, builder: (_) => BasicDialogAlert( title: Text( L.of(context)!.delete_episode_title, ), content: Text(L.of(context)!.delete_episode_confirmation), actions: [ 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: [ 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: [ 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: [ 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( 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: [ 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(context, listen: false); final episodeBloc = Provider.of(context); final podcastBloc = Provider.of(context); final queueBloc = Provider.of(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: [ 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( context: context, builder: (BuildContext context) { return CupertinoActionSheet( actions: [ 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(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( 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(context, listen: false); final episodeBloc = Provider.of(context); final podcastBloc = Provider.of(context); final queueBloc = Provider.of(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: [ 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(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(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( 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: [ 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 = []; 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: [...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); }