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 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class PinepodsLoginService {
static const String userAgent = 'PinePods Mobile/1.0';
/// Verify if the server is a valid PinePods instance
static Future<bool> verifyPinepodsInstance(String serverUrl) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/pinepods_check');
final response = await http.get(
url,
headers: {'User-Agent': userAgent},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['pinepods_instance'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Initial login - returns either API key or MFA session info
static Future<InitialLoginResponse> initialLogin(String serverUrl, String username, String password) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final credentials = base64Encode(utf8.encode('$username:$password'));
final authHeader = 'Basic $credentials';
final url = Uri.parse('$normalizedUrl/api/data/get_key');
final response = await http.get(
url,
headers: {
'Authorization': authHeader,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// Check if MFA is required
if (data['status'] == 'mfa_required' && data['mfa_required'] == true) {
return InitialLoginResponse.mfaRequired(
serverUrl: normalizedUrl,
username: username,
userId: data['user_id'],
mfaSessionToken: data['mfa_session_token'],
);
}
// Normal flow - no MFA required
final apiKey = data['retrieved_key'];
if (apiKey != null) {
return InitialLoginResponse.success(apiKey: apiKey);
}
}
return InitialLoginResponse.failure('Authentication failed');
} catch (e) {
return InitialLoginResponse.failure('Error: ${e.toString()}');
}
}
/// Legacy method for backwards compatibility
@deprecated
static Future<String?> getApiKey(String serverUrl, String username, String password) async {
final result = await initialLogin(serverUrl, username, password);
return result.isSuccess ? result.apiKey : null;
}
/// Verify API key is valid
static Future<bool> verifyApiKey(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_key');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['status'] == 'success';
}
return false;
} catch (e) {
return false;
}
}
/// Get user ID
static Future<int?> getUserId(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/get_user');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['status'] == 'success' && data['retrieved_id'] != null) {
return data['retrieved_id'] as int;
}
}
return null;
} catch (e) {
return null;
}
}
/// Get user details
static Future<UserDetails?> getUserDetails(String serverUrl, String apiKey, int userId) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/user_details_id/$userId');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return UserDetails.fromJson(data);
}
return null;
} catch (e) {
return null;
}
}
/// Get API configuration
static Future<ApiConfig?> getApiConfig(String serverUrl, String apiKey) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/config');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return ApiConfig.fromJson(data);
}
return null;
} catch (e) {
return null;
}
}
/// Check if MFA is enabled for user
static Future<bool> checkMfaEnabled(String serverUrl, String apiKey, int userId) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/check_mfa_enabled/$userId');
final response = await http.get(
url,
headers: {
'Api-Key': apiKey,
'User-Agent': userAgent,
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['mfa_enabled'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Verify MFA code and get API key during login (secure flow)
static Future<String?> verifyMfaAndGetKey(String serverUrl, String mfaSessionToken, String mfaCode) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa_and_get_key');
final requestBody = jsonEncode({
'mfa_session_token': mfaSessionToken,
'mfa_code': mfaCode,
});
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['verified'] == true && data['status'] == 'success') {
return data['retrieved_key'];
}
}
return null;
} catch (e) {
return null;
}
}
/// Legacy MFA verification (for post-login MFA checks)
@deprecated
static Future<bool> verifyMfa(String serverUrl, String apiKey, int userId, String mfaCode) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/verify_mfa');
final requestBody = jsonEncode({
'user_id': userId,
'mfa_code': mfaCode,
});
final response = await http.post(
url,
headers: {
'Api-Key': apiKey,
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return data['verified'] == true;
}
return false;
} catch (e) {
return false;
}
}
/// Complete login flow (new secure MFA implementation)
static Future<LoginResult> login(String serverUrl, String username, String password) async {
try {
// Step 1: Verify server
final isPinepods = await verifyPinepodsInstance(serverUrl);
if (!isPinepods) {
return LoginResult.failure('Not a valid PinePods server');
}
// Step 2: Initial login - get API key or MFA session
final initialResult = await initialLogin(serverUrl, username, password);
if (!initialResult.isSuccess) {
return LoginResult.failure(initialResult.errorMessage ?? 'Login failed');
}
if (initialResult.requiresMfa) {
// MFA required - return MFA prompt state
return LoginResult.mfaRequired(
serverUrl: initialResult.serverUrl!,
username: username,
userId: initialResult.userId!,
mfaSessionToken: initialResult.mfaSessionToken!,
);
}
// No MFA required - complete login with API key
return await _completeLoginWithApiKey(
serverUrl,
username,
initialResult.apiKey!,
);
} catch (e) {
return LoginResult.failure('Error: ${e.toString()}');
}
}
/// Complete MFA login flow
static Future<LoginResult> completeMfaLogin({
required String serverUrl,
required String username,
required String mfaSessionToken,
required String mfaCode,
}) async {
try {
// Verify MFA and get API key
final apiKey = await verifyMfaAndGetKey(serverUrl, mfaSessionToken, mfaCode);
if (apiKey == null) {
return LoginResult.failure('Invalid MFA code');
}
// Complete login with verified API key
return await _completeLoginWithApiKey(serverUrl, username, apiKey);
} catch (e) {
return LoginResult.failure('Error: ${e.toString()}');
}
}
/// Complete login flow with API key (common logic)
static Future<LoginResult> _completeLoginWithApiKey(String serverUrl, String username, String apiKey) async {
// Step 1: Verify API key
final isValidKey = await verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
return LoginResult.failure('API key verification failed');
}
// Step 2: Get user ID
final userId = await getUserId(serverUrl, apiKey);
if (userId == null) {
return LoginResult.failure('Failed to get user ID');
}
// Step 3: Get user details
final userDetails = await getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
return LoginResult.failure('Failed to get user details');
}
// Step 4: Get API configuration
final apiConfig = await getApiConfig(serverUrl, apiKey);
if (apiConfig == null) {
return LoginResult.failure('Failed to get server configuration');
}
return LoginResult.success(
serverUrl: serverUrl,
apiKey: apiKey,
userId: userId,
userDetails: userDetails,
apiConfig: apiConfig,
);
}
}
class InitialLoginResponse {
final bool isSuccess;
final bool requiresMfa;
final String? errorMessage;
final String? apiKey;
final String? serverUrl;
final String? username;
final int? userId;
final String? mfaSessionToken;
InitialLoginResponse._({
required this.isSuccess,
required this.requiresMfa,
this.errorMessage,
this.apiKey,
this.serverUrl,
this.username,
this.userId,
this.mfaSessionToken,
});
factory InitialLoginResponse.success({required String apiKey}) {
return InitialLoginResponse._(
isSuccess: true,
requiresMfa: false,
apiKey: apiKey,
);
}
factory InitialLoginResponse.mfaRequired({
required String serverUrl,
required String username,
required int userId,
required String mfaSessionToken,
}) {
return InitialLoginResponse._(
isSuccess: true,
requiresMfa: true,
serverUrl: serverUrl,
username: username,
userId: userId,
mfaSessionToken: mfaSessionToken,
);
}
factory InitialLoginResponse.failure(String errorMessage) {
return InitialLoginResponse._(
isSuccess: false,
requiresMfa: false,
errorMessage: errorMessage,
);
}
}
class LoginResult {
final bool isSuccess;
final bool requiresMfa;
final String? errorMessage;
final String? serverUrl;
final String? apiKey;
final String? username;
final int? userId;
final String? mfaSessionToken;
final UserDetails? userDetails;
final ApiConfig? apiConfig;
LoginResult._({
required this.isSuccess,
required this.requiresMfa,
this.errorMessage,
this.serverUrl,
this.apiKey,
this.username,
this.userId,
this.mfaSessionToken,
this.userDetails,
this.apiConfig,
});
factory LoginResult.success({
required String serverUrl,
required String apiKey,
required int userId,
required UserDetails userDetails,
required ApiConfig apiConfig,
}) {
return LoginResult._(
isSuccess: true,
requiresMfa: false,
serverUrl: serverUrl,
apiKey: apiKey,
userId: userId,
userDetails: userDetails,
apiConfig: apiConfig,
);
}
factory LoginResult.failure(String errorMessage) {
return LoginResult._(
isSuccess: false,
requiresMfa: false,
errorMessage: errorMessage,
);
}
factory LoginResult.mfaRequired({
required String serverUrl,
required String username,
required int userId,
required String mfaSessionToken,
}) {
return LoginResult._(
isSuccess: false,
requiresMfa: true,
serverUrl: serverUrl,
username: username,
userId: userId,
mfaSessionToken: mfaSessionToken,
);
}
}
class UserDetails {
final int userId;
final String? fullname;
final String? username;
final String? email;
UserDetails({
required this.userId,
this.fullname,
this.username,
this.email,
});
factory UserDetails.fromJson(Map<String, dynamic> json) {
return UserDetails(
userId: json['UserID'],
fullname: json['Fullname'],
username: json['Username'],
email: json['Email'],
);
}
}
class ApiConfig {
final String? apiUrl;
final String? proxyUrl;
final String? proxyHost;
final String? proxyPort;
final String? proxyProtocol;
final String? reverseProxy;
final String? peopleUrl;
ApiConfig({
this.apiUrl,
this.proxyUrl,
this.proxyHost,
this.proxyPort,
this.proxyProtocol,
this.reverseProxy,
this.peopleUrl,
});
factory ApiConfig.fromJson(Map<String, dynamic> json) {
return ApiConfig(
apiUrl: json['api_url'],
proxyUrl: json['proxy_url'],
proxyHost: json['proxy_host'],
proxyPort: json['proxy_port'],
proxyProtocol: json['proxy_protocol'],
reverseProxy: json['reverse_proxy'],
peopleUrl: json['people_url'],
);
}
}

View File

@@ -0,0 +1,405 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
class OidcService {
static const String userAgent = 'PinePods Mobile/1.0';
static const String callbackUrlScheme = 'pinepods';
static const String callbackPath = '/auth/callback';
/// Get available OIDC providers from server
static Future<List<OidcProvider>> getPublicProviders(String serverUrl) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/data/public_oidc_providers');
final response = await http.get(
url,
headers: {'User-Agent': userAgent},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final providers = (data['providers'] as List)
.map((provider) => OidcProvider.fromJson(provider))
.toList();
return providers;
}
return [];
} catch (e) {
return [];
}
}
/// Generate PKCE code verifier and challenge for secure OIDC flow
static OidcPkce generatePkce() {
final codeVerifier = _generateCodeVerifier();
final codeChallenge = _generateCodeChallenge(codeVerifier);
return OidcPkce(
codeVerifier: codeVerifier,
codeChallenge: codeChallenge,
);
}
/// Generate random state parameter
static String generateState() {
final random = Random.secure();
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
return base64UrlEncode(bytes).replaceAll('=', '');
}
/// Store OIDC state on server (matches web implementation)
static Future<bool> storeOidcState({
required String serverUrl,
required String state,
required String clientId,
String? originUrl,
String? codeVerifier,
}) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/auth/store_state');
final requestBody = jsonEncode({
'state': state,
'client_id': clientId,
'origin_url': originUrl,
'code_verifier': codeVerifier,
});
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: requestBody,
);
return response.statusCode == 200;
} catch (e) {
return false;
}
}
/// Build authorization URL and return it for in-app browser use
static Future<String?> buildOidcLoginUrl({
required OidcProvider provider,
required String serverUrl,
required String state,
OidcPkce? pkce,
}) async {
try {
// Store state on server first - use web origin for in-app browser
final stateStored = await storeOidcState(
serverUrl: serverUrl,
state: state,
clientId: provider.clientId,
originUrl: '$serverUrl/oauth/callback', // Use web callback for in-app browser
codeVerifier: pkce?.codeVerifier, // Include PKCE code verifier
);
if (!stateStored) {
return null;
}
// Build authorization URL
final authUri = Uri.parse(provider.authorizationUrl);
final queryParams = <String, String>{
'client_id': provider.clientId,
'response_type': 'code',
'scope': provider.scope,
'redirect_uri': '$serverUrl/api/auth/callback',
'state': state,
};
// Add PKCE parameters if provided
if (pkce != null) {
queryParams['code_challenge'] = pkce.codeChallenge;
queryParams['code_challenge_method'] = 'S256';
}
final authUrl = authUri.replace(queryParameters: queryParams);
return authUrl.toString();
} catch (e) {
return null;
}
}
/// Extract API key from callback URL (for in-app browser)
static String? extractApiKeyFromUrl(String url) {
try {
final uri = Uri.parse(url);
// Check if this is our callback URL with API key
if (uri.path.contains('/oauth/callback')) {
return uri.queryParameters['api_key'];
}
return null;
} catch (e) {
return null;
}
}
/// Handle OIDC callback and extract authentication result
static OidcCallbackResult parseCallback(String callbackUrl) {
try {
final uri = Uri.parse(callbackUrl);
final queryParams = uri.queryParameters;
// Check for error
if (queryParams.containsKey('error')) {
return OidcCallbackResult.error(
error: queryParams['error'] ?? 'Unknown error',
errorDescription: queryParams['error_description'],
);
}
// Check if we have an API key directly (PinePods backend provides this)
final apiKey = queryParams['api_key'];
if (apiKey != null && apiKey.isNotEmpty) {
return OidcCallbackResult.success(
apiKey: apiKey,
state: queryParams['state'],
);
}
// Fallback: Extract traditional OAuth code and state
final code = queryParams['code'];
final state = queryParams['state'];
if (code != null && state != null) {
return OidcCallbackResult.success(
code: code,
state: state,
);
}
return OidcCallbackResult.error(
error: 'missing_parameters',
errorDescription: 'Neither API key nor authorization code found in callback',
);
} catch (e) {
return OidcCallbackResult.error(
error: 'parse_error',
errorDescription: e.toString(),
);
}
}
/// Complete OIDC authentication by verifying with server
static Future<OidcAuthResult> completeAuthentication({
required String serverUrl,
required String code,
required String state,
OidcPkce? pkce,
}) async {
try {
final normalizedUrl = serverUrl.trim().replaceAll(RegExp(r'/$'), '');
final url = Uri.parse('$normalizedUrl/api/auth/oidc_complete');
final requestBody = <String, dynamic>{
'code': code,
'state': state,
};
// Add PKCE verifier if provided
if (pkce != null) {
requestBody['code_verifier'] = pkce.codeVerifier;
}
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'User-Agent': userAgent,
},
body: jsonEncode(requestBody),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return OidcAuthResult.success(
apiKey: data['api_key'],
userId: data['user_id'],
serverUrl: normalizedUrl,
);
} else {
final errorData = jsonDecode(response.body);
return OidcAuthResult.failure(
errorData['error'] ?? 'Authentication failed',
);
}
} catch (e) {
return OidcAuthResult.failure('Network error: ${e.toString()}');
}
}
/// Generate secure random code verifier
static String _generateCodeVerifier() {
final random = Random.secure();
// Generate 32 random bytes (256 bits) which will create a ~43 character base64url string
final bytes = List<int>.generate(32, (i) => random.nextInt(256));
// Use base64url encoding (- and _ instead of + and /) and remove padding
return base64UrlEncode(bytes).replaceAll('=', '');
}
/// Generate code challenge from verifier using SHA256
static String _generateCodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64UrlEncode(digest.bytes)
.replaceAll('=', '')
.replaceAll('+', '-')
.replaceAll('/', '_');
}
}
/// OIDC Provider model
class OidcProvider {
final int providerId;
final String providerName;
final String clientId;
final String authorizationUrl;
final String scope;
final String? buttonColor;
final String? buttonText;
final String? buttonTextColor;
final String? iconSvg;
OidcProvider({
required this.providerId,
required this.providerName,
required this.clientId,
required this.authorizationUrl,
required this.scope,
this.buttonColor,
this.buttonText,
this.buttonTextColor,
this.iconSvg,
});
factory OidcProvider.fromJson(Map<String, dynamic> json) {
return OidcProvider(
providerId: json['provider_id'],
providerName: json['provider_name'],
clientId: json['client_id'],
authorizationUrl: json['authorization_url'],
scope: json['scope'],
buttonColor: json['button_color'],
buttonText: json['button_text'],
buttonTextColor: json['button_text_color'],
iconSvg: json['icon_svg'],
);
}
/// Get display text for the provider button
String get displayText => buttonText ?? 'Login with $providerName';
/// Get button color or default
String get buttonColorHex => buttonColor ?? '#007bff';
/// Get button text color or default
String get buttonTextColorHex => buttonTextColor ?? '#ffffff';
}
/// PKCE (Proof Key for Code Exchange) parameters
class OidcPkce {
final String codeVerifier;
final String codeChallenge;
OidcPkce({
required this.codeVerifier,
required this.codeChallenge,
});
}
/// OIDC callback parsing result
class OidcCallbackResult {
final bool isSuccess;
final String? code;
final String? state;
final String? apiKey;
final String? error;
final String? errorDescription;
OidcCallbackResult._({
required this.isSuccess,
this.code,
this.state,
this.apiKey,
this.error,
this.errorDescription,
});
factory OidcCallbackResult.success({
String? code,
String? state,
String? apiKey,
}) {
return OidcCallbackResult._(
isSuccess: true,
code: code,
state: state,
apiKey: apiKey,
);
}
factory OidcCallbackResult.error({
required String error,
String? errorDescription,
}) {
return OidcCallbackResult._(
isSuccess: false,
error: error,
errorDescription: errorDescription,
);
}
bool get hasApiKey => apiKey != null && apiKey!.isNotEmpty;
bool get hasCode => code != null && code!.isNotEmpty;
}
/// OIDC authentication completion result
class OidcAuthResult {
final bool isSuccess;
final String? apiKey;
final int? userId;
final String? serverUrl;
final String? errorMessage;
OidcAuthResult._({
required this.isSuccess,
this.apiKey,
this.userId,
this.serverUrl,
this.errorMessage,
});
factory OidcAuthResult.success({
required String apiKey,
required int userId,
required String serverUrl,
}) {
return OidcAuthResult._(
isSuccess: true,
apiKey: apiKey,
userId: userId,
serverUrl: serverUrl,
);
}
factory OidcAuthResult.failure(String errorMessage) {
return OidcAuthResult._(
isSuccess: false,
errorMessage: errorMessage,
);
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff