added cargo files

This commit is contained in:
2026-03-03 10:57:43 -05:00
parent 478a90e01b
commit 169df46bc2
813 changed files with 227273 additions and 9 deletions

View File

@@ -0,0 +1,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';
}
}