// 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 createState() => _TranscriptViewState(); } class _TranscriptViewState extends State { final log = Logger('TranscriptView'); final ItemScrollController _itemScrollController = ItemScrollController(); final ScrollOffsetListener _scrollOffsetListener = ScrollOffsetListener.create(recordProgrammaticScrolls: false); final _transcriptSearchController = TextEditingController(); late StreamSubscription _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'(^)(\[?)(?[A-Za-z0-9\s]+)(\]?)(\s?)(:)'); @override void initState() { super.initState(); final audioBloc = Provider.of(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(context, listen: false); final queueBloc = Provider.of(context, listen: false); return StreamBuilder( initialData: QueueEmptyState(), stream: queueBloc.queue, builder: (context, queueSnapshot) { return StreamBuilder( 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 ?? []; // 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 ?? [], 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? 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(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'; } }