added cargo files
This commit is contained in:
170
PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
Normal file
170
PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
// 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/audio_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/chapter.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
/// A [Widget] for displaying a list of Podcast chapters for those
|
||||
/// podcasts that support that chapter tag.
|
||||
// ignore: must_be_immutable
|
||||
class ChapterSelector extends StatefulWidget {
|
||||
final ItemScrollController itemScrollController = ItemScrollController();
|
||||
Episode episode;
|
||||
Chapter? chapter;
|
||||
StreamSubscription? positionSubscription;
|
||||
var chapters = <Chapter>[];
|
||||
|
||||
ChapterSelector({
|
||||
super.key,
|
||||
required this.episode,
|
||||
}) {
|
||||
chapters = episode.chapters.where((c) => c.toc).toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ChapterSelector> createState() => _ChapterSelectorState();
|
||||
}
|
||||
|
||||
class _ChapterSelectorState extends State<ChapterSelector> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
Chapter? lastChapter;
|
||||
bool first = true;
|
||||
|
||||
// Listen for changes in position. If the change in position results in
|
||||
// a change in chapter we scroll to it. This ensures that the current
|
||||
// chapter is always visible.
|
||||
// TODO: Jump only if current chapter is not visible.
|
||||
widget.positionSubscription = audioBloc.playPosition!.listen((event) {
|
||||
var episode = event.episode;
|
||||
|
||||
if (widget.itemScrollController.isAttached) {
|
||||
lastChapter ??= episode!.currentChapter;
|
||||
|
||||
if (lastChapter != episode!.currentChapter) {
|
||||
lastChapter = episode.currentChapter;
|
||||
|
||||
if (!episode.chaptersLoading && episode.chapters.isNotEmpty) {
|
||||
var index = widget.chapters.indexWhere((element) => element == lastChapter);
|
||||
|
||||
if (index >= 0) {
|
||||
if (first) {
|
||||
widget.itemScrollController.jumpTo(index: index);
|
||||
first = false;
|
||||
}
|
||||
// Removed auto-scroll to current chapter during playback
|
||||
// to prevent annoying bouncing behavior
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context);
|
||||
|
||||
return StreamBuilder<Episode?>(
|
||||
stream: audioBloc.nowPlaying,
|
||||
builder: (context, snapshot) {
|
||||
return !snapshot.hasData || snapshot.data!.chaptersLoading
|
||||
? const Align(
|
||||
alignment: Alignment.center,
|
||||
child: PlatformProgressIndicator(),
|
||||
)
|
||||
: ScrollablePositionedList.builder(
|
||||
initialScrollIndex: _initialIndex(snapshot.data),
|
||||
itemScrollController: widget.itemScrollController,
|
||||
itemCount: widget.chapters.length,
|
||||
itemBuilder: (context, i) {
|
||||
final index = i < 0 ? 0 : i;
|
||||
final chapter = widget.chapters[index];
|
||||
final chapterSelected = chapter == snapshot.data!.currentChapter;
|
||||
final textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
);
|
||||
|
||||
/// We should be able to use the selectedTileColor property but, if we do, when
|
||||
/// we scroll the currently selected item out of view, the selected colour is
|
||||
/// still visible behind the transport control. This is a little hack, but fixes
|
||||
/// the issue until I can get ListTile to work correctly.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4.0, 0.0, 4.0, 0.0),
|
||||
child: ListTile(
|
||||
selectedTileColor: Theme.of(context).cardTheme.color,
|
||||
onTap: () {
|
||||
audioBloc.transitionPosition(chapter.startTime);
|
||||
},
|
||||
selected: chapterSelected,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Text(
|
||||
'${index + 1}.',
|
||||
style: textStyle,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
widget.chapters[index].title.trim(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: false,
|
||||
maxLines: 3,
|
||||
style: textStyle,
|
||||
),
|
||||
trailing: Text(
|
||||
_formatStartTime(widget.chapters[index].startTime),
|
||||
style: textStyle,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.positionSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _initialIndex(Episode? e) {
|
||||
var init = 0;
|
||||
|
||||
if (e != null && e.currentChapter != null) {
|
||||
init = widget.chapters.indexWhere((c) => c == e.currentChapter);
|
||||
|
||||
if (init < 0) {
|
||||
init = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return init;
|
||||
}
|
||||
|
||||
String _formatStartTime(double startTime) {
|
||||
var time = Duration(seconds: startTime.ceil());
|
||||
var result = '';
|
||||
|
||||
if (time.inHours > 0) {
|
||||
result =
|
||||
'${time.inHours}:${time.inMinutes.remainder(60).toString().padLeft(2, '0')}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
result = '${time.inMinutes}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
49
PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart
Normal file
49
PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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/widgets.dart';
|
||||
|
||||
/// Custom [Decoration] for the chapters, episode & notes tab selector
|
||||
/// shown in the [NowPlaying] page.
|
||||
class DotDecoration extends Decoration {
|
||||
final Color colour;
|
||||
|
||||
const DotDecoration({required this.colour});
|
||||
|
||||
@override
|
||||
BoxPainter createBoxPainter([void Function()? onChanged]) {
|
||||
return _DotDecorationPainter(decoration: this);
|
||||
}
|
||||
}
|
||||
|
||||
class _DotDecorationPainter extends BoxPainter {
|
||||
final DotDecoration decoration;
|
||||
|
||||
_DotDecorationPainter({required this.decoration});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
|
||||
const double pillWidth = 8.0;
|
||||
const double pillHeight = 3.0;
|
||||
|
||||
final center = configuration.size!.center(offset);
|
||||
final height = configuration.size!.height;
|
||||
|
||||
final newOffset = Offset(center.dx, height - 8);
|
||||
|
||||
final paint = Paint();
|
||||
paint.color = decoration.colour;
|
||||
paint.style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawRRect(
|
||||
RRect.fromLTRBR(
|
||||
newOffset.dx - pillWidth,
|
||||
newOffset.dy - pillHeight,
|
||||
newOffset.dx + pillWidth,
|
||||
newOffset.dy + pillHeight,
|
||||
const Radius.circular(12.0),
|
||||
),
|
||||
paint);
|
||||
}
|
||||
}
|
||||
101
PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart
Normal file
101
PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
// 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/episode.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/transport_controls.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// This class renders the more info widget that is accessed from the 'more'
|
||||
/// button on an episode.
|
||||
///
|
||||
/// The widget is displayed as a draggable, scrollable sheet. This contains
|
||||
/// episode icon and play/pause control, below which the episode title, show
|
||||
/// notes and person(s) details (if available).
|
||||
class EpisodeDetails extends StatefulWidget {
|
||||
final Episode episode;
|
||||
|
||||
const EpisodeDetails({
|
||||
super.key,
|
||||
required this.episode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EpisodeDetails> createState() => _EpisodeDetailsState();
|
||||
}
|
||||
|
||||
class _EpisodeDetailsState extends State<EpisodeDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final episode = widget.episode;
|
||||
|
||||
/// Ensure we do not highlight this as a new episode
|
||||
episode.highlight = false;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
expand: false,
|
||||
builder: (BuildContext context, ScrollController scrollController) {
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ExpansionTile(
|
||||
key: const Key('episodemoreinfo'),
|
||||
trailing: PlayControl(
|
||||
episode: episode,
|
||||
),
|
||||
leading: TileImage(
|
||||
url: episode.thumbImageUrl ?? episode.imageUrl!,
|
||||
size: 56.0,
|
||||
highlight: episode.highlight,
|
||||
),
|
||||
subtitle: EpisodeSubtitle(episode),
|
||||
title: Text(
|
||||
episode.title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
softWrap: false,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
)),
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
episode.title!,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (episode.persons.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 120.0,
|
||||
child: ListView.builder(
|
||||
itemCount: episode.persons.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return PersonAvatar(person: episode.persons[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: PodcastHtml(content: episode.content ?? episode.description!),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
222
PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart
Normal file
222
PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/app_settings.dart';
|
||||
import 'package:pinepods_mobile/entities/funding.dart';
|
||||
import 'package:pinepods_mobile/l10n/L.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dialogs/flutter_dialogs.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// This class is responsible for rendering the funding menu on the podcast details page.
|
||||
///
|
||||
/// It returns either a Material or Cupertino style menu instance depending upon which
|
||||
/// platform we are running on.
|
||||
///
|
||||
/// The target platform is based on the current [Theme]: [ThemeData.platform].
|
||||
class FundingMenu extends StatelessWidget {
|
||||
final List<Funding>? funding;
|
||||
|
||||
const FundingMenu(
|
||||
this.funding, {
|
||||
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 _MaterialFundingMenu(funding);
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return _CupertinoFundingMenu(funding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the material design version of the context menu. This will be rendered
|
||||
/// for all platforms that are not iOS.
|
||||
class _MaterialFundingMenu extends StatelessWidget {
|
||||
final List<Funding>? funding;
|
||||
|
||||
const _MaterialFundingMenu(this.funding);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context);
|
||||
|
||||
return funding == null || funding!.isEmpty
|
||||
? const SizedBox(
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
)
|
||||
: StreamBuilder<AppSettings>(
|
||||
stream: settingsBloc.settings,
|
||||
initialData: AppSettings.sensibleDefaults(),
|
||||
builder: (context, snapshot) {
|
||||
return Semantics(
|
||||
label: L.of(context)!.podcast_funding_dialog_header,
|
||||
child: PopupMenuButton<String>(
|
||||
onSelected: (url) {
|
||||
FundingLink.fundingLink(
|
||||
url,
|
||||
snapshot.data!.externalLinkConsent,
|
||||
context,
|
||||
).then((value) {
|
||||
settingsBloc.setExternalLinkConsent(value);
|
||||
});
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.payment,
|
||||
),
|
||||
itemBuilder: (BuildContext context) {
|
||||
return List<PopupMenuEntry<String>>.generate(funding!.length, (index) {
|
||||
return PopupMenuItem<String>(
|
||||
value: funding![index].url,
|
||||
enabled: true,
|
||||
child: Text(funding![index].value),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the Cupertino context menu and is rendered only when running on
|
||||
/// an iOS device.
|
||||
class _CupertinoFundingMenu extends StatelessWidget {
|
||||
final List<Funding>? funding;
|
||||
|
||||
const _CupertinoFundingMenu(this.funding);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context);
|
||||
|
||||
return funding == null || funding!.isEmpty
|
||||
? const SizedBox(
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
)
|
||||
: StreamBuilder<AppSettings>(
|
||||
stream: settingsBloc.settings,
|
||||
initialData: AppSettings.sensibleDefaults(),
|
||||
builder: (context, snapshot) {
|
||||
return IconButton(
|
||||
tooltip: L.of(context)!.podcast_funding_dialog_header,
|
||||
icon: const Icon(Icons.payment),
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () => showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return CupertinoActionSheet(
|
||||
actions: <Widget>[
|
||||
...List<CupertinoActionSheetAction>.generate(funding!.length, (index) {
|
||||
return CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
FundingLink.fundingLink(
|
||||
funding![index].url,
|
||||
snapshot.data!.externalLinkConsent,
|
||||
context,
|
||||
).then((value) {
|
||||
settingsBloc.setExternalLinkConsent(value);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop('Cancel');
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text(funding![index].value),
|
||||
);
|
||||
}),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context, 'Cancel');
|
||||
},
|
||||
child: Text(L.of(context)!.cancel_option_label),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FundingLink {
|
||||
/// Check the consent status. If this is the first time we have been
|
||||
/// requested to open a funding link, present the user with and
|
||||
/// information dialog first to make clear that the link is provided
|
||||
/// by the podcast owner and not Pinepods.
|
||||
static Future<bool> fundingLink(String url, bool consent, BuildContext context) async {
|
||||
bool? result = false;
|
||||
|
||||
if (consent) {
|
||||
result = true;
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
if (!await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
throw Exception('Could not launch $uri');
|
||||
}
|
||||
} else {
|
||||
result = await showPlatformDialog<bool>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => BasicDialogAlert(
|
||||
title: Semantics(
|
||||
header: true,
|
||||
child: Text(L.of(context)!.podcast_funding_dialog_header),
|
||||
),
|
||||
content: Text(L.of(context)!.consent_message),
|
||||
actions: <Widget>[
|
||||
BasicDialogAction(
|
||||
title: ActionText(
|
||||
L.of(context)!.go_back_button_label,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
),
|
||||
BasicDialogAction(
|
||||
title: ActionText(
|
||||
L.of(context)!.continue_button_label,
|
||||
),
|
||||
iosIsDefaultAction: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result!) {
|
||||
var uri = Uri.parse(url);
|
||||
|
||||
unawaited(
|
||||
canLaunchUrl(uri).then((value) => launchUrl(uri)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Future.value(result);
|
||||
}
|
||||
}
|
||||
360
PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart
Normal file
360
PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart
Normal file
@@ -0,0 +1,360 @@
|
||||
// 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 'dart:ui';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.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/ui/podcast/now_playing.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Displays a mini podcast player widget if a podcast is playing or paused.
|
||||
///
|
||||
/// If stopped a zero height box is built instead. Tapping on the mini player
|
||||
/// will open the main player window.
|
||||
class MiniPlayer extends StatelessWidget {
|
||||
const MiniPlayer({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
return StreamBuilder<AudioState>(
|
||||
stream: audioBloc.playingState,
|
||||
initialData: AudioState.stopped,
|
||||
builder: (context, snapshot) {
|
||||
return snapshot.data != AudioState.stopped &&
|
||||
snapshot.data != AudioState.none &&
|
||||
snapshot.data != AudioState.error
|
||||
? _MiniPlayerBuilder()
|
||||
: const SizedBox(
|
||||
height: 0.0,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniPlayerBuilder extends StatefulWidget {
|
||||
@override
|
||||
_MiniPlayerBuilderState createState() => _MiniPlayerBuilderState();
|
||||
}
|
||||
|
||||
class _MiniPlayerBuilderState extends State<_MiniPlayerBuilder>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _playPauseController;
|
||||
late StreamSubscription<AudioState> _audioStateSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_playPauseController = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 300));
|
||||
_playPauseController.value = 1;
|
||||
|
||||
_audioStateListener();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_audioStateSubscription.cancel();
|
||||
_playPauseController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final placeholderBuilder = PlaceholderBuilder.of(context);
|
||||
|
||||
return Dismissible(
|
||||
key: UniqueKey(),
|
||||
confirmDismiss: (direction) async {
|
||||
await _audioStateSubscription.cancel();
|
||||
audioBloc.transitionState(TransitionState.stop);
|
||||
return true;
|
||||
},
|
||||
direction: DismissDirection.startToEnd,
|
||||
background: Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
height: 64.0,
|
||||
),
|
||||
child: GestureDetector(
|
||||
key: const Key('miniplayergesture'),
|
||||
onTap: () async {
|
||||
await _audioStateSubscription.cancel();
|
||||
|
||||
if (context.mounted) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
routeSettings: const RouteSettings(name: 'nowplaying'),
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext modalContext) {
|
||||
final contextPadding = MediaQuery.of(context).padding.top;
|
||||
final modalPadding = MediaQuery.of(modalContext).padding.top;
|
||||
|
||||
// Get the actual system safe area from the window (works on both iOS and Android)
|
||||
final window = PlatformDispatcher.instance.views.first;
|
||||
final systemPadding = window.padding.top / window.devicePixelRatio;
|
||||
|
||||
// Use the best available padding value
|
||||
double topPadding;
|
||||
if (contextPadding > 0) {
|
||||
topPadding = contextPadding;
|
||||
} else if (modalPadding > 0) {
|
||||
topPadding = modalPadding;
|
||||
} else {
|
||||
// Fall back to system padding if both contexts have 0
|
||||
topPadding = systemPadding;
|
||||
}
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: topPadding),
|
||||
child: const NowPlaying(),
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
_audioStateListener();
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Semantics(
|
||||
header: true,
|
||||
label: L.of(context)!.semantics_mini_player_header,
|
||||
child: Container(
|
||||
height: 66,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
top: Divider.createBorderSide(context,
|
||||
width: 1.0, color: Theme.of(context).dividerColor),
|
||||
bottom: Divider.createBorderSide(context,
|
||||
width: 0.0, color: Theme.of(context).dividerColor),
|
||||
)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4.0, right: 4.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StreamBuilder<Episode?>(
|
||||
stream: audioBloc.nowPlaying,
|
||||
initialData: audioBloc.nowPlaying?.valueOrNull,
|
||||
builder: (context, snapshot) {
|
||||
return StreamBuilder<AudioState>(
|
||||
stream: audioBloc.playingState,
|
||||
builder: (context, stateSnapshot) {
|
||||
var playing =
|
||||
stateSnapshot.data == AudioState.playing;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
height: 58.0,
|
||||
width: 58.0,
|
||||
child: ExcludeSemantics(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: snapshot.hasData
|
||||
? PodcastImage(
|
||||
key: Key(
|
||||
'mini${snapshot.data!.imageUrl}'),
|
||||
url: snapshot.data!.imageUrl!,
|
||||
width: 58.0,
|
||||
height: 58.0,
|
||||
borderRadius: 4.0,
|
||||
placeholder: placeholderBuilder !=
|
||||
null
|
||||
? placeholderBuilder
|
||||
.builder()(context)
|
||||
: const Image(
|
||||
image: AssetImage(
|
||||
'assets/images/favicon.png')),
|
||||
errorPlaceholder:
|
||||
placeholderBuilder != null
|
||||
? placeholderBuilder
|
||||
.errorBuilder()(
|
||||
context)
|
||||
: const Image(
|
||||
image: AssetImage(
|
||||
'assets/images/favicon.png')),
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
snapshot.data?.title ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
snapshot.data?.author ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
SizedBox(
|
||||
height: 52.0,
|
||||
width: 52.0,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 0.0),
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface,
|
||||
width: 0.0)),
|
||||
),
|
||||
onPressed: () {
|
||||
if (playing) {
|
||||
audioBloc.transitionState(
|
||||
TransitionState.fastforward);
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
Icons.forward_30,
|
||||
semanticLabel: L
|
||||
.of(context)!
|
||||
.fast_forward_button_label,
|
||||
size: 36.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 52.0,
|
||||
width: 52.0,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 0.0),
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface,
|
||||
width: 0.0)),
|
||||
),
|
||||
onPressed: () {
|
||||
if (playing) {
|
||||
_pause(audioBloc);
|
||||
} else {
|
||||
_play(audioBloc);
|
||||
}
|
||||
},
|
||||
child: AnimatedIcon(
|
||||
semanticLabel: playing
|
||||
? L.of(context)!.pause_button_label
|
||||
: L.of(context)!.play_button_label,
|
||||
size: 48.0,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
color:
|
||||
Theme.of(context).iconTheme.color,
|
||||
progress: _playPauseController,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}),
|
||||
StreamBuilder<PositionState>(
|
||||
stream: audioBloc.playPosition,
|
||||
initialData: audioBloc.playPosition?.valueOrNull,
|
||||
builder: (context, snapshot) {
|
||||
var cw = 0.0;
|
||||
var position = snapshot.hasData
|
||||
? snapshot.data!.position
|
||||
: const Duration(seconds: 0);
|
||||
var length = snapshot.hasData
|
||||
? snapshot.data!.length
|
||||
: const Duration(seconds: 0);
|
||||
|
||||
if (length.inSeconds > 0) {
|
||||
final pc = length.inSeconds / position.inSeconds;
|
||||
cw = width / pc;
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: cw,
|
||||
height: 1.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
|
||||
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
|
||||
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
|
||||
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
|
||||
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
|
||||
/// little odd.
|
||||
void _audioStateListener() {
|
||||
if (mounted) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
var firstEvent = true;
|
||||
|
||||
_audioStateSubscription = audioBloc.playingState!.listen((event) {
|
||||
if (event == AudioState.playing || event == AudioState.buffering) {
|
||||
if (firstEvent) {
|
||||
_playPauseController.value = 1;
|
||||
firstEvent = false;
|
||||
} else {
|
||||
_playPauseController.forward();
|
||||
}
|
||||
} else {
|
||||
if (firstEvent) {
|
||||
_playPauseController.value = 0;
|
||||
firstEvent = false;
|
||||
} else {
|
||||
_playPauseController.reverse();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _play(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.play);
|
||||
}
|
||||
|
||||
void _pause(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.pause);
|
||||
}
|
||||
}
|
||||
654
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart
Normal file
654
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart
Normal file
@@ -0,0 +1,654 @@
|
||||
// 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/audio_bloc.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/ui/podcast/chapter_selector.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/dot_decoration.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/now_playing_floating_player.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/now_playing_options.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/playback_error_listener.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/player_position_controls.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/player_transport_controls.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/delayed_progress_indicator.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// This is the full-screen player Widget which is invoked by touching the mini player.
|
||||
///
|
||||
/// This is the parent widget of the now playing screen(s). If we are running on a mobile in
|
||||
/// portrait mode, we display the episode details, controls and additional options
|
||||
/// as a draggable view. For tablets in portrait or on desktop, we display a split
|
||||
/// screen. The main details and controls appear in one pane with the additional
|
||||
/// controls in another.
|
||||
///
|
||||
/// TODO: The fade in/out transition applied when scrolling the queue is the first implementation.
|
||||
/// Using [Opacity] is a very inefficient way of achieving this effect, but will do as a place
|
||||
/// holder until a better animation can be achieved.
|
||||
class NowPlaying extends StatefulWidget {
|
||||
const NowPlaying({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NowPlaying> createState() => _NowPlayingState();
|
||||
}
|
||||
|
||||
class _NowPlayingState extends State<NowPlaying> with WidgetsBindingObserver {
|
||||
late StreamSubscription<AudioState> playingStateSubscription;
|
||||
var textGroup = AutoSizeGroup();
|
||||
double scrollPos = 0.0;
|
||||
double opacity = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
var popped = false;
|
||||
|
||||
// If the episode finishes we can close.
|
||||
playingStateSubscription =
|
||||
audioBloc.playingState!.where((state) => state == AudioState.stopped).listen((playingState) async {
|
||||
// Prevent responding to multiple stop events after we've popped and lost context.
|
||||
if (!popped) {
|
||||
popped = true;
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
playingStateSubscription.cancel();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool isMobilePortrait(BuildContext context) {
|
||||
final query = MediaQuery.of(context);
|
||||
return (query.orientation == Orientation.portrait || query.size.width <= 1000);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
final playerBuilder = PlayerControlsBuilder.of(context);
|
||||
|
||||
return Semantics(
|
||||
header: false,
|
||||
label: L.of(context)!.semantics_main_player_header,
|
||||
explicitChildNodes: true,
|
||||
child: StreamBuilder<Episode?>(
|
||||
stream: audioBloc.nowPlaying,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
var duration = snapshot.data == null ? 0 : snapshot.data!.duration;
|
||||
final WidgetBuilder? transportBuilder = playerBuilder?.builder(duration);
|
||||
|
||||
return isMobilePortrait(context)
|
||||
? NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: (notification) {
|
||||
setState(() {
|
||||
if (notification.extent > (notification.minExtent)) {
|
||||
opacity = 1 - (notification.maxExtent - notification.extent);
|
||||
scrollPos = 1.0;
|
||||
} else {
|
||||
opacity = 0.0;
|
||||
scrollPos = 0.0;
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// We need to hide the main player when the floating player is visible to prevent
|
||||
// screen readers from reading both parts of the stack.
|
||||
Visibility(
|
||||
visible: opacity < 1,
|
||||
child: NowPlayingTabs(
|
||||
episode: snapshot.data!,
|
||||
transportBuilder: transportBuilder,
|
||||
),
|
||||
),
|
||||
SizedBox.expand(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
/// Sized boxes without a child are 'invisible' so they do not prevent taps below
|
||||
/// the stack but are still present in the layout. We have a sized box here to stop
|
||||
/// the draggable panel from jumping as you start to pull it up. I am really looking
|
||||
/// forward to the Dart team fixing the nested scroll issues with [DraggableScrollableSheet]
|
||||
SizedBox(
|
||||
height: 64.0,
|
||||
child: scrollPos == 1
|
||||
? Opacity(
|
||||
opacity: opacity,
|
||||
child: const FloatingPlayer(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (MediaQuery.of(context).orientation == Orientation.portrait)
|
||||
const Expanded(
|
||||
child: NowPlayingOptionsSelector(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: NowPlayingTabs(episode: snapshot.data!, transportBuilder: transportBuilder),
|
||||
),
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: NowPlayingOptionsSelectorWide(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This widget displays the episode logo, episode title and current
|
||||
/// chapter if available.
|
||||
///
|
||||
/// If running in portrait this will be in a vertical format; if in
|
||||
/// landscape this will be in a horizontal format. The actual displaying
|
||||
/// of the episode text is handed off to [NowPlayingEpisodeDetails].
|
||||
class NowPlayingEpisode extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final Episode episode;
|
||||
final AutoSizeGroup? textGroup;
|
||||
|
||||
const NowPlayingEpisode({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
required this.episode,
|
||||
required this.textGroup,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeholderBuilder = PlaceholderBuilder.of(context);
|
||||
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: MediaQuery.of(context).orientation == Orientation.portrait || MediaQuery.of(context).size.width >= 1000
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Semantics(
|
||||
label: L.of(context)!.semantic_podcast_artwork_label,
|
||||
child: PodcastImage(
|
||||
key: Key('nowplaying$imageUrl'),
|
||||
url: imageUrl!,
|
||||
width: MediaQuery.of(context).size.width * .75,
|
||||
height: MediaQuery.of(context).size.height * .75,
|
||||
fit: BoxFit.contain,
|
||||
borderRadius: 6.0,
|
||||
placeholder: placeholderBuilder != null
|
||||
? placeholderBuilder.builder()(context)
|
||||
: DelayedCircularProgressIndicator(),
|
||||
errorPlaceholder: placeholderBuilder != null
|
||||
? placeholderBuilder.errorBuilder()(context)
|
||||
: const Image(image: AssetImage('assets/images/favicon.png')),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: NowPlayingEpisodeDetails(
|
||||
episode: episode,
|
||||
textGroup: textGroup,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
bottom: 8.0,
|
||||
),
|
||||
child: PodcastImage(
|
||||
key: Key('nowplaying$imageUrl'),
|
||||
url: imageUrl!,
|
||||
height: 280,
|
||||
width: 280,
|
||||
fit: BoxFit.contain,
|
||||
borderRadius: 8.0,
|
||||
placeholder: placeholderBuilder != null
|
||||
? placeholderBuilder.builder()(context)
|
||||
: DelayedCircularProgressIndicator(),
|
||||
errorPlaceholder: placeholderBuilder != null
|
||||
? placeholderBuilder.errorBuilder()(context)
|
||||
: const Image(image: AssetImage('assets/images/favicon.png')),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: NowPlayingEpisodeDetails(
|
||||
episode: episode,
|
||||
textGroup: textGroup,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This widget is responsible for displaying the main episode details.
|
||||
///
|
||||
/// This displays the current episode title and, if available, the
|
||||
/// current chapter title and optional link.
|
||||
class NowPlayingEpisodeDetails extends StatelessWidget {
|
||||
final Episode? episode;
|
||||
final AutoSizeGroup? textGroup;
|
||||
static const minFontSize = 14.0;
|
||||
|
||||
const NowPlayingEpisodeDetails({
|
||||
super.key,
|
||||
this.episode,
|
||||
this.textGroup,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chapterTitle = episode?.currentChapter?.title ?? '';
|
||||
final chapterUrl = episode?.currentChapter?.url ?? '';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0),
|
||||
child: Semantics(
|
||||
container: true,
|
||||
child: AutoSizeText(
|
||||
episode?.title ?? '',
|
||||
group: textGroup,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
minFontSize: minFontSize,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24.0,
|
||||
),
|
||||
maxLines: episode!.hasChapters ? 3 : 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (episode!.hasChapters)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 0.0, 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Semantics(
|
||||
label: L.of(context)!.semantic_current_chapter_label,
|
||||
container: true,
|
||||
child: AutoSizeText(
|
||||
chapterTitle,
|
||||
group: textGroup,
|
||||
minFontSize: minFontSize,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[300],
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
chapterUrl.isEmpty
|
||||
? const SizedBox(
|
||||
height: 0,
|
||||
width: 0,
|
||||
)
|
||||
: Semantics(
|
||||
label: L.of(context)!.semantic_chapter_link_label,
|
||||
container: true,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Icon(
|
||||
Icons.link,
|
||||
),
|
||||
color: Theme.of(context).primaryIconTheme.color,
|
||||
onPressed: () {
|
||||
_chapterLink(chapterUrl);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _chapterLink(String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
throw 'Could not launch chapter link: $url';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This widget handles the displaying of the episode show notes.
|
||||
///
|
||||
/// This consists of title, show notes and person details
|
||||
/// (where available).
|
||||
class NowPlayingShowNotes extends StatelessWidget {
|
||||
final Episode? episode;
|
||||
|
||||
const NowPlayingShowNotes({
|
||||
super.key,
|
||||
required this.episode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
child: Text(
|
||||
episode!.title!,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (episode!.persons.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 120.0,
|
||||
child: ListView.builder(
|
||||
itemCount: episode!.persons.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return PersonAvatar(person: episode!.persons[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
left: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: PodcastHtml(content: episode?.content ?? episode?.description ?? ''),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget for rendering main episode tabs.
|
||||
///
|
||||
/// This will be episode details and show notes. If the episode supports chapters
|
||||
/// this will be included also. This is the parent widget. The tabs are
|
||||
/// rendered via [EpisodeTabBar] and the tab contents via. [EpisodeTabBarView].
|
||||
class NowPlayingTabs extends StatelessWidget {
|
||||
const NowPlayingTabs({
|
||||
super.key,
|
||||
required this.transportBuilder,
|
||||
required this.episode,
|
||||
});
|
||||
|
||||
final WidgetBuilder? transportBuilder;
|
||||
final Episode episode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: episode.hasChapters ? 3 : 2,
|
||||
initialIndex: episode.hasChapters ? 1 : 0,
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: Theme.of(context)
|
||||
.appBarTheme
|
||||
.systemOverlayStyle!
|
||||
.copyWith(systemNavigationBarColor: Theme.of(context).secondaryHeaderColor),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
elevation: 0.0,
|
||||
leading: IconButton(
|
||||
tooltip: L.of(context)!.minimise_player_window_button_label,
|
||||
icon: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Theme.of(context).primaryIconTheme.color,
|
||||
),
|
||||
onPressed: () => {
|
||||
Navigator.pop(context),
|
||||
},
|
||||
),
|
||||
flexibleSpace: PlaybackErrorListener(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
EpisodeTabBar(
|
||||
chapters: episode.hasChapters,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: EpisodeTabBarView(
|
||||
episode: episode,
|
||||
chapters: episode.hasChapters,
|
||||
),
|
||||
),
|
||||
transportBuilder != null
|
||||
? transportBuilder!(context)
|
||||
: const SizedBox(
|
||||
height: 148.0,
|
||||
child: NowPlayingTransport(),
|
||||
),
|
||||
if (MediaQuery.of(context).orientation == Orientation.portrait)
|
||||
const Expanded(
|
||||
flex: 1,
|
||||
child: NowPlayingOptionsScaffold(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// This class is responsible for rendering the tab selection at the top of the screen.
|
||||
///
|
||||
/// It displays two or three tabs depending upon whether the current episode supports
|
||||
/// (and contains) chapters.
|
||||
class EpisodeTabBar extends StatelessWidget {
|
||||
final bool chapters;
|
||||
|
||||
const EpisodeTabBar({
|
||||
super.key,
|
||||
this.chapters = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TabBar(
|
||||
isScrollable: true,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicator: DotDecoration(colour: Theme.of(context).primaryColor),
|
||||
tabs: [
|
||||
if (chapters)
|
||||
Tab(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(L.of(context)!.chapters_label),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(L.of(context)!.episode_label),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(L.of(context)!.notes_label),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This class is responsible for rendering the tab bodies.
|
||||
///
|
||||
/// This includes the chapter selection view (if the episode supports chapters),
|
||||
/// the episode details (image and description) and the show notes view.
|
||||
class EpisodeTabBarView extends StatelessWidget {
|
||||
final Episode? episode;
|
||||
final AutoSizeGroup? textGroup;
|
||||
final bool chapters;
|
||||
|
||||
const EpisodeTabBarView({
|
||||
super.key,
|
||||
this.episode,
|
||||
this.textGroup,
|
||||
this.chapters = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context);
|
||||
|
||||
return TabBarView(
|
||||
children: [
|
||||
if (chapters)
|
||||
ChapterSelector(
|
||||
episode: episode!,
|
||||
),
|
||||
StreamBuilder<Episode?>(
|
||||
stream: audioBloc.nowPlaying,
|
||||
builder: (context, snapshot) {
|
||||
final e = snapshot.hasData ? snapshot.data! : episode!;
|
||||
|
||||
return NowPlayingEpisode(
|
||||
episode: e,
|
||||
imageUrl: e.positionalImageUrl,
|
||||
textGroup: textGroup,
|
||||
);
|
||||
}),
|
||||
NowPlayingShowNotes(episode: episode),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the parent widget for the episode position and transport
|
||||
/// controls.
|
||||
class NowPlayingTransport extends StatelessWidget {
|
||||
const NowPlayingTransport({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
children: <Widget>[
|
||||
Divider(
|
||||
height: 0.0,
|
||||
),
|
||||
PlayerPositionControls(),
|
||||
PlayerTransportControls(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This widget allows users to inject their own transport controls
|
||||
/// into the app.
|
||||
///
|
||||
/// When rendering the controls, Pinepods will check if a PlayerControlsBuilder
|
||||
/// is in the tree. If so, it will use the builder rather than its own
|
||||
/// transport controls.
|
||||
class PlayerControlsBuilder extends InheritedWidget {
|
||||
final WidgetBuilder Function(int duration) builder;
|
||||
|
||||
const PlayerControlsBuilder({
|
||||
super.key,
|
||||
required this.builder,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static PlayerControlsBuilder? of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<PlayerControlsBuilder>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PlayerControlsBuilder oldWidget) {
|
||||
return builder != oldWidget.builder;
|
||||
}
|
||||
}
|
||||
@@ -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 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.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/ui/widgets/placeholder_builder.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// This widget is based upon [MiniPlayer] and provides an additional play/pause control when
|
||||
/// the episode queue is expanded.
|
||||
///
|
||||
/// At some point we should try to merge the common code between this and [MiniPlayer].
|
||||
class FloatingPlayer extends StatelessWidget {
|
||||
const FloatingPlayer({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
return StreamBuilder<AudioState>(
|
||||
stream: audioBloc.playingState,
|
||||
builder: (context, snapshot) {
|
||||
return (snapshot.hasData &&
|
||||
!(snapshot.data == AudioState.stopped ||
|
||||
snapshot.data == AudioState.none ||
|
||||
snapshot.data == AudioState.error))
|
||||
? _FloatingPlayerBuilder()
|
||||
: const SizedBox(
|
||||
height: 0.0,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _FloatingPlayerBuilder extends StatefulWidget {
|
||||
@override
|
||||
_FloatingPlayerBuilderState createState() => _FloatingPlayerBuilderState();
|
||||
}
|
||||
|
||||
class _FloatingPlayerBuilderState extends State<_FloatingPlayerBuilder> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _playPauseController;
|
||||
late StreamSubscription<AudioState> _audioStateSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
|
||||
_playPauseController.value = 1;
|
||||
|
||||
_audioStateListener();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_audioStateSubscription.cancel();
|
||||
_playPauseController.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
final placeholderBuilder = PlaceholderBuilder.of(context);
|
||||
|
||||
return Container(
|
||||
height: 64,
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: StreamBuilder<Episode?>(
|
||||
stream: audioBloc.nowPlaying,
|
||||
builder: (context, snapshot) {
|
||||
return StreamBuilder<AudioState>(
|
||||
stream: audioBloc.playingState,
|
||||
builder: (context, stateSnapshot) {
|
||||
var playing = stateSnapshot.data == AudioState.playing;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: snapshot.hasData
|
||||
? PodcastImage(
|
||||
key: Key('float${snapshot.data!.imageUrl}'),
|
||||
url: snapshot.data!.imageUrl!,
|
||||
width: 58.0,
|
||||
height: 58.0,
|
||||
borderRadius: 4.0,
|
||||
placeholder: placeholderBuilder != null
|
||||
? placeholderBuilder.builder()(context)
|
||||
: const Image(image: AssetImage('assets/images/favicon.png')),
|
||||
errorPlaceholder: placeholderBuilder != null
|
||||
? placeholderBuilder.errorBuilder()(context)
|
||||
: const Image(image: AssetImage('assets/images/favicon.png')),
|
||||
)
|
||||
: Container(),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
snapshot.data?.title ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(
|
||||
snapshot.data?.author ?? '',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
SizedBox(
|
||||
height: 52.0,
|
||||
width: 52.0,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0.0),
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)),
|
||||
),
|
||||
onPressed: () {
|
||||
if (playing) {
|
||||
audioBloc.transitionState(TransitionState.fastforward);
|
||||
}
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.forward_30,
|
||||
size: 36.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 52.0,
|
||||
width: 52.0,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0.0),
|
||||
shape: CircleBorder(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)),
|
||||
),
|
||||
onPressed: () {
|
||||
if (playing) {
|
||||
_pause(audioBloc);
|
||||
} else {
|
||||
_play(audioBloc);
|
||||
}
|
||||
},
|
||||
child: AnimatedIcon(
|
||||
semanticLabel:
|
||||
playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
|
||||
size: 48.0,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
progress: _playPauseController,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
|
||||
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
|
||||
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
|
||||
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
|
||||
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
|
||||
/// little odd.
|
||||
void _audioStateListener() {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
var firstEvent = true;
|
||||
|
||||
_audioStateSubscription = audioBloc.playingState!.listen((event) {
|
||||
if (event == AudioState.playing || event == AudioState.buffering) {
|
||||
if (firstEvent) {
|
||||
_playPauseController.value = 1;
|
||||
firstEvent = false;
|
||||
} else {
|
||||
_playPauseController.forward();
|
||||
}
|
||||
} else {
|
||||
if (firstEvent) {
|
||||
_playPauseController.value = 0;
|
||||
firstEvent = false;
|
||||
} else {
|
||||
_playPauseController.reverse();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _play(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.play);
|
||||
}
|
||||
|
||||
void _pause(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.pause);
|
||||
}
|
||||
}
|
||||
317
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart
Normal file
317
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart
Normal file
@@ -0,0 +1,317 @@
|
||||
// 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/queue_bloc.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/l10n/L.dart';
|
||||
import 'package:pinepods_mobile/state/queue_event_state.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/transcript_view.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/up_next_view.dart';
|
||||
import 'package:pinepods_mobile/ui/podcast/pinepods_up_next_view.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// This class gives us options that can be dragged up from the bottom of the main player
|
||||
/// window.
|
||||
///
|
||||
/// Currently these options are Up Next & Transcript.
|
||||
///
|
||||
/// This class is an initial version and should by much simpler than it is; however,
|
||||
/// a [NestedScrollView] is the widget we need to implement this UI, there is a current
|
||||
/// issue whereby the scroll view and [DraggableScrollableSheet] clash and therefore cannot
|
||||
/// be used together.
|
||||
///
|
||||
/// See issues [64157](https://github.com/flutter/flutter/issues/64157)
|
||||
/// [67219](https://github.com/flutter/flutter/issues/67219)
|
||||
///
|
||||
/// If anyone can come up with a more elegant solution (and one that does not throw
|
||||
/// an overflow error in debug) please raise and issue/submit a PR.
|
||||
///
|
||||
class NowPlayingOptionsSelector extends StatefulWidget {
|
||||
final double? scrollPos;
|
||||
static const baseSize = 68.0;
|
||||
|
||||
const NowPlayingOptionsSelector({super.key, this.scrollPos});
|
||||
|
||||
@override
|
||||
State<NowPlayingOptionsSelector> createState() => _NowPlayingOptionsSelectorState();
|
||||
}
|
||||
|
||||
class _NowPlayingOptionsSelectorState extends State<NowPlayingOptionsSelector> {
|
||||
DraggableScrollableController? draggableController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
|
||||
final theme = Theme.of(context);
|
||||
final windowHeight = MediaQuery.of(context).size.height;
|
||||
final minSize = NowPlayingOptionsSelector.baseSize / (windowHeight - NowPlayingOptionsSelector.baseSize);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: minSize,
|
||||
minChildSize: minSize,
|
||||
maxChildSize: 1.0,
|
||||
controller: draggableController,
|
||||
// Snap doesn't work as the sheet and scroll controller just don't get along
|
||||
// snap: true,
|
||||
// snapSizes: [minSize, maxSize],
|
||||
builder: (BuildContext context, ScrollController scrollController) {
|
||||
return StreamBuilder<QueueState>(
|
||||
initialData: QueueEmptyState(),
|
||||
stream: queueBloc.queue,
|
||||
builder: (context, queueSnapshot) {
|
||||
final hasTranscript = queueSnapshot.hasData &&
|
||||
queueSnapshot.data?.playing != null &&
|
||||
queueSnapshot.data!.playing!.hasTranscripts;
|
||||
|
||||
return DefaultTabController(
|
||||
animationDuration: !draggableController!.isAttached || draggableController!.size <= minSize
|
||||
? const Duration(seconds: 0)
|
||||
: kTabScrollDuration,
|
||||
length: hasTranscript ? 2 : 1,
|
||||
child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) {
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.expand(
|
||||
height: constraints.maxHeight,
|
||||
),
|
||||
child: Material(
|
||||
color: theme.secondaryHeaderColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).highlightColor,
|
||||
width: 0.0,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(18.0),
|
||||
topRight: Radius.circular(18.0),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
SliderHandle(
|
||||
label: optionsSliderOpen()
|
||||
? L.of(context)!.semantic_playing_options_collapse_label
|
||||
: L.of(context)!.semantic_playing_options_expand_label,
|
||||
onTap: () {
|
||||
if (draggableController != null) {
|
||||
if (draggableController!.size < 1.0) {
|
||||
draggableController!.animateTo(
|
||||
1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} else {
|
||||
draggableController!.animateTo(
|
||||
0.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.0),
|
||||
border: Border(
|
||||
bottom: draggableController != null &&
|
||||
(!draggableController!.isAttached || draggableController!.size <= minSize)
|
||||
? BorderSide.none
|
||||
: BorderSide(color: Colors.grey[800]!, width: 1.0),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
onTap: (index) {
|
||||
DefaultTabController.of(ctx).animateTo(index);
|
||||
|
||||
if (draggableController != null && draggableController!.size < 1.0) {
|
||||
draggableController!.animateTo(
|
||||
1.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
automaticIndicatorColorAdjustment: false,
|
||||
indicatorPadding: EdgeInsets.zero,
|
||||
|
||||
/// Little hack to hide the indicator when closed
|
||||
indicatorColor: draggableController != null &&
|
||||
(!draggableController!.isAttached || draggableController!.size <= minSize)
|
||||
? Theme.of(context).secondaryHeaderColor
|
||||
: null,
|
||||
tabs: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||
child: Text(
|
||||
L.of(context)!.up_next_queue_label.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
if (hasTranscript)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||
child: Text(
|
||||
L.of(context)!.transcript_label.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Padding(padding: EdgeInsets.only(bottom: 12.0)),
|
||||
Expanded(
|
||||
child: Consumer<SettingsBloc>(
|
||||
builder: (context, settingsBloc, child) {
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final isPinepodsConnected = settings.pinepodsServer != null &&
|
||||
settings.pinepodsApiKey != null &&
|
||||
settings.pinepodsUserId != null;
|
||||
|
||||
return TabBarView(
|
||||
children: [
|
||||
isPinepodsConnected
|
||||
? const PinepodsUpNextView()
|
||||
: const UpNextView(),
|
||||
if (hasTranscript)
|
||||
const TranscriptView(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool optionsSliderOpen() {
|
||||
return (draggableController != null && draggableController!.isAttached && draggableController!.size == 1.0);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
draggableController = DraggableScrollableController();
|
||||
super.initState();
|
||||
}
|
||||
}
|
||||
|
||||
class NowPlayingOptionsScaffold extends StatelessWidget {
|
||||
const NowPlayingOptionsScaffold({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
height: NowPlayingOptionsSelector.baseSize - 8.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// This implementation displays the additional options in a tab set outside of a
|
||||
/// draggable sheet.
|
||||
///
|
||||
/// Currently these options are Up Next & Transcript.
|
||||
class NowPlayingOptionsSelectorWide extends StatefulWidget {
|
||||
final double? scrollPos;
|
||||
static const baseSize = 68.0;
|
||||
|
||||
const NowPlayingOptionsSelectorWide({super.key, this.scrollPos});
|
||||
|
||||
@override
|
||||
State<NowPlayingOptionsSelectorWide> createState() => _NowPlayingOptionsSelectorWideState();
|
||||
}
|
||||
|
||||
class _NowPlayingOptionsSelectorWideState extends State<NowPlayingOptionsSelectorWide> {
|
||||
DraggableScrollableController? draggableController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
|
||||
final theme = Theme.of(context);
|
||||
final scrollController = ScrollController();
|
||||
|
||||
return StreamBuilder<QueueState>(
|
||||
initialData: QueueEmptyState(),
|
||||
stream: queueBloc.queue,
|
||||
builder: (context, queueSnapshot) {
|
||||
final hasTranscript = queueSnapshot.hasData &&
|
||||
queueSnapshot.data?.playing != null &&
|
||||
queueSnapshot.data!.playing!.hasTranscripts;
|
||||
|
||||
return DefaultTabController(
|
||||
length: hasTranscript ? 2 : 1,
|
||||
child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) {
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints.expand(
|
||||
height: constraints.maxHeight,
|
||||
),
|
||||
child: Material(
|
||||
color: theme.secondaryHeaderColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.0),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[800]!, width: 1.0),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
automaticIndicatorColorAdjustment: false,
|
||||
tabs: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
|
||||
child: Text(
|
||||
L.of(context)!.up_next_queue_label.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
if (hasTranscript)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
|
||||
child: Text(
|
||||
L.of(context)!.transcript_label.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
const UpNextView(),
|
||||
if (hasTranscript)
|
||||
const TranscriptView(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
82
PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart
Normal file
82
PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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.
|
||||
// ignore_for_file: must_be_immutable
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:pinepods_mobile/entities/person.dart';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// This Widget handles rendering of a person avatar.
|
||||
///
|
||||
/// The data comes from the <person> tag in the Podcasting 2.0 namespace.
|
||||
///
|
||||
/// https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person
|
||||
class PersonAvatar extends StatelessWidget {
|
||||
final Person person;
|
||||
String initials = '';
|
||||
String role = '';
|
||||
|
||||
PersonAvatar({
|
||||
super.key,
|
||||
required this.person,
|
||||
}) {
|
||||
if (person.name.isNotEmpty) {
|
||||
var parts = person.name.split(' ');
|
||||
|
||||
for (var i in parts) {
|
||||
if (i.isNotEmpty) {
|
||||
initials += i.substring(0, 1).toUpperCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (person.role.isNotEmpty) {
|
||||
role = person.role.substring(0, 1).toUpperCase() + person.role.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: person.link != null && person.link!.isNotEmpty
|
||||
? () {
|
||||
final uri = Uri.parse(person.link!);
|
||||
|
||||
unawaited(
|
||||
canLaunchUrl(uri).then((value) => launchUrl(uri)),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: SizedBox(
|
||||
width: 96,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 32,
|
||||
foregroundImage: ExtendedImage.network(
|
||||
person.image!,
|
||||
cache: true,
|
||||
).image,
|
||||
child: Text(initials),
|
||||
),
|
||||
Text(
|
||||
person.name,
|
||||
maxLines: 3,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(role),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
390
PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart
Normal file
390
PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
// lib/ui/podcast/pinepods_up_next_view.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
|
||||
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// PinePods version of the Up Next queue that shows the server queue.
|
||||
///
|
||||
/// This replaces the local queue functionality with server-based queue management.
|
||||
class PinepodsUpNextView extends StatefulWidget {
|
||||
const PinepodsUpNextView({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PinepodsUpNextView> createState() => _PinepodsUpNextViewState();
|
||||
}
|
||||
|
||||
class _PinepodsUpNextViewState extends State<PinepodsUpNextView> {
|
||||
final PinepodsService _pinepodsService = PinepodsService();
|
||||
List<PinepodsEpisode> _queuedEpisodes = [];
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
StreamSubscription? _episodeSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadQueue();
|
||||
_listenToEpisodeChanges();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_episodeSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Listen to episode changes to refresh queue when episodes advance
|
||||
void _listenToEpisodeChanges() {
|
||||
try {
|
||||
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
|
||||
final episodeStream = audioPlayerService.episodeEvent;
|
||||
|
||||
// Check if episodeEvent stream is available
|
||||
if (episodeStream == null) {
|
||||
print('Episode event stream not available');
|
||||
return;
|
||||
}
|
||||
|
||||
String? lastEpisodeGuid;
|
||||
|
||||
_episodeSubscription = episodeStream.listen((episode) {
|
||||
// Only refresh if the episode actually changed (avoid unnecessary refreshes)
|
||||
if (episode != null && episode.guid != lastEpisodeGuid && mounted) {
|
||||
lastEpisodeGuid = episode.guid;
|
||||
|
||||
// Add a small delay to ensure server queue has been updated
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
_loadQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Provider not available, continue without episode listening
|
||||
print('Could not set up episode change listener: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
|
||||
if (settings.pinepodsServer == null ||
|
||||
settings.pinepodsApiKey == null ||
|
||||
settings.pinepodsUserId == null) {
|
||||
setState(() {
|
||||
_errorMessage = 'Not connected to PinePods server';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_pinepodsService.setCredentials(
|
||||
settings.pinepodsServer!,
|
||||
settings.pinepodsApiKey!,
|
||||
);
|
||||
|
||||
final episodes = await _pinepodsService.getQueuedEpisodes(settings.pinepodsUserId!);
|
||||
|
||||
setState(() {
|
||||
_queuedEpisodes = episodes;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reorderQueue(int oldIndex, int newIndex) async {
|
||||
// Adjust indices if moving down the list
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
// Update local state immediately for smooth UI
|
||||
setState(() {
|
||||
final episode = _queuedEpisodes.removeAt(oldIndex);
|
||||
_queuedEpisodes.insert(newIndex, episode);
|
||||
});
|
||||
|
||||
// Get episode IDs in new order
|
||||
final episodeIds = _queuedEpisodes.map((e) => e.episodeId).toList();
|
||||
|
||||
// Call API to update order on server
|
||||
try {
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Not logged in')),
|
||||
);
|
||||
await _loadQueue(); // Reload to restore original order
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
|
||||
|
||||
if (!success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to update queue order')),
|
||||
);
|
||||
await _loadQueue(); // Reload to restore original order
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error updating queue: $e')),
|
||||
);
|
||||
await _loadQueue(); // Reload to restore original order
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeFromQueue(int index) async {
|
||||
final episode = _queuedEpisodes[index];
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Not logged in')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final success = await _pinepodsService.removeQueuedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
setState(() {
|
||||
_queuedEpisodes.removeAt(index);
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Removed from queue')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to remove from queue')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error removing from queue: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearQueue() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Queue'),
|
||||
content: const Text('Are you sure you want to clear the entire queue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
// Remove all episodes from queue
|
||||
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
|
||||
final settings = settingsBloc.currentSettings;
|
||||
final userId = settings.pinepodsUserId;
|
||||
|
||||
if (userId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Not logged in')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove each episode from the queue
|
||||
for (final episode in _queuedEpisodes) {
|
||||
await _pinepodsService.removeQueuedEpisode(
|
||||
episode.episodeId,
|
||||
userId,
|
||||
episode.isYoutube,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_queuedEpisodes.clear();
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Queue cleared')),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error clearing queue: $e')),
|
||||
);
|
||||
await _loadQueue(); // Reload to get current state
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Header with title and clear button
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0),
|
||||
child: Text(
|
||||
'Up Next',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
|
||||
child: TextButton(
|
||||
onPressed: _queuedEpisodes.isEmpty ? null : _clearQueue,
|
||||
child: Text(
|
||||
'Clear',
|
||||
style: Theme.of(context).textTheme.titleSmall!.copyWith(
|
||||
fontSize: 12.0,
|
||||
color: _queuedEpisodes.isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Content area
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Error loading queue',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _loadQueue,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (_queuedEpisodes.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'Your queue is empty. Add episodes to see them here.',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _queuedEpisodes.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final episode = _queuedEpisodes[index];
|
||||
return Dismissible(
|
||||
key: ValueKey('queue_${episode.episodeId}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
onDismissed: (direction) {
|
||||
_removeFromQueue(index);
|
||||
},
|
||||
background: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
key: ValueKey('episode_${episode.episodeId}'),
|
||||
margin: const EdgeInsets.only(bottom: 4),
|
||||
child: DraggableQueueEpisodeCard(
|
||||
episode: episode,
|
||||
index: index,
|
||||
onTap: () {
|
||||
// Could navigate to episode details if needed
|
||||
},
|
||||
onPlayPressed: () {
|
||||
// Could implement play functionality if needed
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onReorder: _reorderQueue,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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/audio_bloc.dart';
|
||||
import 'package:pinepods_mobile/l10n/L.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Listens for errors on the audio BLoC.
|
||||
///
|
||||
/// We receive a code which we then map to an error message. This needs to be placed
|
||||
/// below a [Scaffold].
|
||||
class PlaybackErrorListener extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const PlaybackErrorListener({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaybackErrorListener> createState() => _PlaybackErrorListenerState();
|
||||
}
|
||||
|
||||
class _PlaybackErrorListenerState extends State<PlaybackErrorListener> {
|
||||
StreamSubscription<int>? errorSubscription;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
errorSubscription = audioBloc.playbackError!.listen((code) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_codeToMessage(context, code))));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
errorSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Ideally the BLoC would pass us the message to display; however, as we need a
|
||||
/// context to fetch the correct version of any text string we need to work it out here.
|
||||
String _codeToMessage(BuildContext context, int code) {
|
||||
var result = '';
|
||||
|
||||
switch (code) {
|
||||
case 401:
|
||||
result = L.of(context)!.error_no_connection;
|
||||
break;
|
||||
case 501:
|
||||
result = L.of(context)!.error_playback_fail;
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// 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/l10n/L.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// This class handles the rendering of the positional controls: the current playback
|
||||
/// time, time remaining and the time [Slider].
|
||||
class PlayerPositionControls extends StatefulWidget {
|
||||
const PlayerPositionControls({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerPositionControls> createState() => _PlayerPositionControlsState();
|
||||
}
|
||||
|
||||
class _PlayerPositionControlsState extends State<PlayerPositionControls> {
|
||||
/// Current playback position
|
||||
var currentPosition = 0;
|
||||
|
||||
/// Indicates the user is moving the position slide. We should ignore
|
||||
/// position updates until the user releases the slide.
|
||||
var dragging = false;
|
||||
|
||||
/// Seconds left of this episode.
|
||||
var timeRemaining = 0;
|
||||
|
||||
/// The length of the episode in seconds.
|
||||
var episodeLength = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context);
|
||||
final screenReader = MediaQuery.of(context).accessibleNavigation;
|
||||
|
||||
return StreamBuilder<PositionState>(
|
||||
stream: audioBloc.playPosition,
|
||||
builder: (context, snapshot) {
|
||||
var position = snapshot.hasData ? snapshot.data!.position.inSeconds : 0;
|
||||
episodeLength = snapshot.hasData ? snapshot.data!.length.inSeconds : 0;
|
||||
var divisions = episodeLength == 0 ? 1 : episodeLength;
|
||||
|
||||
// If a screen reader is enabled, will make divisions ten seconds each.
|
||||
if (screenReader) {
|
||||
divisions = episodeLength ~/ 10;
|
||||
}
|
||||
|
||||
if (!dragging) {
|
||||
currentPosition = position;
|
||||
|
||||
if (currentPosition < 0) {
|
||||
currentPosition = 0;
|
||||
}
|
||||
|
||||
if (currentPosition > episodeLength) {
|
||||
currentPosition = episodeLength;
|
||||
}
|
||||
|
||||
timeRemaining = episodeLength - position;
|
||||
|
||||
if (timeRemaining < 0) {
|
||||
timeRemaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
top: 0.0,
|
||||
bottom: 4.0,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
FittedBox(
|
||||
child: Text(
|
||||
_formatDuration(Duration(seconds: currentPosition)),
|
||||
semanticsLabel:
|
||||
'${L.of(context)!.now_playing_episode_position} ${_formatDuration(Duration(seconds: currentPosition))}',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: snapshot.hasData
|
||||
? Slider(
|
||||
label: _formatDuration(Duration(seconds: currentPosition)),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_calculatePositions(value.toInt());
|
||||
|
||||
// Normally, we only want to trigger a position change when the user has finished
|
||||
// sliding; however, with a screen reader enabled that will never trigger. Instead,
|
||||
// we'll use the 'normal' change event.
|
||||
if (screenReader) {
|
||||
return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value);
|
||||
}
|
||||
});
|
||||
},
|
||||
onChangeStart: (value) {
|
||||
if (!snapshot.data!.buffering) {
|
||||
setState(() {
|
||||
dragging = true;
|
||||
_calculatePositions(currentPosition);
|
||||
});
|
||||
}
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
setState(() {
|
||||
dragging = false;
|
||||
});
|
||||
|
||||
return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value);
|
||||
},
|
||||
value: currentPosition.toDouble(),
|
||||
min: 0.0,
|
||||
max: episodeLength.toDouble(),
|
||||
divisions: divisions,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
semanticFormatterCallback: (double newValue) {
|
||||
return _formatDuration(Duration(seconds: currentPosition));
|
||||
})
|
||||
: Slider(
|
||||
onChanged: null,
|
||||
value: 0,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
FittedBox(
|
||||
child: Text(
|
||||
_formatDuration(Duration(seconds: timeRemaining)),
|
||||
textAlign: TextAlign.right,
|
||||
semanticsLabel:
|
||||
'${L.of(context)!.now_playing_episode_time_remaining} ${_formatDuration(Duration(seconds: timeRemaining))}',
|
||||
style: const TextStyle(
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _calculatePositions(int p) {
|
||||
currentPosition = p;
|
||||
timeRemaining = episodeLength - p;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// 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/audio_bloc.dart';
|
||||
import 'package:pinepods_mobile/l10n/L.dart';
|
||||
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/sleep_selector.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/speed_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_spinkit/flutter_spinkit.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Builds a transport control bar for rewind, play and fast-forward.
|
||||
/// See [NowPlaying].
|
||||
class PlayerTransportControls extends StatefulWidget {
|
||||
const PlayerTransportControls({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlayerTransportControls> createState() => _PlayerTransportControlsState();
|
||||
}
|
||||
|
||||
class _PlayerTransportControlsState extends State<PlayerTransportControls> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: StreamBuilder<AudioState>(
|
||||
stream: audioBloc.playingState,
|
||||
initialData: AudioState.none,
|
||||
builder: (context, snapshot) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: <Widget>[
|
||||
const SleepSelectorWidget(),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
return snapshot.data == AudioState.buffering ? null : _rewind(audioBloc);
|
||||
},
|
||||
tooltip: L.of(context)!.rewind_button_label,
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
icon: const Icon(
|
||||
Icons.replay_10,
|
||||
size: 48.0,
|
||||
),
|
||||
),
|
||||
AnimatedPlayButton(audioState: snapshot.data!),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
return snapshot.data == AudioState.buffering ? null : _fastforward(audioBloc);
|
||||
},
|
||||
tooltip: L.of(context)!.fast_forward_button_label,
|
||||
padding: const EdgeInsets.all(0.0),
|
||||
icon: const Icon(
|
||||
Icons.forward_30,
|
||||
size: 48.0,
|
||||
),
|
||||
),
|
||||
const SpeedSelectorWidget(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _rewind(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.rewind);
|
||||
}
|
||||
|
||||
void _fastforward(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.fastforward);
|
||||
}
|
||||
}
|
||||
|
||||
typedef PlayHandler = Function(AudioBloc audioBloc);
|
||||
|
||||
class AnimatedPlayButton extends StatefulWidget {
|
||||
final AudioState audioState;
|
||||
final PlayHandler onPlay;
|
||||
final PlayHandler onPause;
|
||||
|
||||
const AnimatedPlayButton({
|
||||
super.key,
|
||||
required this.audioState,
|
||||
this.onPlay = _onPlay,
|
||||
this.onPause = _onPause,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnimatedPlayButton> createState() => _AnimatedPlayButtonState();
|
||||
}
|
||||
|
||||
void _onPlay(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.play);
|
||||
}
|
||||
|
||||
void _onPause(AudioBloc audioBloc) {
|
||||
audioBloc.transitionState(TransitionState.pause);
|
||||
}
|
||||
|
||||
class _AnimatedPlayButtonState extends State<AnimatedPlayButton> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _playPauseController;
|
||||
late StreamSubscription<AudioState> _audioStateSubscription;
|
||||
bool init = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
_playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
|
||||
|
||||
/// Seems a little hacky, but when we load the form we want the play/pause
|
||||
/// button to be in the correct state. If we are building the first frame,
|
||||
/// just set the animation controller to the correct state; for all other
|
||||
/// frames we want to animate. Doing it this way prevents the play/pause
|
||||
/// button from animating when the form is first loaded.
|
||||
_audioStateSubscription = audioBloc.playingState!.listen((event) {
|
||||
if (event == AudioState.playing || event == AudioState.buffering) {
|
||||
if (init) {
|
||||
_playPauseController.value = 1;
|
||||
init = false;
|
||||
} else {
|
||||
_playPauseController.forward();
|
||||
}
|
||||
} else {
|
||||
if (init) {
|
||||
_playPauseController.value = 0;
|
||||
init = false;
|
||||
} else {
|
||||
_playPauseController.reverse();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playPauseController.dispose();
|
||||
_audioStateSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
final playing = widget.audioState == AudioState.playing;
|
||||
final buffering = widget.audioState == AudioState.buffering;
|
||||
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
if (buffering)
|
||||
SpinKitRing(
|
||||
lineWidth: 4.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 84,
|
||||
),
|
||||
if (!buffering)
|
||||
const SizedBox(
|
||||
height: 84,
|
||||
width: 84,
|
||||
),
|
||||
Tooltip(
|
||||
message: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
shape: CircleBorder(side: BorderSide(color: Theme.of(context).highlightColor, width: 0.0)),
|
||||
backgroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800],
|
||||
foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800],
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
),
|
||||
onPressed: () {
|
||||
if (playing) {
|
||||
widget.onPause(audioBloc);
|
||||
} else {
|
||||
widget.onPlay(audioBloc);
|
||||
}
|
||||
},
|
||||
child: AnimatedIcon(
|
||||
size: 60.0,
|
||||
semanticLabel: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
color: Colors.white,
|
||||
progress: _playPauseController,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
206
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart
Normal file
206
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
// 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/feed.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:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// This class is responsible for rendering the context menu on the podcast details
|
||||
/// page.
|
||||
///
|
||||
/// It returns either a [_MaterialPodcastMenu] or a [_CupertinoContextMenu}
|
||||
/// instance depending upon which platform we are running on.
|
||||
///
|
||||
/// The target platform is based on the current [Theme]: [ThemeData.platform].
|
||||
class PodcastContextMenu extends StatelessWidget {
|
||||
final Podcast podcast;
|
||||
|
||||
const PodcastContextMenu(
|
||||
this.podcast, {
|
||||
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 _MaterialPodcastMenu(podcast);
|
||||
case TargetPlatform.iOS:
|
||||
case TargetPlatform.macOS:
|
||||
return _CupertinoContextMenu(podcast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the material design version of the context menu. This will be rendered
|
||||
/// for all platforms that are not iOS.
|
||||
class _MaterialPodcastMenu extends StatelessWidget {
|
||||
final Podcast podcast;
|
||||
|
||||
const _MaterialPodcastMenu(this.podcast);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = Provider.of<PodcastBloc>(context);
|
||||
|
||||
return StreamBuilder<BlocState<Podcast>>(
|
||||
stream: bloc.details,
|
||||
builder: (context, snapshot) {
|
||||
return PopupMenuButton<String>(
|
||||
onSelected: (event) {
|
||||
togglePlayed(value: event, bloc: bloc);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
),
|
||||
itemBuilder: (BuildContext context) {
|
||||
return <PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'ma',
|
||||
enabled: podcast.subscribed,
|
||||
child: Text(L.of(context)!.mark_episodes_played_label),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'ua',
|
||||
enabled: podcast.subscribed,
|
||||
child: Text(L.of(context)!.mark_episodes_not_played_label),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'refresh',
|
||||
enabled: podcast.link?.isNotEmpty ?? false,
|
||||
child: Text(L.of(context)!.refresh_feed_label),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'web',
|
||||
enabled: podcast.link?.isNotEmpty ?? false,
|
||||
child: Text(L.of(context)!.open_show_website_label),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void togglePlayed({
|
||||
required String value,
|
||||
required PodcastBloc bloc,
|
||||
}) async {
|
||||
if (value == 'ma') {
|
||||
bloc.podcastEvent(PodcastEvent.markAllPlayed);
|
||||
} else if (value == 'ua') {
|
||||
bloc.podcastEvent(PodcastEvent.clearAllPlayed);
|
||||
} else if (value == 'refresh') {
|
||||
bloc.load(Feed(
|
||||
podcast: podcast,
|
||||
refresh: true,
|
||||
));
|
||||
} else if (value == 'web') {
|
||||
final uri = Uri.parse(podcast.link!);
|
||||
|
||||
if (!await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
throw Exception('Could not launch $uri');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the Cupertino context menu and is rendered only when running on
|
||||
/// an iOS device.
|
||||
class _CupertinoContextMenu extends StatelessWidget {
|
||||
final Podcast podcast;
|
||||
|
||||
const _CupertinoContextMenu(this.podcast);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = Provider.of<PodcastBloc>(context);
|
||||
|
||||
return StreamBuilder<BlocState<Podcast>>(
|
||||
stream: bloc.details,
|
||||
builder: (context, snapshot) {
|
||||
return IconButton(
|
||||
tooltip: L.of(context)!.podcast_options_overflow_menu_semantic_label,
|
||||
icon: const Icon(CupertinoIcons.ellipsis),
|
||||
onPressed: () => showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return CupertinoActionSheet(
|
||||
actions: <Widget>[
|
||||
CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () {
|
||||
bloc.podcastEvent(PodcastEvent.markAllPlayed);
|
||||
Navigator.pop(context, 'Cancel');
|
||||
},
|
||||
child: Text(L.of(context)!.mark_episodes_played_label),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () {
|
||||
bloc.podcastEvent(PodcastEvent.clearAllPlayed);
|
||||
Navigator.pop(context, 'Cancel');
|
||||
},
|
||||
child: Text(L.of(context)!.mark_episodes_not_played_label),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () {
|
||||
bloc.load(Feed(
|
||||
podcast: podcast,
|
||||
refresh: true,
|
||||
));
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, 'Cancel');
|
||||
}
|
||||
},
|
||||
child: Text(L.of(context)!.refresh_feed_label),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () async {
|
||||
final uri = Uri.parse(podcast.link!);
|
||||
|
||||
if (!await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
throw Exception('Could not launch $uri');
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context, 'Cancel');
|
||||
}
|
||||
},
|
||||
child: Text(L.of(context)!.open_show_website_label),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () {
|
||||
Navigator.pop(context, 'Cancel');
|
||||
},
|
||||
child: Text(L.of(context)!.cancel_option_label),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
1000
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart
Normal file
1000
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
// 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/queue_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/episode.dart';
|
||||
import 'package:pinepods_mobile/state/queue_event_state.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class PodcastEpisodeList extends StatelessWidget {
|
||||
final List<Episode?>? episodes;
|
||||
final IconData icon;
|
||||
final String emptyMessage;
|
||||
final bool play;
|
||||
final bool download;
|
||||
|
||||
static const _defaultIcon = Icons.add_alert;
|
||||
|
||||
const PodcastEpisodeList({
|
||||
super.key,
|
||||
required this.episodes,
|
||||
required this.play,
|
||||
required this.download,
|
||||
this.icon = _defaultIcon,
|
||||
this.emptyMessage = '',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (episodes != null && episodes!.isNotEmpty) {
|
||||
var queueBloc = Provider.of<QueueBloc>(context);
|
||||
|
||||
return StreamBuilder<QueueState>(
|
||||
stream: queueBloc.queue,
|
||||
builder: (context, snapshot) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
var queued = false;
|
||||
var playing = false;
|
||||
var episode = episodes![index]!;
|
||||
|
||||
if (snapshot.hasData) {
|
||||
var playingGuid = snapshot.data!.playing?.guid;
|
||||
|
||||
queued = snapshot.data!.queue.any((element) => element.guid == episode.guid);
|
||||
|
||||
playing = playingGuid == episode.guid;
|
||||
}
|
||||
|
||||
return EpisodeTile(
|
||||
episode: episode,
|
||||
download: download,
|
||||
play: play,
|
||||
playing: playing,
|
||||
queued: queued,
|
||||
);
|
||||
},
|
||||
childCount: episodes!.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
));
|
||||
});
|
||||
} else {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
icon,
|
||||
size: 75,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
Text(
|
||||
emptyMessage,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart
Normal file
54
PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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/episode.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
|
||||
/// This class displays the show notes for the selected podcast.
|
||||
///
|
||||
/// We make use of [Html] to render the notes and, if in HTML format, display the
|
||||
/// correct formatting, links etc.
|
||||
class ShowNotes extends StatelessWidget {
|
||||
final ScrollController _sliverScrollController = ScrollController();
|
||||
final Episode episode;
|
||||
|
||||
ShowNotes({
|
||||
super.key,
|
||||
required this.episode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: CustomScrollView(controller: _sliverScrollController, slivers: <Widget>[
|
||||
SliverAppBar(
|
||||
title: Text(episode.podcast!),
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0),
|
||||
child: Text(episode.title ?? '', style: textTheme.titleLarge),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 0.0),
|
||||
child: PodcastHtml(content: episode.content ?? episode.description!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
532
PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart
Normal file
532
PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart
Normal file
@@ -0,0 +1,532 @@
|
||||
// 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/audio_bloc.dart';
|
||||
import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/person.dart';
|
||||
import 'package:pinepods_mobile/entities/transcript.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/state/transcript_state_event.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
|
||||
import 'package:extended_image/extended_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// This class handles the rendering of the podcast transcript (where available).
|
||||
// ignore: must_be_immutable
|
||||
class TranscriptView extends StatefulWidget {
|
||||
const TranscriptView({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TranscriptView> createState() => _TranscriptViewState();
|
||||
}
|
||||
|
||||
class _TranscriptViewState extends State<TranscriptView> {
|
||||
final log = Logger('TranscriptView');
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ScrollOffsetListener _scrollOffsetListener = ScrollOffsetListener.create(recordProgrammaticScrolls: false);
|
||||
final _transcriptSearchController = TextEditingController();
|
||||
late StreamSubscription<PositionState> _positionSubscription;
|
||||
int position = 0;
|
||||
bool autoScroll = true;
|
||||
bool autoScrollEnabled = true;
|
||||
bool forceTranscriptUpdate = false;
|
||||
bool first = true;
|
||||
bool scrolling = false;
|
||||
bool isHtmlTranscript = false;
|
||||
String speaker = '';
|
||||
RegExp exp = RegExp(r'(^)(\[?)(?<speaker>[A-Za-z0-9\s]+)(\]?)(\s?)(:)');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
|
||||
Subtitle? subtitle;
|
||||
int index = 0;
|
||||
// If the user initiates scrolling, disable auto scroll.
|
||||
_scrollOffsetListener.changes.listen((event) {
|
||||
if (!scrolling) {
|
||||
setState(() {
|
||||
autoScroll = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to playback position updates and scroll to the correct items in the transcript
|
||||
// if we have auto scroll enabled.
|
||||
_positionSubscription = audioBloc.playPosition!.listen((event) {
|
||||
if (_itemScrollController.isAttached && !isHtmlTranscript) {
|
||||
var transcript = event.episode?.transcript;
|
||||
|
||||
if (transcript != null && transcript.subtitles.isNotEmpty) {
|
||||
subtitle ??= transcript.subtitles[index];
|
||||
|
||||
if (index == 0) {
|
||||
var match = exp.firstMatch(subtitle?.data ?? '');
|
||||
|
||||
if (match != null) {
|
||||
setState(() {
|
||||
speaker = match.namedGroup('speaker') ?? '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Our we outside the range of our current transcript.
|
||||
if (event.position.inMilliseconds < subtitle!.start.inMilliseconds ||
|
||||
event.position.inMilliseconds > subtitle!.end!.inMilliseconds ||
|
||||
forceTranscriptUpdate) {
|
||||
forceTranscriptUpdate = false;
|
||||
// Will the next in the list do?
|
||||
if (transcript.subtitles.length > (index + 1) &&
|
||||
event.position.inMilliseconds >= transcript.subtitles[index + 1].start.inMilliseconds &&
|
||||
event.position.inMilliseconds < transcript.subtitles[index + 1].end!.inMilliseconds) {
|
||||
index++;
|
||||
subtitle = transcript.subtitles[index];
|
||||
|
||||
if (subtitle != null && subtitle!.speaker.isNotEmpty) {
|
||||
speaker = subtitle!.speaker;
|
||||
} else {
|
||||
var match = exp.firstMatch(transcript.subtitles[index].data ?? '');
|
||||
|
||||
if (match != null) {
|
||||
speaker = match.namedGroup('speaker') ?? '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
subtitle = transcript.subtitles
|
||||
.where((a) => (event.position.inMilliseconds >= a.start.inMilliseconds &&
|
||||
event.position.inMilliseconds < a.end!.inMilliseconds))
|
||||
.first;
|
||||
|
||||
index = transcript.subtitles.indexOf(subtitle!);
|
||||
|
||||
/// If we have had to jump more than one position within the transcript, we may
|
||||
/// need to back scan the conversation to find the current speaker.
|
||||
if (subtitle!.speaker.isNotEmpty) {
|
||||
speaker = subtitle!.speaker;
|
||||
} else {
|
||||
/// Scan backwards a maximum of 50 lines to see if we can find a speaker
|
||||
var speakFound = false;
|
||||
var count = 50;
|
||||
var countIndex = index;
|
||||
|
||||
while (!speakFound && count-- > 0 && countIndex >= 0) {
|
||||
var match = exp.firstMatch(transcript.subtitles[countIndex].data!);
|
||||
|
||||
countIndex--;
|
||||
|
||||
if (match != null) {
|
||||
speaker = match.namedGroup('speaker') ?? '';
|
||||
|
||||
if (speaker.isNotEmpty) {
|
||||
setState(() {
|
||||
speakFound = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// We don't have a transcript entry for this position.
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitle != null) {
|
||||
setState(() {
|
||||
position = subtitle!.start.inMilliseconds;
|
||||
});
|
||||
}
|
||||
|
||||
if (autoScroll) {
|
||||
if (first) {
|
||||
_itemScrollController.jumpTo(index: index);
|
||||
first = false;
|
||||
} else {
|
||||
scrolling = true;
|
||||
_itemScrollController.scrollTo(index: index, duration: const Duration(milliseconds: 50)).then((value) {
|
||||
scrolling = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_positionSubscription.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
|
||||
|
||||
return StreamBuilder<QueueState>(
|
||||
initialData: QueueEmptyState(),
|
||||
stream: queueBloc.queue,
|
||||
builder: (context, queueSnapshot) {
|
||||
return StreamBuilder<TranscriptState>(
|
||||
stream: audioBloc.nowPlayingTranscript,
|
||||
builder: (context, transcriptSnapshot) {
|
||||
if (transcriptSnapshot.hasData) {
|
||||
if (transcriptSnapshot.data is TranscriptLoadingState) {
|
||||
return const Align(
|
||||
alignment: Alignment.center,
|
||||
child: PlatformProgressIndicator(),
|
||||
);
|
||||
} else if (transcriptSnapshot.data is TranscriptUnavailableState ||
|
||||
!transcriptSnapshot.data!.transcript!.transcriptAvailable) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Transcript Error',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load transcript. The episode has transcript support but there was an error retrieving or parsing the transcript data.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final items = transcriptSnapshot.data!.transcript?.subtitles ?? <Subtitle>[];
|
||||
|
||||
// Detect if this is an HTML transcript (single item with HTMLFULL marker)
|
||||
final isLikelyHtmlTranscript = items.length == 1 &&
|
||||
items.first.data != null &&
|
||||
items.first.data!.startsWith('{{HTMLFULL}}');
|
||||
|
||||
// Update the state flag for HTML transcript detection
|
||||
if (isLikelyHtmlTranscript != isHtmlTranscript) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
isHtmlTranscript = isLikelyHtmlTranscript;
|
||||
if (isHtmlTranscript) {
|
||||
autoScroll = false;
|
||||
autoScrollEnabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0, left: 16.0, right: 16.0),
|
||||
child: TextField(
|
||||
controller: _transcriptSearchController,
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.all(0.0),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
_transcriptSearchController.clear();
|
||||
audioBloc.filterTranscript(TranscriptClearEvent());
|
||||
setState(() {
|
||||
autoScrollEnabled = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
||||
borderSide: BorderSide.none,
|
||||
gapPadding: 0.0,
|
||||
),
|
||||
hintText: L.of(context)!.search_transcript_label,
|
||||
),
|
||||
onSubmitted: ((search) {
|
||||
if (search.isNotEmpty) {
|
||||
setState(() {
|
||||
autoScrollEnabled = false;
|
||||
autoScroll = false;
|
||||
});
|
||||
audioBloc.filterTranscript(TranscriptFilterEvent(search: search));
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
if (!isHtmlTranscript)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(L.of(context)!.auto_scroll_transcript_label),
|
||||
Switch(
|
||||
value: autoScroll,
|
||||
onChanged: autoScrollEnabled
|
||||
? (bool enableAutoScroll) {
|
||||
setState(() {
|
||||
autoScroll = enableAutoScroll;
|
||||
|
||||
if (enableAutoScroll) {
|
||||
forceTranscriptUpdate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isHtmlTranscript &&
|
||||
queueSnapshot.hasData &&
|
||||
queueSnapshot.data?.playing != null &&
|
||||
queueSnapshot.data!.playing!.persons.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
width: double.infinity,
|
||||
height: 72.0,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: queueSnapshot.data!.playing!.persons.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
var person = queueSnapshot.data!.playing!.persons[index];
|
||||
var selected = false;
|
||||
|
||||
// Some speakers are - delimited so won't match
|
||||
speaker = speaker.replaceAll('-', ' ');
|
||||
|
||||
if (speaker.isNotEmpty &&
|
||||
person.name.toLowerCase().startsWith(speaker.toLowerCase())) {
|
||||
selected = true;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.orange : Colors.transparent, shape: BoxShape.circle),
|
||||
child: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundImage: ExtendedImage.network(
|
||||
person.image!,
|
||||
cache: true,
|
||||
).image,
|
||||
child: const Text(''),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Expanded(
|
||||
/// A simple way to ensure the builder is visible before attempting to use it.
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return constraints.minHeight > 60.0
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemScrollController: _itemScrollController,
|
||||
scrollOffsetListener: _scrollOffsetListener,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
var i = items[index];
|
||||
return Wrap(
|
||||
children: [
|
||||
SubtitleWidget(
|
||||
subtitle: i,
|
||||
persons: queueSnapshot.data?.playing?.persons ?? <Person>[],
|
||||
highlight: i.start.inMilliseconds == position,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
)
|
||||
: Container();
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Each transcript is made up of one or more subtitles. Each [Subtitle] represents one
|
||||
/// line of the transcript. This widget handles rendering the passed line.
|
||||
class SubtitleWidget extends StatelessWidget {
|
||||
final Subtitle subtitle;
|
||||
final List<Person>? persons;
|
||||
final bool highlight;
|
||||
static const margin = Duration(milliseconds: 1000);
|
||||
|
||||
const SubtitleWidget({
|
||||
super.key,
|
||||
required this.subtitle,
|
||||
this.persons,
|
||||
this.highlight = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
final data = subtitle.data ?? '';
|
||||
final isFullHtmlTranscript = data.startsWith('{{HTMLFULL}}');
|
||||
|
||||
// For full HTML transcripts, render as a simple container without timing or clickability
|
||||
if (isFullHtmlTranscript) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildSubtitleContent(context),
|
||||
);
|
||||
}
|
||||
|
||||
// For timed transcripts (JSON, SRT, chunked HTML), render with timing and clickability
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
final p = subtitle.start + margin;
|
||||
audioBloc.transitionPosition(p.inSeconds.toDouble());
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0),
|
||||
color: highlight ? Theme.of(context).cardTheme.color : Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subtitle.speaker.isEmpty
|
||||
? _formatDuration(subtitle.start)
|
||||
: '${_formatDuration(subtitle.start)} - ${subtitle.speaker}',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
_buildSubtitleContent(context),
|
||||
const Padding(padding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 16.0))
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubtitleContent(BuildContext context) {
|
||||
final data = subtitle.data ?? '';
|
||||
|
||||
// Check if this is full HTML content (single document)
|
||||
if (data.startsWith('{{HTMLFULL}}')) {
|
||||
final htmlContent = data.substring(12); // Remove '{{HTMLFULL}}' marker
|
||||
|
||||
return Html(
|
||||
data: htmlContent,
|
||||
style: {
|
||||
'body': Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(Theme.of(context).textTheme.bodyMedium?.fontSize ?? 14),
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily,
|
||||
lineHeight: const LineHeight(1.5),
|
||||
),
|
||||
'a': Style(
|
||||
color: Theme.of(context).primaryColor,
|
||||
textDecoration: TextDecoration.underline,
|
||||
),
|
||||
'p': Style(
|
||||
margin: Margins.only(bottom: 12),
|
||||
padding: HtmlPaddings.zero,
|
||||
),
|
||||
'h1, h2, h3, h4, h5, h6': Style(
|
||||
margin: Margins.only(top: 16, bottom: 8),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
'strong, b': Style(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
'em, i': Style(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
launchUrl(uri);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
// Check if this is chunked HTML content (legacy)
|
||||
else if (data.startsWith('{{HTML}}')) {
|
||||
final htmlContent = data.substring(8); // Remove '{{HTML}}' marker
|
||||
|
||||
return Html(
|
||||
data: htmlContent,
|
||||
style: {
|
||||
'body': Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16),
|
||||
color: Theme.of(context).textTheme.titleMedium?.color,
|
||||
fontFamily: Theme.of(context).textTheme.titleMedium?.fontFamily,
|
||||
),
|
||||
'a': Style(
|
||||
color: Theme.of(context).primaryColor,
|
||||
textDecoration: TextDecoration.underline,
|
||||
),
|
||||
'p': Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
launchUrl(uri);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Render as plain text for non-HTML content
|
||||
return Text(
|
||||
data,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final hh = (duration.inHours).toString().padLeft(2, '0');
|
||||
final mm = (duration.inMinutes % 60).toString().padLeft(2, '0');
|
||||
final ss = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||||
|
||||
return '$hh:$mm:$ss';
|
||||
}
|
||||
}
|
||||
277
PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart
Normal file
277
PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart
Normal file
@@ -0,0 +1,277 @@
|
||||
// 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/settings/settings_bloc.dart';
|
||||
import 'package:pinepods_mobile/entities/app_settings.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/ui/podcast/now_playing.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/download_button.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/play_pause_button.dart';
|
||||
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dialogs/flutter_dialogs.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Handles the state of the episode transport controls.
|
||||
///
|
||||
/// This currently consists of the [PlayControl] and [DownloadControl]
|
||||
/// to handle the play/pause and download control state respectively.
|
||||
class PlayControl extends StatelessWidget {
|
||||
final Episode episode;
|
||||
|
||||
const PlayControl({
|
||||
super.key,
|
||||
required this.episode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
|
||||
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
|
||||
|
||||
return SizedBox(
|
||||
height: 48.0,
|
||||
width: 48.0,
|
||||
child: StreamBuilder<_PlayerControlState>(
|
||||
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
|
||||
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final audioState = snapshot.data!.audioState;
|
||||
final nowPlaying = snapshot.data!.episode;
|
||||
|
||||
if (episode.downloadState != DownloadState.downloading && episode.downloadState != DownloadState.queued) {
|
||||
// If this episode is the one we are playing, allow the user
|
||||
// to toggle between play and pause.
|
||||
if (snapshot.hasData && nowPlaying?.guid == episode.guid) {
|
||||
if (audioState == AudioState.playing) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
audioBloc.transitionState(TransitionState.pause);
|
||||
},
|
||||
child: PlayPauseButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.pause_button_label,
|
||||
icon: Icons.pause,
|
||||
),
|
||||
);
|
||||
} else if (audioState == AudioState.buffering) {
|
||||
return PlayPauseBusyButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.pause_button_label,
|
||||
icon: Icons.pause,
|
||||
);
|
||||
} else if (audioState == AudioState.pausing) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
audioBloc.transitionState(TransitionState.play);
|
||||
optionalShowNowPlaying(context, settings);
|
||||
},
|
||||
child: PlayPauseButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.play_button_label,
|
||||
icon: Icons.play_arrow,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If this episode is not the one we are playing, allow the
|
||||
// user to start playing this episode.
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
audioBloc.play(episode);
|
||||
optionalShowNowPlaying(context, settings);
|
||||
},
|
||||
child: PlayPauseButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.play_button_label,
|
||||
icon: Icons.play_arrow,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// We are currently downloading this episode. Do not allow
|
||||
// the user to play it until the download is complete.
|
||||
return Opacity(
|
||||
opacity: 0.2,
|
||||
child: PlayPauseButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.play_button_label,
|
||||
icon: Icons.play_arrow,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// We have no playing information at the moment. Show a play button
|
||||
// until the stream wakes up.
|
||||
if (episode.downloadState != DownloadState.downloading) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
audioBloc.play(episode);
|
||||
optionalShowNowPlaying(context, settings);
|
||||
},
|
||||
child: PlayPauseButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.play_button_label,
|
||||
icon: Icons.play_arrow,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Opacity(
|
||||
opacity: 0.2,
|
||||
child: PlayPauseButton(
|
||||
title: episode.title!,
|
||||
label: L.of(context)!.play_button_label,
|
||||
icon: Icons.play_arrow,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DownloadControl extends StatelessWidget {
|
||||
final Episode episode;
|
||||
|
||||
const DownloadControl({
|
||||
super.key,
|
||||
required this.episode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final audioBloc = Provider.of<AudioBloc>(context);
|
||||
final podcastBloc = Provider.of<PodcastBloc>(context);
|
||||
|
||||
return SizedBox(
|
||||
height: 48.0,
|
||||
width: 48.0,
|
||||
child: StreamBuilder<_PlayerControlState>(
|
||||
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
|
||||
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final audioState = snapshot.data!.audioState;
|
||||
final nowPlaying = snapshot.data!.episode;
|
||||
|
||||
if (nowPlaying?.guid == episode.guid &&
|
||||
(audioState == AudioState.playing || audioState == AudioState.buffering)) {
|
||||
if (episode.downloadState != DownloadState.downloaded) {
|
||||
return Opacity(
|
||||
opacity: 0.2,
|
||||
child: DownloadButton(
|
||||
onPressed: () => podcastBloc.downloadEpisode(episode),
|
||||
title: episode.title!,
|
||||
icon: Icons.save_alt,
|
||||
percent: 0,
|
||||
label: L.of(context)!.download_episode_button_label,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Opacity(
|
||||
opacity: 0.2,
|
||||
child: DownloadButton(
|
||||
onPressed: () => podcastBloc.downloadEpisode(episode),
|
||||
title: episode.title!,
|
||||
icon: Icons.check,
|
||||
percent: 0,
|
||||
label: L.of(context)!.download_episode_button_label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (episode.downloadState == DownloadState.downloaded) {
|
||||
return DownloadButton(
|
||||
onPressed: () => podcastBloc.downloadEpisode(episode),
|
||||
title: episode.title!,
|
||||
icon: Icons.check,
|
||||
percent: 0,
|
||||
label: L.of(context)!.download_episode_button_label,
|
||||
);
|
||||
} else if (episode.downloadState == DownloadState.queued) {
|
||||
return DownloadButton(
|
||||
onPressed: () => _showCancelDialog(context),
|
||||
title: episode.title!,
|
||||
icon: Icons.timer_outlined,
|
||||
percent: 0,
|
||||
label: L.of(context)!.download_episode_button_label,
|
||||
);
|
||||
} else if (episode.downloadState == DownloadState.downloading) {
|
||||
return DownloadButton(
|
||||
onPressed: () => _showCancelDialog(context),
|
||||
title: episode.title!,
|
||||
icon: Icons.timer_outlined,
|
||||
percent: episode.downloadPercentage!,
|
||||
label: L.of(context)!.download_episode_button_label,
|
||||
);
|
||||
}
|
||||
|
||||
return DownloadButton(
|
||||
onPressed: () => podcastBloc.downloadEpisode(episode),
|
||||
title: episode.title!,
|
||||
icon: Icons.save_alt,
|
||||
percent: 0,
|
||||
label: L.of(context)!.download_episode_button_label,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCancelDialog(BuildContext context) {
|
||||
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
|
||||
|
||||
return showPlatformDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => BasicDialogAlert(
|
||||
title: Text(
|
||||
L.of(context)!.stop_download_title,
|
||||
),
|
||||
content: Text(L.of(context)!.stop_download_confirmation),
|
||||
actions: <Widget>[
|
||||
BasicDialogAction(
|
||||
title: ActionText(
|
||||
L.of(context)!.continue_button_label,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
BasicDialogAction(
|
||||
title: ActionText(
|
||||
L.of(context)!.stop_download_button_label,
|
||||
),
|
||||
iosIsDefaultAction: true,
|
||||
onPressed: () {
|
||||
episodeBloc.deleteDownload(episode);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
185
PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart
Normal file
185
PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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/queue_bloc.dart';
|
||||
import 'package:pinepods_mobile/l10n/L.dart';
|
||||
import 'package:pinepods_mobile/state/queue_event_state.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
|
||||
import 'package:pinepods_mobile/ui/widgets/draggable_episode_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dialogs/flutter_dialogs.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// This class is responsible for rendering the Up Next queue feature.
|
||||
///
|
||||
/// The user can see the currently playing item and the current queue. The user can
|
||||
/// re-arrange items in the queue, remove individual items or completely clear the queue.
|
||||
class UpNextView extends StatelessWidget {
|
||||
const UpNextView({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
|
||||
|
||||
return StreamBuilder<QueueState>(
|
||||
initialData: QueueEmptyState(),
|
||||
stream: queueBloc.queue,
|
||||
builder: (context, snapshot) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0),
|
||||
child: Text(
|
||||
L.of(context)!.now_playing_queue_label,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0.0),
|
||||
child: DraggableEpisodeTile(
|
||||
key: const Key('detileplaying'),
|
||||
episode: snapshot.data!.playing!,
|
||||
draggable: false,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
|
||||
child: Text(
|
||||
L.of(context)!.up_next_queue_label,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
|
||||
child: TextButton(
|
||||
onPressed: snapshot.hasData && snapshot.data!.queue.isEmpty
|
||||
? null
|
||||
: () {
|
||||
showPlatformDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (_) => BasicDialogAlert(
|
||||
title: Text(
|
||||
L.of(context)!.queue_clear_label_title,
|
||||
),
|
||||
content: Text(L.of(context)!.queue_clear_label),
|
||||
actions: <Widget>[
|
||||
BasicDialogAction(
|
||||
title: ActionText(
|
||||
L.of(context)!.cancel_button_label,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
BasicDialogAction(
|
||||
title: ActionText(
|
||||
Theme.of(context).platform == TargetPlatform.iOS
|
||||
? L.of(context)!.queue_clear_button_label.toUpperCase()
|
||||
: L.of(context)!.queue_clear_button_label,
|
||||
),
|
||||
iosIsDefaultAction: true,
|
||||
iosIsDestructiveAction: true,
|
||||
onPressed: () {
|
||||
queueBloc.queueEvent(QueueClearEvent());
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: snapshot.hasData && snapshot.data!.queue.isEmpty
|
||||
? Text(
|
||||
L.of(context)!.clear_queue_button_label,
|
||||
style: Theme.of(context).textTheme.titleSmall!.copyWith(
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
L.of(context)!.clear_queue_button_label,
|
||||
style: Theme.of(context).textTheme.titleSmall!.copyWith(
|
||||
fontSize: 12.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
snapshot.hasData && snapshot.data!.queue.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
L.of(context)!.empty_queue_message,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: snapshot.hasData ? snapshot.data!.queue.length : 0,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return Dismissible(
|
||||
key: ValueKey('disqueue${snapshot.data!.queue[index].guid}'),
|
||||
direction: DismissDirection.endToStart,
|
||||
onDismissed: (direction) {
|
||||
queueBloc.queueEvent(QueueRemoveEvent(episode: snapshot.data!.queue[index]));
|
||||
},
|
||||
child: DraggableEpisodeTile(
|
||||
key: ValueKey('tilequeue${snapshot.data!.queue[index].guid}'),
|
||||
index: index,
|
||||
episode: snapshot.data!.queue[index],
|
||||
playable: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
onReorder: (int oldIndex, int newIndex) {
|
||||
/// Seems odd to have to do this, but this -1 was taken from
|
||||
/// the Flutter docs.
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
queueBloc.queueEvent(QueueMoveEvent(
|
||||
episode: snapshot.data!.queue[oldIndex],
|
||||
oldIndex: oldIndex,
|
||||
newIndex: newIndex,
|
||||
));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user