added cargo files
This commit is contained in:
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user