added cargo files
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user