Files
PinePods-nix/PinePods-0.8.2/mobile/lib/services/pinepods/pinepods_audio_service.dart
2026-03-03 10:57:43 -05:00

505 lines
17 KiB
Dart

// lib/services/pinepods/pinepods_audio_service.dart
import 'dart:async';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/chapter.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/entities/transcript.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:logging/logging.dart';
class PinepodsAudioService {
final log = Logger('PinepodsAudioService');
final AudioPlayerService _audioPlayerService;
final PinepodsService _pinepodsService;
final SettingsBloc _settingsBloc;
Timer? _episodeUpdateTimer;
Timer? _userStatsTimer;
int? _currentEpisodeId;
int? _currentUserId;
bool _isYoutube = false;
double _lastRecordedPosition = 0;
/// Callbacks for pause/stop events
Function()? _onPauseCallback;
Function()? _onStopCallback;
PinepodsAudioService(
this._audioPlayerService,
this._pinepodsService,
this._settingsBloc, {
Function()? onPauseCallback,
Function()? onStopCallback,
}) : _onPauseCallback = onPauseCallback,
_onStopCallback = onStopCallback;
/// Play a PinePods episode with full server integration
Future<void> playPinepodsEpisode({
required PinepodsEpisode pinepodsEpisode,
bool resume = true,
}) async {
try {
final settings = _settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
log.warning('No user ID found - cannot play episode with server tracking');
return;
}
_currentUserId = userId;
_isYoutube = pinepodsEpisode.isYoutube;
log.info('Starting PinePods episode playback: ${pinepodsEpisode.episodeTitle}');
// Use the episode ID that's already available from the PinepodsEpisode
final episodeId = pinepodsEpisode.episodeId;
if (episodeId == 0) {
log.warning('Episode ID is 0 - cannot track playback');
return;
}
_currentEpisodeId = episodeId;
// Get podcast ID for settings
final podcastId = await _pinepodsService.getPodcastIdFromEpisode(
episodeId,
userId,
pinepodsEpisode.isYoutube,
);
// Get playback settings (speed, skip times)
final playDetails = await _pinepodsService.getPlayEpisodeDetails(
userId,
podcastId,
pinepodsEpisode.isYoutube,
);
// Fetch podcast 2.0 data including chapters
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(episodeId, userId);
// Convert PinepodsEpisode to Episode for the audio player
final episode = _convertToEpisode(pinepodsEpisode, playDetails, podcast2Data);
// Set playback speed
await _audioPlayerService.setPlaybackSpeed(playDetails.playbackSpeed);
// Start playing with the existing audio service
await _audioPlayerService.playEpisode(episode: episode, resume: resume);
// Handle skip intro if enabled and episode just started
if (playDetails.startSkip > 0 && !resume) {
await Future.delayed(const Duration(milliseconds: 500)); // Wait for player to initialize
await _audioPlayerService.seek(position: playDetails.startSkip);
}
// Add to history
log.info('Adding episode $episodeId to history for user $userId');
final initialPosition = resume ? (pinepodsEpisode.listenDuration ?? 0).toDouble() : 0.0;
await _pinepodsService.recordListenDuration(
episodeId,
userId,
initialPosition, // Send seconds like web app does
pinepodsEpisode.isYoutube,
);
// Queue episode for tracking
log.info('Queueing episode $episodeId for user $userId');
await _pinepodsService.queueEpisode(
episodeId,
userId,
pinepodsEpisode.isYoutube,
);
// Increment played count
log.info('Incrementing played count for user $userId');
await _pinepodsService.incrementPlayed(userId);
// Start periodic updates
_startPeriodicUpdates();
log.info('PinePods episode playback started successfully');
} catch (e) {
log.severe('Error playing PinePods episode: $e');
rethrow;
}
}
/// Start periodic updates to server
void _startPeriodicUpdates() {
_stopPeriodicUpdates(); // Clean up any existing timers
log.info('Starting periodic updates - episode position every 15s, user stats every 60s');
// Episode position updates every 15 seconds (more frequent for reliability)
_episodeUpdateTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => _safeUpdateEpisodePosition(),
);
// User listen time updates every 60 seconds
_userStatsTimer = Timer.periodic(
const Duration(seconds: 60),
(_) => _safeUpdateUserListenTime(),
);
}
/// Safely update episode position without affecting playback
void _safeUpdateEpisodePosition() async {
try {
await _updateEpisodePosition();
} catch (e) {
log.warning('Periodic sync completely failed but playback continues: $e');
// Completely isolate any network failures from affecting playback
}
}
/// Update episode position on server
Future<void> _updateEpisodePosition() async {
// Updating episode position
if (_currentEpisodeId == null || _currentUserId == null) {
log.warning('Skipping scheduled sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
return;
}
try {
final positionState = _audioPlayerService.playPosition?.value;
if (positionState == null) return;
final currentPosition = positionState.position.inSeconds.toDouble();
// Only update if position has changed by more than 2 seconds (more responsive)
if ((currentPosition - _lastRecordedPosition).abs() > 2) {
// Convert seconds to minutes for the API
final currentPositionMinutes = currentPosition / 60.0;
// Position changed, syncing to server
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
currentPosition, // Send seconds like web app does
_isYoutube,
);
_lastRecordedPosition = currentPosition;
// Sync completed successfully
}
} catch (e) {
log.warning('Failed to update episode position: $e');
}
}
/// Safely update user listen time without affecting playback
void _safeUpdateUserListenTime() async {
try {
await _updateUserListenTime();
} catch (e) {
log.warning('User stats sync completely failed but playback continues: $e');
// Completely isolate any network failures from affecting playback
}
}
/// Update user listen time statistics
Future<void> _updateUserListenTime() async {
if (_currentUserId == null) return;
try {
await _pinepodsService.incrementListenTime(_currentUserId!);
// User listen time updated
} catch (e) {
log.warning('Failed to update user listen time: $e');
}
}
/// Sync current position to server immediately (for pause/stop events)
Future<void> syncCurrentPositionToServer() async {
// Syncing current position to server
if (_currentEpisodeId == null || _currentUserId == null) {
log.warning('Cannot sync - missing episode ID ($_currentEpisodeId) or user ID ($_currentUserId)');
return;
}
try {
final positionState = _audioPlayerService.playPosition?.value;
if (positionState == null) {
log.warning('Cannot sync - positionState is null');
return;
}
final currentPosition = positionState.position.inSeconds.toDouble();
log.info('Syncing position to server: ${currentPosition}s for episode $_currentEpisodeId');
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
currentPosition, // Send seconds like web app does
_isYoutube,
);
_lastRecordedPosition = currentPosition;
log.info('Successfully synced position to server: ${currentPosition}s');
} catch (e) {
log.warning('Failed to sync position to server: $e');
log.warning('Stack trace: ${StackTrace.current}');
}
}
/// Get server position for current episode
Future<double?> getServerPosition() async {
if (_currentEpisodeId == null || _currentUserId == null) return null;
try {
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
_currentEpisodeId!,
_currentUserId!,
isYoutube: _isYoutube,
);
return episodeMetadata?.listenDuration?.toDouble();
} catch (e) {
log.warning('Failed to get server position: $e');
return null;
}
}
/// Get server position for any episode
Future<double?> getServerPositionForEpisode(int episodeId, int userId, bool isYoutube) async {
try {
final episodeMetadata = await _pinepodsService.getEpisodeMetadata(
episodeId,
userId,
isYoutube: isYoutube,
);
return episodeMetadata?.listenDuration?.toDouble();
} catch (e) {
log.warning('Failed to get server position for episode $episodeId: $e');
return null;
}
}
/// Record listen duration when episode ends or is stopped
Future<void> recordListenDuration(double listenDuration) async {
if (_currentEpisodeId == null || _currentUserId == null) return;
try {
await _pinepodsService.recordListenDuration(
_currentEpisodeId!,
_currentUserId!,
listenDuration,
_isYoutube,
);
log.info('Recorded listen duration: ${listenDuration}s');
} catch (e) {
log.warning('Failed to record listen duration: $e');
}
}
/// Handle pause event - sync position to server
Future<void> onPause() async {
try {
await syncCurrentPositionToServer();
log.info('Pause event handled - position synced to server');
} catch (e) {
log.warning('Pause sync failed but pause succeeded: $e');
}
_onPauseCallback?.call();
}
/// Handle stop event - sync position to server
Future<void> onStop() async {
try {
await syncCurrentPositionToServer();
log.info('Stop event handled - position synced to server');
} catch (e) {
log.warning('Stop sync failed but stop succeeded: $e');
}
_onStopCallback?.call();
}
/// Stop periodic updates
void _stopPeriodicUpdates() {
_episodeUpdateTimer?.cancel();
_userStatsTimer?.cancel();
_episodeUpdateTimer = null;
_userStatsTimer = null;
}
/// Convert PinepodsEpisode to Episode for the audio player
Episode _convertToEpisode(PinepodsEpisode pinepodsEpisode, PlayEpisodeDetails playDetails, Map<String, dynamic>? podcast2Data) {
// Determine the content URL
String contentUrl;
if (pinepodsEpisode.downloaded && _currentEpisodeId != null && _currentUserId != null) {
// Use stream URL for local episodes
contentUrl = _pinepodsService.getStreamUrl(
_currentEpisodeId!,
_currentUserId!,
isYoutube: pinepodsEpisode.isYoutube,
isLocal: true,
);
} else if (pinepodsEpisode.isYoutube && _currentEpisodeId != null && _currentUserId != null) {
// Use stream URL for YouTube episodes
contentUrl = _pinepodsService.getStreamUrl(
_currentEpisodeId!,
_currentUserId!,
isYoutube: true,
isLocal: false,
);
} else {
// Use original URL for external episodes
contentUrl = pinepodsEpisode.episodeUrl;
}
// Process podcast 2.0 data
List<Chapter> chapters = [];
List<Person> persons = [];
List<TranscriptUrl> transcriptUrls = [];
String? chaptersUrl;
if (podcast2Data != null) {
// Extract chapters data
final chaptersData = podcast2Data['chapters'] as List<dynamic>?;
if (chaptersData != null) {
try {
chapters = chaptersData.map((chapterData) {
return Chapter(
title: chapterData['title'] ?? '',
startTime: _parseDouble(chapterData['startTime'] ?? chapterData['start_time'] ?? 0) ?? 0.0,
endTime: _parseDouble(chapterData['endTime'] ?? chapterData['end_time']),
imageUrl: chapterData['img'] ?? chapterData['image'],
url: chapterData['url'],
toc: chapterData['toc'] ?? true,
);
}).toList();
log.info('Loaded ${chapters.length} chapters from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing chapters from podcast 2.0 data: $e');
}
}
// Extract chapters URL if available
chaptersUrl = podcast2Data['chapters_url'];
// Extract persons data
final personsData = podcast2Data['people'] as List<dynamic>?;
if (personsData != null) {
try {
persons = personsData.map((personData) {
return Person(
name: personData['name'] ?? '',
role: personData['role'] ?? '',
group: personData['group'] ?? '',
image: personData['img'],
link: personData['href'],
);
}).toList();
log.info('Loaded ${persons.length} persons from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing persons from podcast 2.0 data: $e');
}
}
// Extract transcript data
final transcriptsData = podcast2Data['transcripts'] as List<dynamic>?;
if (transcriptsData != null) {
try {
transcriptUrls = transcriptsData.map((transcriptData) {
TranscriptFormat format = TranscriptFormat.unsupported;
// Determine format from URL, mime_type, or type field
final url = transcriptData['url'] ?? '';
final mimeType = transcriptData['mime_type'] ?? '';
final type = transcriptData['type'] ?? '';
// Processing transcript
if (url.toLowerCase().contains('.json') ||
mimeType.toLowerCase().contains('json') ||
type.toLowerCase().contains('json')) {
format = TranscriptFormat.json;
// Detected JSON transcript
} else if (url.toLowerCase().contains('.srt') ||
mimeType.toLowerCase().contains('srt') ||
type.toLowerCase().contains('srt') ||
type.toLowerCase().contains('subrip') ||
url.toLowerCase().contains('subrip')) {
format = TranscriptFormat.subrip;
// Detected SubRip transcript
} else if (url.toLowerCase().contains('transcript') ||
mimeType.toLowerCase().contains('html') ||
type.toLowerCase().contains('html')) {
format = TranscriptFormat.html;
// Detected HTML transcript
} else {
log.warning('Transcript format not recognized: mimeType=$mimeType, type=$type');
}
return TranscriptUrl(
url: url,
type: format,
language: transcriptData['language'] ?? transcriptData['lang'] ?? 'en',
rel: transcriptData['rel'],
);
}).toList();
log.info('Loaded ${transcriptUrls.length} transcript URLs from podcast 2.0 data');
} catch (e) {
log.warning('Error parsing transcripts from podcast 2.0 data: $e');
}
}
}
return Episode(
guid: pinepodsEpisode.episodeUrl,
podcast: pinepodsEpisode.podcastName,
title: pinepodsEpisode.episodeTitle,
description: pinepodsEpisode.episodeDescription,
link: pinepodsEpisode.episodeUrl,
publicationDate: DateTime.tryParse(pinepodsEpisode.episodePubDate) ?? DateTime.now(),
author: '',
duration: (pinepodsEpisode.episodeDuration * 1000).round(), // Convert to milliseconds
contentUrl: contentUrl,
position: pinepodsEpisode.completed ? 0 : ((pinepodsEpisode.listenDuration ?? 0) * 1000).round(), // Convert to milliseconds, reset to 0 for completed episodes
imageUrl: pinepodsEpisode.episodeArtwork,
played: pinepodsEpisode.completed,
chapters: chapters,
chaptersUrl: chaptersUrl,
persons: persons,
transcriptUrls: transcriptUrls,
);
}
/// Helper method to safely parse double values
double? _parseDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) {
try {
return double.parse(value);
} catch (e) {
log.warning('Failed to parse double from string: $value');
return null;
}
}
return null;
}
/// Clean up resources
void dispose() {
_stopPeriodicUpdates();
_currentEpisodeId = null;
_currentUserId = null;
}
}