added cargo files
This commit is contained in:
26
PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart
Normal file
26
PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
62
PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart
Normal file
62
PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
164
PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart
Normal file
164
PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
219
PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart
Normal file
219
PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
957
PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart
Normal file
957
PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart
Normal 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);
|
||||
}
|
||||
148
PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart
Normal file
148
PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
116
PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart
Normal file
116
PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
162
PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart
Normal file
162
PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
174
PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart
Normal file
174
PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
168
PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart
Normal file
168
PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart
Normal 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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart
Normal file
77
PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
250
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart
Normal file
250
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart
Normal file
50
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart
Normal 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,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart
Normal file
263
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
99
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart
Normal file
99
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart
Normal file
136
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart
Normal file
34
PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart
Normal file
44
PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
242
PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart
Normal file
242
PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart
Normal file
138
PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
298
PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart
Normal file
298
PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart
Normal file
43
PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
241
PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart
Normal file
241
PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
76
PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart
Normal file
76
PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
51
PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart
Normal file
51
PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart
Normal 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user