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,26 @@
// 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 'dart:io';
import 'package:flutter/widgets.dart';
/// This is a simple wrapper for the [Text] widget that is intended to
/// be used with action dialogs.
///
/// It should be supplied with a text value in sentence case. If running on
/// Android this will be shifted to all upper case to meet the Material Design
/// guidelines; otherwise it will be displayed as is to fit in the with iOS
/// developer guidelines.
class ActionText extends StatelessWidget {
/// The text to display which will be shifted to all upper-case on Android.
final String text;
const ActionText(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Platform.isAndroid ? Text(text.toUpperCase()) : Text(text);
}
}

View File

@@ -0,0 +1,46 @@
// 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:flutter/material.dart';
/// An [IconButton] cannot have a background or border.
///
/// This class wraps an IconButton in a shape so that it can have a background.
class DecoratedIconButton extends StatelessWidget {
final Color decorationColour;
final Color iconColour;
final IconData icon;
final VoidCallback onPressed;
const DecoratedIconButton({
super.key,
required this.iconColour,
required this.decorationColour,
required this.icon,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Center(
child: Ink(
width: 42.0,
height: 42.0,
decoration: ShapeDecoration(
color: decorationColour,
shape: const CircleBorder(),
),
child: IconButton(
icon: Icon(icon),
padding: const EdgeInsets.all(0.0),
color: iconColour,
onPressed: onPressed,
),
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
// 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/ui/widgets/platform_progress_indicator.dart';
import 'package:flutter/material.dart';
/// This class returns a platform-specific spinning indicator after a time specified
/// in milliseconds.
///
/// Defaults to 1 second. This can be used as a place holder for cached images. By
/// delaying for several milliseconds it can reduce the occurrences of placeholders
/// flashing on screen as the cached image is loaded. Images that take longer to fetch
/// or process from the cache will result in a [PlatformProgressIndicator] indicator
/// being displayed.
class DelayedCircularProgressIndicator extends StatelessWidget {
final f = Future.delayed(const Duration(milliseconds: 1000), () => Container());
DelayedCircularProgressIndicator({
super.key,
});
@override
Widget build(BuildContext context) {
return FutureBuilder<Widget>(
future: f,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return const Center(
child: PlatformProgressIndicator(),
);
} else {
return Container();
}
});
}
}

View File

@@ -0,0 +1,62 @@
// 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:flutter/material.dart';
import 'package:percent_indicator/percent_indicator.dart';
/// Displays a download button for an episode.
///
/// Can be passed a percentage representing the download progress which
/// the button will then animate to show progress.
class DownloadButton extends StatelessWidget {
final String label;
final String title;
final IconData icon;
final int percent;
final VoidCallback onPressed;
const DownloadButton({
super.key,
required this.label,
required this.title,
required this.icon,
required this.percent,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
var progress = percent.toDouble() / 100;
return Semantics(
label: '$label $title',
child: InkWell(
onTap: onPressed,
child: CircularPercentIndicator(
radius: 19.0,
lineWidth: 1.5,
backgroundColor: Theme.of(context).primaryColor,
progressColor: Theme.of(context).indicatorColor,
animation: true,
animateFromLastPercent: true,
percent: progress,
center: percent > 0
? Text(
'$percent%',
style: const TextStyle(
fontSize: 12.0,
),
)
: Icon(
icon,
size: 22.0,
/// Why is this not picking up the theme like other widgets?!?!?!
color: Theme.of(context).primaryColor,
),
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Renders an episode within the queue which can be dragged to re-order the queue.
class DraggableEpisodeTile extends StatelessWidget {
final Episode episode;
final int index;
final bool draggable;
final bool playable;
const DraggableEpisodeTile({
super.key,
required this.episode,
this.index = 0,
this.draggable = true,
this.playable = false,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return ListTile(
key: Key('DT${episode.guid}'),
enabled: playable,
leading: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl ?? '',
size: 56.0,
highlight: episode.highlight,
),
title: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
subtitle: EpisodeSubtitle(episode),
trailing: draggable
? ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
)
: const SizedBox(
width: 0.0,
height: 0.0,
),
onTap: () {
if (playable) {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(episode);
optionalShowNowPlaying(context, settings);
}
},
);
}
}

View File

@@ -0,0 +1,265 @@
// lib/ui/widgets/draggable_queue_episode_card.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
class DraggableQueueEpisodeCard extends StatelessWidget {
final PinepodsEpisode episode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPressed;
final int index; // Add index for drag listener
const DraggableQueueEpisodeCard({
Key? key,
required this.episode,
required this.index,
this.onTap,
this.onLongPress,
this.onPlayPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
elevation: 1,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
ReorderableDragStartListener(
index: index,
child: Container(
width: 24,
height: 50,
margin: const EdgeInsets.only(right: 12),
child: Center(
child: Icon(
Icons.drag_indicator,
color: Colors.grey[600],
size: 20,
),
),
),
),
// Episode artwork
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: episode.episodeArtwork.isNotEmpty
? Image.network(
episode.episodeArtwork,
width: 50,
height: 50,
fit: BoxFit.cover,
cacheWidth: 100,
cacheHeight: 100,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.podcasts,
color: Colors.grey[600],
size: 24,
),
);
},
)
: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.podcasts,
color: Colors.grey[600],
size: 24,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.episodeTitle,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
episode.podcastName,
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.episodePubDate.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.calendar_today,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
_formatDate(episode.episodePubDate),
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
if (episode.episodeDuration > 0) ...[
const SizedBox(width: 12),
Icon(
Icons.access_time,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
_formatDuration(episode.episodeDuration),
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
],
],
),
],
// Progress bar if episode has been started
if (episode.listenDuration != null && episode.listenDuration! > 0 && episode.episodeDuration > 0) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: episode.listenDuration! / episode.episodeDuration,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor.withOpacity(0.7),
),
),
],
],
),
),
// Status indicators and play button
Column(
children: [
if (onPlayPressed != null)
IconButton(
onPressed: onPlayPressed,
icon: Icon(
episode.completed
? Icons.check_circle
: ((episode.listenDuration != null && episode.listenDuration! > 0) ? Icons.play_circle_filled : Icons.play_circle_outline),
color: episode.completed
? Colors.green
: Theme.of(context).primaryColor,
size: 28,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episode.saved)
Icon(
Icons.bookmark,
size: 16,
color: Colors.orange[600],
),
if (episode.downloaded)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.download_done,
size: 16,
color: Colors.green[600],
),
),
if (episode.queued)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.queue_music,
size: 16,
color: Colors.blue[600],
),
),
],
),
],
),
],
),
),
),
);
}
String _formatDate(String dateString) {
try {
final date = DateTime.parse(dateString);
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) {
return 'Today';
} else if (difference == 1) {
return 'Yesterday';
} else if (difference < 7) {
return '${difference}d ago';
} else if (difference < 30) {
return '${(difference / 7).floor()}w ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
} catch (e) {
return dateString;
}
}
String _formatDuration(int seconds) {
if (seconds <= 0) return '';
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
}

View File

@@ -0,0 +1,164 @@
// lib/ui/widgets/episode_context_menu.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
class EpisodeContextMenu extends StatelessWidget {
final PinepodsEpisode episode;
final VoidCallback? onSave;
final VoidCallback? onRemoveSaved;
final VoidCallback? onDownload;
final VoidCallback? onLocalDownload;
final VoidCallback? onDeleteLocalDownload;
final VoidCallback? onQueue;
final VoidCallback? onMarkComplete;
final VoidCallback? onDismiss;
final bool isDownloadedLocally;
const EpisodeContextMenu({
Key? key,
required this.episode,
this.onSave,
this.onRemoveSaved,
this.onDownload,
this.onLocalDownload,
this.onDeleteLocalDownload,
this.onQueue,
this.onMarkComplete,
this.onDismiss,
this.isDownloadedLocally = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onDismiss, // Dismiss when tapping outside
child: Container(
color: Colors.black.withOpacity(0.3), // Semi-transparent overlay
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: GestureDetector(
onTap: () {}, // Prevent dismissal when tapping the menu itself
child: Material(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
elevation: 10,
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 400,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode title
Text(
episode.episodeTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
episode.podcastName,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 8),
// Menu options
_buildMenuOption(
context,
icon: episode.saved ? Icons.bookmark_remove : Icons.bookmark_add,
text: episode.saved ? 'Remove from Saved' : 'Save Episode',
onTap: episode.saved ? onRemoveSaved : onSave,
),
_buildMenuOption(
context,
icon: episode.downloaded ? Icons.delete_outline : Icons.cloud_download_outlined,
text: episode.downloaded ? 'Delete from Server' : 'Download to Server',
onTap: onDownload,
),
_buildMenuOption(
context,
icon: isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined,
text: isDownloadedLocally ? 'Delete Local Download' : 'Download Locally',
onTap: isDownloadedLocally ? onDeleteLocalDownload : onLocalDownload,
),
_buildMenuOption(
context,
icon: episode.queued ? Icons.queue_music : Icons.add_to_queue,
text: episode.queued ? 'Remove from Queue' : 'Add to Queue',
onTap: onQueue,
),
_buildMenuOption(
context,
icon: episode.completed ? Icons.check_circle : Icons.check_circle_outline,
text: episode.completed ? 'Mark as Incomplete' : 'Mark as Complete',
onTap: onMarkComplete,
),
],
),
),
),
),
),
),
),
);
}
Widget _buildMenuOption(
BuildContext context, {
required IconData icon,
required String text,
VoidCallback? onTap,
bool enabled = true,
}) {
return InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Row(
children: [
Icon(
icon,
size: 20,
color: enabled
? Theme.of(context).iconTheme.color
: Theme.of(context).disabledColor,
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 14,
color: enabled
? Theme.of(context).textTheme.bodyLarge?.color
: Theme.of(context).disabledColor,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
// lib/ui/widgets/episode_description.dart
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_html_svg/flutter_html_svg.dart';
import 'package:flutter_html_table/flutter_html_table.dart';
import 'package:url_launcher/url_launcher.dart';
/// A specialized widget for displaying episode descriptions with clickable timestamps.
///
/// This widget extends the basic HTML display functionality to parse timestamp patterns
/// like "43:53" or "1:23:45" and make them clickable for navigation within the episode.
class EpisodeDescription extends StatelessWidget {
final String content;
final FontSize? fontSize;
final Function(Duration)? onTimestampTap;
const EpisodeDescription({
super.key,
required this.content,
this.fontSize,
this.onTimestampTap,
});
@override
Widget build(BuildContext context) {
// For now, let's use a simpler approach - just display the HTML with custom link handling
// We'll parse timestamps in the onLinkTap handler
return Html(
data: _processTimestamps(content),
extensions: const [
SvgHtmlExtension(),
TableHtmlExtension(),
],
style: {
'html': Style(
fontSize: FontSize(16.25),
lineHeight: LineHeight.percent(110),
),
'p': Style(
margin: Margins.only(
top: 0,
bottom: 12,
),
),
'.timestamp': Style(
color: const Color(0xFF539e8a),
textDecoration: TextDecoration.underline,
),
},
onLinkTap: (url, _, __) {
if (url != null && url.startsWith('timestamp:') && onTimestampTap != null) {
// Handle timestamp links
final secondsStr = url.substring(10); // Remove 'timestamp:' prefix
final seconds = int.tryParse(secondsStr);
if (seconds != null) {
final duration = Duration(seconds: seconds);
onTimestampTap!(duration);
}
} else if (url != null) {
// Handle regular links
canLaunchUrl(Uri.parse(url)).then((value) => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
));
}
},
);
}
/// Parses content and wraps timestamps with clickable links
String _processTimestamps(String htmlContent) {
// Regex pattern to match timestamp formats:
// - MM:SS (e.g., 43:53)
// - H:MM:SS (e.g., 1:23:45)
// - HH:MM:SS (e.g., 12:34:56)
final timestampRegex = RegExp(r'\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b');
return htmlContent.replaceAllMapped(timestampRegex, (match) {
final fullMatch = match.group(0)!;
final hours = match.group(1);
final minutes = match.group(2)!;
final seconds = match.group(3)!;
// Calculate total seconds for the timestamp
int totalSeconds = int.parse(seconds);
totalSeconds += int.parse(minutes) * 60;
if (hours != null) {
totalSeconds += int.parse(hours) * 3600;
}
// Return the timestamp wrapped in a clickable link
return '<a href="timestamp:$totalSeconds" style="color: #539e8a; text-decoration: underline;">$fullMatch</a>';
});
}
}

View File

@@ -0,0 +1,212 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to filter the episodes.
class EpisodeFilterSelectorWidget extends StatefulWidget {
final Podcast? podcast;
const EpisodeFilterSelectorWidget({
required this.podcast,
super.key,
});
@override
State<EpisodeFilterSelectorWidget> createState() => _EpisodeFilterSelectorWidgetState();
}
class _EpisodeFilterSelectorWidgetState extends State<EpisodeFilterSelectorWidget> {
@override
Widget build(BuildContext context) {
var podcastBloc = Provider.of<PodcastBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<BlocState<Podcast>>(
stream: podcastBloc.details,
initialData: BlocEmptyState<Podcast>(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: IconButton(
icon: Icon(
widget.podcast == null || widget.podcast!.filter == PodcastEpisodeFilter.none
? Icons.filter_alt_outlined
: Icons.filter_alt_off_outlined,
semanticLabel: L.of(context)!.episode_filter_semantic_label,
),
visualDensity: VisualDensity.compact,
onPressed: widget.podcast != null && widget.podcast!.subscribed
? () {
showModalBottomSheet<void>(
isScrollControlled: true,
barrierLabel: L.of(context)!.scrim_episode_filter_selector,
context: context,
backgroundColor: theme.secondaryHeaderColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return EpisodeFilterSlider(
podcast: widget.podcast!,
);
});
}
: null,
),
),
),
],
);
});
}
}
class EpisodeFilterSlider extends StatefulWidget {
final Podcast podcast;
const EpisodeFilterSlider({
required this.podcast,
super.key,
});
@override
State<EpisodeFilterSlider> createState() => _EpisodeFilterSliderState();
}
class _EpisodeFilterSliderState extends State<EpisodeFilterSlider> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Semantics(
header: true,
child: Text(
'Episode Filter',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
shrinkWrap: true,
children: [
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_none_label,
filter: PodcastEpisodeFilter.none,
selectedFilter: widget.podcast.filter,
),
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_started_label,
filter: PodcastEpisodeFilter.started,
selectedFilter: widget.podcast.filter,
),
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_played_label,
filter: PodcastEpisodeFilter.played,
selectedFilter: widget.podcast.filter,
),
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_unplayed_label,
filter: PodcastEpisodeFilter.notPlayed,
selectedFilter: widget.podcast.filter,
),
const Divider(),
],
),
)
]);
}
}
class EpisodeFilterSelectorEntry extends StatelessWidget {
const EpisodeFilterSelectorEntry({
super.key,
required this.label,
required this.filter,
required this.selectedFilter,
});
final String label;
final PodcastEpisodeFilter filter;
final PodcastEpisodeFilter selectedFilter;
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
switch (filter) {
case PodcastEpisodeFilter.none:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterNone);
break;
case PodcastEpisodeFilter.started:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterStarted);
break;
case PodcastEpisodeFilter.played:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterFinished);
break;
case PodcastEpisodeFilter.notPlayed:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterNotFinished);
break;
}
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Semantics(
selected: filter == selectedFilter,
child: Text(
label,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (filter == selectedFilter)
const Icon(
Icons.check,
size: 18.0,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,219 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to filter the episodes.
class EpisodeSortSelectorWidget extends StatefulWidget {
final Podcast? podcast;
const EpisodeSortSelectorWidget({
required this.podcast,
super.key,
});
@override
State<EpisodeSortSelectorWidget> createState() => _EpisodeSortSelectorWidgetState();
}
class _EpisodeSortSelectorWidgetState extends State<EpisodeSortSelectorWidget> {
@override
Widget build(BuildContext context) {
var podcastBloc = Provider.of<PodcastBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<BlocState<Podcast>>(
stream: podcastBloc.details,
initialData: BlocEmptyState<Podcast>(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: IconButton(
icon: Icon(
Icons.sort,
semanticLabel: L.of(context)!.episode_sort_semantic_label,
),
visualDensity: VisualDensity.compact,
onPressed: widget.podcast != null && widget.podcast!.subscribed
? () {
showModalBottomSheet<void>(
barrierLabel: L.of(context)!.scrim_episode_sort_selector,
isScrollControlled: true,
context: context,
backgroundColor: theme.secondaryHeaderColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return EpisodeSortSlider(
podcast: widget.podcast!,
);
});
}
: null,
),
),
),
],
);
});
}
}
class EpisodeSortSlider extends StatefulWidget {
final Podcast podcast;
const EpisodeSortSlider({
required this.podcast,
super.key,
});
@override
State<EpisodeSortSlider> createState() => _EpisodeSortSliderState();
}
class _EpisodeSortSliderState extends State<EpisodeSortSlider> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Semantics(
header: true,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.episode_sort_semantic_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
shrinkWrap: true,
children: [
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_none_label,
sort: PodcastEpisodeSort.none,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_latest_first_label,
sort: PodcastEpisodeSort.latestFirst,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_earliest_first_label,
sort: PodcastEpisodeSort.earliestFirst,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_alphabetical_ascending_label,
sort: PodcastEpisodeSort.alphabeticalAscending,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_alphabetical_descending_label,
sort: PodcastEpisodeSort.alphabeticalDescending,
selectedSort: widget.podcast.sort,
),
const Divider(),
],
),
)
]);
}
}
class EpisodeSortSelectorEntry extends StatelessWidget {
const EpisodeSortSelectorEntry({
super.key,
required this.label,
required this.sort,
required this.selectedSort,
});
final String label;
final PodcastEpisodeSort sort;
final PodcastEpisodeSort selectedSort;
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
switch (sort) {
case PodcastEpisodeSort.none:
podcastBloc.podcastEvent(PodcastEvent.episodeSortDefault);
break;
case PodcastEpisodeSort.latestFirst:
podcastBloc.podcastEvent(PodcastEvent.episodeSortLatest);
break;
case PodcastEpisodeSort.earliestFirst:
podcastBloc.podcastEvent(PodcastEvent.episodeSortEarliest);
break;
case PodcastEpisodeSort.alphabeticalAscending:
podcastBloc.podcastEvent(PodcastEvent.episodeSortAlphabeticalAscending);
break;
case PodcastEpisodeSort.alphabeticalDescending:
podcastBloc.podcastEvent(PodcastEvent.episodeSortAlphabeticalDescending);
break;
}
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Semantics(
selected: sort == selectedSort,
child: Text(
label,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (sort == selectedSort)
const Icon(
Icons.check,
size: 18.0,
),
],
),
),
);
}
}

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);
}

View File

@@ -0,0 +1,148 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Allows the user to select the layout for the library and discovery panes.
/// Can select from a list or different sized grids.
class LayoutSelectorWidget extends StatefulWidget {
const LayoutSelectorWidget({
super.key,
});
@override
State<LayoutSelectorWidget> createState() => _LayoutSelectorWidgetState();
}
class _LayoutSelectorWidgetState extends State<LayoutSelectorWidget> {
var speed = 1.0;
@override
void initState() {
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
speed = settingsBloc.currentSettings.playbackSpeed;
super.initState();
}
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
final mode = snapshot.data!.layout;
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 8.0, right: 8.0),
child: Icon(
Icons.grid_view,
size: 18,
),
),
ExcludeSemantics(
child: Text(
L.of(context)!.layout_label,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
)),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: OutlinedButton(
onPressed: () {
settingsBloc.layoutMode(0);
},
style: OutlinedButton.styleFrom(
backgroundColor: mode == 0 ? Theme.of(context).primaryColor : null,
),
child: Semantics(
selected: mode == 0,
label: L.of(context)!.semantics_layout_option_list,
child: Icon(
Icons.list,
color: mode == 0 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
right: 8.0,
),
child: OutlinedButton(
onPressed: () {
settingsBloc.layoutMode(1);
},
style: OutlinedButton.styleFrom(
backgroundColor: mode == 1 ? Theme.of(context).primaryColor : null,
),
child: Semantics(
selected: mode == 1,
label: L.of(context)!.semantics_layout_option_compact_grid,
child: Icon(
Icons.grid_on,
color: mode == 1 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
right: 8.0,
),
child: OutlinedButton(
onPressed: () {
settingsBloc.layoutMode(2);
},
style: OutlinedButton.styleFrom(
backgroundColor: mode == 2 ? Theme.of(context).primaryColor : null,
),
child: Semantics(
selected: mode == 2,
label: L.of(context)!.semantics_layout_option_grid,
child: Icon(
Icons.grid_view,
color: mode == 2 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor,
),
),
),
),
]),
),
const SizedBox(
height: 8.0,
),
],
);
});
}
}

View File

@@ -0,0 +1,116 @@
// lib/ui/widgets/lazy_network_image.dart
import 'package:flutter/material.dart';
class LazyNetworkImage extends StatefulWidget {
final String imageUrl;
final double width;
final double height;
final BoxFit fit;
final Widget? placeholder;
final Widget? errorWidget;
final BorderRadius? borderRadius;
const LazyNetworkImage({
super.key,
required this.imageUrl,
required this.width,
required this.height,
this.fit = BoxFit.cover,
this.placeholder,
this.errorWidget,
this.borderRadius,
});
@override
State<LazyNetworkImage> createState() => _LazyNetworkImageState();
}
class _LazyNetworkImageState extends State<LazyNetworkImage> {
bool _shouldLoad = false;
Widget get _defaultPlaceholder => Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: widget.borderRadius,
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 24,
),
);
Widget get _defaultErrorWidget => Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: widget.borderRadius,
),
child: const Icon(
Icons.broken_image,
color: Colors.grey,
size: 24,
),
);
@override
void initState() {
super.initState();
// Delay loading slightly to allow the widget to be built first
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_shouldLoad = true;
});
}
});
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: widget.borderRadius ?? BorderRadius.zero,
child: _shouldLoad && widget.imageUrl.isNotEmpty
? Image.network(
widget.imageUrl,
width: widget.width,
height: widget.height,
fit: widget.fit,
cacheWidth: (widget.width * 2).round(), // 2x for better quality on high-DPI
cacheHeight: (widget.height * 2).round(),
errorBuilder: (context, error, stackTrace) {
return widget.errorWidget ?? _defaultErrorWidget;
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: widget.borderRadius,
),
child: Center(
child: SizedBox(
width: widget.width * 0.4,
height: widget.height * 0.4,
child: CircularProgressIndicator(
strokeWidth: 2,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
),
);
},
)
: widget.placeholder ?? _defaultPlaceholder,
);
}
}

View File

@@ -0,0 +1,162 @@
// lib/ui/widgets/offline_episode_tile.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:intl/intl.dart' show DateFormat;
/// A custom episode tile specifically for offline downloaded episodes.
/// This bypasses the legacy PlayControl system and uses a custom play callback.
class OfflineEpisodeTile extends StatelessWidget {
final Episode episode;
final VoidCallback? onPlayPressed;
final VoidCallback? onTap;
const OfflineEpisodeTile({
super.key,
required this.episode,
this.onPlayPressed,
this.onTap,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: ListTile(
onTap: onTap,
leading: Stack(
alignment: Alignment.bottomLeft,
children: [
Opacity(
opacity: episode.played ? 0.5 : 1.0,
child: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl!,
size: 56.0,
highlight: episode.highlight,
),
),
// Progress indicator
SizedBox(
height: 5.0,
width: 56.0 * (episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
title: Opacity(
opacity: episode.played ? 0.5 : 1.0,
child: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: textTheme.bodyMedium,
),
),
subtitle: Opacity(
opacity: episode.played ? 0.5 : 1.0,
child: _EpisodeSubtitle(episode),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Offline indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.offline_pin,
size: 12,
color: Colors.green[700],
),
const SizedBox(width: 4),
Text(
'Offline',
style: TextStyle(
fontSize: 10,
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 8),
// Custom play button that bypasses legacy audio system
SizedBox(
width: 48,
height: 48,
child: IconButton(
onPressed: onPlayPressed,
icon: Icon(
Icons.play_arrow,
color: Theme.of(context).primaryColor,
),
tooltip: L.of(context)?.play_button_label ?? 'Play',
),
),
],
),
),
);
}
}
class _EpisodeSubtitle extends StatelessWidget {
final Episode episode;
final String date;
final Duration length;
_EpisodeSubtitle(this.episode)
: 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,
),
);
}
}

View File

@@ -0,0 +1,174 @@
// lib/ui/widgets/paginated_episode_list.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/offline_episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/shimmer_episode_tile.dart';
class PaginatedEpisodeList extends StatefulWidget {
final List<dynamic> episodes; // Can be PinepodsEpisode or Episode
final bool isServerEpisodes;
final bool isOfflineMode; // New flag for offline mode
final Function(dynamic episode)? onEpisodeTap;
final Function(dynamic episode, int globalIndex)? onEpisodeLongPress;
final Function(dynamic episode)? onPlayPressed;
final int pageSize;
const PaginatedEpisodeList({
super.key,
required this.episodes,
required this.isServerEpisodes,
this.isOfflineMode = false,
this.onEpisodeTap,
this.onEpisodeLongPress,
this.onPlayPressed,
this.pageSize = 20, // Show 20 episodes at a time
});
@override
State<PaginatedEpisodeList> createState() => _PaginatedEpisodeListState();
}
class _PaginatedEpisodeListState extends State<PaginatedEpisodeList> {
int _currentPage = 0;
bool _isLoadingMore = false;
int get _totalPages => (widget.episodes.length / widget.pageSize).ceil();
int get _currentEndIndex => (_currentPage + 1) * widget.pageSize;
int get _displayedCount => _currentEndIndex.clamp(0, widget.episodes.length);
List<dynamic> get _displayedEpisodes =>
widget.episodes.take(_displayedCount).toList();
Future<void> _loadMoreEpisodes() async {
if (_isLoadingMore || _currentPage + 1 >= _totalPages) return;
setState(() {
_isLoadingMore = true;
});
// Simulate a small delay to show loading state
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_currentPage++;
_isLoadingMore = false;
});
}
Widget _buildEpisodeWidget(dynamic episode, int globalIndex) {
if (widget.isServerEpisodes && episode is PinepodsEpisode) {
return PinepodsEpisodeCard(
episode: episode,
onTap: widget.onEpisodeTap != null
? () => widget.onEpisodeTap!(episode)
: null,
onLongPress: widget.onEpisodeLongPress != null
? () => widget.onEpisodeLongPress!(episode, globalIndex)
: null,
onPlayPressed: widget.onPlayPressed != null
? () => widget.onPlayPressed!(episode)
: null,
);
} else if (!widget.isServerEpisodes && episode is Episode) {
// Use offline episode tile when in offline mode to bypass legacy audio system
if (widget.isOfflineMode) {
return OfflineEpisodeTile(
episode: episode,
onTap: widget.onEpisodeTap != null
? () => widget.onEpisodeTap!(episode)
: null,
onPlayPressed: widget.onPlayPressed != null
? () => widget.onPlayPressed!(episode)
: null,
);
} else {
return EpisodeTile(
episode: episode,
download: false,
play: true,
);
}
}
return const SizedBox.shrink(); // Fallback
}
@override
Widget build(BuildContext context) {
if (widget.episodes.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
// Display current episodes
..._displayedEpisodes.asMap().entries.map((entry) {
final index = entry.key;
final episode = entry.value;
final globalIndex = widget.episodes.indexOf(episode);
return _buildEpisodeWidget(episode, globalIndex);
}).toList(),
// Loading shimmer for more episodes
if (_isLoadingMore) ...[
...List.generate(3, (index) => const ShimmerEpisodeTile()),
],
// Load more button or loading indicator
if (_currentPage + 1 < _totalPages && !_isLoadingMore) ...[
const SizedBox(height: 8),
if (_isLoadingMore)
const Padding(
padding: EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Loading more episodes...'),
],
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _loadMoreEpisodes,
icon: const Icon(Icons.expand_more),
label: Text(
'Load ${(_displayedCount + widget.pageSize).clamp(0, widget.episodes.length) - _displayedCount} more episodes '
'(${widget.episodes.length - _displayedCount} remaining)',
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
),
] else if (widget.episodes.length > widget.pageSize) ...[
// Show completion message for large lists
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'All ${widget.episodes.length} episodes loaded',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,168 @@
// lib/ui/widgets/pinepods_episode_card.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/lazy_network_image.dart';
class PinepodsEpisodeCard extends StatelessWidget {
final PinepodsEpisode episode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPressed;
const PinepodsEpisodeCard({
Key? key,
required this.episode,
this.onTap,
this.onLongPress,
this.onPlayPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
elevation: 1,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork with lazy loading
LazyNetworkImage(
imageUrl: episode.episodeArtwork,
width: 50,
height: 50,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(6),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.episodeTitle,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
episode.podcastName,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Text(
episode.formattedPubDate,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
const SizedBox(width: 8),
Text(
episode.formattedDuration,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
),
// Progress bar if episode has been started
if (episode.isStarted) ...[
const SizedBox(height: 6),
LinearProgressIndicator(
value: episode.progressPercentage / 100,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
minHeight: 2,
),
],
],
),
),
// Status indicators and play button
Column(
children: [
if (onPlayPressed != null)
IconButton(
onPressed: onPlayPressed,
icon: Icon(
episode.completed
? Icons.check_circle
: ((episode.listenDuration != null && episode.listenDuration! > 0)
? Icons.play_circle_filled
: Icons.play_circle_outline),
color: episode.completed
? Colors.green
: Theme.of(context).primaryColor,
size: 28,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episode.saved)
Icon(
Icons.bookmark,
size: 16,
color: Colors.orange[600],
),
if (episode.downloaded)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.download_done,
size: 16,
color: Colors.green[600],
),
),
if (episode.queued)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.queue_music,
size: 16,
color: Colors.blue[600],
),
),
],
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
// 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/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
class PinepodsPodcastGridTile extends StatelessWidget {
final Podcast podcast;
const PinepodsPodcastGridTile({
super.key,
required this.podcast,
});
UnifiedPinepodsPodcast _convertToUnifiedPodcast() {
return UnifiedPinepodsPodcast(
id: podcast.id ?? 0,
indexId: 0, // Default value for subscribed podcasts
title: podcast.title,
url: podcast.url,
originalUrl: podcast.url,
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0, // Default value
categories: null,
explicit: false, // Default value
episodeCount: podcast.episodes.length, // Use actual episode count
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
final unifiedPodcast = _convertToUnifiedPodcast();
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true, // These are subscribed podcasts
),
),
);
},
child: Semantics(
label: podcast.title,
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 18.0,
),
),
),
),
);
}
}
class PinepodsPodcastTitledGridTile extends StatelessWidget {
final Podcast podcast;
const PinepodsPodcastTitledGridTile({
super.key,
required this.podcast,
});
UnifiedPinepodsPodcast _convertToUnifiedPodcast() {
return UnifiedPinepodsPodcast(
id: podcast.id ?? 0,
indexId: 0, // Default value for subscribed podcasts
title: podcast.title,
url: podcast.url,
originalUrl: podcast.url,
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0, // Default value
categories: null,
explicit: false, // Default value
episodeCount: podcast.episodes.length, // Use actual episode count
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: () {
final unifiedPodcast = _convertToUnifiedPodcast();
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true, // These are subscribed podcasts
),
),
);
},
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: Column(
children: [
TileImage(
url: podcast.imageUrl!,
size: 128.0,
),
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Text(
podcast.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
// 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/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
class PinepodsPodcastTile extends StatelessWidget {
final Podcast podcast;
const PinepodsPodcastTile({
super.key,
required this.podcast,
});
UnifiedPinepodsPodcast _convertToUnifiedPodcast() {
return UnifiedPinepodsPodcast(
id: podcast.id ?? 0,
indexId: 0, // Default value for subscribed podcasts
title: podcast.title,
url: podcast.url,
originalUrl: podcast.url,
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0, // Default value
categories: null,
explicit: false, // Default value
episodeCount: podcast.episodes.length, // Use actual episode count
);
}
@override
Widget build(BuildContext context) {
return ListTile(
onTap: () {
final unifiedPodcast = _convertToUnifiedPodcast();
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true, // These are subscribed podcasts
),
),
);
},
minVerticalPadding: 9,
leading: ExcludeSemantics(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 60,
),
),
),
title: Text(
podcast.title,
maxLines: 1,
),
subtitle: Text(
'${podcast.copyright ?? ''}\n',
maxLines: 2,
),
isThreeLine: false,
);
}
}

View File

@@ -0,0 +1,25 @@
// 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:flutter/material.dart';
class PlaceholderBuilder extends InheritedWidget {
final WidgetBuilder Function() builder;
final WidgetBuilder Function() errorBuilder;
const PlaceholderBuilder({
super.key,
required this.builder,
required this.errorBuilder,
required super.child,
});
static PlaceholderBuilder? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PlaceholderBuilder>();
}
@override
bool updateShouldNotify(PlaceholderBuilder oldWidget) {
return builder != oldWidget.builder || errorBuilder != oldWidget.errorBuilder;
}
}

View File

@@ -0,0 +1,58 @@
// 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 'dart:io';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:flutter/material.dart';
/// Simple widget for rendering either the standard Android close or iOS Back button.
class PlatformBackButton extends StatelessWidget {
final Color decorationColour;
final Color iconColour;
final VoidCallback onPressed;
const PlatformBackButton({
super.key,
required this.iconColour,
required this.decorationColour,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Semantics(
button: true,
child: Center(
child: SizedBox(
height: 48.0,
width: 48.0,
child: InkWell(
onTap: onPressed,
child: Container(
margin: const EdgeInsets.all(6.0),
height: 48.0,
width: 48.0,
decoration: ShapeDecoration(
color: decorationColour,
shape: const CircleBorder(),
),
child: Padding(
padding: EdgeInsets.only(left: Platform.isIOS ? 8.0 : 0.0),
child: Icon(
Platform.isIOS ? Icons.arrow_back_ios : Icons.close,
size: Platform.isIOS ? 20.0 : 26.0,
semanticLabel: L.of(context)?.go_back_button_label,
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,32 @@
// 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:flutter/cupertino.dart';
import 'package:flutter/material.dart';
/// The class returns a circular progress indicator that is appropriate for the platform
/// it is running on.
///
/// This boils down to a [CupertinoActivityIndicator] when running on iOS or MacOS
/// and a [CircularProgressIndicator] for everything else.
class PlatformProgressIndicator extends StatelessWidget {
const PlatformProgressIndicator({
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return const CircularProgressIndicator();
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return const CupertinoActivityIndicator();
}
}
}

View File

@@ -0,0 +1,77 @@
// 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:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:percent_indicator/percent_indicator.dart';
class PlayPauseButton extends StatelessWidget {
final IconData icon;
final String label;
final String title;
const PlayPauseButton({
super.key,
required this.icon,
required this.label,
required this.title,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: '$label $title',
child: CircularPercentIndicator(
radius: 19.0,
lineWidth: 1.5,
backgroundColor: Theme.of(context).primaryColor,
percent: 0.0,
center: Icon(
icon,
size: 22.0,
/// Why is this not picking up the theme like other widgets?!?!?!
color: Theme.of(context).primaryColor,
),
),
);
}
}
class PlayPauseBusyButton extends StatelessWidget {
final IconData icon;
final String label;
final String title;
const PlayPauseBusyButton({
super.key,
required this.icon,
required this.label,
required this.title,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: '$label $title',
child: Stack(
children: <Widget>[
SizedBox(
height: 48.0,
width: 48.0,
child: Icon(
icon,
size: 22.0,
color: Theme.of(context).primaryColor,
),
),
SpinKitRing(
lineWidth: 1.5,
color: Theme.of(context).primaryColor,
size: 38.0,
),
],
));
}
}

View File

@@ -0,0 +1,250 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/podcast/podcast_details.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PodcastGridTile extends StatelessWidget {
final Podcast podcast;
const PodcastGridTile({
super.key,
required this.podcast,
});
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return GestureDetector(
onTap: () async {
await _navigateToPodcastDetails(context, podcastBloc);
},
child: Semantics(
label: podcast.title,
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 18.0,
),
),
),
),
);
}
Future<void> _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async {
// Check if this is a PinePods setup and if the podcast is already subscribed
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
// Check if podcast is already subscribed
final pinepodsService = PinepodsService();
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final isSubscribed = await pinepodsService.checkPodcastExists(
podcast.title,
podcast.url!,
settings.pinepodsUserId!
);
if (isSubscribed) {
// Get the internal PinePods database ID
final internalPodcastId = await pinepodsService.getPodcastId(
settings.pinepodsUserId!,
podcast.url!,
podcast.title
);
// Use PinePods podcast details for subscribed podcasts
final unifiedPodcast = UnifiedPinepodsPodcast(
id: internalPodcastId ?? 0,
indexId: 0, // Default for subscribed podcasts
title: podcast.title,
url: podcast.url ?? '',
originalUrl: podcast.url ?? '',
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0,
explicit: false,
episodeCount: 0, // Will be loaded
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true,
),
),
);
}
return;
}
} catch (e) {
// If check fails, fall through to standard podcast details
print('Error checking subscription status: $e');
}
}
// Use standard podcast details for non-subscribed or non-PinePods setups
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) => PodcastDetails(podcast, podcastBloc),
),
);
}
}
}
class PodcastTitledGridTile extends StatelessWidget {
final Podcast podcast;
const PodcastTitledGridTile({
super.key,
required this.podcast,
});
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
final theme = Theme.of(context);
return GestureDetector(
onTap: () async {
await _navigateToPodcastDetails(context, podcastBloc);
},
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: Column(
children: [
TileImage(
url: podcast.imageUrl!,
size: 128.0,
),
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Text(
podcast.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
),
),
],
),
),
),
);
}
Future<void> _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async {
// Check if this is a PinePods setup and if the podcast is already subscribed
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
// Check if podcast is already subscribed
final pinepodsService = PinepodsService();
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final isSubscribed = await pinepodsService.checkPodcastExists(
podcast.title,
podcast.url!,
settings.pinepodsUserId!
);
if (isSubscribed) {
// Get the internal PinePods database ID
final internalPodcastId = await pinepodsService.getPodcastId(
settings.pinepodsUserId!,
podcast.url!,
podcast.title
);
// Use PinePods podcast details for subscribed podcasts
final unifiedPodcast = UnifiedPinepodsPodcast(
id: internalPodcastId ?? 0,
indexId: 0, // Default for subscribed podcasts
title: podcast.title,
url: podcast.url ?? '',
originalUrl: podcast.url ?? '',
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0,
explicit: false,
episodeCount: 0, // Will be loaded
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true,
),
),
);
}
return;
}
} catch (e) {
// If check fails, fall through to standard podcast details
print('Error checking subscription status: $e');
}
}
// Use standard podcast details for non-subscribed or non-PinePods setups
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) => PodcastDetails(podcast, podcastBloc),
),
);
}
}
}

View File

@@ -0,0 +1,50 @@
// 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:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_html_svg/flutter_html_svg.dart';
import 'package:flutter_html_table/flutter_html_table.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is a simple, common wrapper around the flutter_html Html widget.
///
/// This wrapper allows us to remove some of the HTML tags which can cause rendering
/// issues when viewing podcast descriptions on a mobile device.
class PodcastHtml extends StatelessWidget {
final String content;
final FontSize? fontSize;
const PodcastHtml({
super.key,
required this.content,
this.fontSize,
});
@override
Widget build(BuildContext context) {
return Html(
data: content,
extensions: const [
SvgHtmlExtension(),
TableHtmlExtension(),
],
style: {
'html': Style(
fontSize: FontSize(16.25),
lineHeight: LineHeight.percent(110),
),
'p': Style(
margin: Margins.only(
top: 0,
bottom: 12,
),
),
},
onLinkTap: (url, _, __) => canLaunchUrl(Uri.parse(url!)).then((value) => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
)),
);
}
}

View File

@@ -0,0 +1,263 @@
// 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/core/environment.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
/// This class handles rendering of podcast images from a url.
/// Images will be cached for quicker fetching on subsequent requests. An optional placeholder
/// and error placeholder can be specified which will be rendered whilst the image is loading
/// or has failed to load.
///
/// We cache the image at a fixed sized of 480 regardless of render size. By doing this, large
/// podcast artwork will not slow the application down and the same image rendered at different
/// sizes will return the same cache hit reducing the need for fetching the image several times
/// for differing render sizes.
// ignore: must_be_immutable
class PodcastImage extends StatefulWidget {
final String url;
final double height;
final double width;
final BoxFit fit;
final bool highlight;
final double borderRadius;
final Widget? placeholder;
final Widget? errorPlaceholder;
const PodcastImage({
super.key,
required this.url,
this.height = double.infinity,
this.width = double.infinity,
this.fit = BoxFit.cover,
this.placeholder,
this.errorPlaceholder,
this.highlight = false,
this.borderRadius = 0.0,
});
@override
State<PodcastImage> createState() => _PodcastImageState();
}
class _PodcastImageState extends State<PodcastImage> with TickerProviderStateMixin {
static const cacheWidth = 480;
/// There appears to be a bug in extended image that causes images to
/// be re-fetched if headers have been set. We'll leave headers for now.
final headers = <String, String>{'User-Agent': Environment.userAgent()};
@override
Widget build(BuildContext context) {
return ExtendedImage.network(
widget.url,
key: widget.key,
width: widget.height,
height: widget.width,
cacheWidth: cacheWidth,
fit: widget.fit,
cache: true,
loadStateChanged: (ExtendedImageState state) {
Widget renderWidget;
if (state.extendedImageLoadState == LoadState.failed) {
renderWidget = ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: widget.errorPlaceholder ??
SizedBox(
width: widget.width,
height: widget.height,
),
);
} else {
renderWidget = AnimatedCrossFade(
crossFadeState: state.wasSynchronouslyLoaded || state.extendedImageLoadState == LoadState.completed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
firstChild: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: widget.placeholder ??
SizedBox(
width: widget.width,
height: widget.height,
),
),
secondChild: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: ExtendedRawImage(
image: state.extendedImageInfo?.image,
fit: widget.fit,
),
),
layoutBuilder: (
Widget topChild,
Key topChildKey,
Widget bottomChild,
Key bottomChildKey,
) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: widget.highlight
? [
PositionedDirectional(
key: bottomChildKey,
child: bottomChild,
),
PositionedDirectional(
key: topChildKey,
child: topChild,
),
Positioned(
top: -1.5,
right: -1.5,
child: Container(
width: 13,
height: 13,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).canvasColor,
),
),
),
Positioned(
top: 0.0,
right: 0.0,
child: Container(
width: 10.0,
height: 10.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).indicatorColor,
),
),
),
]
: [
PositionedDirectional(
key: bottomChildKey,
child: bottomChild,
),
PositionedDirectional(
key: topChildKey,
child: topChild,
),
],
);
},
);
}
return renderWidget;
},
);
}
}
class PodcastBannerImage extends StatefulWidget {
final String url;
final double height;
final double width;
final BoxFit fit;
final double borderRadius;
final Widget? placeholder;
final Widget? errorPlaceholder;
const PodcastBannerImage({
super.key,
required this.url,
this.height = double.infinity,
this.width = double.infinity,
this.fit = BoxFit.cover,
this.placeholder,
this.errorPlaceholder,
this.borderRadius = 0.0,
});
@override
State<PodcastBannerImage> createState() => _PodcastBannerImageState();
}
class _PodcastBannerImageState extends State<PodcastBannerImage> with TickerProviderStateMixin {
static const cacheWidth = 480;
/// There appears to be a bug in extended image that causes images to
/// be re-fetched if headers have been set. We'll leave headers for now.
final headers = <String, String>{'User-Agent': Environment.userAgent()};
@override
Widget build(BuildContext context) {
return ExtendedImage.network(
widget.url,
key: widget.key,
width: widget.height,
height: widget.width,
cacheWidth: cacheWidth,
fit: widget.fit,
cache: true,
loadStateChanged: (ExtendedImageState state) {
Widget renderWidget;
if (state.extendedImageLoadState == LoadState.failed) {
renderWidget = Container(
alignment: Alignment.topCenter,
width: widget.width - 2.0,
height: widget.height - 2.0,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: widget.errorPlaceholder ??
SizedBox(
width: widget.width - 2.0,
height: widget.height - 2.0,
),
),
);
} else {
renderWidget = AnimatedCrossFade(
crossFadeState: state.wasSynchronouslyLoaded || state.extendedImageLoadState == LoadState.completed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(seconds: 1),
firstChild: widget.placeholder ??
SizedBox(
width: widget.width,
height: widget.height,
),
secondChild: ExtendedRawImage(
width: widget.width,
height: widget.height,
image: state.extendedImageInfo?.image,
fit: widget.fit,
),
layoutBuilder: (
Widget topChild,
Key topChildKey,
Widget bottomChild,
Key bottomChildKey,
) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
PositionedDirectional(
key: bottomChildKey,
child: bottomChild,
),
PositionedDirectional(
key: topChildKey,
child: topChild,
),
],
);
},
);
}
return renderWidget;
},
);
}
}

View File

@@ -0,0 +1,99 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_tile.dart';
import 'package:flutter/material.dart';
import 'package:podcast_search/podcast_search.dart' as search;
import 'package:provider/provider.dart';
class PodcastList extends StatelessWidget {
const PodcastList({
super.key,
required this.results,
});
final search.SearchResult results;
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
if (results.items.isNotEmpty) {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
var mode = settingsSnapshot.data!.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final i = results.items[index];
final p = Podcast.fromSearchResultItem(i);
return PodcastTile(podcast: p);
},
childCount: results.items.length,
addAutomaticKeepAlives: false,
));
}
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final i = results.items[index];
final p = Podcast.fromSearchResultItem(i);
return PodcastGridTile(podcast: p);
},
childCount: results.items.length,
),
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
});
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
L.of(context)!.no_search_results_message,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,136 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/podcast/podcast_details.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PodcastTile extends StatelessWidget {
final Podcast podcast;
const PodcastTile({
super.key,
required this.podcast,
});
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return ListTile(
onTap: () async {
await _navigateToPodcastDetails(context, podcastBloc);
},
minVerticalPadding: 9,
leading: ExcludeSemantics(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 60,
),
),
),
title: Text(
podcast.title,
maxLines: 1,
),
/// A ListTile's density changes depending upon whether we have 2 or more lines of text. We
/// manually add a newline character here to ensure the density is consistent whether the
/// podcast subtitle spans 1 or more lines. Bit of a hack, but a simple solution.
subtitle: Text(
'${podcast.copyright ?? ''}\n',
maxLines: 2,
),
isThreeLine: false,
);
}
Future<void> _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async {
// Check if this is a PinePods setup and if the podcast is already subscribed
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
// Check if podcast is already subscribed
final pinepodsService = PinepodsService();
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final isSubscribed = await pinepodsService.checkPodcastExists(
podcast.title,
podcast.url!,
settings.pinepodsUserId!
);
if (isSubscribed) {
// Get the internal PinePods database ID
final internalPodcastId = await pinepodsService.getPodcastId(
settings.pinepodsUserId!,
podcast.url!,
podcast.title
);
// Use PinePods podcast details for subscribed podcasts
final unifiedPodcast = UnifiedPinepodsPodcast(
id: internalPodcastId ?? 0,
indexId: 0, // Default for subscribed podcasts
title: podcast.title,
url: podcast.url ?? '',
originalUrl: podcast.url ?? '',
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0,
explicit: false,
episodeCount: 0, // Will be loaded
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true,
),
),
);
}
return;
}
} catch (e) {
// If check fails, fall through to standard podcast details
print('Error checking subscription status: $e');
}
}
// Use standard podcast details for non-subscribed or non-PinePods setups
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) => PodcastDetails(podcast, podcastBloc),
),
);
}
}
}

View File

@@ -0,0 +1,34 @@
// lib/ui/widgets/restart_widget.dart
import 'package:flutter/material.dart';
class RestartWidget extends StatefulWidget {
final Widget child;
const RestartWidget({Key? key, required this.child}) : super(key: key);
static void restartApp(BuildContext context) {
context.findAncestorStateOfType<_RestartWidgetState>()?.restartApp();
}
@override
_RestartWidgetState createState() => _RestartWidgetState();
}
class _RestartWidgetState extends State<RestartWidget> {
Key key = UniqueKey();
void restartApp() {
setState(() {
key = UniqueKey();
});
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: key,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,44 @@
// 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:flutter/material.dart';
/// A transitioning route that slides the child in from the
/// right.
class SlideRightRoute extends PageRouteBuilder<void> {
final Widget widget;
@override
final RouteSettings settings;
SlideRightRoute({
required this.widget,
required this.settings,
}) : super(
pageBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return widget;
},
settings: settings,
transitionsBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(
1.0,
0.0,
),
end: Offset.zero,
).animate(animation),
child: child,
);
});
}

View File

@@ -0,0 +1,242 @@
// lib/ui/widgets/server_error_page.dart
import 'package:flutter/material.dart';
class ServerErrorPage extends StatelessWidget {
final String? errorMessage;
final VoidCallback? onRetry;
final String? title;
final String? subtitle;
final bool showLogo;
const ServerErrorPage({
Key? key,
this.errorMessage,
this.onRetry,
this.title,
this.subtitle,
this.showLogo = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// PinePods Logo
if (showLogo) ...[
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/pinepods-logo.png',
width: 120,
height: 120,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// Fallback if logo image fails to load
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.podcasts,
size: 64,
color: Theme.of(context).primaryColor,
),
);
},
),
),
const SizedBox(height: 32),
],
// Error Icon
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.cloud_off_rounded,
size: 48,
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 24),
// Title
Text(
title ?? 'Server Unavailable',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Subtitle
Text(
subtitle ?? 'Unable to connect to the PinePods server',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Error Message (if provided)
if (errorMessage != null && errorMessage!.isNotEmpty) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.error.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 6),
Text(
'Error Details',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.error,
),
),
],
),
const SizedBox(height: 8),
Text(
errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
const SizedBox(height: 24),
],
// Troubleshooting suggestions
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.lightbulb_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 6),
Text(
'Troubleshooting Tips',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
_buildTroubleshootingTip(context, '• Check your internet connection'),
_buildTroubleshootingTip(context, '• Verify server settings in the app'),
_buildTroubleshootingTip(context, '• Ensure the PinePods server is running'),
_buildTroubleshootingTip(context, '• Contact your administrator if the issue persists'),
],
),
),
const SizedBox(height: 32),
// Action Buttons
if (onRetry != null)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
);
}
Widget _buildTroubleshootingTip(BuildContext context, String tip) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
tip,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
}
/// A specialized server error page for SliverFillRemaining usage
class SliverServerErrorPage extends StatelessWidget {
final String? errorMessage;
final VoidCallback? onRetry;
final String? title;
final String? subtitle;
final bool showLogo;
const SliverServerErrorPage({
Key? key,
this.errorMessage,
this.onRetry,
this.title,
this.subtitle,
this.showLogo = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: ServerErrorPage(
errorMessage: errorMessage,
onRetry: onRetry,
title: title,
subtitle: subtitle,
showLogo: showLogo,
),
);
}
}

View File

@@ -0,0 +1,138 @@
// lib/ui/widgets/shimmer_episode_tile.dart
import 'package:flutter/material.dart';
class ShimmerEpisodeTile extends StatefulWidget {
const ShimmerEpisodeTile({super.key});
@override
State<ShimmerEpisodeTile> createState() => _ShimmerEpisodeTileState();
}
class _ShimmerEpisodeTileState extends State<ShimmerEpisodeTile>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
_shimmerController = AnimationController.unbounded(vsync: this)
..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shimmer image placeholder
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: LinearGradient(
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
stops: const [0.1, 0.3, 0.4],
begin: const Alignment(-1.0, -0.3),
end: const Alignment(1.0, 0.3),
transform: _SlidingGradientTransform(_shimmerController.value),
),
),
);
},
),
const SizedBox(width: 12),
// Shimmer text content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title placeholder
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
stops: const [0.1, 0.3, 0.4],
begin: const Alignment(-1.0, -0.3),
end: const Alignment(1.0, 0.3),
transform: _SlidingGradientTransform(_shimmerController.value),
),
),
);
},
),
const SizedBox(height: 8),
// Subtitle placeholder
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: 120,
height: 12,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
stops: const [0.1, 0.3, 0.4],
begin: const Alignment(-1.0, -0.3),
end: const Alignment(1.0, 0.3),
transform: _SlidingGradientTransform(_shimmerController.value),
),
),
);
},
),
],
),
),
],
),
),
);
}
}
class _SlidingGradientTransform extends GradientTransform {
const _SlidingGradientTransform(this.slidePercent);
final double slidePercent;
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}

View File

@@ -0,0 +1,298 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/sleep.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to change the playback speed and toggle audio effects.
///
/// The two audio effects, trim silence and volume boost, are currently Android only.
class SleepSelectorWidget extends StatefulWidget {
const SleepSelectorWidget({
super.key,
});
@override
State<SleepSelectorWidget> createState() => _SleepSelectorWidgetState();
}
class _SleepSelectorWidgetState extends State<SleepSelectorWidget> {
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final settingsBloc = Provider.of<SettingsBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: StreamBuilder<Sleep>(
stream: audioBloc.sleepStream,
initialData: Sleep(type: SleepType.none),
builder: (context, sleepSnapshot) {
var sl = '';
if (sleepSnapshot.hasData) {
var s = sleepSnapshot.data!;
switch(s.type) {
case SleepType.none:
sl = '';
case SleepType.time:
sl = '${L.of(context)!.now_playing_episode_time_remaining} ${SleepSlider.formatDuration(s.timeRemaining)}';
case SleepType.episode:
sl = '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_episode_label}';
}
}
return IconButton(
icon: sleepSnapshot.data?.type != SleepType.none ? Icon(
Icons.bedtime,
semanticLabel: '${L.of(context)!.sleep_timer_label}. $sl',
size: 20.0,
) : Icon(
Icons.bedtime_outlined,
semanticLabel: L.of(context)!.sleep_timer_label,
size: 20.0,
),
onPressed: () {
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
backgroundColor: theme.secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_sleep_timer_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return const SleepSlider();
});
},
);
}
),
),
),
],
);
});
}
}
class SleepSlider extends StatefulWidget {
const SleepSlider({super.key});
static String formatDuration(Duration duration) {
String twoDigits(int n) {
if (n >= 10) return '$n';
return '0$n';
}
var twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).toInt());
var twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).toInt());
return '${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
}
@override
State<SleepSlider> createState() => _SleepSliderState();
}
class _SleepSliderState extends State<SleepSlider> {
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<Sleep>(
stream: audioBloc.sleepStream,
initialData: Sleep(type: SleepType.none),
builder: (context, snapshot) {
var s = snapshot.data;
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Semantics(
header: true,
child: Text(
L.of(context)!.sleep_timer_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
if (s != null && s.type == SleepType.none)
Text(
'(${L.of(context)!.sleep_off_label})',
semanticsLabel: '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_off_label}',
style: Theme.of(context).textTheme.bodyLarge,
),
if (s != null && s.type == SleepType.time)
Text(
'(${SleepSlider.formatDuration(s.timeRemaining)})',
semanticsLabel:
'${L.of(context)!.semantic_current_value_label} ${SleepSlider.formatDuration(s.timeRemaining)}',
style: Theme.of(context).textTheme.bodyLarge,
),
if (s != null && s.type == SleepType.episode)
Text(
'(${L.of(context)!.sleep_episode_label})',
semanticsLabel:
'${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_episode_label}',
style: Theme.of(context).textTheme.bodyLarge,
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
shrinkWrap: true,
children: [
SleepSelectorEntry(
sleep: Sleep(type: SleepType.none),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 5),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 10),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 15),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 30),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 45),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 60),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.episode,
),
current: s,
),
],
),
)
]);
});
}
}
class SleepSelectorEntry extends StatelessWidget {
const SleepSelectorEntry({
super.key,
required this.sleep,
required this.current,
});
final Sleep sleep;
final Sleep? current;
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
audioBloc.sleep(Sleep(
type: sleep.type,
duration: sleep.duration,
));
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
if (sleep.type == SleepType.none)
Text(
L.of(context)!.sleep_off_label,
style: Theme.of(context).textTheme.bodyLarge,
),
if (sleep.type == SleepType.time)
Text(
L.of(context)!.sleep_minute_label(sleep.duration.inMinutes.toString()),
style: Theme.of(context).textTheme.bodyLarge,
),
if (sleep.type == SleepType.episode)
Text(
L.of(context)!.sleep_episode_label,
style: Theme.of(context).textTheme.bodyLarge,
),
if (sleep == current)
const Icon(
Icons.check,
size: 18.0,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
// 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:flutter/material.dart';
/// This class generates a simple 'handle' icon that can be added on widgets such as
/// scrollable sheets and bottom dialogs.
///
/// When running with a screen reader, the handle icon becomes selectable with an
/// optional label and tap callback. This makes it easier to open/close.
class SliderHandle extends StatelessWidget {
final GestureTapCallback? onTap;
final String label;
const SliderHandle({
super.key,
this.onTap,
this.label = '',
});
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).hintColor,
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,241 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/core/extensions.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to change the playback speed and toggle audio effects.
///
/// The two audio effects, trim silence and volume boost, are currently Android only.
class SpeedSelectorWidget extends StatefulWidget {
const SpeedSelectorWidget({
super.key,
});
@override
State<SpeedSelectorWidget> createState() => _SpeedSelectorWidgetState();
}
class _SpeedSelectorWidgetState extends State<SpeedSelectorWidget> {
var speed = 1.0;
@override
void initState() {
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
speed = settingsBloc.currentSettings.playbackSpeed;
super.initState();
}
@override
Widget build(BuildContext context) {
var settingsBloc = Provider.of<SettingsBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () {
showModalBottomSheet<void>(
context: context,
backgroundColor: theme.secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_speed_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return const SpeedSlider();
});
},
child: SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: Semantics(
button: true,
child: Text(
semanticsLabel: '${L.of(context)!.playback_speed_label} ${snapshot.data!.playbackSpeed.toTenth}',
snapshot.data!.playbackSpeed == 1.0 ? 'x1' : 'x${snapshot.data!.playbackSpeed.toTenth}',
style: TextStyle(
fontSize: 16.0,
color: Theme.of(context).iconTheme.color,
),
),
),
),
),
),
],
);
});
}
}
class SpeedSlider extends StatefulWidget {
const SpeedSlider({super.key});
@override
State<SpeedSlider> createState() => _SpeedSliderState();
}
class _SpeedSliderState extends State<SpeedSlider> {
var speed = 1.0;
var trimSilence = false;
var volumeBoost = false;
@override
void initState() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
speed = settingsBloc.currentSettings.playbackSpeed;
trimSilence = settingsBloc.currentSettings.trimSilence;
volumeBoost = settingsBloc.currentSettings.volumeBoost;
super.initState();
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.audio_settings_playback_speed_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'${speed.toStringAsFixed(1)}x',
style: Theme.of(context).textTheme.headlineSmall,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: IconButton(
tooltip: L.of(context)!.semantics_decrease_playback_speed,
iconSize: 28.0,
icon: const Icon(Icons.remove_circle_outline),
onPressed: (speed <= 0.5)
? null
: () {
setState(() {
speed -= 0.1;
speed = speed.toTenth;
audioBloc.playbackSpeed(speed);
settingsBloc.setPlaybackSpeed(speed);
});
},
),
),
Expanded(
flex: 4,
child: Slider(
value: speed.toTenth,
min: 0.5,
max: 2.0,
divisions: 15,
onChanged: (value) {
setState(() {
speed = value;
});
},
onChangeEnd: (value) {
audioBloc.playbackSpeed(speed);
settingsBloc.setPlaybackSpeed(value);
},
),
),
Expanded(
child: IconButton(
tooltip: L.of(context)!.semantics_increase_playback_speed,
iconSize: 28.0,
icon: const Icon(Icons.add_circle_outline),
onPressed: (speed > 1.9)
? null
: () {
setState(() {
speed += 0.1;
speed = speed.toTenth;
audioBloc.playbackSpeed(speed);
settingsBloc.setPlaybackSpeed(speed);
});
},
),
),
],
),
const SizedBox(
height: 8.0,
),
const Divider(),
if (theme.platform == TargetPlatform.android) ...[
/// Disable the trim silence option for now until the positioning bug
/// in just_audio is resolved.
// ListTile(
// title: Text(L.of(context).audio_effect_trim_silence_label),
// trailing: Switch.adaptive(
// value: trimSilence,
// onChanged: (value) {
// setState(() {
// trimSilence = value;
// audioBloc.trimSilence(value);
// settingsBloc.trimSilence(value);
// });
// },
// ),
// ),
ListTile(
title: Text(L.of(context)!.audio_effect_volume_boost_label),
trailing: Switch.adaptive(
value: volumeBoost,
onChanged: (boost) {
setState(() {
volumeBoost = boost;
audioBloc.volumeBoost(boost);
settingsBloc.volumeBoost(boost);
});
},
),
),
] else
const SizedBox(
width: 0.0,
height: 0.0,
),
],
);
}
}

View File

@@ -0,0 +1,76 @@
// 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 'dart:async';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SyncSpinner extends StatefulWidget {
const SyncSpinner({super.key});
@override
State<SyncSpinner> createState() => _SyncSpinnerState();
}
class _SyncSpinnerState extends State<SyncSpinner> with SingleTickerProviderStateMixin {
late AnimationController _controller;
StreamSubscription<BlocState<void>>? subscription;
Widget? _child;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_child = const Icon(
Icons.refresh,
size: 16.0,
);
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
subscription = podcastBloc.backgroundLoading.listen((event) {
if (event is BlocSuccessfulState<void> || event is BlocErrorState<void>) {
_controller.stop();
}
});
}
@override
void dispose() {
_controller.dispose();
subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
return StreamBuilder<BlocState<void>>(
initialData: BlocEmptyState<void>(),
stream: podcastBloc.backgroundLoading,
builder: (context, snapshot) {
final state = snapshot.data;
return state is BlocLoadingState<void>
? RotationTransition(
turns: _controller,
child: _child,
)
: const SizedBox(
width: 0.0,
height: 0.0,
);
});
}
}

View File

@@ -0,0 +1,51 @@
// 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/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
class TileImage extends StatelessWidget {
const TileImage({
super.key,
required this.url,
required this.size,
this.highlight = false,
});
/// The URL of the image to display.
final String url;
/// The size of the image container; both height and width.
final double size;
final bool highlight;
@override
Widget build(BuildContext context) {
final placeholderBuilder = PlaceholderBuilder.of(context);
return PodcastImage(
key: Key('tile$url'),
highlight: highlight,
url: url,
height: size,
width: size,
borderRadius: 4.0,
fit: BoxFit.contain,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: const Image(
fit: BoxFit.contain,
image: AssetImage('assets/images/favicon.png'),
),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(
fit: BoxFit.contain,
image: AssetImage('assets/images/favicon.png'),
),
);
}
}