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,163 @@
// lib/ui/auth/auth_wrapper.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/ui/auth/pinepods_startup_login.dart';
import 'package:provider/provider.dart';
class AuthWrapper extends StatefulWidget {
final Widget child;
const AuthWrapper({
Key? key,
required this.child,
}) : super(key: key);
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
bool _hasInitializedTheme = false;
@override
Widget build(BuildContext context) {
return Consumer<SettingsBloc>(
builder: (context, settingsBloc, _) {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: settingsBloc.currentSettings,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
final settings = snapshot.data!;
// Check if PinePods server is configured
final hasServer = settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty;
final hasApiKey = settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty;
if (hasServer && hasApiKey) {
// User is logged in, fetch theme from server if not already done
if (!_hasInitializedTheme) {
_hasInitializedTheme = true;
// Fetch theme from server on next frame to avoid modifying state during build
WidgetsBinding.instance.addPostFrameCallback((_) {
settingsBloc.fetchThemeFromServer();
});
}
// Show main app
return widget.child;
} else {
// User needs to login, reset theme initialization flag
_hasInitializedTheme = false;
// Show startup login
return PinepodsStartupLogin(
onLoginSuccess: () {
// Force rebuild to check auth state again
// The StreamBuilder will automatically rebuild when settings change
},
);
}
},
);
},
);
}
}
// Alternative version if you want more explicit control
class AuthChecker extends StatefulWidget {
final Widget authenticatedChild;
final Widget? unauthenticatedChild;
const AuthChecker({
Key? key,
required this.authenticatedChild,
this.unauthenticatedChild,
}) : super(key: key);
@override
State<AuthChecker> createState() => _AuthCheckerState();
}
class _AuthCheckerState extends State<AuthChecker> {
bool _isCheckingAuth = true;
bool _isAuthenticated = false;
@override
void initState() {
super.initState();
_checkAuthStatus();
}
void _checkAuthStatus() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final hasServer = settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty;
final hasApiKey = settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty;
setState(() {
_isAuthenticated = hasServer && hasApiKey;
_isCheckingAuth = false;
});
}
void _onLoginSuccess() {
setState(() {
_isAuthenticated = true;
});
}
@override
Widget build(BuildContext context) {
if (_isCheckingAuth) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
if (_isAuthenticated) {
return widget.authenticatedChild;
} else {
return widget.unauthenticatedChild ??
PinepodsStartupLogin(onLoginSuccess: _onLoginSuccess);
}
}
}
// Simple authentication status provider
class AuthStatus extends InheritedWidget {
final bool isAuthenticated;
final VoidCallback? onAuthChanged;
const AuthStatus({
Key? key,
required this.isAuthenticated,
this.onAuthChanged,
required Widget child,
}) : super(key: key, child: child);
static AuthStatus? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AuthStatus>();
}
@override
bool updateShouldNotify(AuthStatus oldWidget) {
return isAuthenticated != oldWidget.isAuthenticated;
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
class OidcBrowser extends StatefulWidget {
final String authUrl;
final String serverUrl;
final Function(String apiKey) onSuccess;
final Function(String error) onError;
const OidcBrowser({
super.key,
required this.authUrl,
required this.serverUrl,
required this.onSuccess,
required this.onError,
});
@override
State<OidcBrowser> createState() => _OidcBrowserState();
}
class _OidcBrowserState extends State<OidcBrowser> {
late final WebViewController _controller;
bool _isLoading = true;
String _currentUrl = '';
bool _callbackTriggered = false; // Prevent duplicate callbacks
@override
void initState() {
super.initState();
_initializeWebView();
}
void _initializeWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
setState(() {
_currentUrl = url;
_isLoading = true;
});
_checkForCallback(url);
},
onPageFinished: (String url) {
setState(() {
_isLoading = false;
});
_checkForCallback(url);
},
onNavigationRequest: (NavigationRequest request) {
_checkForCallback(request.url);
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(widget.authUrl));
}
void _checkForCallback(String url) {
if (_callbackTriggered) return; // Prevent duplicate callbacks
// Check if we've reached the callback URL with an API key
final apiKey = OidcService.extractApiKeyFromUrl(url);
if (apiKey != null) {
_callbackTriggered = true; // Mark callback as triggered
widget.onSuccess(apiKey);
return;
}
// Check for error in callback URL
final uri = Uri.tryParse(url);
if (uri != null && uri.path.contains('/oauth/callback')) {
final error = uri.queryParameters['error'];
if (error != null) {
_callbackTriggered = true; // Mark callback as triggered
final errorDescription = uri.queryParameters['description'] ?? uri.queryParameters['details'] ?? 'Authentication failed';
widget.onError('$error: $errorDescription');
return;
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sign In'),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
widget.onError('User cancelled authentication');
},
),
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
],
),
body: Column(
children: [
// URL bar for debugging
if (MediaQuery.of(context).size.height > 600)
Container(
padding: const EdgeInsets.all(8.0),
color: Colors.grey[200],
child: Row(
children: [
const Icon(Icons.link, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
_currentUrl,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// WebView
Expanded(
child: WebViewWidget(
controller: _controller,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,777 @@
// lib/ui/auth/pinepods_startup_login.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/login_service.dart';
import 'package:pinepods_mobile/services/pinepods/oidc_service.dart';
import 'package:pinepods_mobile/services/auth_notifier.dart';
import 'package:pinepods_mobile/ui/auth/oidc_browser.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'dart:math';
import 'dart:async';
class PinepodsStartupLogin extends StatefulWidget {
final VoidCallback? onLoginSuccess;
const PinepodsStartupLogin({
Key? key,
this.onLoginSuccess,
}) : super(key: key);
@override
State<PinepodsStartupLogin> createState() => _PinepodsStartupLoginState();
}
class _PinepodsStartupLoginState extends State<PinepodsStartupLogin> {
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _mfaController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showMfaField = false;
bool _isLoadingOidc = false;
String _errorMessage = '';
String? _tempServerUrl;
String? _tempUsername;
int? _tempUserId;
String? _tempMfaSessionToken;
List<OidcProvider> _oidcProviders = [];
bool _hasCheckedOidc = false;
Timer? _oidcCheckTimer;
// List of background images - you can add your own images to assets/images/
final List<String> _backgroundImages = [
'assets/images/1.webp',
'assets/images/2.webp',
'assets/images/3.webp',
'assets/images/4.webp',
'assets/images/5.webp',
'assets/images/6.webp',
'assets/images/7.webp',
'assets/images/8.webp',
'assets/images/9.webp',
];
late String _selectedBackground;
@override
void initState() {
super.initState();
// Select a random background image
final random = Random();
_selectedBackground = _backgroundImages[random.nextInt(_backgroundImages.length)];
// Listen for server URL changes to check OIDC providers
_serverController.addListener(_onServerUrlChanged);
// Register global login success callback
AuthNotifier.setGlobalLoginSuccessCallback(_handleLoginSuccess);
}
void _onServerUrlChanged() {
final serverUrl = _serverController.text.trim();
// Cancel any existing timer
_oidcCheckTimer?.cancel();
// Reset OIDC state
setState(() {
_oidcProviders.clear();
_hasCheckedOidc = false;
_isLoadingOidc = false;
});
// Only check if URL looks complete and valid
if (serverUrl.isNotEmpty &&
(serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) &&
_isValidUrl(serverUrl)) {
// Debounce the API call - wait 1 second after user stops typing
_oidcCheckTimer = Timer(const Duration(seconds: 1), () {
_checkOidcProviders(serverUrl);
});
}
}
bool _isValidUrl(String url) {
try {
final uri = Uri.parse(url);
// Check if it has a proper host (not just protocol)
return uri.hasScheme &&
uri.host.isNotEmpty &&
uri.host.contains('.') && // Must have at least one dot for domain
uri.host.length > 3; // Minimum reasonable length
} catch (e) {
return false;
}
}
Future<void> _checkOidcProviders(String serverUrl) async {
// Allow rechecking if server URL changed
final currentUrl = _serverController.text.trim();
if (currentUrl != serverUrl) return; // URL changed while we were waiting
setState(() {
_isLoadingOidc = true;
});
try {
final providers = await OidcService.getPublicProviders(serverUrl);
// Double-check the URL hasn't changed during the API call
if (mounted && _serverController.text.trim() == serverUrl) {
setState(() {
_oidcProviders = providers;
_hasCheckedOidc = true;
_isLoadingOidc = false;
});
}
} catch (e) {
// Only update state if URL hasn't changed
if (mounted && _serverController.text.trim() == serverUrl) {
setState(() {
_oidcProviders.clear();
_hasCheckedOidc = true;
_isLoadingOidc = false;
});
}
}
}
// Manual retry when user focuses on other fields (like username)
void _retryOidcCheck() {
final serverUrl = _serverController.text.trim();
if (serverUrl.isNotEmpty &&
_isValidUrl(serverUrl) &&
!_hasCheckedOidc &&
!_isLoadingOidc) {
_checkOidcProviders(serverUrl);
}
}
Future<void> _handleOidcLogin(OidcProvider provider) async {
final serverUrl = _serverController.text.trim();
if (serverUrl.isEmpty) {
setState(() {
_errorMessage = 'Please enter a server URL first';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Generate PKCE and state parameters for security
final pkce = OidcService.generatePkce();
final state = OidcService.generateState();
// Build authorization URL for in-app browser
final authUrl = await OidcService.buildOidcLoginUrl(
provider: provider,
serverUrl: serverUrl,
state: state,
pkce: pkce,
);
if (authUrl == null) {
setState(() {
_errorMessage = 'Failed to prepare OIDC authentication URL';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = false;
});
// Launch in-app browser
if (mounted) {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => OidcBrowser(
authUrl: authUrl,
serverUrl: serverUrl,
onSuccess: (apiKey) async {
Navigator.of(context).pop(); // Close the browser
await _completeOidcLogin(apiKey, serverUrl);
},
onError: (error) {
Navigator.of(context).pop(); // Close the browser
setState(() {
_errorMessage = 'Authentication failed: $error';
});
},
),
),
);
}
} catch (e) {
setState(() {
_errorMessage = 'OIDC login error: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _completeOidcLogin(String apiKey, String serverUrl) async {
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Verify API key
final isValidKey = await PinepodsLoginService.verifyApiKey(serverUrl, apiKey);
if (!isValidKey) {
throw Exception('API key verification failed');
}
// Get user ID
final userId = await PinepodsLoginService.getUserId(serverUrl, apiKey);
if (userId == null) {
throw Exception('Failed to get user ID');
}
// Get user details
final userDetails = await PinepodsLoginService.getUserDetails(serverUrl, apiKey, userId);
if (userDetails == null) {
throw Exception('Failed to get user details');
}
// Store credentials
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(serverUrl);
settingsBloc.setPinepodsApiKey(apiKey);
settingsBloc.setPinepodsUserId(userId);
// Set additional user details if available
if (userDetails.username != null) {
settingsBloc.setPinepodsUsername(userDetails.username!);
}
if (userDetails.email != null) {
settingsBloc.setPinepodsEmail(userDetails.email!);
}
// Fetch theme from server
try {
await settingsBloc.fetchThemeFromServer();
} catch (e) {
// Theme fetch failure is non-critical
}
// Notify login success
AuthNotifier.notifyLoginSuccess();
// Call the callback if provided
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to complete login: ${e.toString()}';
_isLoading = false;
});
}
}
}
Future<void> _connectToPinepods() async {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
if (_showMfaField && _tempMfaSessionToken != null) {
// Complete MFA login flow
final mfaCode = _mfaController.text.trim();
final result = await PinepodsLoginService.completeMfaLogin(
serverUrl: _tempServerUrl!,
username: _tempUsername!,
mfaSessionToken: _tempMfaSessionToken!,
mfaCode: mfaCode,
);
if (result.isSuccess) {
// Save the connection details including user ID
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
// Fetch theme from server after successful login
await settingsBloc.fetchThemeFromServer();
setState(() {
_isLoading = false;
});
// Call success callback
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'MFA verification failed';
_isLoading = false;
});
}
} else {
// Initial login flow
final serverUrl = _serverController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text;
final result = await PinepodsLoginService.login(
serverUrl,
username,
password,
);
if (result.isSuccess) {
// Save the connection details including user ID
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
// Fetch theme from server after successful login
await settingsBloc.fetchThemeFromServer();
setState(() {
_isLoading = false;
});
// Call success callback
if (widget.onLoginSuccess != null) {
widget.onLoginSuccess!();
}
} else if (result.requiresMfa) {
// Store MFA session info and show MFA field
setState(() {
_tempServerUrl = result.serverUrl;
_tempUsername = result.username;
_tempUserId = result.userId;
_tempMfaSessionToken = result.mfaSessionToken;
_showMfaField = true;
_isLoading = false;
_errorMessage = 'Please enter your MFA code';
});
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'Login failed';
_isLoading = false;
});
}
}
} catch (e) {
setState(() {
_errorMessage = 'Error: ${e.toString()}';
_isLoading = false;
});
}
}
void _resetMfa() {
setState(() {
_showMfaField = false;
_tempServerUrl = null;
_tempUsername = null;
_tempUserId = null;
_tempMfaSessionToken = null;
_mfaController.clear();
_errorMessage = '';
});
}
/// Parse hex color string to Color object
Color _parseColor(String hexColor) {
try {
final hex = hexColor.replaceAll('#', '');
if (hex.length == 6) {
return Color(int.parse('FF$hex', radix: 16));
} else if (hex.length == 8) {
return Color(int.parse(hex, radix: 16));
}
} catch (e) {
// Fallback to default color on parsing error
}
return Theme.of(context).primaryColor;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(_selectedBackground),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.6),
BlendMode.darken,
),
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// App Logo/Title
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/favicon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.headset,
size: 48,
color: Colors.white,
),
);
},
),
),
),
),
const SizedBox(height: 16),
Text(
'Welcome to PinePods',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Connect to your PinePods server to get started',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Server URL Field
TextFormField(
controller: _serverController,
decoration: InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-pinepods-server.com',
prefixIcon: const Icon(Icons.dns),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a server URL';
}
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return 'URL must start with http:// or https://';
}
return null;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Username Field
Focus(
onFocusChange: (hasFocus) {
if (hasFocus) {
// User focused on username field, retry OIDC check if needed
_retryOidcCheck();
}
},
child: TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: const Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your username';
}
return null;
},
textInputAction: TextInputAction.next,
),
),
const SizedBox(height: 16),
// Password Field
TextFormField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
textInputAction: _showMfaField ? TextInputAction.next : TextInputAction.done,
onFieldSubmitted: (_) {
if (!_showMfaField) {
_connectToPinepods();
}
},
enabled: !_showMfaField,
),
// MFA Field (shown when MFA is required)
if (_showMfaField) ...[
const SizedBox(height: 16),
TextFormField(
controller: _mfaController,
decoration: InputDecoration(
labelText: 'MFA Code',
hintText: 'Enter 6-digit code',
prefixIcon: const Icon(Icons.security),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: _resetMfa,
tooltip: 'Cancel MFA',
),
),
keyboardType: TextInputType.number,
maxLength: 6,
validator: (value) {
if (_showMfaField && (value == null || value.isEmpty)) {
return 'Please enter your MFA code';
}
if (_showMfaField && value!.length != 6) {
return 'MFA code must be 6 digits';
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _connectToPinepods(),
),
],
// Error Message
if (_errorMessage.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
],
const SizedBox(height: 24),
// Connect Button
ElevatedButton(
onPressed: _isLoading ? null : _connectToPinepods,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(
_showMfaField ? 'Verify MFA Code' : 'Connect to PinePods',
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 16),
// OIDC Providers Section
if (_oidcProviders.isNotEmpty && !_showMfaField) ...[
// Divider
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Or continue with',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
// OIDC Provider Buttons
..._oidcProviders.map((provider) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _handleOidcLogin(provider),
style: ElevatedButton.styleFrom(
backgroundColor: _parseColor(provider.buttonColorHex),
foregroundColor: _parseColor(provider.buttonTextColorHex),
padding: const EdgeInsets.all(16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (provider.iconSvg != null && provider.iconSvg!.isNotEmpty)
Container(
width: 20,
height: 20,
margin: const EdgeInsets.only(right: 8),
child: const Icon(Icons.account_circle, size: 20),
),
Text(
provider.displayText,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
)),
const SizedBox(height: 16),
],
// Loading indicator for OIDC discovery
if (_isLoadingOidc) ...[
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(height: 16),
],
// Additional Info
Text(
'Don\'t have a PinePods server? Visit pinepods.online to learn more.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
),
),
);
}
/// Handle login success from any source (traditional or OIDC)
void _handleLoginSuccess() {
if (mounted) {
widget.onLoginSuccess?.call();
}
}
@override
void dispose() {
_oidcCheckTimer?.cancel();
_serverController.removeListener(_onServerUrlChanged);
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_mfaController.dispose();
// Clear global callback to prevent memory leaks
AuthNotifier.clearGlobalLoginSuccessCallback();
super.dispose();
}
}

View File

@@ -0,0 +1,656 @@
// lib/ui/debug/debug_logs_page.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
class DebugLogsPage extends StatefulWidget {
const DebugLogsPage({Key? key}) : super(key: key);
@override
State<DebugLogsPage> createState() => _DebugLogsPageState();
}
class _DebugLogsPageState extends State<DebugLogsPage> {
final AppLogger _logger = AppLogger();
final ScrollController _scrollController = ScrollController();
List<LogEntry> _logs = [];
LogLevel? _selectedLevel;
bool _showDeviceInfo = true;
List<File> _sessionFiles = [];
bool _hasPreviousCrash = false;
@override
void initState() {
super.initState();
_loadLogs();
_loadSessionFiles();
}
void _loadLogs() {
setState(() {
if (_selectedLevel == null) {
_logs = _logger.logs;
} else {
_logs = _logger.getLogsByLevel(_selectedLevel!);
}
});
}
Future<void> _copyLogsToClipboard() async {
try {
final formattedLogs = _logger.getFormattedLogs();
await Clipboard.setData(ClipboardData(text: formattedLogs));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs copied to clipboard!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy logs: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _loadSessionFiles() async {
try {
final files = await _logger.getSessionFiles();
final hasCrash = await _logger.hasPreviousCrash();
setState(() {
_sessionFiles = files;
_hasPreviousCrash = hasCrash;
});
} catch (e) {
print('Failed to load session files: $e');
}
}
Future<void> _copyCurrentSessionToClipboard() async {
try {
final formattedLogs = _logger.getFormattedLogsWithSessionInfo();
await Clipboard.setData(ClipboardData(text: formattedLogs));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Current session logs copied to clipboard!'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy logs: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _copySessionFileToClipboard(File sessionFile) async {
try {
final content = await sessionFile.readAsString();
final deviceInfo = _logger.deviceInfo?.formattedInfo ?? 'Device info not available';
final formattedContent = '$deviceInfo\n\n${'=' * 50}\nSession File: ${sessionFile.path.split('/').last}\n${'=' * 50}\n\n$content';
await Clipboard.setData(ClipboardData(text: formattedContent));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Session ${sessionFile.path.split('/').last} copied to clipboard!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy session file: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _copyCrashLogToClipboard() async {
try {
final crashPath = _logger.crashLogPath;
if (crashPath == null) {
throw Exception('Crash log path not available');
}
final crashFile = File(crashPath);
final content = await crashFile.readAsString();
await Clipboard.setData(ClipboardData(text: content));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Crash log copied to clipboard!'),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to copy crash log: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Future<void> _openBugTracker() async {
const url = 'https://github.com/madeofpendletonwool/pinepods/issues';
try {
final uri = Uri.parse(url);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open bug tracker: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _clearLogs() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
_logger.clearLogs();
_loadLogs();
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logs cleared'),
backgroundColor: Colors.orange,
),
);
},
child: const Text('Clear'),
),
],
),
);
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
Color _getLevelColor(LogLevel level) {
switch (level) {
case LogLevel.debug:
return Colors.grey;
case LogLevel.info:
return Colors.blue;
case LogLevel.warning:
return Colors.orange;
case LogLevel.error:
return Colors.red;
case LogLevel.critical:
return Colors.purple;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug Logs'),
elevation: 0,
actions: [
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'filter':
_showFilterDialog();
break;
case 'clear':
_clearLogs();
break;
case 'refresh':
_loadLogs();
break;
case 'scroll_bottom':
_scrollToBottom();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'filter',
child: Row(
children: [
Icon(Icons.filter_list),
SizedBox(width: 8),
Text('Filter'),
],
),
),
const PopupMenuItem(
value: 'refresh',
child: Row(
children: [
Icon(Icons.refresh),
SizedBox(width: 8),
Text('Refresh'),
],
),
),
const PopupMenuItem(
value: 'scroll_bottom',
child: Row(
children: [
Icon(Icons.vertical_align_bottom),
SizedBox(width: 8),
Text('Scroll to Bottom'),
],
),
),
const PopupMenuItem(
value: 'clear',
child: Row(
children: [
Icon(Icons.clear_all),
SizedBox(width: 8),
Text('Clear Logs'),
],
),
),
],
),
],
),
body: Column(
children: [
// Header with device info toggle and stats
Container(
padding: const EdgeInsets.all(16.0),
color: Theme.of(context).cardColor,
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'Total Entries: ${_logs.length}',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (_selectedLevel != null)
Chip(
label: Text(_selectedLevel!.name.toUpperCase()),
backgroundColor: _getLevelColor(_selectedLevel!).withOpacity(0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
_selectedLevel = null;
});
_loadLogs();
},
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _copyCurrentSessionToClipboard,
icon: const Icon(Icons.copy),
label: const Text('Copy Current'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _openBugTracker,
icon: const Icon(Icons.bug_report),
label: const Text('Report Bug'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
const Divider(height: 1),
// Session Files Section
if (_sessionFiles.isNotEmpty || _hasPreviousCrash)
ExpansionTile(
title: const Text('Session Files & Crash Logs'),
leading: const Icon(Icons.folder),
initiallyExpanded: false,
children: [
if (_hasPreviousCrash)
ListTile(
leading: const Icon(Icons.warning, color: Colors.red),
title: const Text('Previous Crash Log'),
subtitle: const Text('Tap to copy crash log to clipboard'),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: _copyCrashLogToClipboard,
),
onTap: _copyCrashLogToClipboard,
),
..._sessionFiles.map((file) {
final fileName = file.path.split('/').last;
final isCurrentSession = fileName.contains(_logger.currentSessionPath?.split('/').last?.replaceFirst('session_', '').replaceFirst('.log', '') ?? '');
return ListTile(
leading: Icon(
isCurrentSession ? Icons.play_circle : Icons.history,
color: isCurrentSession ? Colors.green : Colors.grey,
),
title: Text(fileName),
subtitle: Text(
'Modified: ${file.lastModifiedSync().toString().substring(0, 16)}${isCurrentSession ? ' (Current)' : ''}',
style: TextStyle(
fontSize: 12,
color: isCurrentSession ? Colors.green : Colors.grey[600],
),
),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copySessionFileToClipboard(file),
),
onTap: () => _copySessionFileToClipboard(file),
);
}).toList(),
if (_sessionFiles.isEmpty && !_hasPreviousCrash)
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'No session files available yet',
style: TextStyle(color: Colors.grey),
),
),
],
),
// Device info section (collapsible)
if (_showDeviceInfo && _logger.deviceInfo != null)
ExpansionTile(
title: const Text('Device Information'),
leading: const Icon(Icons.phone_android),
initiallyExpanded: false,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Text(
_logger.deviceInfo!.formattedInfo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
),
],
),
// Logs list
Expanded(
child: _logs.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'No logs found',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
SizedBox(height: 8),
Text(
'Use the app to generate logs',
style: TextStyle(color: Colors.grey),
),
],
),
)
: ListView.builder(
controller: _scrollController,
itemCount: _logs.length,
itemBuilder: (context, index) {
final log = _logs[index];
return _buildLogEntry(log);
},
),
),
],
),
floatingActionButton: _logs.isNotEmpty
? FloatingActionButton(
onPressed: _scrollToBottom,
tooltip: 'Scroll to bottom',
child: const Icon(Icons.vertical_align_bottom),
)
: null,
);
}
Widget _buildLogEntry(LogEntry log) {
final levelColor = _getLevelColor(log.level);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Card(
elevation: 1,
child: ExpansionTile(
leading: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: levelColor,
shape: BoxShape.circle,
),
),
title: Text(
log.message,
style: const TextStyle(fontSize: 14),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${log.timestamp.toString().substring(0, 19)}${log.levelString}${log.tag}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
log.formattedMessage,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
if (log.stackTrace != null && log.stackTrace!.isNotEmpty) ...[
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
const Text(
'Stack Trace:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
SelectableText(
log.stackTrace!,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
),
),
],
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: log.formattedMessage));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Log entry copied to clipboard')),
);
},
icon: const Icon(Icons.copy, size: 16),
label: const Text('Copy'),
),
],
),
],
),
),
),
],
),
),
);
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filter Logs'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Show only logs of level:'),
const SizedBox(height: 16),
...LogLevel.values.map((level) => RadioListTile<LogLevel?>(
title: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: _getLevelColor(level),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(level.name.toUpperCase()),
],
),
value: level,
groupValue: _selectedLevel,
onChanged: (value) {
setState(() {
_selectedLevel = value;
});
},
)),
RadioListTile<LogLevel?>(
title: const Text('All Levels'),
value: null,
groupValue: _selectedLevel,
onChanged: (value) {
setState(() {
_selectedLevel = null;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
_loadLogs();
Navigator.of(context).pop();
},
child: const Text('Apply'),
),
],
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,19 @@
// 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 'package:pinepods_mobile/ui/pinepods/downloads.dart';
import 'package:flutter/material.dart';
/// Displays a list of currently downloaded podcast episodes.
/// This is a wrapper that redirects to the new PinePods downloads implementation.
class Downloads extends StatelessWidget {
const Downloads({
super.key,
});
@override
Widget build(BuildContext context) {
return const PinepodsDownloads();
}
}

View File

@@ -0,0 +1,115 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This class displays the list of podcasts the user is currently following.
class Library extends StatefulWidget {
const Library({
super.key,
});
@override
State<Library> createState() => _LibraryState();
}
class _LibraryState extends State<Library> {
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
final settingsBloc = Provider.of<SettingsBloc>(context);
return StreamBuilder<List<Podcast>>(
stream: podcastBloc.subscriptions,
builder: (context, snapshot) {
if (snapshot.hasData) {
if (snapshot.data!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.headset,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
L.of(context)!.no_subscriptions_message,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
} else {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
var mode = settingsSnapshot.data!.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PodcastTile(podcast: snapshot.data!.elementAt(index));
},
childCount: snapshot.data!.length,
addAutomaticKeepAlives: false,
));
}
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PodcastGridTile(podcast: snapshot.data!.elementAt(index));
},
childCount: snapshot.data!.length,
),
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
});
}
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
});
}
}

View File

@@ -0,0 +1,390 @@
// lib/ui/pinepods/create_playlist.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:provider/provider.dart';
class CreatePlaylistPage extends StatefulWidget {
const CreatePlaylistPage({Key? key}) : super(key: key);
@override
State<CreatePlaylistPage> createState() => _CreatePlaylistPageState();
}
class _CreatePlaylistPageState extends State<CreatePlaylistPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final PinepodsService _pinepodsService = PinepodsService();
bool _isLoading = false;
String _selectedIcon = 'ph-playlist';
bool _includeUnplayed = true;
bool _includePartiallyPlayed = true;
bool _includePlayed = false;
String _minDuration = '';
String _maxDuration = '';
String _sortOrder = 'newest_first';
bool _groupByPodcast = false;
String _maxEpisodes = '';
final List<Map<String, String>> _availableIcons = [
{'name': 'ph-playlist', 'icon': '🎵'},
{'name': 'ph-music-notes', 'icon': '🎶'},
{'name': 'ph-play-circle', 'icon': '▶️'},
{'name': 'ph-headphones', 'icon': '🎧'},
{'name': 'ph-star', 'icon': ''},
{'name': 'ph-heart', 'icon': '❤️'},
{'name': 'ph-bookmark', 'icon': '🔖'},
{'name': 'ph-clock', 'icon': ''},
{'name': 'ph-calendar', 'icon': '📅'},
{'name': 'ph-timer', 'icon': '⏲️'},
{'name': 'ph-shuffle', 'icon': '🔀'},
{'name': 'ph-repeat', 'icon': '🔁'},
{'name': 'ph-microphone', 'icon': '🎤'},
{'name': 'ph-queue', 'icon': '📋'},
{'name': 'ph-fire', 'icon': '🔥'},
{'name': 'ph-lightning', 'icon': ''},
{'name': 'ph-coffee', 'icon': ''},
{'name': 'ph-moon', 'icon': '🌙'},
{'name': 'ph-sun', 'icon': '☀️'},
{'name': 'ph-rocket', 'icon': '🚀'},
];
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
Future<void> _createPlaylist() async {
if (!_formKey.currentState!.validate()) {
return;
}
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not connected to PinePods server')),
);
return;
}
setState(() {
_isLoading = true;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final request = CreatePlaylistRequest(
userId: settings.pinepodsUserId!,
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
podcastIds: const [], // For now, we'll create without podcast filtering
includeUnplayed: _includeUnplayed,
includePartiallyPlayed: _includePartiallyPlayed,
includePlayed: _includePlayed,
minDuration: _minDuration.isNotEmpty ? int.tryParse(_minDuration) : null,
maxDuration: _maxDuration.isNotEmpty ? int.tryParse(_maxDuration) : null,
sortOrder: _sortOrder,
groupByPodcast: _groupByPodcast,
maxEpisodes: _maxEpisodes.isNotEmpty ? int.tryParse(_maxEpisodes) : null,
iconName: _selectedIcon,
playProgressMin: null, // Simplified for now
playProgressMax: null,
timeFilterHours: null,
);
final success = await _pinepodsService.createPlaylist(request);
if (success) {
if (mounted) {
Navigator.of(context).pop(true); // Return true to indicate success
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlist created successfully!')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to create playlist')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error creating playlist: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create Playlist'),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
actions: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
TextButton(
onPressed: _createPlaylist,
child: const Text('Create'),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Name field
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Playlist Name',
border: OutlineInputBorder(),
hintText: 'Enter playlist name',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter a playlist name';
}
return null;
},
),
const SizedBox(height: 16),
// Description field
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (Optional)',
border: OutlineInputBorder(),
hintText: 'Enter playlist description',
),
maxLines: 3,
),
const SizedBox(height: 16),
// Icon selector
Text(
'Icon',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Container(
height: 120,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _availableIcons.length,
itemBuilder: (context, index) {
final icon = _availableIcons[index];
final isSelected = _selectedIcon == icon['name'];
return GestureDetector(
onTap: () {
setState(() {
_selectedIcon = icon['name']!;
});
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.2)
: null,
border: Border.all(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
icon['icon']!,
style: const TextStyle(fontSize: 20),
),
),
),
);
},
),
),
const SizedBox(height: 24),
Text(
'Episode Filters',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Episode filters
CheckboxListTile(
title: const Text('Include Unplayed'),
value: _includeUnplayed,
onChanged: (value) {
setState(() {
_includeUnplayed = value ?? true;
});
},
),
CheckboxListTile(
title: const Text('Include Partially Played'),
value: _includePartiallyPlayed,
onChanged: (value) {
setState(() {
_includePartiallyPlayed = value ?? true;
});
},
),
CheckboxListTile(
title: const Text('Include Played'),
value: _includePlayed,
onChanged: (value) {
setState(() {
_includePlayed = value ?? false;
});
},
),
const SizedBox(height: 16),
// Duration range
Text(
'Duration Range (minutes)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Min',
border: OutlineInputBorder(),
hintText: 'Any',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_minDuration = value;
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
decoration: const InputDecoration(
labelText: 'Max',
border: OutlineInputBorder(),
hintText: 'Any',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_maxDuration = value;
},
),
),
],
),
const SizedBox(height: 16),
// Sort order
DropdownButtonFormField<String>(
value: _sortOrder,
decoration: const InputDecoration(
labelText: 'Sort Order',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'newest_first', child: Text('Newest First')),
DropdownMenuItem(value: 'oldest_first', child: Text('Oldest First')),
DropdownMenuItem(value: 'shortest_first', child: Text('Shortest First')),
DropdownMenuItem(value: 'longest_first', child: Text('Longest First')),
],
onChanged: (value) {
setState(() {
_sortOrder = value!;
});
},
),
const SizedBox(height: 16),
// Max episodes
TextFormField(
decoration: const InputDecoration(
labelText: 'Max Episodes (Optional)',
border: OutlineInputBorder(),
hintText: 'Leave blank for no limit',
),
keyboardType: TextInputType.number,
onChanged: (value) {
_maxEpisodes = value;
},
),
const SizedBox(height: 16),
// Group by podcast
CheckboxListTile(
title: const Text('Group by Podcast'),
subtitle: const Text('Group episodes by their podcast'),
value: _groupByPodcast,
onChanged: (value) {
setState(() {
_groupByPodcast = value ?? false;
});
},
),
const SizedBox(height: 32),
],
),
),
);
}
}

View File

@@ -0,0 +1,968 @@
// lib/ui/pinepods/downloads.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/download/download_service.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
import 'package:logging/logging.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsDownloads extends StatefulWidget {
const PinepodsDownloads({super.key});
@override
State<PinepodsDownloads> createState() => _PinepodsDownloadsState();
}
class _PinepodsDownloadsState extends State<PinepodsDownloads> {
final log = Logger('PinepodsDownloads');
final PinepodsService _pinepodsService = PinepodsService();
List<PinepodsEpisode> _serverDownloads = [];
List<Episode> _localDownloads = [];
Map<String, List<PinepodsEpisode>> _serverDownloadsByPodcast = {};
Map<String, List<Episode>> _localDownloadsByPodcast = {};
bool _isLoadingServerDownloads = false;
bool _isLoadingLocalDownloads = false;
String? _errorMessage;
Set<String> _expandedPodcasts = {};
int? _contextMenuEpisodeIndex;
bool _isServerEpisode = false;
// Search functionality
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
Map<String, List<PinepodsEpisode>> _filteredServerDownloadsByPodcast = {};
Map<String, List<Episode>> _filteredLocalDownloadsByPodcast = {};
@override
void initState() {
super.initState();
_loadDownloads();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterDownloads();
});
}
void _filterDownloads() {
// Filter server downloads
_filteredServerDownloadsByPodcast = {};
for (final entry in _serverDownloadsByPodcast.entries) {
final podcastName = entry.key;
final episodes = entry.value;
if (_searchQuery.isEmpty) {
_filteredServerDownloadsByPodcast[podcastName] = List.from(episodes);
} else {
final filteredEpisodes = episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
if (filteredEpisodes.isNotEmpty) {
_filteredServerDownloadsByPodcast[podcastName] = filteredEpisodes;
}
}
}
// Filter local downloads (will be called when local downloads are loaded)
_filterLocalDownloads();
}
void _filterLocalDownloads([Map<String, List<Episode>>? localDownloadsByPodcast]) {
final downloadsToFilter = localDownloadsByPodcast ?? _localDownloadsByPodcast;
_filteredLocalDownloadsByPodcast = {};
for (final entry in downloadsToFilter.entries) {
final podcastName = entry.key;
final episodes = entry.value;
if (_searchQuery.isEmpty) {
_filteredLocalDownloadsByPodcast[podcastName] = List.from(episodes);
} else {
final filteredEpisodes = episodes.where((episode) {
return (episode.title ?? '').toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
if (filteredEpisodes.isNotEmpty) {
_filteredLocalDownloadsByPodcast[podcastName] = filteredEpisodes;
}
}
}
}
Future<void> _loadDownloads() async {
await Future.wait([
_loadServerDownloads(),
_loadLocalDownloads(),
]);
}
Future<void> _loadServerDownloads() async {
setState(() {
_isLoadingServerDownloads = true;
_errorMessage = null;
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final downloads = await _pinepodsService.getServerDownloads(settings.pinepodsUserId!);
setState(() {
_serverDownloads = downloads;
_serverDownloadsByPodcast = _groupEpisodesByPodcast(downloads);
_filterDownloads(); // Initialize filtered data
_isLoadingServerDownloads = false;
});
} else {
setState(() {
_isLoadingServerDownloads = false;
});
}
} catch (e) {
log.severe('Error loading server downloads: $e');
setState(() {
_errorMessage = 'Failed to load server downloads: $e';
_isLoadingServerDownloads = false;
});
}
}
Future<void> _loadLocalDownloads() async {
setState(() {
_isLoadingLocalDownloads = true;
});
try {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
episodeBloc.fetchDownloads(false);
// Debug: Let's also directly check what the repository returns
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
final directDownloads = await podcastBloc.podcastService.loadDownloads();
print('DEBUG: Direct downloads from repository: ${directDownloads.length} episodes');
for (var episode in directDownloads) {
print('DEBUG: Episode: ${episode.title}, GUID: ${episode.guid}, Downloaded: ${episode.downloaded}, Percentage: ${episode.downloadPercentage}');
}
setState(() {
_isLoadingLocalDownloads = false;
});
} catch (e) {
log.severe('Error loading local downloads: $e');
setState(() {
_isLoadingLocalDownloads = false;
});
}
}
Map<String, List<PinepodsEpisode>> _groupEpisodesByPodcast(List<PinepodsEpisode> episodes) {
final grouped = <String, List<PinepodsEpisode>>{};
for (final episode in episodes) {
final podcastName = episode.podcastName;
if (!grouped.containsKey(podcastName)) {
grouped[podcastName] = [];
}
grouped[podcastName]!.add(episode);
}
// Sort episodes within each podcast by publication date (newest first)
for (final episodes in grouped.values) {
episodes.sort((a, b) {
try {
final dateA = DateTime.parse(a.episodePubDate);
final dateB = DateTime.parse(b.episodePubDate);
return dateB.compareTo(dateA); // newest first
} catch (e) {
return 0;
}
});
}
return grouped;
}
Map<String, List<Episode>> _groupLocalEpisodesByPodcast(List<Episode> episodes) {
final grouped = <String, List<Episode>>{};
for (final episode in episodes) {
final podcastName = episode.podcast ?? 'Unknown Podcast';
if (!grouped.containsKey(podcastName)) {
grouped[podcastName] = [];
}
grouped[podcastName]!.add(episode);
}
// Sort episodes within each podcast by publication date (newest first)
for (final episodes in grouped.values) {
episodes.sort((a, b) {
if (a.publicationDate == null || b.publicationDate == null) {
return 0;
}
return b.publicationDate!.compareTo(a.publicationDate!);
});
}
return grouped;
}
void _togglePodcastExpansion(String podcastKey) {
setState(() {
if (_expandedPodcasts.contains(podcastKey)) {
_expandedPodcasts.remove(podcastKey);
} else {
_expandedPodcasts.add(podcastKey);
}
});
}
Future<void> _handleServerEpisodeDelete(PinepodsEpisode episode) async {
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsUserId != null) {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
settings.pinepodsUserId!,
episode.isYoutube,
);
if (success) {
// Remove from local state
setState(() {
_serverDownloads.removeWhere((e) => e.episodeId == episode.episodeId);
_serverDownloadsByPodcast = _groupEpisodesByPodcast(_serverDownloads);
_filterDownloads(); // Update filtered lists after removal
});
} else {
_showErrorSnackBar('Failed to delete episode from server');
}
}
} catch (e) {
log.severe('Error deleting server episode: $e');
_showErrorSnackBar('Error deleting episode: $e');
}
}
void _handleLocalEpisodeDelete(Episode episode) {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
episodeBloc.deleteDownload(episode);
// The episode bloc will automatically update the downloads stream
// which will trigger a UI refresh
}
void _showContextMenu(int episodeIndex, bool isServerEpisode) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
_isServerEpisode = isServerEpisode;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
_isServerEpisode = false;
});
}
Future<void> _localDownloadServerEpisode(int episodeIndex) async {
final episode = _serverDownloads[episodeIndex];
try {
// Convert PinepodsEpisode to Episode for local download
final localEpisode = Episode(
guid: 'pinepods_${episode.episodeId}_${DateTime.now().millisecondsSinceEpoch}',
pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}',
podcast: episode.podcastName,
title: episode.episodeTitle,
description: episode.episodeDescription,
imageUrl: episode.episodeArtwork,
contentUrl: episode.episodeUrl,
duration: episode.episodeDuration,
publicationDate: DateTime.tryParse(episode.episodePubDate),
author: episode.podcastName,
season: 0,
episode: 0,
position: episode.listenDuration ?? 0,
played: episode.completed,
chapters: [],
transcriptUrls: [],
);
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
// First save the episode to the repository so it can be tracked
await podcastBloc.podcastService.saveEpisode(localEpisode);
// Use the download service from podcast bloc
final success = await podcastBloc.downloadService.downloadEpisode(localEpisode);
if (success) {
_showSnackBar('Episode download started', Colors.green);
} else {
_showSnackBar('Failed to start download', Colors.red);
}
} catch (e) {
_showSnackBar('Error starting local download: $e', Colors.red);
}
_hideContextMenu();
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Widget _buildPodcastDropdown(String podcastKey, List<dynamic> episodes, {bool isServerDownload = false, String? displayName}) {
final isExpanded = _expandedPodcasts.contains(podcastKey);
final title = displayName ?? podcastKey;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Column(
children: [
ListTile(
leading: Icon(
isServerDownload ? Icons.cloud_download : Icons.file_download,
color: isServerDownload ? Colors.blue : Colors.green,
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${episodes.length} episode${episodes.length != 1 ? 's' : ''}' +
(episodes.length > 20 ? ' (showing 20 at a time)' : '')
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episodes.length > 20)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Large',
style: TextStyle(
fontSize: 10,
color: Colors.orange[800],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
),
],
),
onTap: () => _togglePodcastExpansion(podcastKey),
),
if (isExpanded)
PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: isServerDownload,
onEpisodeTap: isServerDownload
? (episode) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
}
: null,
onEpisodeLongPress: isServerDownload
? (episode, globalIndex) {
// Find the index in the full _serverDownloads list
final serverIndex = _serverDownloads.indexWhere((e) => e.episodeId == episode.episodeId);
_showContextMenu(serverIndex >= 0 ? serverIndex : globalIndex, true);
}
: null,
onPlayPressed: isServerDownload
? (episode) => _playServerEpisode(episode)
: (episode) => _playLocalEpisode(episode),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final episodeBloc = Provider.of<EpisodeBloc>(context);
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isServerEpisode) {
// Show server episode context menu
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: _serverDownloads[episodeIndex],
onDownload: () {
Navigator.of(context).pop();
_handleServerEpisodeDelete(_serverDownloads[episodeIndex]);
_hideContextMenu();
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadServerEpisode(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
}
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return StreamBuilder<BlocState>(
stream: episodeBloc.downloads,
builder: (context, snapshot) {
final localDownloadsState = snapshot.data;
List<Episode> currentLocalDownloads = [];
Map<String, List<Episode>> currentLocalDownloadsByPodcast = {};
if (localDownloadsState is BlocPopulatedState<List<Episode>>) {
currentLocalDownloads = localDownloadsState.results ?? [];
currentLocalDownloadsByPodcast = _groupLocalEpisodesByPodcast(currentLocalDownloads);
}
final isLoading = _isLoadingServerDownloads ||
_isLoadingLocalDownloads ||
(localDownloadsState is BlocLoadingState);
if (isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: PlatformProgressIndicator()),
);
}
// Update filtered local downloads when local downloads change
_filterLocalDownloads(currentLocalDownloadsByPodcast);
if (_errorMessage != null) {
// Check if this is a server connection error - show offline mode for downloads
if (_errorMessage!.isServerConnectionError) {
// Show offline downloads only with special UI
return _buildOfflineDownloadsView(_filteredLocalDownloadsByPodcast);
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!.userFriendlyMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadDownloads,
child: const Text('Retry'),
),
],
),
),
);
}
}
if (_filteredLocalDownloadsByPodcast.isEmpty && _filteredServerDownloadsByPodcast.isEmpty) {
if (_searchQuery.isNotEmpty) {
// Show no search results message
return MultiSliver(
children: [
_buildSearchBar(),
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No downloads found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'No downloads match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
);
} else {
// Show empty downloads message
return SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.download_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No downloads found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Downloaded episodes will appear here',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildDownloadsList(),
],
);
},
);
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter episodes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
);
}
Widget _buildDownloadsList() {
return SliverList(
delegate: SliverChildListDelegate([
// Local Downloads Section
if (_filteredLocalDownloadsByPodcast.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.smartphone, color: Colors.green[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Local Downloads'
: 'Local Downloads (${_countFilteredEpisodes(_filteredLocalDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green[600],
),
),
],
),
),
..._filteredLocalDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'local_$podcastName';
return _buildPodcastDropdown(
podcastKey,
episodes,
isServerDownload: false,
displayName: podcastName,
);
}).toList(),
],
// Server Downloads Section
if (_filteredServerDownloadsByPodcast.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
children: [
Icon(Icons.cloud_download, color: Colors.blue[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Server Downloads'
: 'Server Downloads (${_countFilteredEpisodes(_filteredServerDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue[600],
),
),
],
),
),
..._filteredServerDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'server_$podcastName';
return _buildPodcastDropdown(
podcastKey,
episodes,
isServerDownload: true,
displayName: podcastName,
);
}).toList(),
],
// Bottom padding
const SizedBox(height: 100),
]),
);
}
int _countFilteredEpisodes(Map<String, List<dynamic>> downloadsByPodcast) {
return downloadsByPodcast.values.fold(0, (sum, episodes) => sum + episodes.length);
}
void _playServerEpisode(PinepodsEpisode episode) {
// TODO: Implement server episode playback
// This would involve getting the stream URL from the server
// and playing it through the audio service
log.info('Playing server episode: ${episode.episodeTitle}');
_showErrorSnackBar('Server episode playback not yet implemented');
}
Future<void> _playLocalEpisode(Episode episode) async {
try {
log.info('Playing local episode: ${episode.title}');
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Use the regular audio player service for offline playback
// This bypasses the PinePods service and server dependencies
await audioPlayerService.playEpisode(episode: episode, resume: true);
log.info('Successfully started local episode playback');
} catch (e) {
log.severe('Error playing local episode: $e');
_showErrorSnackBar('Failed to play episode: $e');
}
}
Widget _buildOfflinePodcastDropdown(String podcastKey, List<Episode> episodes, {String? displayName}) {
final isExpanded = _expandedPodcasts.contains(podcastKey);
final title = displayName ?? podcastKey;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: Column(
children: [
ListTile(
leading: Icon(
Icons.offline_pin,
color: Colors.green[700],
),
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
'${episodes.length} episode${episodes.length != 1 ? 's' : ''} available offline'
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Offline',
style: TextStyle(
fontSize: 10,
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
),
],
),
onTap: () => _togglePodcastExpansion(podcastKey),
),
if (isExpanded)
PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: false,
isOfflineMode: true,
onPlayPressed: (episode) => _playLocalEpisode(episode),
),
],
),
);
}
Widget _buildOfflineDownloadsView(Map<String, List<Episode>> localDownloadsByPodcast) {
return MultiSliver(
children: [
// Offline banner
SliverToBoxAdapter(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Colors.orange[100],
border: Border.all(color: Colors.orange[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.cloud_off,
color: Colors.orange[800],
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Offline Mode',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange[800],
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'Server unavailable. Showing local downloads only.',
style: TextStyle(
color: Colors.orange[700],
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: () {
setState(() {
_errorMessage = null;
});
_loadDownloads();
},
icon: Icon(
Icons.refresh,
size: 16,
color: Colors.orange[800],
),
label: Text(
'Retry',
style: TextStyle(
color: Colors.orange[800],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange[50],
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
),
// Search bar for filtering local downloads
_buildSearchBar(),
// Local downloads content
if (localDownloadsByPodcast.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No local downloads',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Download episodes while online to access them here',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildListDelegate([
// Local downloads header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.smartphone, color: Colors.green[600]),
const SizedBox(width: 8),
Text(
_searchQuery.isEmpty
? 'Local Downloads'
: 'Local Downloads (${_countFilteredEpisodes(localDownloadsByPodcast)})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green[600],
),
),
],
),
),
// Local downloads by podcast
...localDownloadsByPodcast.entries.map((entry) {
final podcastName = entry.key;
final episodes = entry.value;
final podcastKey = 'offline_local_$podcastName';
return _buildOfflinePodcastDropdown(
podcastKey,
episodes,
displayName: podcastName,
);
}).toList(),
// Bottom padding
const SizedBox(height: 100),
]),
),
],
);
}
}

View File

@@ -0,0 +1,963 @@
// lib/ui/pinepods/episode_details.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/audio/default_audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/episode_description.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/podcast/mini_player.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
class PinepodsEpisodeDetails extends StatefulWidget {
final PinepodsEpisode initialEpisode;
const PinepodsEpisodeDetails({
Key? key,
required this.initialEpisode,
}) : super(key: key);
@override
State<PinepodsEpisodeDetails> createState() => _PinepodsEpisodeDetailsState();
}
class _PinepodsEpisodeDetailsState extends State<PinepodsEpisodeDetails> {
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
PinepodsEpisode? _episode;
bool _isLoading = true;
String _errorMessage = '';
List<Person> _persons = [];
bool _isDownloadedLocally = false;
@override
void initState() {
super.initState();
_episode = widget.initialEpisode;
_loadEpisodeDetails();
_checkLocalDownloadStatus();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _checkLocalDownloadStatus() async {
if (_episode == null) return;
final isDownloaded = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, _episode!);
if (mounted) {
setState(() {
_isDownloadedLocally = isDownloaded;
});
}
}
Future<void> _localDownloadEpisode() async {
if (_episode == null) return;
final success = await LocalDownloadUtils.localDownloadEpisode(context, _episode!);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
await _checkLocalDownloadStatus(); // Update button state
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload() async {
if (_episode == null) return;
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, _episode!);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
await _checkLocalDownloadStatus(); // Update button state
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
Future<void> _loadEpisodeDetails() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodeDetails = await _pinepodsService.getEpisodeMetadata(
_episode!.episodeId,
userId,
isYoutube: _episode!.isYoutube,
personEpisode: false, // Adjust if needed
);
if (episodeDetails != null) {
// Fetch podcast 2.0 data for persons information
final podcast2Data = await _pinepodsService.fetchPodcasting2Data(
episodeDetails.episodeId,
userId,
);
List<Person> persons = [];
if (podcast2Data != null) {
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();
print('Loaded ${persons.length} persons from episode 2.0 data');
} catch (e) {
print('Error parsing persons data: $e');
}
}
}
setState(() {
_episode = episodeDetails;
_persons = persons;
_isLoading = false;
});
} else {
setState(() {
_errorMessage = 'Failed to load episode details';
_isLoading = false;
});
}
} catch (e) {
setState(() {
_errorMessage = 'Error loading episode details: ${e.toString()}';
_isLoading = false;
});
}
}
bool _isCurrentEpisodePlaying() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
final currentEpisode = audioPlayerService.nowPlaying;
return currentEpisode != null && currentEpisode.guid == _episode!.episodeUrl;
} catch (e) {
return false;
}
}
bool _isAudioPlaying() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// This method is no longer needed since we're using StreamBuilder
return false;
} catch (e) {
return false;
}
}
Future<void> _togglePlayPause() async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Check if this episode is currently playing
if (_isCurrentEpisodePlaying()) {
// This episode is loaded, check current state and toggle
final currentState = audioPlayerService.playingState;
if (currentState != null) {
// Listen to the current state
final state = await currentState.first;
if (state == AudioState.playing) {
await audioPlayerService.pause();
} else {
await audioPlayerService.play();
}
} else {
await audioPlayerService.play();
}
} else {
// Start playing this episode
await playPinepodsEpisodeWithOptionalFullScreen(
context,
_audioService!,
_episode!,
resume: _episode!.isStarted,
);
}
} catch (e) {
_showSnackBar('Failed to control playback: ${e.toString()}', Colors.red);
}
}
Future<void> _handleTimestampTap(Duration timestamp) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
// Check if this episode is currently playing
final currentEpisode = audioPlayerService.nowPlaying;
final isCurrentEpisode = currentEpisode != null &&
currentEpisode.guid == _episode!.episodeUrl;
if (!isCurrentEpisode) {
// Start playing the episode first
await playPinepodsEpisodeWithOptionalFullScreen(
context,
_audioService!,
_episode!,
resume: false, // Start from beginning initially
);
// Wait a moment for the episode to start loading
await Future.delayed(const Duration(milliseconds: 500));
}
// Seek to the timestamp (convert Duration to seconds as int)
await audioPlayerService.seek(position: timestamp.inSeconds);
} catch (e) {
_showSnackBar('Failed to jump to timestamp: ${e.toString()}', Colors.red);
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
Future<void> _saveEpisode() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
Future<void> _removeSavedEpisode() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, saved: false);
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
Future<void> _toggleQueue() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.queued) {
success = await _pinepodsService.removeQueuedEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, queued: false);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
Future<void> _toggleDownload() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.downloaded) {
success = await _pinepodsService.deleteEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
}
} else {
success = await _pinepodsService.downloadEpisode(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update download', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating download: $e', Colors.red);
}
}
Future<void> _toggleComplete() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
bool success;
if (_episode!.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
_episode!.episodeId,
userId,
_episode!.isYoutube,
);
if (success) {
setState(() {
_episode = _updateEpisodeProperty(_episode!, completed: true);
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
podcastId: episode.podcastId,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Future<void> _navigateToPodcast() async {
if (_episode!.podcastId == null) {
_showSnackBar('Podcast ID not available', Colors.orange);
return;
}
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
// Fetch the actual podcast details to get correct episode count
final podcastDetails = await _pinepodsService.getPodcastDetailsById(_episode!.podcastId!, userId);
final podcast = UnifiedPinepodsPodcast(
id: _episode!.podcastId!,
indexId: 0,
title: _episode!.podcastName,
url: podcastDetails?['feedurl'] ?? '',
originalUrl: podcastDetails?['feedurl'] ?? '',
link: podcastDetails?['websiteurl'] ?? '',
description: podcastDetails?['description'] ?? '',
author: podcastDetails?['author'] ?? '',
ownerName: podcastDetails?['author'] ?? '',
image: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
artwork: podcastDetails?['artworkurl'] ?? _episode!.episodeArtwork,
lastUpdateTime: 0,
explicit: podcastDetails?['explicit'] ?? false,
episodeCount: podcastDetails?['episodecount'] ?? 0,
);
// Navigate to podcast details - same as podcast tile does
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: podcast,
isFollowing: true, // Assume following since we have a podcast ID
),
),
);
} catch (e) {
_showSnackBar('Error navigating to podcast: $e', Colors.red);
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return Scaffold(
appBar: AppBar(
title: const Text('Episode Details'),
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading episode details...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return Scaffold(
appBar: AppBar(
title: const Text('Episode Details'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadEpisodeDetails,
child: const Text('Retry'),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_episode!.podcastName),
elevation: 0,
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork and basic info
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _episode!.episodeArtwork.isNotEmpty
? Image.network(
_episode!.episodeArtwork,
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 48,
),
);
},
)
: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 48,
),
),
),
const SizedBox(width: 16),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Clickable podcast name
GestureDetector(
onTap: () => _navigateToPodcast(),
child: Text(
_episode!.podcastName,
style: Theme.of(context).textTheme.titleMedium!.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: Theme.of(context).primaryColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
_episode!.episodeTitle,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
_episode!.formattedDuration,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
_episode!.formattedPubDate,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Colors.grey[600],
),
),
if (_episode!.isStarted) ...[
const SizedBox(height: 8),
Text(
'Listened: ${_episode!.formattedListenDuration}',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).primaryColor,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: _episode!.progressPercentage / 100,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
],
],
),
),
],
),
const SizedBox(height: 24),
// Action buttons
Column(
children: [
// First row: Play, Save, Queue (3 buttons, each 1/3 width)
Row(
children: [
// Play/Pause button
Expanded(
child: StreamBuilder<AudioState>(
stream: Provider.of<AudioPlayerService>(context, listen: false).playingState,
builder: (context, snapshot) {
final isCurrentEpisode = _isCurrentEpisodePlaying();
final isPlaying = snapshot.data == AudioState.playing;
final isCurrentlyPlaying = isCurrentEpisode && isPlaying;
IconData icon;
String label;
if (_episode!.completed) {
icon = Icons.replay;
label = 'Replay';
} else if (isCurrentlyPlaying) {
icon = Icons.pause;
label = 'Pause';
} else {
icon = Icons.play_arrow;
label = 'Play';
}
return OutlinedButton.icon(
onPressed: _togglePlayPause,
icon: Icon(icon),
label: Text(label),
);
},
),
),
const SizedBox(width: 8),
// Save/Unsave button
Expanded(
child: OutlinedButton.icon(
onPressed: _episode!.saved ? _removeSavedEpisode : _saveEpisode,
icon: Icon(
_episode!.saved ? Icons.bookmark : Icons.bookmark_outline,
color: _episode!.saved ? Colors.orange : null,
),
label: Text(_episode!.saved ? 'Saved' : 'Save'),
),
),
const SizedBox(width: 8),
// Queue button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleQueue,
icon: Icon(
_episode!.queued ? Icons.queue_music : Icons.queue_music_outlined,
color: _episode!.queued ? Colors.purple : null,
),
label: Text(_episode!.queued ? 'Queued' : 'Queue'),
),
),
],
),
const SizedBox(height: 8),
// Second row: Download, Complete (2 buttons, each 1/2 width)
Row(
children: [
// Download button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleDownload,
icon: Icon(
_episode!.downloaded ? Icons.download_done : Icons.download_outlined,
color: _episode!.downloaded ? Colors.blue : null,
),
label: Text(_episode!.downloaded ? 'Downloaded' : 'Download'),
),
),
const SizedBox(width: 8),
// Complete button
Expanded(
child: OutlinedButton.icon(
onPressed: _toggleComplete,
icon: Icon(
_episode!.completed ? Icons.check_circle : Icons.check_circle_outline,
color: _episode!.completed ? Colors.green : null,
),
label: Text(_episode!.completed ? 'Complete' : 'Mark Complete'),
),
),
],
),
const SizedBox(height: 8),
// Third row: Local Download (full width)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isDownloadedLocally ? _deleteLocalDownload : _localDownloadEpisode,
icon: Icon(
_isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined,
color: _isDownloadedLocally ? Colors.red : Colors.green,
),
label: Text(_isDownloadedLocally ? 'Delete Local Download' : 'Download Locally'),
style: OutlinedButton.styleFrom(
side: BorderSide(
color: _isDownloadedLocally ? Colors.red : Colors.green,
),
),
),
),
],
),
],
),
// Hosts/Guests section
if (_persons.isNotEmpty) ...[
const SizedBox(height: 24),
Align(
alignment: Alignment.centerLeft,
child: Text(
'Hosts & Guests',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _persons.length,
itemBuilder: (context, index) {
final person = _persons[index];
return Container(
width: 70,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey[300],
),
child: person.image != null && person.image!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(25),
child: PodcastImage(
url: person.image!,
width: 50,
height: 50,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.person,
size: 30,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Text(
person.name,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
),
],
const SizedBox(height: 32),
// Episode description
Text(
'Description',
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
EpisodeDescription(
content: _episode!.episodeDescription,
onTimestampTap: _handleTimestampTap,
),
],
),
),
),
const MiniPlayer(),
],
),
);
}
@override
void dispose() {
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
}

View File

@@ -0,0 +1,817 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:pinepods_mobile/services/search_history_service.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/paginated_episode_list.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
/// Episode search page for finding episodes in user's subscriptions
///
/// This page allows users to search through episodes in their subscribed podcasts
/// with debounced search input and animated loading states.
class EpisodeSearchPage extends StatefulWidget {
const EpisodeSearchPage({Key? key}) : super(key: key);
@override
State<EpisodeSearchPage> createState() => _EpisodeSearchPageState();
}
class _EpisodeSearchPageState extends State<EpisodeSearchPage> with TickerProviderStateMixin {
final PinepodsService _pinepodsService = PinepodsService();
final SearchHistoryService _searchHistoryService = SearchHistoryService();
final TextEditingController _searchController = TextEditingController();
final FocusNode _focusNode = FocusNode();
Timer? _debounceTimer;
List<SearchEpisodeResult> _searchResults = [];
List<String> _searchHistory = [];
bool _isLoading = false;
bool _hasSearched = false;
bool _showHistory = false;
String? _errorMessage;
String _currentQuery = '';
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
// Animation controllers
late AnimationController _fadeAnimationController;
late AnimationController _slideAnimationController;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_setupAnimations();
_setupSearch();
}
void _setupAnimations() {
// Fade animation for results
_fadeAnimationController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _fadeAnimationController,
curve: Curves.easeInOut,
));
// Slide animation for search bar
_slideAnimationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0),
end: const Offset(0, -0.2),
).animate(CurvedAnimation(
parent: _slideAnimationController,
curve: Curves.easeInOut,
));
}
void _setupSearch() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
}
_searchController.addListener(_onSearchChanged);
_loadSearchHistory();
}
Future<void> _loadSearchHistory() async {
final history = await _searchHistoryService.getEpisodeSearchHistory();
if (mounted) {
setState(() {
_searchHistory = history;
});
}
}
void _selectHistoryItem(String searchTerm) {
_searchController.text = searchTerm;
_performSearch(searchTerm);
}
Future<void> _removeHistoryItem(String searchTerm) async {
await _searchHistoryService.removeEpisodeSearchTerm(searchTerm);
await _loadSearchHistory();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Playing ${episode.episodeTitle}'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showContextMenu(int episodeIndex) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode saved', Colors.green);
// Update local state
setState(() {
_searchResults[episodeIndex] = SearchEpisodeResult(
podcastId: _searchResults[episodeIndex].podcastId,
podcastName: _searchResults[episodeIndex].podcastName,
artworkUrl: _searchResults[episodeIndex].artworkUrl,
author: _searchResults[episodeIndex].author,
categories: _searchResults[episodeIndex].categories,
description: _searchResults[episodeIndex].description,
episodeCount: _searchResults[episodeIndex].episodeCount,
feedUrl: _searchResults[episodeIndex].feedUrl,
websiteUrl: _searchResults[episodeIndex].websiteUrl,
explicit: _searchResults[episodeIndex].explicit,
userId: _searchResults[episodeIndex].userId,
episodeId: _searchResults[episodeIndex].episodeId,
episodeTitle: _searchResults[episodeIndex].episodeTitle,
episodeDescription: _searchResults[episodeIndex].episodeDescription,
episodePubDate: _searchResults[episodeIndex].episodePubDate,
episodeArtwork: _searchResults[episodeIndex].episodeArtwork,
episodeUrl: _searchResults[episodeIndex].episodeUrl,
episodeDuration: _searchResults[episodeIndex].episodeDuration,
completed: _searchResults[episodeIndex].completed,
saved: true, // We just saved it
queued: _searchResults[episodeIndex].queued,
downloaded: _searchResults[episodeIndex].downloaded,
isYoutube: _searchResults[episodeIndex].isYoutube,
listenDuration: _searchResults[episodeIndex].listenDuration,
);
});
} else if (mounted) {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from saved', Colors.orange);
} else if (mounted) {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
_showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual download implementation would depend on download service integration
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
_showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange);
// Note: Actual delete implementation would depend on download service integration
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
_showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual local download implementation would depend on download service integration
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.queued) {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from queue', Colors.orange);
}
} else {
final success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode added to queue', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.completed) {
final success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as incomplete', Colors.orange);
}
} else {
final success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as complete', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating completion status: $e', Colors.red);
}
}
}
void _showSnackBar(String message, Color backgroundColor) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
}
void _onSearchChanged() {
final query = _searchController.text.trim();
setState(() {
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
});
if (_debounceTimer?.isActive ?? false) {
_debounceTimer!.cancel();
}
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
if (query.isNotEmpty && query != _currentQuery) {
_currentQuery = query;
_performSearch(query);
} else if (query.isEmpty) {
_clearResults();
}
});
}
Future<void> _performSearch(String query) async {
setState(() {
_isLoading = true;
_errorMessage = null;
_showHistory = false;
});
// Save search term to history
await _searchHistoryService.addEpisodeSearchTerm(query);
await _loadSearchHistory();
// Animate search bar to top
_slideAnimationController.forward();
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
throw Exception('Not logged in');
}
final results = await _pinepodsService.searchEpisodes(userId, query);
setState(() {
_searchResults = results;
_isLoading = false;
_hasSearched = true;
});
// Animate results in
_fadeAnimationController.forward();
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
_hasSearched = true;
_searchResults = [];
});
}
}
void _clearResults() {
setState(() {
_searchResults = [];
_hasSearched = false;
_errorMessage = null;
_currentQuery = '';
_showHistory = _searchHistory.isNotEmpty;
});
_fadeAnimationController.reset();
_slideAnimationController.reverse();
}
Widget _buildSearchBar() {
return SlideTransition(
position: _slideAnimation,
child: Container(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor.withOpacity(0.1),
Theme.of(context).primaryColor.withOpacity(0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: TextField(
controller: _searchController,
focusNode: _focusNode,
style: Theme.of(context).textTheme.bodyLarge,
onTap: () {
setState(() {
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
});
},
decoration: InputDecoration(
hintText: 'Search for episodes...',
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
prefixIcon: Icon(
Icons.search,
color: Theme.of(context).primaryColor,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: Theme.of(context).primaryColor,
),
onPressed: () {
_searchController.clear();
_clearResults();
_focusNode.requestFocus();
},
)
: null,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
),
),
),
),
);
}
Widget _buildLoadingIndicator() {
return Container(
padding: const EdgeInsets.all(64),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Searching...',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).primaryColor,
),
),
],
),
);
}
Widget _buildEmptyState() {
if (!_hasSearched) {
return Container(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Search Your Episodes',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Find episodes from your subscribed podcasts',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.center,
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).hintColor,
),
const SizedBox(height: 16),
Text(
'No Episodes Found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Try adjusting your search terms',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
),
],
),
);
}
Widget _buildErrorState() {
return Container(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Search Error',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
_errorMessage ?? 'Unknown error occurred',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_currentQuery.isNotEmpty) {
_performSearch(_currentQuery);
}
},
child: const Text('Try Again'),
),
],
),
);
}
Widget _buildResults() {
// Convert search results to PinepodsEpisode objects
final episodes = _searchResults.map((result) => result.toPinepodsEpisode()).toList();
return FadeTransition(
opacity: _fadeAnimation,
child: PaginatedEpisodeList(
episodes: episodes,
isServerEpisodes: true,
pageSize: 20, // Show 20 episodes at a time for good performance
onEpisodeTap: (episode) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onEpisodeLongPress: (episode, globalIndex) {
// Find the original index in _searchResults for context menu
final originalIndex = _searchResults.indexWhere(
(result) => result.episodeId == episode.episodeId
);
if (originalIndex != -1) {
_showContextMenu(originalIndex);
}
},
onPlayPressed: (episode) => _playEpisode(episode),
),
);
}
Widget _buildSearchHistory() {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Recent Searches',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_searchHistory.isNotEmpty)
TextButton(
onPressed: () async {
await _searchHistoryService.clearEpisodeSearchHistory();
await _loadSearchHistory();
},
child: Text(
'Clear All',
style: TextStyle(
color: Theme.of(context).hintColor,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 8),
..._searchHistory.take(10).map((searchTerm) => Card(
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
dense: true,
leading: Icon(
Icons.history,
color: Theme.of(context).hintColor,
size: 20,
),
title: Text(
searchTerm,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: Icon(
Icons.close,
size: 18,
color: Theme.of(context).hintColor,
),
onPressed: () => _removeHistoryItem(searchTerm),
),
onTap: () => _selectHistoryItem(searchTerm),
),
)).toList(),
],
),
);
}
@override
Widget build(BuildContext context) {
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!; // Store locally to avoid null issues
final episode = _searchResults[episodeIndex].toPinepodsEpisode();
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return SliverFillRemaining(
child: GestureDetector(
onTap: () {
// Dismiss keyboard when tapping outside
FocusScope.of(context).unfocus();
},
child: Column(
children: [
_buildSearchBar(),
Expanded(
child: SingleChildScrollView(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _showHistory
? _buildSearchHistory()
: _isLoading
? _buildLoadingIndicator()
: _errorMessage != null
? _buildErrorState()
: _searchResults.isEmpty
? _buildEmptyState()
: _buildResults(),
),
),
),
],
),
),
);
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchController.dispose();
_focusNode.dispose();
_fadeAnimationController.dispose();
_slideAnimationController.dispose();
super.dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,745 @@
// lib/ui/pinepods/history.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsHistory extends StatefulWidget {
const PinepodsHistory({Key? key}) : super(key: key);
@override
State<PinepodsHistory> createState() => _PinepodsHistoryState();
}
class _PinepodsHistoryState extends State<PinepodsHistory> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
List<PinepodsEpisode> _filteredEpisodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadHistory();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterEpisodes();
});
}
void _filterEpisodes() {
if (_searchQuery.isEmpty) {
_filteredEpisodes = List.from(_episodes);
} else {
_filteredEpisodes = _episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) ||
episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadHistory() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodes = await _pinepodsService.getUserHistory(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
// Sort episodes by publication date (newest first)
_episodes.sort((a, b) {
try {
final dateA = DateTime.parse(a.episodePubDate);
final dateB = DateTime.parse(b.episodePubDate);
return dateB.compareTo(dateA); // Newest first
} catch (e) {
return 0; // Keep original order if parsing fails
}
});
_filterEpisodes(); // Initialize filtered list
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load listening history: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadHistory();
}
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('Starting ${episode.episodeTitle}...'),
],
),
duration: const Duration(seconds: 2),
),
);
await _audioService!.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: episode.isStarted,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now playing: ${episode.episodeTitle}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
isDownloadedLocally: isDownloadedLocally,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onDeleteLocalDownload: () {
Navigator.of(context).pop();
_deleteLocalDownload(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
},
),
);
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.downloadEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Episode download queued!', Colors.green);
} else {
_showSnackBar('Failed to queue download', Colors.red);
}
} catch (e) {
_showSnackBar('Error downloading episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Episode deleted from server', Colors.orange);
} else {
_showSnackBar('Failed to delete episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error deleting episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.queued) {
success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
_filterEpisodes(); // Update filtered list to reflect changes
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
_hideContextMenu();
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading listening history...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverServerErrorPage(
errorMessage: _errorMessage.isServerConnectionError
? null
: _errorMessage,
onRetry: _refresh,
title: 'History Unavailable',
subtitle: _errorMessage.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load listening history',
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No listening history',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you listen to will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildEpisodesList(),
],
);
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter episodes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
);
}
Widget _buildEpisodesList() {
// Check if search returned no results
if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No episodes match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
// Header
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_searchQuery.isEmpty
? 'Listening History'
: 'Search Results (${_filteredEpisodes.length})',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
);
}
// Episodes (index - 1 because of header)
final episodeIndex = index - 1;
final episode = _filteredEpisodes[episodeIndex];
// Find the original index for context menu operations
final originalIndex = _episodes.indexOf(episode);
return PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: originalIndex >= 0 ? () => _showContextMenu(originalIndex) : null,
onPlayPressed: () => _playEpisode(episode),
);
},
childCount: _filteredEpisodes.length + 1, // +1 for header
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
// lib/ui/pinepods/more_menu.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/ui/library/downloads.dart';
import 'package:pinepods_mobile/ui/settings/settings.dart';
import 'package:pinepods_mobile/ui/pinepods/saved.dart';
import 'package:pinepods_mobile/ui/pinepods/history.dart';
class PinepodsMoreMenu extends StatelessWidget {
// Constructor with optional key parameter
const PinepodsMoreMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'More Options',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildMenuItem(
context,
'Downloads',
Icons.download_outlined,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Downloads')),
body: const CustomScrollView(
slivers: [Downloads()],
),
),
),
),
),
_buildMenuItem(
context,
'Saved Episodes',
Icons.bookmark_outline,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Saved Episodes')),
body: const CustomScrollView(
slivers: [PinepodsSaved()],
),
),
),
),
),
_buildMenuItem(
context,
'History',
Icons.history,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: false,
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('History')),
body: const CustomScrollView(
slivers: [PinepodsHistory()],
),
),
),
),
),
_buildMenuItem(
context,
'Settings',
Icons.settings_outlined,
() => Navigator.push(
context,
MaterialPageRoute<void>(
fullscreenDialog: true,
settings: const RouteSettings(name: 'settings'),
builder: (context) => const Settings(),
),
),
),
],
),
),
]),
);
}
Widget _buildMenuItem(
BuildContext context,
String title,
IconData icon,
VoidCallback onTap,
) {
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
child: ListTile(
leading: Icon(icon),
title: Text(title),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
),
);
}
}

View File

@@ -0,0 +1,572 @@
// lib/ui/pinepods/playlist_episodes.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:provider/provider.dart';
class PlaylistEpisodesPage extends StatefulWidget {
final PlaylistData playlist;
const PlaylistEpisodesPage({
Key? key,
required this.playlist,
}) : super(key: key);
@override
State<PlaylistEpisodesPage> createState() => _PlaylistEpisodesPageState();
}
class _PlaylistEpisodesPageState extends State<PlaylistEpisodesPage> {
final PinepodsService _pinepodsService = PinepodsService();
PlaylistEpisodesResponse? _playlistResponse;
bool _isLoading = true;
String? _errorMessage;
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
@override
void initState() {
super.initState();
_loadPlaylistEpisodes();
}
Future<void> _loadPlaylistEpisodes() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final response = await _pinepodsService.getPlaylistEpisodes(
settings.pinepodsUserId!,
widget.playlist.playlistId,
);
setState(() {
_playlistResponse = response;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
String _getEmptyStateMessage() {
switch (widget.playlist.name) {
case 'Fresh Releases':
return 'No new episodes have been released in the last 24 hours. Check back later for fresh content!';
case 'Currently Listening':
return 'Start listening to some episodes and they\'ll appear here for easy access.';
case 'Almost Done':
return 'You don\'t have any episodes that are near completion. Keep listening!';
default:
return 'No episodes match the current playlist criteria. Try adjusting the filters or add more podcasts.';
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
_showSnackBar('Audio service not available', Colors.red);
return;
}
try {
await _audioService!.playPinepodsEpisode(pinepodsEpisode: episode);
} catch (e) {
if (mounted) {
_showSnackBar('Failed to play episode: $e', Colors.red);
}
}
}
void _showContextMenu(int episodeIndex) {
setState(() {
_contextMenuEpisodeIndex = episodeIndex;
});
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode saved', Colors.green);
} else if (mounted) {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
}
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from saved', Colors.orange);
} else if (mounted) {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
if (mounted) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
}
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual download implementation would depend on download service integration
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Delete requested for ${episode.episodeTitle}', Colors.orange);
// Note: Actual delete implementation would depend on download service integration
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
_showSnackBar('Local download started for ${episode.episodeTitle}', Colors.blue);
// Note: Actual local download implementation would depend on download service integration
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.queued) {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode removed from queue', Colors.orange);
}
} else {
final success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode added to queue', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
}
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _playlistResponse!.episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
try {
if (episode.completed) {
final success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as incomplete', Colors.orange);
}
} else {
final success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success && mounted) {
_showSnackBar('Episode marked as complete', Colors.green);
}
}
} catch (e) {
if (mounted) {
_showSnackBar('Error updating completion status: $e', Colors.red);
}
}
}
void _showSnackBar(String message, Color backgroundColor) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
// Show context menu as a modal overlay if needed
if (_contextMenuEpisodeIndex != null) {
final episodeIndex = _contextMenuEpisodeIndex!;
final episode = _playlistResponse!.episodes[episodeIndex];
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
_hideContextMenu();
},
),
);
});
// Reset the context menu index after storing it locally
_contextMenuEpisodeIndex = null;
}
return Scaffold(
appBar: AppBar(
title: Text(widget.playlist.name),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
PlatformProgressIndicator(),
SizedBox(height: 16),
Text('Loading playlist episodes...'),
],
),
);
}
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 75,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Error loading playlist',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPlaylistEpisodes,
child: const Text('Retry'),
),
],
),
),
);
}
if (_playlistResponse == null) {
return const Center(
child: Text('No data available'),
);
}
return CustomScrollView(
slivers: [
// Playlist header
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(_playlistResponse!.playlistInfo.iconName),
size: 48,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_playlistResponse!.playlistInfo.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (_playlistResponse!.playlistInfo.description != null &&
_playlistResponse!.playlistInfo.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_playlistResponse!.playlistInfo.description!,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'${_playlistResponse!.playlistInfo.episodeCount ?? _playlistResponse!.episodes.length} episodes',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
],
),
),
],
),
],
),
),
),
// Episodes list
if (_playlistResponse!.episodes.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.playlist_remove,
size: 75,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'No Episodes Found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
_getEmptyStateMessage(),
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = _playlistResponse!.episodes[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
child: PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
childCount: _playlistResponse!.episodes.length,
),
),
],
);
}
}

View File

@@ -0,0 +1,546 @@
// lib/ui/pinepods/playlists.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/ui/pinepods/playlist_episodes.dart';
import 'package:pinepods_mobile/ui/pinepods/create_playlist.dart';
import 'package:provider/provider.dart';
class PinepodsPlaylists extends StatefulWidget {
const PinepodsPlaylists({Key? key}) : super(key: key);
@override
State<PinepodsPlaylists> createState() => _PinepodsPlaylistsState();
}
class _PinepodsPlaylistsState extends State<PinepodsPlaylists> {
final PinepodsService _pinepodsService = PinepodsService();
List<PlaylistData>? _playlists;
bool _isLoading = true;
String? _errorMessage;
Set<int> _selectedPlaylists = {};
bool _isSelectionMode = false;
@override
void initState() {
super.initState();
_loadPlaylists();
}
/// Calculate responsive cross axis count for playlist grid
int _getPlaylistCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for playlist cards
double _getPlaylistAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for multi-line descriptions + padding
return 1.8; // Allows space for title + 2-3 lines of description + proper padding
}
return 1.1; // Standard aspect ratio for multi-column layouts
}
Future<void> _loadPlaylists() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final playlists = await _pinepodsService.getUserPlaylists(settings.pinepodsUserId!);
setState(() {
_playlists = playlists;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedPlaylists.clear();
}
});
}
void _togglePlaylistSelection(int playlistId) {
setState(() {
if (_selectedPlaylists.contains(playlistId)) {
_selectedPlaylists.remove(playlistId);
} else {
_selectedPlaylists.add(playlistId);
}
});
}
Future<void> _deleteSelectedPlaylists() async {
if (_selectedPlaylists.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playlists'),
content: Text('Are you sure you want to delete ${_selectedPlaylists.length} playlist${_selectedPlaylists.length == 1 ? '' : 's'}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
try {
for (final playlistId in _selectedPlaylists) {
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlistId);
}
setState(() {
_selectedPlaylists.clear();
_isSelectionMode = false;
});
_loadPlaylists(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlists deleted successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting playlists: $e')),
);
}
}
}
Future<void> _deletePlaylist(PlaylistData playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playlist'),
content: Text('Are you sure you want to delete "${playlist.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
try {
await _pinepodsService.deletePlaylist(settings.pinepodsUserId!, playlist.playlistId);
_loadPlaylists(); // Refresh the list
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playlist deleted successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting playlist: $e')),
);
}
}
}
void _openPlaylist(PlaylistData playlist) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlaylistEpisodesPage(playlist: playlist),
),
);
}
void _createPlaylist() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CreatePlaylistPage(),
),
);
if (result == true) {
_loadPlaylists(); // Refresh the list
}
}
IconData _getPlaylistIcon(String? iconName) {
if (iconName == null) return Icons.playlist_play;
// Map common icon names to Material icons
switch (iconName) {
case 'ph-playlist':
return Icons.playlist_play;
case 'ph-music-notes':
return Icons.music_note;
case 'ph-play-circle':
return Icons.play_circle;
case 'ph-headphones':
return Icons.headphones;
case 'ph-star':
return Icons.star;
case 'ph-heart':
return Icons.favorite;
case 'ph-bookmark':
return Icons.bookmark;
case 'ph-clock':
return Icons.access_time;
case 'ph-calendar':
return Icons.calendar_today;
case 'ph-timer':
return Icons.timer;
case 'ph-shuffle':
return Icons.shuffle;
case 'ph-repeat':
return Icons.repeat;
case 'ph-microphone':
return Icons.mic;
case 'ph-queue':
return Icons.queue_music;
default:
return Icons.playlist_play;
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
if (_errorMessage != null) {
return SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: _loadPlaylists,
title: 'Playlists Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load your playlists',
);
}
if (_playlists == null || _playlists!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.playlist_play,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No playlists found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Create a smart playlist to get started!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _createPlaylist,
icon: const Icon(Icons.add),
label: const Text('Create Playlist'),
),
],
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate([
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with action buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Smart Playlists',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
if (_isSelectionMode) ...[
IconButton(
icon: const Icon(Icons.close),
onPressed: _toggleSelectionMode,
tooltip: 'Cancel',
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: _selectedPlaylists.isNotEmpty ? _deleteSelectedPlaylists : null,
tooltip: 'Delete selected (${_selectedPlaylists.length})',
),
] else ...[
IconButton(
icon: const Icon(Icons.select_all),
onPressed: _toggleSelectionMode,
tooltip: 'Select multiple',
),
IconButton(
icon: const Icon(Icons.add),
onPressed: _createPlaylist,
tooltip: 'Create playlist',
),
],
],
),
],
),
// Info banner for selection mode
if (_isSelectionMode)
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).primaryColor.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.info_outline,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'System playlists cannot be deleted.',
style: TextStyle(fontSize: 14),
),
),
],
),
),
const SizedBox(height: 8),
// Playlists grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _getPlaylistCrossAxisCount(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: _getPlaylistAspectRatio(context),
),
itemCount: _playlists!.length,
itemBuilder: (context, index) {
final playlist = _playlists![index];
final isSelected = _selectedPlaylists.contains(playlist.playlistId);
final canSelect = _isSelectionMode && !playlist.isSystemPlaylist;
return GestureDetector(
onTap: () {
if (_isSelectionMode && !playlist.isSystemPlaylist) {
_togglePlaylistSelection(playlist.playlistId);
} else if (!_isSelectionMode) {
_openPlaylist(playlist);
}
},
child: Card(
elevation: isSelected ? 8 : 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.1)
: null,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_getPlaylistIcon(playlist.iconName),
size: 32,
color: Theme.of(context).primaryColor,
),
const Spacer(),
if (playlist.isSystemPlaylist)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'System',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.secondary,
),
),
),
],
),
const SizedBox(height: 12),
Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${playlist.episodeCount ?? 0} episodes',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
if (playlist.description != null && playlist.description!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
playlist.description!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.bodySmall?.color,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// Selection checkbox
if (canSelect)
Positioned(
top: 8,
left: 8,
child: Checkbox(
value: isSelected,
onChanged: (value) {
_togglePlaylistSelection(playlist.playlistId);
},
),
),
// Delete button for non-system playlists (when not in selection mode)
if (!_isSelectionMode && !playlist.isSystemPlaylist)
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: () => _deletePlaylist(playlist),
color: Theme.of(context).colorScheme.error.withOpacity(0.7),
),
),
],
),
),
);
},
),
],
),
),
]),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_podcast_tile.dart';
import 'package:pinepods_mobile/ui/widgets/layout_selector.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
/// This class displays the list of podcasts the user is subscribed to on the PinePods server.
class PinepodsPodcasts extends StatefulWidget {
const PinepodsPodcasts({
super.key,
});
@override
State<PinepodsPodcasts> createState() => _PinepodsPodcastsState();
}
class _PinepodsPodcastsState extends State<PinepodsPodcasts> {
List<Podcast>? _podcasts;
List<Podcast>? _filteredPodcasts;
bool _isLoading = true;
String? _errorMessage;
final PinepodsService _pinepodsService = PinepodsService();
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadPodcasts();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterPodcasts();
});
}
void _filterPodcasts() {
if (_podcasts == null) {
_filteredPodcasts = null;
return;
}
if (_searchQuery.isEmpty) {
_filteredPodcasts = List.from(_podcasts!);
} else {
_filteredPodcasts = _podcasts!.where((podcast) {
return podcast.title.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
Future<void> _loadPodcasts() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please connect in Settings.';
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Initialize the service with the stored credentials
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final podcasts = await _pinepodsService.getUserPodcasts(settings.pinepodsUserId!);
setState(() {
_podcasts = podcasts;
_filterPodcasts(); // Initialize filtered list
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter podcasts...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
const SizedBox(width: 12),
Material(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Theme.of(context).secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_layout_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) => const LayoutSelectorWidget(),
);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.dashboard,
size: 20,
),
),
),
),
],
),
),
);
}
Widget _buildPodcastList(AppSettings settings) {
final podcasts = _filteredPodcasts ?? [];
if (podcasts.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search_off,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No podcasts match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
var mode = settings.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
// List view
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PinepodsPodcastTile(podcast: podcasts[index]);
},
childCount: podcasts.length,
addAutomaticKeepAlives: false,
),
);
}
// Grid view
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return PinepodsPodcastGridTile(podcast: podcasts[index]);
},
childCount: podcasts.length,
),
);
}
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
if (_isLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
}
if (_errorMessage != null) {
return SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: _loadPodcasts,
title: 'Podcasts Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load your podcasts',
);
}
if (_podcasts == null || _podcasts!.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.podcasts,
size: 75,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'You haven\'t subscribed to any podcasts yet. Search for podcasts to get started!',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
return MultiSliver(
children: [
_buildSearchBar(),
_buildPodcastList(settingsSnapshot.data!),
],
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
},
);
}
}

View File

@@ -0,0 +1,805 @@
// lib/ui/pinepods/queue.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
class PinepodsQueue extends StatefulWidget {
const PinepodsQueue({Key? key}) : super(key: key);
@override
State<PinepodsQueue> createState() => _PinepodsQueueState();
}
class _PinepodsQueueState extends State<PinepodsQueue> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
// Auto-scroll related variables
bool _isDragging = false;
bool _isAutoScrolling = false;
@override
void initState() {
super.initState();
_loadQueuedEpisodes();
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadQueuedEpisodes() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodes = await _pinepodsService.getQueuedEpisodes(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load queued episodes: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadQueuedEpisodes();
}
Future<void> _reorderEpisodes(int oldIndex, int newIndex) async {
// Adjust indices if moving down the list
if (newIndex > oldIndex) {
newIndex -= 1;
}
// Update local state immediately for smooth UI
setState(() {
final episode = _episodes.removeAt(oldIndex);
_episodes.insert(newIndex, episode);
});
// Get episode IDs in new order
final episodeIds = _episodes.map((e) => e.episodeId).toList();
// Call API to update order on server
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
if (!success) {
_showSnackBar('Failed to update queue order', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
}
} catch (e) {
_showSnackBar('Error updating queue order: $e', Colors.red);
// Reload to restore original order if API call fails
await _loadQueuedEpisodes();
}
}
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('Starting ${episode.episodeTitle}...'),
],
),
duration: const Duration(seconds: 2),
),
);
await _audioService!.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: episode.isStarted,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now playing: ${episode.episodeTitle}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
isDownloadedLocally: isDownloadedLocally,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onDeleteLocalDownload: () {
Navigator.of(context).pop();
_deleteLocalDownload(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
},
),
);
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: false);
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.downloadEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
} else {
_showSnackBar('Failed to queue download', Colors.red);
}
} catch (e) {
_showSnackBar('Error downloading episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
} else {
_showSnackBar('Failed to delete episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error deleting episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.queued) {
success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
// REMOVE the episode from the list since it's no longer queued
setState(() {
_episodes.removeAt(episodeIndex);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
// This shouldn't happen since all episodes here are already queued
// But just in case, we'll handle it
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
_hideContextMenu();
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
void _startAutoScroll(bool scrollUp) async {
if (_isAutoScrolling) return;
_isAutoScrolling = true;
while (_isDragging && _isAutoScrolling) {
// Find the nearest ScrollView controller
final ScrollController? scrollController = Scrollable.maybeOf(context)?.widget.controller;
if (scrollController != null && scrollController.hasClients) {
final currentOffset = scrollController.offset;
final maxScrollExtent = scrollController.position.maxScrollExtent;
if (scrollUp && currentOffset > 0) {
// Scroll up
final newOffset = (currentOffset - 8.0).clamp(0.0, maxScrollExtent);
scrollController.jumpTo(newOffset);
} else if (!scrollUp && currentOffset < maxScrollExtent) {
// Scroll down
final newOffset = (currentOffset + 8.0).clamp(0.0, maxScrollExtent);
scrollController.jumpTo(newOffset);
} else {
break; // Reached the edge
}
}
await Future.delayed(const Duration(milliseconds: 16));
}
_isAutoScrolling = false;
}
void _stopAutoScroll() {
_isAutoScrolling = false;
}
void _checkAutoScroll(double globalY) {
if (!_isDragging) return;
final MediaQueryData mediaQuery = MediaQuery.of(context);
final double screenHeight = mediaQuery.size.height;
final double topPadding = mediaQuery.padding.top;
final double bottomPadding = mediaQuery.padding.bottom;
const double autoScrollThreshold = 80.0;
if (globalY < topPadding + autoScrollThreshold) {
// Near top, scroll up
if (!_isAutoScrolling) {
_startAutoScroll(true);
}
} else if (globalY > screenHeight - bottomPadding - autoScrollThreshold) {
// Near bottom, scroll down
if (!_isAutoScrolling) {
_startAutoScroll(false);
}
} else {
// In the middle, stop auto-scrolling
_stopAutoScroll();
}
}
@override
void dispose() {
_stopAutoScroll();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading queue...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refresh,
child: const Text('Retry'),
),
],
),
),
),
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue_music_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No queued episodes',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you queue will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return _buildEpisodesList();
}
Widget _buildEpisodesList() {
return SliverMainAxisGroup(
slivers: [
// Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Queue',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Text(
'Drag to reorder',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
],
),
),
),
// Auto-scrolling reorderable episodes list wrapped with pointer detection
SliverToBoxAdapter(
child: Listener(
onPointerMove: (details) {
if (_isDragging) {
_checkAutoScroll(details.position.dy);
}
},
child: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderStart: (index) {
setState(() {
_isDragging = true;
});
},
onReorderEnd: (index) {
setState(() {
_isDragging = false;
});
_stopAutoScroll();
},
onReorder: _reorderEpisodes,
itemCount: _episodes.length,
itemBuilder: (context, index) {
final episode = _episodes[index];
return Container(
key: ValueKey(episode.episodeId),
margin: const EdgeInsets.only(bottom: 4),
child: DraggableQueueEpisodeCard(
episode: episode,
index: index,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(index),
onPlayPressed: () => _playEpisode(episode),
),
);
},
),
),
),
],
);
}
}

View File

@@ -0,0 +1,730 @@
// lib/ui/pinepods/saved.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_context_menu.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/pinepods/episode_details.dart';
import 'package:pinepods_mobile/ui/utils/local_download_utils.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:pinepods_mobile/ui/utils/position_utils.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:pinepods_mobile/services/global_services.dart';
import 'package:provider/provider.dart';
import 'package:sliver_tools/sliver_tools.dart';
class PinepodsSaved extends StatefulWidget {
const PinepodsSaved({Key? key}) : super(key: key);
@override
State<PinepodsSaved> createState() => _PinepodsSavedState();
}
class _PinepodsSavedState extends State<PinepodsSaved> {
bool _isLoading = false;
String _errorMessage = '';
List<PinepodsEpisode> _episodes = [];
List<PinepodsEpisode> _filteredEpisodes = [];
final PinepodsService _pinepodsService = PinepodsService();
// Use global audio service instead of creating local instance
int? _contextMenuEpisodeIndex;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
_loadSavedEpisodes();
_searchController.addListener(_onSearchChanged);
}
@override
void dispose() {
_searchController.dispose();
// Don't dispose global audio service - it should persist across pages
super.dispose();
}
void _onSearchChanged() {
setState(() {
_searchQuery = _searchController.text;
_filterEpisodes();
});
}
void _filterEpisodes() {
if (_searchQuery.isEmpty) {
_filteredEpisodes = List.from(_episodes);
} else {
_filteredEpisodes = _episodes.where((episode) {
return episode.episodeTitle.toLowerCase().contains(_searchQuery.toLowerCase()) ||
episode.podcastName.toLowerCase().contains(_searchQuery.toLowerCase());
}).toList();
}
}
PinepodsAudioService? get _audioService => GlobalServices.pinepodsAudioService;
Future<void> _loadSavedEpisodes() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server. Please login first.';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
GlobalServices.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
final userId = settings.pinepodsUserId!;
final episodes = await _pinepodsService.getSavedEpisodes(userId);
// Enrich episodes with best available positions (local vs server)
final enrichedEpisodes = await PositionUtils.enrichEpisodesWithBestPositions(
context,
_pinepodsService,
episodes,
userId,
);
setState(() {
_episodes = enrichedEpisodes;
_filterEpisodes(); // Initialize filtered list
_isLoading = false;
});
// After loading episodes, check their local download status
await LocalDownloadUtils.loadLocalDownloadStatuses(context, enrichedEpisodes);
} catch (e) {
setState(() {
_errorMessage = 'Failed to load saved episodes: ${e.toString()}';
_isLoading = false;
});
}
}
Future<void> _refresh() async {
// Clear local download status cache on refresh
LocalDownloadUtils.clearCache();
await _loadSavedEpisodes();
}
Future<void> _playEpisode(PinepodsEpisode episode) async {
if (_audioService == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Audio service not available'),
backgroundColor: Colors.red,
),
);
return;
}
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 12),
Text('Starting ${episode.episodeTitle}...'),
],
),
duration: const Duration(seconds: 2),
),
);
await _audioService!.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: episode.isStarted,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Now playing: ${episode.episodeTitle}'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to play episode: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
Future<void> _showContextMenu(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final isDownloadedLocally = await LocalDownloadUtils.isEpisodeDownloadedLocally(context, episode);
if (!mounted) return;
showDialog(
context: context,
barrierColor: Colors.black.withOpacity(0.3),
builder: (context) => EpisodeContextMenu(
episode: episode,
isDownloadedLocally: isDownloadedLocally,
onSave: () {
Navigator.of(context).pop();
_saveEpisode(episodeIndex);
},
onRemoveSaved: () {
Navigator.of(context).pop();
_removeSavedEpisode(episodeIndex);
},
onDownload: episode.downloaded
? () {
Navigator.of(context).pop();
_deleteEpisode(episodeIndex);
}
: () {
Navigator.of(context).pop();
_downloadEpisode(episodeIndex);
},
onLocalDownload: () {
Navigator.of(context).pop();
_localDownloadEpisode(episodeIndex);
},
onDeleteLocalDownload: () {
Navigator.of(context).pop();
_deleteLocalDownload(episodeIndex);
},
onQueue: () {
Navigator.of(context).pop();
_toggleQueueEpisode(episodeIndex);
},
onMarkComplete: () {
Navigator.of(context).pop();
_toggleMarkComplete(episodeIndex);
},
onDismiss: () {
Navigator.of(context).pop();
},
),
);
}
Future<void> _localDownloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final success = await LocalDownloadUtils.localDownloadEpisode(context, episode);
if (success) {
LocalDownloadUtils.showSnackBar(context, 'Episode download started', Colors.green);
} else {
LocalDownloadUtils.showSnackBar(context, 'Failed to start download', Colors.red);
}
}
Future<void> _deleteLocalDownload(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final deletedCount = await LocalDownloadUtils.deleteLocalDownload(context, episode);
if (deletedCount > 0) {
LocalDownloadUtils.showSnackBar(
context,
'Deleted $deletedCount local download${deletedCount > 1 ? 's' : ''}',
Colors.orange
);
} else {
LocalDownloadUtils.showSnackBar(context, 'Local download not found', Colors.red);
}
}
void _hideContextMenu() {
setState(() {
_contextMenuEpisodeIndex = null;
});
}
Future<void> _saveEpisode(int episodeIndex) async {
// This shouldn't be called since all episodes here are already saved
// But just in case, we'll handle it
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.saveEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(_episodes[episodeIndex], saved: true);
});
_showSnackBar('Episode saved!', Colors.green);
} else {
_showSnackBar('Failed to save episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error saving episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _removeSavedEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.removeSavedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
// REMOVE the episode from the list since it's no longer saved
setState(() {
_episodes.removeAt(episodeIndex);
_filterEpisodes(); // Update filtered list after removal
});
_showSnackBar('Removed from saved episodes', Colors.orange);
} else {
_showSnackBar('Failed to remove saved episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error removing saved episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _downloadEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.downloadEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: true);
});
_showSnackBar('Episode download queued!', Colors.green);
} else {
_showSnackBar('Failed to queue download', Colors.red);
}
} catch (e) {
_showSnackBar('Error downloading episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _deleteEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final success = await _pinepodsService.deleteEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, downloaded: false);
});
_showSnackBar('Episode deleted from server', Colors.orange);
} else {
_showSnackBar('Failed to delete episode', Colors.red);
}
} catch (e) {
_showSnackBar('Error deleting episode: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleQueueEpisode(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.queued) {
success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: false);
});
_showSnackBar('Removed from queue', Colors.orange);
}
} else {
success = await _pinepodsService.queueEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, queued: true);
});
_showSnackBar('Added to queue!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update queue', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating queue: $e', Colors.red);
}
_hideContextMenu();
}
Future<void> _toggleMarkComplete(int episodeIndex) async {
final episode = _episodes[episodeIndex];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in', Colors.red);
return;
}
_pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
bool success;
if (episode.completed) {
success = await _pinepodsService.markEpisodeUncompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: false);
});
_showSnackBar('Marked as incomplete', Colors.orange);
}
} else {
success = await _pinepodsService.markEpisodeCompleted(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_episodes[episodeIndex] = _updateEpisodeProperty(episode, completed: true);
});
_showSnackBar('Marked as complete!', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to update completion status', Colors.red);
}
} catch (e) {
_showSnackBar('Error updating completion: $e', Colors.red);
}
_hideContextMenu();
}
PinepodsEpisode _updateEpisodeProperty(
PinepodsEpisode episode, {
bool? saved,
bool? downloaded,
bool? queued,
bool? completed,
}) {
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: episode.listenDuration,
episodeId: episode.episodeId,
completed: completed ?? episode.completed,
saved: saved ?? episode.saved,
queued: queued ?? episode.queued,
downloaded: downloaded ?? episode.downloaded,
isYoutube: episode.isYoutube,
);
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading saved episodes...'),
],
),
),
);
}
if (_errorMessage.isNotEmpty) {
return SliverServerErrorPage(
errorMessage: _errorMessage.isServerConnectionError
? null
: _errorMessage,
onRetry: _refresh,
title: 'Saved Episodes Unavailable',
subtitle: _errorMessage.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to load saved episodes',
);
}
if (_episodes.isEmpty) {
return const SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_outline,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No saved episodes',
style: TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'Episodes you save will appear here',
style: TextStyle(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return MultiSliver(
children: [
_buildSearchBar(),
_buildEpisodesList(),
],
);
}
Widget _buildSearchBar() {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Filter episodes...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: Theme.of(context).cardColor,
),
),
),
);
}
Widget _buildEpisodesList() {
// Check if search returned no results
if (_filteredEpisodes.isEmpty && _searchQuery.isNotEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 16),
Text(
'No episodes found',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'No episodes match "$_searchQuery"',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
// Header
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_searchQuery.isEmpty
? 'Saved Episodes'
: 'Search Results (${_filteredEpisodes.length})',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _refresh,
),
],
),
);
}
// Episodes (index - 1 because of header)
final episodeIndex = index - 1;
final episode = _filteredEpisodes[episodeIndex];
// Find the original index for context menu operations
final originalIndex = _episodes.indexOf(episode);
return PinepodsEpisodeCard(
episode: episode,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsEpisodeDetails(
initialEpisode: episode,
),
),
);
},
onLongPress: () => _showContextMenu(originalIndex),
onPlayPressed: () => _playEpisode(episode),
);
},
childCount: _filteredEpisodes.length + 1, // +1 for header
),
);
}
}

View File

@@ -0,0 +1,674 @@
// lib/ui/pinepods/search.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/search_history_service.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/server_error_page.dart';
import 'package:pinepods_mobile/services/error_handling_service.dart';
import 'package:provider/provider.dart';
class PinepodsSearch extends StatefulWidget {
final String? searchTerm;
const PinepodsSearch({
super.key,
this.searchTerm,
});
@override
State<PinepodsSearch> createState() => _PinepodsSearchState();
}
class _PinepodsSearchState extends State<PinepodsSearch> {
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
final PinepodsService _pinepodsService = PinepodsService();
final SearchHistoryService _searchHistoryService = SearchHistoryService();
SearchProvider _selectedProvider = SearchProvider.podcastIndex;
bool _isLoading = false;
bool _showHistory = false;
String? _errorMessage;
List<UnifiedPinepodsPodcast> _searchResults = [];
List<String> _searchHistory = [];
Set<String> _addedPodcastUrls = {};
@override
void initState() {
super.initState();
_searchFocusNode = FocusNode();
_searchController = TextEditingController();
if (widget.searchTerm != null) {
_searchController.text = widget.searchTerm!;
_performSearch(widget.searchTerm!);
} else {
_loadSearchHistory();
}
_initializeCredentials();
_searchController.addListener(_onSearchChanged);
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
@override
void dispose() {
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
Future<void> _loadSearchHistory() async {
final history = await _searchHistoryService.getPodcastSearchHistory();
if (mounted) {
setState(() {
_searchHistory = history;
_showHistory = _searchController.text.isEmpty && history.isNotEmpty;
});
}
}
void _onSearchChanged() {
final query = _searchController.text.trim();
setState(() {
_showHistory = query.isEmpty && _searchHistory.isNotEmpty;
});
}
void _selectHistoryItem(String searchTerm) {
_searchController.text = searchTerm;
_performSearch(searchTerm);
}
Future<void> _removeHistoryItem(String searchTerm) async {
await _searchHistoryService.removePodcastSearchTerm(searchTerm);
await _loadSearchHistory();
}
Future<void> _performSearch(String query) async {
if (query.trim().isEmpty) {
setState(() {
_searchResults = [];
_errorMessage = null;
_showHistory = _searchHistory.isNotEmpty;
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
_showHistory = false;
});
// Save search term to history
await _searchHistoryService.addPodcastSearchTerm(query);
await _loadSearchHistory();
try {
final result = await _pinepodsService.searchPodcasts(query, _selectedProvider);
final podcasts = result.getUnifiedPodcasts();
setState(() {
_searchResults = podcasts;
_isLoading = false;
});
// Check which podcasts are already added
await _checkAddedPodcasts();
} catch (e) {
setState(() {
_errorMessage = 'Search failed: $e';
_isLoading = false;
_searchResults = [];
});
}
}
Future<void> _checkAddedPodcasts() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) return;
for (final podcast in _searchResults) {
try {
final exists = await _pinepodsService.checkPodcastExists(
podcast.title,
podcast.url,
userId,
);
if (exists) {
setState(() {
_addedPodcastUrls.add(podcast.url);
});
}
} catch (e) {
// Ignore individual check failures
print('Failed to check podcast ${podcast.title}: $e');
}
}
}
Future<void> _togglePodcast(UnifiedPinepodsPodcast podcast) async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
_showSnackBar('Not logged in to PinePods server', Colors.red);
return;
}
final isAdded = _addedPodcastUrls.contains(podcast.url);
try {
bool success;
if (isAdded) {
success = await _pinepodsService.removePodcast(
podcast.title,
podcast.url,
userId,
);
if (success) {
setState(() {
_addedPodcastUrls.remove(podcast.url);
});
_showSnackBar('Podcast removed', Colors.orange);
}
} else {
success = await _pinepodsService.addPodcast(podcast, userId);
if (success) {
setState(() {
_addedPodcastUrls.add(podcast.url);
});
_showSnackBar('Podcast added', Colors.green);
}
}
if (!success) {
_showSnackBar('Failed to ${isAdded ? 'remove' : 'add'} podcast', Colors.red);
}
} catch (e) {
_showSnackBar('Error: $e', Colors.red);
}
}
void _showSnackBar(String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
Widget _buildSearchHistorySliver() {
return SliverFillRemaining(
hasScrollBody: false,
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Recent Podcast Searches',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_searchHistory.isNotEmpty)
TextButton(
onPressed: () async {
await _searchHistoryService.clearPodcastSearchHistory();
await _loadSearchHistory();
},
child: Text(
'Clear All',
style: TextStyle(
color: Theme.of(context).hintColor,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 16),
if (_searchHistory.isEmpty)
Center(
child: Column(
children: [
const SizedBox(height: 50),
Icon(
Icons.search,
size: 64,
color: Theme.of(context).primaryColor.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
'Search for Podcasts',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Enter a search term above to find new podcasts to subscribe to',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
textAlign: TextAlign.center,
),
],
),
)
else
..._searchHistory.take(10).map((searchTerm) => Card(
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
dense: true,
leading: Icon(
Icons.history,
color: Theme.of(context).hintColor,
size: 20,
),
title: Text(
searchTerm,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: Icon(
Icons.close,
size: 18,
color: Theme.of(context).hintColor,
),
onPressed: () => _removeHistoryItem(searchTerm),
),
onTap: () => _selectHistoryItem(searchTerm),
),
)).toList(),
],
),
),
);
}
Widget _buildPodcastCard(UnifiedPinepodsPodcast podcast) {
final isAdded = _addedPodcastUrls.contains(podcast.url);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PinepodsPodcastDetails(
podcast: podcast,
isFollowing: isAdded,
onFollowChanged: (following) {
setState(() {
if (following) {
_addedPodcastUrls.add(podcast.url);
} else {
_addedPodcastUrls.remove(podcast.url);
}
});
},
),
),
);
},
child: Column(
children: [
// Podcast image and info
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Podcast artwork
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: podcast.artwork.isNotEmpty
? Image.network(
podcast.artwork,
width: 80,
height: 80,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 32,
),
);
},
)
: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 32,
),
),
),
const SizedBox(width: 12),
// Podcast info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
podcast.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
if (podcast.author.isNotEmpty)
Text(
'By ${podcast.author}',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
podcast.description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.mic,
size: 16,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
'${podcast.episodeCount} episode${podcast.episodeCount != 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 16),
if (podcast.explicit)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'E',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
),
// Follow/Unfollow button
IconButton(
onPressed: () => _togglePodcast(podcast),
icon: Icon(
isAdded ? Icons.remove_circle : Icons.add_circle,
color: isAdded ? Colors.red : Colors.green,
),
tooltip: isAdded ? 'Remove podcast' : 'Add podcast',
),
],
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
leading: IconButton(
tooltip: 'Back',
icon: Platform.isAndroid
? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor)
: const Icon(Icons.arrow_back_ios),
onPressed: () => Navigator.pop(context),
),
title: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: widget.searchTerm != null ? false : true,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
onTap: () {
setState(() {
_showHistory = _searchController.text.isEmpty && _searchHistory.isNotEmpty;
});
},
decoration: const InputDecoration(
hintText: 'Search for podcasts',
border: InputBorder.none,
),
style: TextStyle(
color: Theme.of(context).primaryIconTheme.color,
fontSize: 18.0,
decorationColor: Theme.of(context).scaffoldBackgroundColor,
),
onSubmitted: _performSearch,
),
floating: false,
pinned: true,
snap: false,
actions: <Widget>[
IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchResults = [];
_errorMessage = null;
_showHistory = _searchHistory.isNotEmpty;
});
FocusScope.of(context).requestFocus(_searchFocusNode);
SystemChannels.textInput.invokeMethod<String>('TextInput.show');
},
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
const Text(
'Search Provider: ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Expanded(
child: DropdownButton<SearchProvider>(
value: _selectedProvider,
isExpanded: true,
items: SearchProvider.values.map((provider) {
return DropdownMenuItem(
value: provider,
child: Text(provider.name),
);
}).toList(),
onChanged: (provider) {
if (provider != null) {
setState(() {
_selectedProvider = provider;
});
// Re-search with new provider if there's a current search
if (_searchController.text.isNotEmpty) {
_performSearch(_searchController.text);
}
}
},
),
),
],
),
),
),
),
// Search results or history
if (_showHistory)
_buildSearchHistorySliver()
else if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: PlatformProgressIndicator()),
)
else if (_errorMessage != null)
SliverServerErrorPage(
errorMessage: _errorMessage!.isServerConnectionError
? null
: _errorMessage,
onRetry: () => _performSearch(_searchController.text),
title: 'Search Unavailable',
subtitle: _errorMessage!.isServerConnectionError
? 'Unable to connect to the PinePods server'
: 'Failed to search for podcasts',
)
else if (_searchResults.isEmpty && _searchController.text.isNotEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No podcasts found',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Try searching with different keywords or switch search provider',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
)
else if (_searchResults.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Search for podcasts',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Enter a search term to find podcasts',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
else
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _buildPodcastCard(_searchResults[index]);
},
childCount: _searchResults.length,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,503 @@
// lib/ui/pinepods/user_stats.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/user_stats.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/core/environment.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
class PinepodsUserStats extends StatefulWidget {
const PinepodsUserStats({super.key});
@override
State<PinepodsUserStats> createState() => _PinepodsUserStatsState();
}
class _PinepodsUserStatsState extends State<PinepodsUserStats> {
final PinepodsService _pinepodsService = PinepodsService();
UserStats? _userStats;
String? _pinepodsVersion;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initializeCredentials();
_loadUserStats();
}
void _initializeCredentials() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null && settings.pinepodsApiKey != null) {
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
}
}
/// Calculate responsive cross axis count for stats grid
int _getStatsCrossAxisCount(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth > 1200) return 4; // Very wide screens (large tablets, desktop)
if (screenWidth > 800) return 3; // Wide tablets like iPad
if (screenWidth > 500) return 2; // Standard phones and small tablets
return 1; // Very small phones (< 500px)
}
/// Calculate responsive aspect ratio for stats cards
double _getStatsAspectRatio(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth <= 500) {
// Single column on small screens - generous height for content + proper padding
return 2.2; // Allows space for icon + title + value + padding, handles text wrapping
}
return 1.0; // Square aspect ratio for multi-column layouts
}
Future<void> _loadUserStats() async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
setState(() {
_errorMessage = 'Not logged in';
_isLoading = false;
});
return;
}
try {
final futures = await Future.wait([
_pinepodsService.getUserStats(userId),
_pinepodsService.getPinepodsVersion(),
]);
setState(() {
_userStats = futures[0] as UserStats;
_pinepodsVersion = futures[1] as String;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Failed to load stats: $e';
_isLoading = false;
});
}
}
Future<void> _launchUrl(String url) async {
final logger = AppLogger();
logger.info('UserStats', 'Attempting to launch URL: $url');
try {
final uri = Uri.parse(url);
// Try to launch directly first (works better on Android)
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched) {
logger.warning('UserStats', 'Direct URL launch failed, checking if URL can be launched');
// If direct launch fails, check if URL can be launched
final canLaunch = await canLaunchUrl(uri);
if (!canLaunch) {
throw Exception('No app available to handle this URL');
}
} else {
logger.info('UserStats', 'Successfully launched URL: $url');
}
} catch (e) {
logger.error('UserStats', 'Failed to launch URL: $url', e.toString());
// Show error if URL can't be launched
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open link: $url'),
backgroundColor: Colors.red,
),
);
}
}
}
Widget _buildStatCard(String label, String value, {IconData? icon, Color? iconColor}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 32,
color: iconColor ?? Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
],
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Build sync status card that fits in the grid with consistent styling
Widget _buildSyncStatCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return _buildStatCard(
'Sync Status',
stats.syncStatusDescription,
icon: isNotSyncing ? Icons.sync_disabled : Icons.sync,
iconColor: isNotSyncing ? Colors.grey : null,
);
}
Widget _buildSyncStatusCard() {
if (_userStats == null) return const SizedBox.shrink();
final stats = _userStats!;
final isNotSyncing = stats.podSyncType.toLowerCase() == 'none';
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Icon(
isNotSyncing ? Icons.sync_disabled : Icons.sync,
size: 32,
color: isNotSyncing ? Colors.grey : Theme.of(context).primaryColor,
),
const SizedBox(height: 8),
Text(
'Podcast Sync Status',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
stats.syncStatusDescription,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (!isNotSyncing && stats.gpodderUrl.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
stats.gpodderUrl,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _buildInfoCard() {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// PinePods Logo
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
image: const DecorationImage(
image: AssetImage('assets/images/pinepods-logo.png'),
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 16),
Text(
'App Version: v${Environment.projectVersion}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Server Version: ${_pinepodsVersion ?? "Unknown"}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Thanks for using PinePods! This app was born from a love for podcasts, of homelabs, and a desire to have a secure and central location to manage personal data.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
height: 1.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'Copyright © 2025 Gooseberry Development',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The PinePods Mobile App is an open-source podcast player adapted from the Anytime Podcast Player (© 2020 Ben Hills). Portions of this application retain the original BSD 3-Clause license.',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
height: 1.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _launchUrl('https://github.com/amugofjava/anytime_podcast_player'),
child: Text(
'View original project on GitHub',
style: TextStyle(
fontSize: 12,
decoration: TextDecoration.underline,
color: Theme.of(context).primaryColor,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
// Buttons
Column(
children: [
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://pinepods.online'),
icon: const Icon(Icons.description),
label: const Text('PinePods Documentation'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://github.com/madeofpendletonwool/pinepods'),
icon: const Icon(Icons.code),
label: const Text('PinePods GitHub Repo'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _launchUrl('https://www.buymeacoffee.com/collinscoffee'),
icon: const Icon(Icons.coffee),
label: const Text('Buy me a Coffee'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
showLicensePage(context: context);
},
icon: const Icon(Icons.article_outlined),
label: const Text('Open Source Licenses'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('User Statistics'),
centerTitle: true,
),
body: _isLoading
? const Center(child: PlatformProgressIndicator())
: _errorMessage != null
? Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadUserStats();
},
child: const Text('Retry'),
),
],
),
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Statistics Grid
GridView.count(
crossAxisCount: _getStatsCrossAxisCount(context),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: _getStatsAspectRatio(context),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
children: [
_buildStatCard(
'User Created',
_userStats?.formattedUserCreated ?? '',
icon: Icons.calendar_today,
),
_buildStatCard(
'Podcasts Played',
_userStats?.podcastsPlayed.toString() ?? '',
icon: Icons.play_circle,
),
_buildStatCard(
'Time Listened',
_userStats?.formattedTimeListened ?? '',
icon: Icons.access_time,
),
_buildStatCard(
'Podcasts Added',
_userStats?.podcastsAdded.toString() ?? '',
icon: Icons.library_add,
),
_buildStatCard(
'Episodes Saved',
_userStats?.episodesSaved.toString() ?? '',
icon: Icons.bookmark,
),
_buildStatCard(
'Episodes Downloaded',
_userStats?.episodesDownloaded.toString() ?? '',
icon: Icons.download,
),
// Add sync status as a stat card to maintain consistent layout
_buildSyncStatCard(),
],
),
const SizedBox(height: 16),
// Info Card
_buildInfoCard(),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
// 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/entities/chapter.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
/// A [Widget] for displaying a list of Podcast chapters for those
/// podcasts that support that chapter tag.
// ignore: must_be_immutable
class ChapterSelector extends StatefulWidget {
final ItemScrollController itemScrollController = ItemScrollController();
Episode episode;
Chapter? chapter;
StreamSubscription? positionSubscription;
var chapters = <Chapter>[];
ChapterSelector({
super.key,
required this.episode,
}) {
chapters = episode.chapters.where((c) => c.toc).toList(growable: false);
}
@override
State<ChapterSelector> createState() => _ChapterSelectorState();
}
class _ChapterSelectorState extends State<ChapterSelector> {
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
Chapter? lastChapter;
bool first = true;
// Listen for changes in position. If the change in position results in
// a change in chapter we scroll to it. This ensures that the current
// chapter is always visible.
// TODO: Jump only if current chapter is not visible.
widget.positionSubscription = audioBloc.playPosition!.listen((event) {
var episode = event.episode;
if (widget.itemScrollController.isAttached) {
lastChapter ??= episode!.currentChapter;
if (lastChapter != episode!.currentChapter) {
lastChapter = episode.currentChapter;
if (!episode.chaptersLoading && episode.chapters.isNotEmpty) {
var index = widget.chapters.indexWhere((element) => element == lastChapter);
if (index >= 0) {
if (first) {
widget.itemScrollController.jumpTo(index: index);
first = false;
}
// Removed auto-scroll to current chapter during playback
// to prevent annoying bouncing behavior
}
}
}
}
});
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
return StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
return !snapshot.hasData || snapshot.data!.chaptersLoading
? const Align(
alignment: Alignment.center,
child: PlatformProgressIndicator(),
)
: ScrollablePositionedList.builder(
initialScrollIndex: _initialIndex(snapshot.data),
itemScrollController: widget.itemScrollController,
itemCount: widget.chapters.length,
itemBuilder: (context, i) {
final index = i < 0 ? 0 : i;
final chapter = widget.chapters[index];
final chapterSelected = chapter == snapshot.data!.currentChapter;
final textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 14,
fontWeight: FontWeight.normal,
);
/// We should be able to use the selectedTileColor property but, if we do, when
/// we scroll the currently selected item out of view, the selected colour is
/// still visible behind the transport control. This is a little hack, but fixes
/// the issue until I can get ListTile to work correctly.
return Padding(
padding: const EdgeInsets.fromLTRB(4.0, 0.0, 4.0, 0.0),
child: ListTile(
selectedTileColor: Theme.of(context).cardTheme.color,
onTap: () {
audioBloc.transitionPosition(chapter.startTime);
},
selected: chapterSelected,
leading: Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
'${index + 1}.',
style: textStyle,
),
),
title: Text(
widget.chapters[index].title.trim(),
overflow: TextOverflow.ellipsis,
softWrap: false,
maxLines: 3,
style: textStyle,
),
trailing: Text(
_formatStartTime(widget.chapters[index].startTime),
style: textStyle,
),
),
);
},
);
});
}
@override
void dispose() {
widget.positionSubscription?.cancel();
super.dispose();
}
int _initialIndex(Episode? e) {
var init = 0;
if (e != null && e.currentChapter != null) {
init = widget.chapters.indexWhere((c) => c == e.currentChapter);
if (init < 0) {
init = 0;
}
}
return init;
}
String _formatStartTime(double startTime) {
var time = Duration(seconds: startTime.ceil());
var result = '';
if (time.inHours > 0) {
result =
'${time.inHours}:${time.inMinutes.remainder(60).toString().padLeft(2, '0')}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}';
} else {
result = '${time.inMinutes}:${time.inSeconds.remainder(60).toString().padLeft(2, '0')}';
}
return result;
}
}

View File

@@ -0,0 +1,49 @@
// 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 'package:flutter/widgets.dart';
/// Custom [Decoration] for the chapters, episode & notes tab selector
/// shown in the [NowPlaying] page.
class DotDecoration extends Decoration {
final Color colour;
const DotDecoration({required this.colour});
@override
BoxPainter createBoxPainter([void Function()? onChanged]) {
return _DotDecorationPainter(decoration: this);
}
}
class _DotDecorationPainter extends BoxPainter {
final DotDecoration decoration;
_DotDecorationPainter({required this.decoration});
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
const double pillWidth = 8.0;
const double pillHeight = 3.0;
final center = configuration.size!.center(offset);
final height = configuration.size!.height;
final newOffset = Offset(center.dx, height - 8);
final paint = Paint();
paint.color = decoration.colour;
paint.style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromLTRBR(
newOffset.dx - pillWidth,
newOffset.dy - pillHeight,
newOffset.dx + pillWidth,
newOffset.dy + pillHeight,
const Radius.circular(12.0),
),
paint);
}
}

View File

@@ -0,0 +1,101 @@
// 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 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
import 'package:pinepods_mobile/ui/podcast/transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
/// This class renders the more info widget that is accessed from the 'more'
/// button on an episode.
///
/// The widget is displayed as a draggable, scrollable sheet. This contains
/// episode icon and play/pause control, below which the episode title, show
/// notes and person(s) details (if available).
class EpisodeDetails extends StatefulWidget {
final Episode episode;
const EpisodeDetails({
super.key,
required this.episode,
});
@override
State<EpisodeDetails> createState() => _EpisodeDetailsState();
}
class _EpisodeDetailsState extends State<EpisodeDetails> {
@override
Widget build(BuildContext context) {
final episode = widget.episode;
/// Ensure we do not highlight this as a new episode
episode.highlight = false;
return DraggableScrollableSheet(
initialChildSize: 0.6,
expand: false,
builder: (BuildContext context, ScrollController scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ExpansionTile(
key: const Key('episodemoreinfo'),
trailing: PlayControl(
episode: episode,
),
leading: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl!,
size: 56.0,
highlight: episode.highlight,
),
subtitle: EpisodeSubtitle(episode),
title: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: Theme.of(context).textTheme.bodyMedium,
)),
const Divider(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
episode.title!,
style: Theme.of(context).textTheme.titleLarge!.copyWith(fontWeight: FontWeight.bold),
),
),
),
if (episode.persons.isNotEmpty)
SizedBox(
height: 120.0,
child: ListView.builder(
itemCount: episode.persons.length,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
return PersonAvatar(person: episode.persons[index]);
},
),
),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: PodcastHtml(content: episode.content ?? episode.description!),
)
],
),
);
});
}
}

View File

@@ -0,0 +1,222 @@
// 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/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/funding.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is responsible for rendering the funding menu on the podcast details page.
///
/// It returns either a Material or Cupertino style menu instance depending upon which
/// platform we are running on.
///
/// The target platform is based on the current [Theme]: [ThemeData.platform].
class FundingMenu extends StatelessWidget {
final List<Funding>? funding;
const FundingMenu(
this.funding, {
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return _MaterialFundingMenu(funding);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _CupertinoFundingMenu(funding);
}
}
}
/// This is the material design version of the context menu. This will be rendered
/// for all platforms that are not iOS.
class _MaterialFundingMenu extends StatelessWidget {
final List<Funding>? funding;
const _MaterialFundingMenu(this.funding);
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
return funding == null || funding!.isEmpty
? const SizedBox(
width: 0.0,
height: 0.0,
)
: StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Semantics(
label: L.of(context)!.podcast_funding_dialog_header,
child: PopupMenuButton<String>(
onSelected: (url) {
FundingLink.fundingLink(
url,
snapshot.data!.externalLinkConsent,
context,
).then((value) {
settingsBloc.setExternalLinkConsent(value);
});
},
icon: const Icon(
Icons.payment,
),
itemBuilder: (BuildContext context) {
return List<PopupMenuEntry<String>>.generate(funding!.length, (index) {
return PopupMenuItem<String>(
value: funding![index].url,
enabled: true,
child: Text(funding![index].value),
);
});
},
),
);
});
}
}
/// This is the Cupertino context menu and is rendered only when running on
/// an iOS device.
class _CupertinoFundingMenu extends StatelessWidget {
final List<Funding>? funding;
const _CupertinoFundingMenu(this.funding);
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
return funding == null || funding!.isEmpty
? const SizedBox(
width: 0.0,
height: 0.0,
)
: StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return IconButton(
tooltip: L.of(context)!.podcast_funding_dialog_header,
icon: const Icon(Icons.payment),
visualDensity: VisualDensity.compact,
onPressed: () => showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
...List<CupertinoActionSheetAction>.generate(funding!.length, (index) {
return CupertinoActionSheetAction(
onPressed: () {
FundingLink.fundingLink(
funding![index].url,
snapshot.data!.externalLinkConsent,
context,
).then((value) {
settingsBloc.setExternalLinkConsent(value);
if (context.mounted) {
Navigator.of(context).pop('Cancel');
}
});
},
child: Text(funding![index].value),
);
}),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_option_label),
),
);
},
),
);
});
}
}
class FundingLink {
/// Check the consent status. If this is the first time we have been
/// requested to open a funding link, present the user with and
/// information dialog first to make clear that the link is provided
/// by the podcast owner and not Pinepods.
static Future<bool> fundingLink(String url, bool consent, BuildContext context) async {
bool? result = false;
if (consent) {
result = true;
final uri = Uri.parse(url);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
} else {
result = await showPlatformDialog<bool>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Semantics(
header: true,
child: Text(L.of(context)!.podcast_funding_dialog_header),
),
content: Text(L.of(context)!.consent_message),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.go_back_button_label,
),
onPressed: () {
Navigator.pop(context, false);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.continue_button_label,
),
iosIsDefaultAction: true,
onPressed: () {
Navigator.pop(context, true);
},
),
],
),
);
if (result!) {
var uri = Uri.parse(url);
unawaited(
canLaunchUrl(uri).then((value) => launchUrl(uri)),
);
}
}
return Future.value(result);
}
}

View File

@@ -0,0 +1,360 @@
// 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 'dart:ui';
import 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Displays a mini podcast player widget if a podcast is playing or paused.
///
/// If stopped a zero height box is built instead. Tapping on the mini player
/// will open the main player window.
class MiniPlayer extends StatelessWidget {
const MiniPlayer({
super.key,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
initialData: AudioState.stopped,
builder: (context, snapshot) {
return snapshot.data != AudioState.stopped &&
snapshot.data != AudioState.none &&
snapshot.data != AudioState.error
? _MiniPlayerBuilder()
: const SizedBox(
height: 0.0,
);
});
}
}
class _MiniPlayerBuilder extends StatefulWidget {
@override
_MiniPlayerBuilderState createState() => _MiniPlayerBuilderState();
}
class _MiniPlayerBuilderState extends State<_MiniPlayerBuilder>
with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
@override
void initState() {
super.initState();
_playPauseController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
_playPauseController.value = 1;
_audioStateListener();
}
@override
void dispose() {
_audioStateSubscription.cancel();
_playPauseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final width = MediaQuery.of(context).size.width;
final placeholderBuilder = PlaceholderBuilder.of(context);
return Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) async {
await _audioStateSubscription.cancel();
audioBloc.transitionState(TransitionState.stop);
return true;
},
direction: DismissDirection.startToEnd,
background: Container(
color: Theme.of(context).colorScheme.surface,
height: 64.0,
),
child: GestureDetector(
key: const Key('miniplayergesture'),
onTap: () async {
await _audioStateSubscription.cancel();
if (context.mounted) {
showModalBottomSheet<void>(
context: context,
routeSettings: const RouteSettings(name: 'nowplaying'),
isScrollControlled: true,
builder: (BuildContext modalContext) {
final contextPadding = MediaQuery.of(context).padding.top;
final modalPadding = MediaQuery.of(modalContext).padding.top;
// Get the actual system safe area from the window (works on both iOS and Android)
final window = PlatformDispatcher.instance.views.first;
final systemPadding = window.padding.top / window.devicePixelRatio;
// Use the best available padding value
double topPadding;
if (contextPadding > 0) {
topPadding = contextPadding;
} else if (modalPadding > 0) {
topPadding = modalPadding;
} else {
// Fall back to system padding if both contexts have 0
topPadding = systemPadding;
}
return Padding(
padding: EdgeInsets.only(top: topPadding),
child: const NowPlaying(),
);
},
).then((_) {
_audioStateListener();
});
}
},
child: Semantics(
header: true,
label: L.of(context)!.semantics_mini_player_header,
child: Container(
height: 66,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: Divider.createBorderSide(context,
width: 1.0, color: Theme.of(context).dividerColor),
bottom: Divider.createBorderSide(context,
width: 0.0, color: Theme.of(context).dividerColor),
)),
child: Padding(
padding: const EdgeInsets.only(left: 4.0, right: 4.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
initialData: audioBloc.nowPlaying?.valueOrNull,
builder: (context, snapshot) {
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, stateSnapshot) {
var playing =
stateSnapshot.data == AudioState.playing;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
SizedBox(
height: 58.0,
width: 58.0,
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: snapshot.hasData
? PodcastImage(
key: Key(
'mini${snapshot.data!.imageUrl}'),
url: snapshot.data!.imageUrl!,
width: 58.0,
height: 58.0,
borderRadius: 4.0,
placeholder: placeholderBuilder !=
null
? placeholderBuilder
.builder()(context)
: const Image(
image: AssetImage(
'assets/images/favicon.png')),
errorPlaceholder:
placeholderBuilder != null
? placeholderBuilder
.errorBuilder()(
context)
: const Image(
image: AssetImage(
'assets/images/favicon.png')),
)
: Container(),
),
),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
snapshot.data?.title ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium,
),
Padding(
padding:
const EdgeInsets.only(top: 4.0),
child: Text(
snapshot.data?.author ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall,
),
),
],
)),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(
color: Theme.of(context)
.colorScheme
.surface,
width: 0.0)),
),
onPressed: () {
if (playing) {
audioBloc.transitionState(
TransitionState.fastforward);
}
},
child: Icon(
Icons.forward_30,
semanticLabel: L
.of(context)!
.fast_forward_button_label,
size: 36.0,
),
),
),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(
color: Theme.of(context)
.colorScheme
.surface,
width: 0.0)),
),
onPressed: () {
if (playing) {
_pause(audioBloc);
} else {
_play(audioBloc);
}
},
child: AnimatedIcon(
semanticLabel: playing
? L.of(context)!.pause_button_label
: L.of(context)!.play_button_label,
size: 48.0,
icon: AnimatedIcons.play_pause,
color:
Theme.of(context).iconTheme.color,
progress: _playPauseController,
),
),
),
],
);
});
}),
StreamBuilder<PositionState>(
stream: audioBloc.playPosition,
initialData: audioBloc.playPosition?.valueOrNull,
builder: (context, snapshot) {
var cw = 0.0;
var position = snapshot.hasData
? snapshot.data!.position
: const Duration(seconds: 0);
var length = snapshot.hasData
? snapshot.data!.length
: const Duration(seconds: 0);
if (length.inSeconds > 0) {
final pc = length.inSeconds / position.inSeconds;
cw = width / pc;
}
return Container(
width: cw,
height: 1.0,
color: Theme.of(context).primaryColor,
);
}),
],
),
),
),
),
),
);
}
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
/// little odd.
void _audioStateListener() {
if (mounted) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var firstEvent = true;
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (firstEvent) {
_playPauseController.value = 1;
firstEvent = false;
} else {
_playPauseController.forward();
}
} else {
if (firstEvent) {
_playPauseController.value = 0;
firstEvent = false;
} else {
_playPauseController.reverse();
}
}
});
}
}
void _play(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _pause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
}

View File

@@ -0,0 +1,654 @@
// 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/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/chapter_selector.dart';
import 'package:pinepods_mobile/ui/podcast/dot_decoration.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing_floating_player.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing_options.dart';
import 'package:pinepods_mobile/ui/podcast/person_avatar.dart';
import 'package:pinepods_mobile/ui/podcast/playback_error_listener.dart';
import 'package:pinepods_mobile/ui/podcast/player_position_controls.dart';
import 'package:pinepods_mobile/ui/podcast/player_transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/delayed_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This is the full-screen player Widget which is invoked by touching the mini player.
///
/// This is the parent widget of the now playing screen(s). If we are running on a mobile in
/// portrait mode, we display the episode details, controls and additional options
/// as a draggable view. For tablets in portrait or on desktop, we display a split
/// screen. The main details and controls appear in one pane with the additional
/// controls in another.
///
/// TODO: The fade in/out transition applied when scrolling the queue is the first implementation.
/// Using [Opacity] is a very inefficient way of achieving this effect, but will do as a place
/// holder until a better animation can be achieved.
class NowPlaying extends StatefulWidget {
const NowPlaying({
super.key,
});
@override
State<NowPlaying> createState() => _NowPlayingState();
}
class _NowPlayingState extends State<NowPlaying> with WidgetsBindingObserver {
late StreamSubscription<AudioState> playingStateSubscription;
var textGroup = AutoSizeGroup();
double scrollPos = 0.0;
double opacity = 0.0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var popped = false;
// If the episode finishes we can close.
playingStateSubscription =
audioBloc.playingState!.where((state) => state == AudioState.stopped).listen((playingState) async {
// Prevent responding to multiple stop events after we've popped and lost context.
if (!popped) {
popped = true;
if (mounted) {
Navigator.of(context).pop();
}
}
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
playingStateSubscription.cancel();
super.dispose();
}
bool isMobilePortrait(BuildContext context) {
final query = MediaQuery.of(context);
return (query.orientation == Orientation.portrait || query.size.width <= 1000);
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final playerBuilder = PlayerControlsBuilder.of(context);
return Semantics(
header: false,
label: L.of(context)!.semantics_main_player_header,
explicitChildNodes: true,
child: StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
var duration = snapshot.data == null ? 0 : snapshot.data!.duration;
final WidgetBuilder? transportBuilder = playerBuilder?.builder(duration);
return isMobilePortrait(context)
? NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
setState(() {
if (notification.extent > (notification.minExtent)) {
opacity = 1 - (notification.maxExtent - notification.extent);
scrollPos = 1.0;
} else {
opacity = 0.0;
scrollPos = 0.0;
}
});
return true;
},
child: Stack(
fit: StackFit.expand,
children: [
// We need to hide the main player when the floating player is visible to prevent
// screen readers from reading both parts of the stack.
Visibility(
visible: opacity < 1,
child: NowPlayingTabs(
episode: snapshot.data!,
transportBuilder: transportBuilder,
),
),
SizedBox.expand(
child: SafeArea(
child: Column(
children: [
/// Sized boxes without a child are 'invisible' so they do not prevent taps below
/// the stack but are still present in the layout. We have a sized box here to stop
/// the draggable panel from jumping as you start to pull it up. I am really looking
/// forward to the Dart team fixing the nested scroll issues with [DraggableScrollableSheet]
SizedBox(
height: 64.0,
child: scrollPos == 1
? Opacity(
opacity: opacity,
child: const FloatingPlayer(),
)
: null,
),
if (MediaQuery.of(context).orientation == Orientation.portrait)
const Expanded(
child: NowPlayingOptionsSelector(),
),
],
),
)),
],
),
)
: Row(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
flex: 1,
child: NowPlayingTabs(episode: snapshot.data!, transportBuilder: transportBuilder),
),
const Expanded(
flex: 1,
child: NowPlayingOptionsSelectorWide(),
),
],
);
}),
);
}
}
/// This widget displays the episode logo, episode title and current
/// chapter if available.
///
/// If running in portrait this will be in a vertical format; if in
/// landscape this will be in a horizontal format. The actual displaying
/// of the episode text is handed off to [NowPlayingEpisodeDetails].
class NowPlayingEpisode extends StatelessWidget {
final String? imageUrl;
final Episode episode;
final AutoSizeGroup? textGroup;
const NowPlayingEpisode({
super.key,
required this.imageUrl,
required this.episode,
required this.textGroup,
});
@override
Widget build(BuildContext context) {
final placeholderBuilder = PlaceholderBuilder.of(context);
return OrientationBuilder(
builder: (context, orientation) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: MediaQuery.of(context).orientation == Orientation.portrait || MediaQuery.of(context).size.width >= 1000
? Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
flex: 7,
child: Semantics(
label: L.of(context)!.semantic_podcast_artwork_label,
child: PodcastImage(
key: Key('nowplaying$imageUrl'),
url: imageUrl!,
width: MediaQuery.of(context).size.width * .75,
height: MediaQuery.of(context).size.height * .75,
fit: BoxFit.contain,
borderRadius: 6.0,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: DelayedCircularProgressIndicator(),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
),
),
),
Expanded(
flex: 3,
child: NowPlayingEpisodeDetails(
episode: episode,
textGroup: textGroup,
),
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(
left: 8.0,
bottom: 8.0,
),
child: PodcastImage(
key: Key('nowplaying$imageUrl'),
url: imageUrl!,
height: 280,
width: 280,
fit: BoxFit.contain,
borderRadius: 8.0,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: DelayedCircularProgressIndicator(),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
),
),
),
Expanded(
flex: 5,
child: NowPlayingEpisodeDetails(
episode: episode,
textGroup: textGroup,
),
),
],
),
);
},
);
}
}
/// This widget is responsible for displaying the main episode details.
///
/// This displays the current episode title and, if available, the
/// current chapter title and optional link.
class NowPlayingEpisodeDetails extends StatelessWidget {
final Episode? episode;
final AutoSizeGroup? textGroup;
static const minFontSize = 14.0;
const NowPlayingEpisodeDetails({
super.key,
this.episode,
this.textGroup,
});
@override
Widget build(BuildContext context) {
final chapterTitle = episode?.currentChapter?.title ?? '';
final chapterUrl = episode?.currentChapter?.url ?? '';
return Column(
children: [
Expanded(
flex: 5,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0),
child: Semantics(
container: true,
child: AutoSizeText(
episode?.title ?? '',
group: textGroup,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
minFontSize: minFontSize,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24.0,
),
maxLines: episode!.hasChapters ? 3 : 4,
),
),
),
),
if (episode!.hasChapters)
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 0.0, 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Semantics(
label: L.of(context)!.semantic_current_chapter_label,
container: true,
child: AutoSizeText(
chapterTitle,
group: textGroup,
minFontSize: minFontSize,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.grey[300],
fontWeight: FontWeight.normal,
fontSize: 16.0,
),
maxLines: 2,
),
),
),
chapterUrl.isEmpty
? const SizedBox(
height: 0,
width: 0,
)
: Semantics(
label: L.of(context)!.semantic_chapter_link_label,
container: true,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(
Icons.link,
),
color: Theme.of(context).primaryIconTheme.color,
onPressed: () {
_chapterLink(chapterUrl);
}),
),
],
),
),
),
],
);
}
void _chapterLink(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
throw 'Could not launch chapter link: $url';
}
}
}
/// This widget handles the displaying of the episode show notes.
///
/// This consists of title, show notes and person details
/// (where available).
class NowPlayingShowNotes extends StatelessWidget {
final Episode? episode;
const NowPlayingShowNotes({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
bottom: 16.0,
),
child: Text(
episode!.title!,
style: Theme.of(context).textTheme.titleLarge!.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (episode!.persons.isNotEmpty)
SizedBox(
height: 120.0,
child: ListView.builder(
itemCount: episode!.persons.length,
scrollDirection: Axis.horizontal,
itemBuilder: (BuildContext context, int index) {
return PersonAvatar(person: episode!.persons[index]);
},
),
),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
left: 8.0,
right: 8.0,
),
child: PodcastHtml(content: episode?.content ?? episode?.description ?? ''),
),
],
),
),
);
}
}
/// Widget for rendering main episode tabs.
///
/// This will be episode details and show notes. If the episode supports chapters
/// this will be included also. This is the parent widget. The tabs are
/// rendered via [EpisodeTabBar] and the tab contents via. [EpisodeTabBarView].
class NowPlayingTabs extends StatelessWidget {
const NowPlayingTabs({
super.key,
required this.transportBuilder,
required this.episode,
});
final WidgetBuilder? transportBuilder;
final Episode episode;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: episode.hasChapters ? 3 : 2,
initialIndex: episode.hasChapters ? 1 : 0,
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context)
.appBarTheme
.systemOverlayStyle!
.copyWith(systemNavigationBarColor: Theme.of(context).secondaryHeaderColor),
child: Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0.0,
leading: IconButton(
tooltip: L.of(context)!.minimise_player_window_button_label,
icon: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).primaryIconTheme.color,
),
onPressed: () => {
Navigator.pop(context),
},
),
flexibleSpace: PlaybackErrorListener(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
EpisodeTabBar(
chapters: episode.hasChapters,
),
],
),
),
),
body: Column(
children: [
Expanded(
flex: 5,
child: EpisodeTabBarView(
episode: episode,
chapters: episode.hasChapters,
),
),
transportBuilder != null
? transportBuilder!(context)
: const SizedBox(
height: 148.0,
child: NowPlayingTransport(),
),
if (MediaQuery.of(context).orientation == Orientation.portrait)
const Expanded(
flex: 1,
child: NowPlayingOptionsScaffold(),
),
],
),
),
));
}
}
/// This class is responsible for rendering the tab selection at the top of the screen.
///
/// It displays two or three tabs depending upon whether the current episode supports
/// (and contains) chapters.
class EpisodeTabBar extends StatelessWidget {
final bool chapters;
const EpisodeTabBar({
super.key,
this.chapters = false,
});
@override
Widget build(BuildContext context) {
return TabBar(
isScrollable: true,
indicatorSize: TabBarIndicatorSize.tab,
indicator: DotDecoration(colour: Theme.of(context).primaryColor),
tabs: [
if (chapters)
Tab(
child: Align(
alignment: Alignment.center,
child: Text(L.of(context)!.chapters_label),
),
),
Tab(
child: Align(
alignment: Alignment.center,
child: Text(L.of(context)!.episode_label),
),
),
Tab(
child: Align(
alignment: Alignment.center,
child: Text(L.of(context)!.notes_label),
),
),
],
);
}
}
/// This class is responsible for rendering the tab bodies.
///
/// This includes the chapter selection view (if the episode supports chapters),
/// the episode details (image and description) and the show notes view.
class EpisodeTabBarView extends StatelessWidget {
final Episode? episode;
final AutoSizeGroup? textGroup;
final bool chapters;
const EpisodeTabBarView({
super.key,
this.episode,
this.textGroup,
this.chapters = false,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
return TabBarView(
children: [
if (chapters)
ChapterSelector(
episode: episode!,
),
StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
final e = snapshot.hasData ? snapshot.data! : episode!;
return NowPlayingEpisode(
episode: e,
imageUrl: e.positionalImageUrl,
textGroup: textGroup,
);
}),
NowPlayingShowNotes(episode: episode),
],
);
}
}
/// This is the parent widget for the episode position and transport
/// controls.
class NowPlayingTransport extends StatelessWidget {
const NowPlayingTransport({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: <Widget>[
Divider(
height: 0.0,
),
PlayerPositionControls(),
PlayerTransportControls(),
],
);
}
}
/// This widget allows users to inject their own transport controls
/// into the app.
///
/// When rendering the controls, Pinepods will check if a PlayerControlsBuilder
/// is in the tree. If so, it will use the builder rather than its own
/// transport controls.
class PlayerControlsBuilder extends InheritedWidget {
final WidgetBuilder Function(int duration) builder;
const PlayerControlsBuilder({
super.key,
required this.builder,
required super.child,
});
static PlayerControlsBuilder? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PlayerControlsBuilder>();
}
@override
bool updateShouldNotify(PlayerControlsBuilder oldWidget) {
return builder != oldWidget.builder;
}
}

View File

@@ -0,0 +1,219 @@
// 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/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget is based upon [MiniPlayer] and provides an additional play/pause control when
/// the episode queue is expanded.
///
/// At some point we should try to merge the common code between this and [MiniPlayer].
class FloatingPlayer extends StatelessWidget {
const FloatingPlayer({
super.key,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, snapshot) {
return (snapshot.hasData &&
!(snapshot.data == AudioState.stopped ||
snapshot.data == AudioState.none ||
snapshot.data == AudioState.error))
? _FloatingPlayerBuilder()
: const SizedBox(
height: 0.0,
);
});
}
}
class _FloatingPlayerBuilder extends StatefulWidget {
@override
_FloatingPlayerBuilderState createState() => _FloatingPlayerBuilderState();
}
class _FloatingPlayerBuilderState extends State<_FloatingPlayerBuilder> with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
@override
void initState() {
super.initState();
_playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
_playPauseController.value = 1;
_audioStateListener();
}
@override
void dispose() {
_audioStateSubscription.cancel();
_playPauseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final placeholderBuilder = PlaceholderBuilder.of(context);
return Container(
height: 64,
color: Theme.of(context).canvasColor,
child: StreamBuilder<Episode?>(
stream: audioBloc.nowPlaying,
builder: (context, snapshot) {
return StreamBuilder<AudioState>(
stream: audioBloc.playingState,
builder: (context, stateSnapshot) {
var playing = stateSnapshot.data == AudioState.playing;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8.0),
child: snapshot.hasData
? PodcastImage(
key: Key('float${snapshot.data!.imageUrl}'),
url: snapshot.data!.imageUrl!,
width: 58.0,
height: 58.0,
borderRadius: 4.0,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(image: AssetImage('assets/images/favicon.png')),
)
: Container(),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
snapshot.data?.title ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium,
),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
snapshot.data?.author ?? '',
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall,
),
),
],
)),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)),
),
onPressed: () {
if (playing) {
audioBloc.transitionState(TransitionState.fastforward);
}
},
child: const Icon(
Icons.forward_30,
size: 36.0,
),
),
),
SizedBox(
height: 52.0,
width: 52.0,
child: TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 0.0),
shape: CircleBorder(
side: BorderSide(color: Theme.of(context).colorScheme.surface, width: 0.0)),
),
onPressed: () {
if (playing) {
_pause(audioBloc);
} else {
_play(audioBloc);
}
},
child: AnimatedIcon(
semanticLabel:
playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
size: 48.0,
icon: AnimatedIcons.play_pause,
color: Theme.of(context).iconTheme.color,
progress: _playPauseController,
),
),
),
],
);
});
}),
);
}
/// We call this method to setup a listener for changing [AudioState]. This in turns calls upon the [_pauseController]
/// to animate the play/pause icon. The [AudioBloc] playingState method is backed by a [BehaviorSubject] so we'll
/// always get the current state when we subscribe. This, however, has a side effect causing the play/pause icon to
/// animate when returning from the full-size player, which looks a little odd. Therefore, on the first event we move
/// the controller to the correct state without animating. This feels a little hacky, but stops the UI from looking a
/// little odd.
void _audioStateListener() {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
var firstEvent = true;
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (firstEvent) {
_playPauseController.value = 1;
firstEvent = false;
} else {
_playPauseController.forward();
}
} else {
if (firstEvent) {
_playPauseController.value = 0;
firstEvent = false;
} else {
_playPauseController.reverse();
}
}
});
}
void _play(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _pause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
}

View File

@@ -0,0 +1,317 @@
// 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 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/podcast/transcript_view.dart';
import 'package:pinepods_mobile/ui/podcast/up_next_view.dart';
import 'package:pinepods_mobile/ui/podcast/pinepods_up_next_view.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This class gives us options that can be dragged up from the bottom of the main player
/// window.
///
/// Currently these options are Up Next & Transcript.
///
/// This class is an initial version and should by much simpler than it is; however,
/// a [NestedScrollView] is the widget we need to implement this UI, there is a current
/// issue whereby the scroll view and [DraggableScrollableSheet] clash and therefore cannot
/// be used together.
///
/// See issues [64157](https://github.com/flutter/flutter/issues/64157)
/// [67219](https://github.com/flutter/flutter/issues/67219)
///
/// If anyone can come up with a more elegant solution (and one that does not throw
/// an overflow error in debug) please raise and issue/submit a PR.
///
class NowPlayingOptionsSelector extends StatefulWidget {
final double? scrollPos;
static const baseSize = 68.0;
const NowPlayingOptionsSelector({super.key, this.scrollPos});
@override
State<NowPlayingOptionsSelector> createState() => _NowPlayingOptionsSelectorState();
}
class _NowPlayingOptionsSelectorState extends State<NowPlayingOptionsSelector> {
DraggableScrollableController? draggableController;
@override
Widget build(BuildContext context) {
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
final theme = Theme.of(context);
final windowHeight = MediaQuery.of(context).size.height;
final minSize = NowPlayingOptionsSelector.baseSize / (windowHeight - NowPlayingOptionsSelector.baseSize);
return DraggableScrollableSheet(
initialChildSize: minSize,
minChildSize: minSize,
maxChildSize: 1.0,
controller: draggableController,
// Snap doesn't work as the sheet and scroll controller just don't get along
// snap: true,
// snapSizes: [minSize, maxSize],
builder: (BuildContext context, ScrollController scrollController) {
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, queueSnapshot) {
final hasTranscript = queueSnapshot.hasData &&
queueSnapshot.data?.playing != null &&
queueSnapshot.data!.playing!.hasTranscripts;
return DefaultTabController(
animationDuration: !draggableController!.isAttached || draggableController!.size <= minSize
? const Duration(seconds: 0)
: kTabScrollDuration,
length: hasTranscript ? 2 : 1,
child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) {
return SingleChildScrollView(
controller: scrollController,
child: ConstrainedBox(
constraints: BoxConstraints.expand(
height: constraints.maxHeight,
),
child: Material(
color: theme.secondaryHeaderColor,
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).highlightColor,
width: 0.0,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(18.0),
topRight: Radius.circular(18.0),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
SliderHandle(
label: optionsSliderOpen()
? L.of(context)!.semantic_playing_options_collapse_label
: L.of(context)!.semantic_playing_options_expand_label,
onTap: () {
if (draggableController != null) {
if (draggableController!.size < 1.0) {
draggableController!.animateTo(
1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
} else {
draggableController!.animateTo(
0.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
}
}
},
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.0),
border: Border(
bottom: draggableController != null &&
(!draggableController!.isAttached || draggableController!.size <= minSize)
? BorderSide.none
: BorderSide(color: Colors.grey[800]!, width: 1.0),
),
),
child: TabBar(
onTap: (index) {
DefaultTabController.of(ctx).animateTo(index);
if (draggableController != null && draggableController!.size < 1.0) {
draggableController!.animateTo(
1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
);
}
},
automaticIndicatorColorAdjustment: false,
indicatorPadding: EdgeInsets.zero,
/// Little hack to hide the indicator when closed
indicatorColor: draggableController != null &&
(!draggableController!.isAttached || draggableController!.size <= minSize)
? Theme.of(context).secondaryHeaderColor
: null,
tabs: [
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.up_next_queue_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
if (hasTranscript)
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.transcript_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
],
),
),
const Padding(padding: EdgeInsets.only(bottom: 12.0)),
Expanded(
child: Consumer<SettingsBloc>(
builder: (context, settingsBloc, child) {
final settings = settingsBloc.currentSettings;
final isPinepodsConnected = settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null;
return TabBarView(
children: [
isPinepodsConnected
? const PinepodsUpNextView()
: const UpNextView(),
if (hasTranscript)
const TranscriptView(),
],
);
},
),
),
],
),
),
),
);
}),
);
});
},
);
}
bool optionsSliderOpen() {
return (draggableController != null && draggableController!.isAttached && draggableController!.size == 1.0);
}
@override
void initState() {
draggableController = DraggableScrollableController();
super.initState();
}
}
class NowPlayingOptionsScaffold extends StatelessWidget {
const NowPlayingOptionsScaffold({super.key});
@override
Widget build(BuildContext context) {
return const SizedBox(
height: NowPlayingOptionsSelector.baseSize - 8.0,
);
}
}
/// This implementation displays the additional options in a tab set outside of a
/// draggable sheet.
///
/// Currently these options are Up Next & Transcript.
class NowPlayingOptionsSelectorWide extends StatefulWidget {
final double? scrollPos;
static const baseSize = 68.0;
const NowPlayingOptionsSelectorWide({super.key, this.scrollPos});
@override
State<NowPlayingOptionsSelectorWide> createState() => _NowPlayingOptionsSelectorWideState();
}
class _NowPlayingOptionsSelectorWideState extends State<NowPlayingOptionsSelectorWide> {
DraggableScrollableController? draggableController;
@override
Widget build(BuildContext context) {
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
final theme = Theme.of(context);
final scrollController = ScrollController();
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, queueSnapshot) {
final hasTranscript = queueSnapshot.hasData &&
queueSnapshot.data?.playing != null &&
queueSnapshot.data!.playing!.hasTranscripts;
return DefaultTabController(
length: hasTranscript ? 2 : 1,
child: LayoutBuilder(builder: (BuildContext ctx, BoxConstraints constraints) {
return SingleChildScrollView(
controller: scrollController,
child: ConstrainedBox(
constraints: BoxConstraints.expand(
height: constraints.maxHeight,
),
child: Material(
color: theme.secondaryHeaderColor,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.0),
border: Border(
bottom: BorderSide(color: Colors.grey[800]!, width: 1.0),
),
),
child: TabBar(
automaticIndicatorColorAdjustment: false,
tabs: [
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
child: Text(
L.of(context)!.up_next_queue_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
if (hasTranscript)
Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 16.0),
child: Text(
L.of(context)!.transcript_label.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
),
],
),
),
Expanded(
child: TabBarView(
children: [
const UpNextView(),
if (hasTranscript)
const TranscriptView(),
],
),
),
],
),
),
),
);
}),
);
});
}
}

View File

@@ -0,0 +1,82 @@
// 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.
// ignore_for_file: must_be_immutable
import 'dart:async';
import 'package:pinepods_mobile/entities/person.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// This Widget handles rendering of a person avatar.
///
/// The data comes from the <person> tag in the Podcasting 2.0 namespace.
///
/// https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#person
class PersonAvatar extends StatelessWidget {
final Person person;
String initials = '';
String role = '';
PersonAvatar({
super.key,
required this.person,
}) {
if (person.name.isNotEmpty) {
var parts = person.name.split(' ');
for (var i in parts) {
if (i.isNotEmpty) {
initials += i.substring(0, 1).toUpperCase();
}
}
}
if (person.role.isNotEmpty) {
role = person.role.substring(0, 1).toUpperCase() + person.role.substring(1);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: person.link != null && person.link!.isNotEmpty
? () {
final uri = Uri.parse(person.link!);
unawaited(
canLaunchUrl(uri).then((value) => launchUrl(uri)),
);
}
: null,
child: SizedBox(
width: 96,
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 32,
foregroundImage: ExtendedImage.network(
person.image!,
cache: true,
).image,
child: Text(initials),
),
Text(
person.name,
maxLines: 3,
textAlign: TextAlign.center,
),
Text(role),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,390 @@
// lib/ui/podcast/pinepods_up_next_view.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_queue_episode_card.dart';
import 'package:provider/provider.dart';
import 'dart:async';
/// PinePods version of the Up Next queue that shows the server queue.
///
/// This replaces the local queue functionality with server-based queue management.
class PinepodsUpNextView extends StatefulWidget {
const PinepodsUpNextView({
Key? key,
}) : super(key: key);
@override
State<PinepodsUpNextView> createState() => _PinepodsUpNextViewState();
}
class _PinepodsUpNextViewState extends State<PinepodsUpNextView> {
final PinepodsService _pinepodsService = PinepodsService();
List<PinepodsEpisode> _queuedEpisodes = [];
bool _isLoading = true;
String? _errorMessage;
StreamSubscription? _episodeSubscription;
@override
void initState() {
super.initState();
_loadQueue();
_listenToEpisodeChanges();
}
@override
void dispose() {
_episodeSubscription?.cancel();
super.dispose();
}
/// Listen to episode changes to refresh queue when episodes advance
void _listenToEpisodeChanges() {
try {
final audioPlayerService = Provider.of<AudioPlayerService>(context, listen: false);
final episodeStream = audioPlayerService.episodeEvent;
// Check if episodeEvent stream is available
if (episodeStream == null) {
print('Episode event stream not available');
return;
}
String? lastEpisodeGuid;
_episodeSubscription = episodeStream.listen((episode) {
// Only refresh if the episode actually changed (avoid unnecessary refreshes)
if (episode != null && episode.guid != lastEpisodeGuid && mounted) {
lastEpisodeGuid = episode.guid;
// Add a small delay to ensure server queue has been updated
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_loadQueue();
}
});
}
});
} catch (e) {
// Provider not available, continue without episode listening
print('Could not set up episode change listener: $e');
}
}
Future<void> _loadQueue() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer == null ||
settings.pinepodsApiKey == null ||
settings.pinepodsUserId == null) {
setState(() {
_errorMessage = 'Not connected to PinePods server';
_isLoading = false;
});
return;
}
_pinepodsService.setCredentials(
settings.pinepodsServer!,
settings.pinepodsApiKey!,
);
final episodes = await _pinepodsService.getQueuedEpisodes(settings.pinepodsUserId!);
setState(() {
_queuedEpisodes = episodes;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
Future<void> _reorderQueue(int oldIndex, int newIndex) async {
// Adjust indices if moving down the list
if (newIndex > oldIndex) {
newIndex -= 1;
}
// Update local state immediately for smooth UI
setState(() {
final episode = _queuedEpisodes.removeAt(oldIndex);
_queuedEpisodes.insert(newIndex, episode);
});
// Get episode IDs in new order
final episodeIds = _queuedEpisodes.map((e) => e.episodeId).toList();
// Call API to update order on server
try {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not logged in')),
);
await _loadQueue(); // Reload to restore original order
return;
}
final success = await _pinepodsService.reorderQueue(userId, episodeIds);
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to update queue order')),
);
await _loadQueue(); // Reload to restore original order
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error updating queue: $e')),
);
await _loadQueue(); // Reload to restore original order
}
}
Future<void> _removeFromQueue(int index) async {
final episode = _queuedEpisodes[index];
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not logged in')),
);
return;
}
try {
final success = await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
if (success) {
setState(() {
_queuedEpisodes.removeAt(index);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Removed from queue')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to remove from queue')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error removing from queue: $e')),
);
}
}
Future<void> _clearQueue() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Queue'),
content: const Text('Are you sure you want to clear the entire queue?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Clear'),
),
],
),
);
if (confirmed != true) return;
// Remove all episodes from queue
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
final userId = settings.pinepodsUserId;
if (userId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Not logged in')),
);
return;
}
try {
// Remove each episode from the queue
for (final episode in _queuedEpisodes) {
await _pinepodsService.removeQueuedEpisode(
episode.episodeId,
userId,
episode.isYoutube,
);
}
setState(() {
_queuedEpisodes.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error clearing queue: $e')),
);
await _loadQueue(); // Reload to get current state
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Header with title and clear button
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0),
child: Text(
'Up Next',
style: Theme.of(context).textTheme.titleLarge,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
child: TextButton(
onPressed: _queuedEpisodes.isEmpty ? null : _clearQueue,
child: Text(
'Clear',
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: _queuedEpisodes.isEmpty
? Theme.of(context).disabledColor
: Theme.of(context).primaryColor,
),
),
),
),
],
),
// Content area
if (_isLoading)
const Padding(
padding: EdgeInsets.all(24.0),
child: Center(
child: CircularProgressIndicator(),
),
)
else if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
Text(
'Error loading queue',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadQueue,
child: const Text('Retry'),
),
],
),
)
else if (_queuedEpisodes.isEmpty)
Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
'Your queue is empty. Add episodes to see them here.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
)
else
Expanded(
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: _queuedEpisodes.length,
itemBuilder: (BuildContext context, int index) {
final episode = _queuedEpisodes[index];
return Dismissible(
key: ValueKey('queue_${episode.episodeId}'),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
_removeFromQueue(index);
},
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete,
color: Colors.white,
),
),
child: Container(
key: ValueKey('episode_${episode.episodeId}'),
margin: const EdgeInsets.only(bottom: 4),
child: DraggableQueueEpisodeCard(
episode: episode,
index: index,
onTap: () {
// Could navigate to episode details if needed
},
onPlayPressed: () {
// Could implement play functionality if needed
},
),
),
);
},
onReorder: _reorderQueue,
),
),
],
);
}
}

View File

@@ -0,0 +1,70 @@
// 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/l10n/L.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Listens for errors on the audio BLoC.
///
/// We receive a code which we then map to an error message. This needs to be placed
/// below a [Scaffold].
class PlaybackErrorListener extends StatefulWidget {
final Widget child;
const PlaybackErrorListener({
super.key,
required this.child,
});
@override
State<PlaybackErrorListener> createState() => _PlaybackErrorListenerState();
}
class _PlaybackErrorListenerState extends State<PlaybackErrorListener> {
StreamSubscription<int>? errorSubscription;
@override
Widget build(BuildContext context) {
return widget.child;
}
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
errorSubscription = audioBloc.playbackError!.listen((code) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_codeToMessage(context, code))));
}
});
}
@override
void dispose() {
errorSubscription?.cancel();
super.dispose();
}
/// Ideally the BLoC would pass us the message to display; however, as we need a
/// context to fetch the correct version of any text string we need to work it out here.
String _codeToMessage(BuildContext context, int code) {
var result = '';
switch (code) {
case 401:
result = L.of(context)!.error_no_connection;
break;
case 501:
result = L.of(context)!.error_playback_fail;
break;
}
return result;
}
}

View File

@@ -0,0 +1,170 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This class handles the rendering of the positional controls: the current playback
/// time, time remaining and the time [Slider].
class PlayerPositionControls extends StatefulWidget {
const PlayerPositionControls({
super.key,
});
@override
State<PlayerPositionControls> createState() => _PlayerPositionControlsState();
}
class _PlayerPositionControlsState extends State<PlayerPositionControls> {
/// Current playback position
var currentPosition = 0;
/// Indicates the user is moving the position slide. We should ignore
/// position updates until the user releases the slide.
var dragging = false;
/// Seconds left of this episode.
var timeRemaining = 0;
/// The length of the episode in seconds.
var episodeLength = 0;
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
final screenReader = MediaQuery.of(context).accessibleNavigation;
return StreamBuilder<PositionState>(
stream: audioBloc.playPosition,
builder: (context, snapshot) {
var position = snapshot.hasData ? snapshot.data!.position.inSeconds : 0;
episodeLength = snapshot.hasData ? snapshot.data!.length.inSeconds : 0;
var divisions = episodeLength == 0 ? 1 : episodeLength;
// If a screen reader is enabled, will make divisions ten seconds each.
if (screenReader) {
divisions = episodeLength ~/ 10;
}
if (!dragging) {
currentPosition = position;
if (currentPosition < 0) {
currentPosition = 0;
}
if (currentPosition > episodeLength) {
currentPosition = episodeLength;
}
timeRemaining = episodeLength - position;
if (timeRemaining < 0) {
timeRemaining = 0;
}
}
return Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 0.0,
bottom: 4.0,
),
child: Row(
children: <Widget>[
FittedBox(
child: Text(
_formatDuration(Duration(seconds: currentPosition)),
semanticsLabel:
'${L.of(context)!.now_playing_episode_position} ${_formatDuration(Duration(seconds: currentPosition))}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
Expanded(
child: snapshot.hasData
? Slider(
label: _formatDuration(Duration(seconds: currentPosition)),
onChanged: (value) {
setState(() {
_calculatePositions(value.toInt());
// Normally, we only want to trigger a position change when the user has finished
// sliding; however, with a screen reader enabled that will never trigger. Instead,
// we'll use the 'normal' change event.
if (screenReader) {
return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value);
}
});
},
onChangeStart: (value) {
if (!snapshot.data!.buffering) {
setState(() {
dragging = true;
_calculatePositions(currentPosition);
});
}
},
onChangeEnd: (value) {
setState(() {
dragging = false;
});
return snapshot.data!.buffering ? null : audioBloc.transitionPosition(value);
},
value: currentPosition.toDouble(),
min: 0.0,
max: episodeLength.toDouble(),
divisions: divisions,
activeColor: Theme.of(context).primaryColor,
semanticFormatterCallback: (double newValue) {
return _formatDuration(Duration(seconds: currentPosition));
})
: Slider(
onChanged: null,
value: 0,
min: 0.0,
max: 1.0,
activeColor: Theme.of(context).primaryColor,
),
),
FittedBox(
child: Text(
_formatDuration(Duration(seconds: timeRemaining)),
textAlign: TextAlign.right,
semanticsLabel:
'${L.of(context)!.now_playing_episode_time_remaining} ${_formatDuration(Duration(seconds: timeRemaining))}',
style: const TextStyle(
fontFeatures: [FontFeature.tabularFigures()],
),
),
),
],
),
);
});
}
void _calculatePositions(int p) {
currentPosition = p;
timeRemaining = episodeLength - p;
}
String _formatDuration(Duration duration) {
String twoDigits(int n) {
if (n >= 10) return '$n';
return '0$n';
}
var twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).toInt());
var twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).toInt());
return '${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
}
}

View File

@@ -0,0 +1,202 @@
// 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/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/widgets/sleep_selector.dart';
import 'package:pinepods_mobile/ui/widgets/speed_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:provider/provider.dart';
/// Builds a transport control bar for rewind, play and fast-forward.
/// See [NowPlaying].
class PlayerTransportControls extends StatefulWidget {
const PlayerTransportControls({
super.key,
});
@override
State<PlayerTransportControls> createState() => _PlayerTransportControlsState();
}
class _PlayerTransportControlsState extends State<PlayerTransportControls> {
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: StreamBuilder<AudioState>(
stream: audioBloc.playingState,
initialData: AudioState.none,
builder: (context, snapshot) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: <Widget>[
const SleepSelectorWidget(),
IconButton(
onPressed: () {
return snapshot.data == AudioState.buffering ? null : _rewind(audioBloc);
},
tooltip: L.of(context)!.rewind_button_label,
padding: const EdgeInsets.all(0.0),
icon: const Icon(
Icons.replay_10,
size: 48.0,
),
),
AnimatedPlayButton(audioState: snapshot.data!),
IconButton(
onPressed: () {
return snapshot.data == AudioState.buffering ? null : _fastforward(audioBloc);
},
tooltip: L.of(context)!.fast_forward_button_label,
padding: const EdgeInsets.all(0.0),
icon: const Icon(
Icons.forward_30,
size: 48.0,
),
),
const SpeedSelectorWidget(),
],
);
}),
);
}
void _rewind(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.rewind);
}
void _fastforward(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.fastforward);
}
}
typedef PlayHandler = Function(AudioBloc audioBloc);
class AnimatedPlayButton extends StatefulWidget {
final AudioState audioState;
final PlayHandler onPlay;
final PlayHandler onPause;
const AnimatedPlayButton({
super.key,
required this.audioState,
this.onPlay = _onPlay,
this.onPause = _onPause,
});
@override
State<AnimatedPlayButton> createState() => _AnimatedPlayButtonState();
}
void _onPlay(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.play);
}
void _onPause(AudioBloc audioBloc) {
audioBloc.transitionState(TransitionState.pause);
}
class _AnimatedPlayButtonState extends State<AnimatedPlayButton> with SingleTickerProviderStateMixin {
late AnimationController _playPauseController;
late StreamSubscription<AudioState> _audioStateSubscription;
bool init = true;
@override
void initState() {
super.initState();
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
_playPauseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
/// Seems a little hacky, but when we load the form we want the play/pause
/// button to be in the correct state. If we are building the first frame,
/// just set the animation controller to the correct state; for all other
/// frames we want to animate. Doing it this way prevents the play/pause
/// button from animating when the form is first loaded.
_audioStateSubscription = audioBloc.playingState!.listen((event) {
if (event == AudioState.playing || event == AudioState.buffering) {
if (init) {
_playPauseController.value = 1;
init = false;
} else {
_playPauseController.forward();
}
} else {
if (init) {
_playPauseController.value = 0;
init = false;
} else {
_playPauseController.reverse();
}
}
});
}
@override
void dispose() {
_playPauseController.dispose();
_audioStateSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final playing = widget.audioState == AudioState.playing;
final buffering = widget.audioState == AudioState.buffering;
return Stack(
alignment: AlignmentDirectional.center,
children: [
if (buffering)
SpinKitRing(
lineWidth: 4.0,
color: Theme.of(context).primaryColor,
size: 84,
),
if (!buffering)
const SizedBox(
height: 84,
width: 84,
),
Tooltip(
message: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
child: TextButton(
style: TextButton.styleFrom(
shape: CircleBorder(side: BorderSide(color: Theme.of(context).highlightColor, width: 0.0)),
backgroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800],
foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.orange : Colors.grey[800],
padding: const EdgeInsets.all(6.0),
),
onPressed: () {
if (playing) {
widget.onPause(audioBloc);
} else {
widget.onPlay(audioBloc);
}
},
child: AnimatedIcon(
size: 60.0,
semanticLabel: playing ? L.of(context)!.pause_button_label : L.of(context)!.play_button_label,
icon: AnimatedIcons.play_pause,
color: Colors.white,
progress: _playPauseController,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,206 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/feed.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is responsible for rendering the context menu on the podcast details
/// page.
///
/// It returns either a [_MaterialPodcastMenu] or a [_CupertinoContextMenu}
/// instance depending upon which platform we are running on.
///
/// The target platform is based on the current [Theme]: [ThemeData.platform].
class PodcastContextMenu extends StatelessWidget {
final Podcast podcast;
const PodcastContextMenu(
this.podcast, {
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return _MaterialPodcastMenu(podcast);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return _CupertinoContextMenu(podcast);
}
}
}
/// This is the material design version of the context menu. This will be rendered
/// for all platforms that are not iOS.
class _MaterialPodcastMenu extends StatelessWidget {
final Podcast podcast;
const _MaterialPodcastMenu(this.podcast);
@override
Widget build(BuildContext context) {
final bloc = Provider.of<PodcastBloc>(context);
return StreamBuilder<BlocState<Podcast>>(
stream: bloc.details,
builder: (context, snapshot) {
return PopupMenuButton<String>(
onSelected: (event) {
togglePlayed(value: event, bloc: bloc);
},
icon: const Icon(
Icons.more_vert,
),
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'ma',
enabled: podcast.subscribed,
child: Text(L.of(context)!.mark_episodes_played_label),
),
PopupMenuItem<String>(
value: 'ua',
enabled: podcast.subscribed,
child: Text(L.of(context)!.mark_episodes_not_played_label),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'refresh',
enabled: podcast.link?.isNotEmpty ?? false,
child: Text(L.of(context)!.refresh_feed_label),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'web',
enabled: podcast.link?.isNotEmpty ?? false,
child: Text(L.of(context)!.open_show_website_label),
),
];
},
);
});
}
void togglePlayed({
required String value,
required PodcastBloc bloc,
}) async {
if (value == 'ma') {
bloc.podcastEvent(PodcastEvent.markAllPlayed);
} else if (value == 'ua') {
bloc.podcastEvent(PodcastEvent.clearAllPlayed);
} else if (value == 'refresh') {
bloc.load(Feed(
podcast: podcast,
refresh: true,
));
} else if (value == 'web') {
final uri = Uri.parse(podcast.link!);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
}
}
}
/// This is the Cupertino context menu and is rendered only when running on
/// an iOS device.
class _CupertinoContextMenu extends StatelessWidget {
final Podcast podcast;
const _CupertinoContextMenu(this.podcast);
@override
Widget build(BuildContext context) {
final bloc = Provider.of<PodcastBloc>(context);
return StreamBuilder<BlocState<Podcast>>(
stream: bloc.details,
builder: (context, snapshot) {
return IconButton(
tooltip: L.of(context)!.podcast_options_overflow_menu_semantic_label,
icon: const Icon(CupertinoIcons.ellipsis),
onPressed: () => showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
bloc.podcastEvent(PodcastEvent.markAllPlayed);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.mark_episodes_played_label),
),
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
bloc.podcastEvent(PodcastEvent.clearAllPlayed);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.mark_episodes_not_played_label),
),
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
bloc.load(Feed(
podcast: podcast,
refresh: true,
));
if (context.mounted) {
Navigator.pop(context, 'Cancel');
}
},
child: Text(L.of(context)!.refresh_feed_label),
),
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () async {
final uri = Uri.parse(podcast.link!);
if (!await launchUrl(
uri,
mode: LaunchMode.externalApplication,
)) {
throw Exception('Could not launch $uri');
}
if (context.mounted) {
Navigator.pop(context, 'Cancel');
}
},
child: Text(L.of(context)!.open_show_website_label),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_option_label),
),
);
},
),
);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
// 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 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PodcastEpisodeList extends StatelessWidget {
final List<Episode?>? episodes;
final IconData icon;
final String emptyMessage;
final bool play;
final bool download;
static const _defaultIcon = Icons.add_alert;
const PodcastEpisodeList({
super.key,
required this.episodes,
required this.play,
required this.download,
this.icon = _defaultIcon,
this.emptyMessage = '',
});
@override
Widget build(BuildContext context) {
if (episodes != null && episodes!.isNotEmpty) {
var queueBloc = Provider.of<QueueBloc>(context);
return StreamBuilder<QueueState>(
stream: queueBloc.queue,
builder: (context, snapshot) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
var queued = false;
var playing = false;
var episode = episodes![index]!;
if (snapshot.hasData) {
var playingGuid = snapshot.data!.playing?.guid;
queued = snapshot.data!.queue.any((element) => element.guid == episode.guid);
playing = playingGuid == episode.guid;
}
return EpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
},
childCount: episodes!.length,
addAutomaticKeepAlives: false,
));
});
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
icon,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
emptyMessage,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,54 @@
// 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 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_html.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
/// This class displays the show notes for the selected podcast.
///
/// We make use of [Html] to render the notes and, if in HTML format, display the
/// correct formatting, links etc.
class ShowNotes extends StatelessWidget {
final ScrollController _sliverScrollController = ScrollController();
final Episode episode;
ShowNotes({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: CustomScrollView(controller: _sliverScrollController, slivers: <Widget>[
SliverAppBar(
title: Text(episode.podcast!),
floating: false,
pinned: true,
snap: false,
),
SliverToBoxAdapter(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0),
child: Text(episode.title ?? '', style: textTheme.titleLarge),
),
Padding(
padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 0.0),
child: PodcastHtml(content: episode.content ?? episode.description!),
),
],
),
),
]));
}
}

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

View File

@@ -0,0 +1,277 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/audio/audio_player_service.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/download_button.dart';
import 'package:pinepods_mobile/ui/widgets/play_pause_button.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
/// Handles the state of the episode transport controls.
///
/// This currently consists of the [PlayControl] and [DownloadControl]
/// to handle the play/pause and download control state respectively.
class PlayControl extends StatelessWidget {
final Episode episode;
const PlayControl({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
return SizedBox(
height: 48.0,
width: 48.0,
child: StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (snapshot.hasData) {
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
if (episode.downloadState != DownloadState.downloading && episode.downloadState != DownloadState.queued) {
// If this episode is the one we are playing, allow the user
// to toggle between play and pause.
if (snapshot.hasData && nowPlaying?.guid == episode.guid) {
if (audioState == AudioState.playing) {
return InkWell(
onTap: () {
audioBloc.transitionState(TransitionState.pause);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.pause_button_label,
icon: Icons.pause,
),
);
} else if (audioState == AudioState.buffering) {
return PlayPauseBusyButton(
title: episode.title!,
label: L.of(context)!.pause_button_label,
icon: Icons.pause,
);
} else if (audioState == AudioState.pausing) {
return InkWell(
onTap: () {
audioBloc.transitionState(TransitionState.play);
optionalShowNowPlaying(context, settings);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
}
}
// If this episode is not the one we are playing, allow the
// user to start playing this episode.
return InkWell(
onTap: () {
audioBloc.play(episode);
optionalShowNowPlaying(context, settings);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
} else {
// We are currently downloading this episode. Do not allow
// the user to play it until the download is complete.
return Opacity(
opacity: 0.2,
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
}
} else {
// We have no playing information at the moment. Show a play button
// until the stream wakes up.
if (episode.downloadState != DownloadState.downloading) {
return InkWell(
onTap: () {
audioBloc.play(episode);
optionalShowNowPlaying(context, settings);
},
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
} else {
return Opacity(
opacity: 0.2,
child: PlayPauseButton(
title: episode.title!,
label: L.of(context)!.play_button_label,
icon: Icons.play_arrow,
),
);
}
}
}),
);
}
}
class DownloadControl extends StatelessWidget {
final Episode episode;
const DownloadControl({
super.key,
required this.episode,
});
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context);
final podcastBloc = Provider.of<PodcastBloc>(context);
return SizedBox(
height: 48.0,
width: 48.0,
child: StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (snapshot.hasData) {
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
if (nowPlaying?.guid == episode.guid &&
(audioState == AudioState.playing || audioState == AudioState.buffering)) {
if (episode.downloadState != DownloadState.downloaded) {
return Opacity(
opacity: 0.2,
child: DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.save_alt,
percent: 0,
label: L.of(context)!.download_episode_button_label,
),
);
} else {
return Opacity(
opacity: 0.2,
child: DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.check,
percent: 0,
label: L.of(context)!.download_episode_button_label,
),
);
}
}
}
if (episode.downloadState == DownloadState.downloaded) {
return DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.check,
percent: 0,
label: L.of(context)!.download_episode_button_label,
);
} else if (episode.downloadState == DownloadState.queued) {
return DownloadButton(
onPressed: () => _showCancelDialog(context),
title: episode.title!,
icon: Icons.timer_outlined,
percent: 0,
label: L.of(context)!.download_episode_button_label,
);
} else if (episode.downloadState == DownloadState.downloading) {
return DownloadButton(
onPressed: () => _showCancelDialog(context),
title: episode.title!,
icon: Icons.timer_outlined,
percent: episode.downloadPercentage!,
label: L.of(context)!.download_episode_button_label,
);
}
return DownloadButton(
onPressed: () => podcastBloc.downloadEpisode(episode),
title: episode.title!,
icon: Icons.save_alt,
percent: 0,
label: L.of(context)!.download_episode_button_label,
);
}),
);
}
Future<void> _showCancelDialog(BuildContext context) {
final episodeBloc = Provider.of<EpisodeBloc>(context, listen: false);
return showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(
L.of(context)!.stop_download_title,
),
content: Text(L.of(context)!.stop_download_confirmation),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.continue_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.stop_download_button_label,
),
iosIsDefaultAction: true,
onPressed: () {
episodeBloc.deleteDownload(episode);
Navigator.pop(context);
},
),
],
),
);
}
}
/// This class acts as a wrapper between the current audio state and
/// downloadables. Saves all that nesting of StreamBuilders.
class _PlayerControlState {
final AudioState audioState;
final Episode? episode;
_PlayerControlState(this.audioState, this.episode);
}

View File

@@ -0,0 +1,185 @@
// 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 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/queue_event_state.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/draggable_episode_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
/// This class is responsible for rendering the Up Next queue feature.
///
/// The user can see the currently playing item and the current queue. The user can
/// re-arrange items in the queue, remove individual items or completely clear the queue.
class UpNextView extends StatelessWidget {
const UpNextView({
super.key,
});
@override
Widget build(BuildContext context) {
final queueBloc = Provider.of<QueueBloc>(context, listen: false);
return StreamBuilder<QueueState>(
initialData: QueueEmptyState(),
stream: queueBloc.queue,
builder: (context, snapshot) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 24.0, 8.0),
child: Text(
L.of(context)!.now_playing_queue_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 0.0),
child: DraggableEpisodeTile(
key: const Key('detileplaying'),
episode: snapshot.data!.playing!,
draggable: false,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
child: Text(
L.of(context)!.up_next_queue_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0.0, 24.0, 8.0),
child: TextButton(
onPressed: snapshot.hasData && snapshot.data!.queue.isEmpty
? null
: () {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(
L.of(context)!.queue_clear_label_title,
),
content: Text(L.of(context)!.queue_clear_label),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.cancel_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(
Theme.of(context).platform == TargetPlatform.iOS
? L.of(context)!.queue_clear_button_label.toUpperCase()
: L.of(context)!.queue_clear_button_label,
),
iosIsDefaultAction: true,
iosIsDestructiveAction: true,
onPressed: () {
queueBloc.queueEvent(QueueClearEvent());
Navigator.pop(context);
},
),
],
),
);
},
child: snapshot.hasData && snapshot.data!.queue.isEmpty
? Text(
L.of(context)!.clear_queue_button_label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: Theme.of(context).disabledColor,
),
)
: Text(
L.of(context)!.clear_queue_button_label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: Theme.of(context).primaryColor,
),
),
),
),
],
),
snapshot.hasData && snapshot.data!.queue.isEmpty
? Padding(
padding: const EdgeInsets.all(24.0),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
border: Border.all(
color: Theme.of(context).dividerColor,
),
borderRadius: const BorderRadius.all(Radius.circular(10))),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
L.of(context)!.empty_queue_message,
style: Theme.of(context).textTheme.titleMedium,
),
),
),
)
: Expanded(
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: snapshot.hasData ? snapshot.data!.queue.length : 0,
itemBuilder: (BuildContext context, int index) {
return Dismissible(
key: ValueKey('disqueue${snapshot.data!.queue[index].guid}'),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
queueBloc.queueEvent(QueueRemoveEvent(episode: snapshot.data!.queue[index]));
},
child: DraggableEpisodeTile(
key: ValueKey('tilequeue${snapshot.data!.queue[index].guid}'),
index: index,
episode: snapshot.data!.queue[index],
playable: true,
),
);
},
onReorder: (int oldIndex, int newIndex) {
/// Seems odd to have to do this, but this -1 was taken from
/// the Flutter docs.
if (oldIndex < newIndex) {
newIndex -= 1;
}
queueBloc.queueEvent(QueueMoveEvent(
episode: snapshot.data!.queue[oldIndex],
oldIndex: oldIndex,
newIndex: newIndex,
));
},
),
),
],
);
});
}
}

View File

@@ -0,0 +1,111 @@
// 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:io';
import 'package:pinepods_mobile/bloc/search/search_bloc.dart';
import 'package:pinepods_mobile/bloc/search/search_state_event.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/search/search_results.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
/// This widget renders the search bar and allows the user to search for podcasts.
class Search extends StatefulWidget {
final String? searchTerm;
const Search({
super.key,
this.searchTerm,
});
@override
State<Search> createState() => _SearchState();
}
class _SearchState extends State<Search> {
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
@override
void initState() {
super.initState();
final bloc = Provider.of<SearchBloc>(context, listen: false);
bloc.search(SearchClearEvent());
_searchFocusNode = FocusNode();
_searchController = TextEditingController();
if (widget.searchTerm != null) {
bloc.search(SearchTermEvent(widget.searchTerm!));
_searchController.text = widget.searchTerm!;
}
}
@override
void dispose() {
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bloc = Provider.of<SearchBloc>(context);
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
leading: IconButton(
tooltip: L.of(context)!.search_back_button_label,
icon: Platform.isAndroid
? Icon(Icons.arrow_back, color: Theme.of(context).appBarTheme.foregroundColor)
: const Icon(Icons.arrow_back_ios),
onPressed: () => Navigator.pop(context),
),
title: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
autofocus: widget.searchTerm != null ? false : true,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: L.of(context)!.search_for_podcasts_hint,
border: InputBorder.none,
),
style: TextStyle(
color: Theme.of(context).primaryIconTheme.color,
fontSize: 18.0,
decorationColor: Theme.of(context).scaffoldBackgroundColor),
onSubmitted: ((value) {
SemanticsService.announce(L.of(context)!.semantic_announce_searching, TextDirection.ltr);
bloc.search(SearchTermEvent(value));
})),
floating: false,
pinned: true,
snap: false,
actions: <Widget>[
IconButton(
tooltip: L.of(context)!.clear_search_button_label,
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
FocusScope.of(context).requestFocus(_searchFocusNode);
SystemChannels.textInput.invokeMethod<String>('TextInput.show');
},
),
],
),
SearchResults(data: bloc.results!),
],
),
);
}
}

View File

@@ -0,0 +1,78 @@
// 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 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/search_slide_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'search.dart';
class SearchBar extends StatefulWidget {
const SearchBar({super.key});
@override
State<SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<SearchBar> {
late TextEditingController _searchController;
late FocusNode _searchFocusNode;
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_searchController.addListener(() {
setState(() {});
});
_searchFocusNode = FocusNode();
}
@override
void dispose() {
_searchFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 16),
title: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.search,
decoration: InputDecoration(hintText: L.of(context)!.search_for_podcasts_hint, border: InputBorder.none),
style: TextStyle(
color: Theme.of(context).primaryIconTheme.color,
fontSize: 18.0,
decorationColor: Theme.of(context).scaffoldBackgroundColor),
onSubmitted: (value) async {
await Navigator.push(
context,
SlideRightRoute(
widget: Search(searchTerm: value),
settings: const RouteSettings(name: 'search'),
));
_searchController.clear();
},
),
trailing: IconButton(
padding: EdgeInsets.zero,
tooltip: _searchFocusNode.hasFocus ? L.of(context)!.clear_search_button_label : null,
color: _searchFocusNode.hasFocus ? Theme.of(context).iconTheme.color : null,
splashColor: _searchFocusNode.hasFocus ? Theme.of(context).splashColor : Colors.transparent,
highlightColor: _searchFocusNode.hasFocus ? Theme.of(context).highlightColor : Colors.transparent,
icon: Icon(_searchController.text.isEmpty && !_searchFocusNode.hasFocus ? Icons.search : Icons.clear),
onPressed: () {
_searchController.clear();
FocusScope.of(context).requestFocus(FocusNode());
SystemChannels.textInput.invokeMethod<String>('TextInput.show');
}),
);
}
}

View File

@@ -0,0 +1,74 @@
// 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 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_list.dart';
import 'package:flutter/material.dart';
import 'package:podcast_search/podcast_search.dart' as search;
class SearchResults extends StatelessWidget {
final Stream<BlocState> data;
const SearchResults({
super.key,
required this.data,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<BlocState>(
stream: data,
builder: (BuildContext context, AsyncSnapshot<BlocState> snapshot) {
final state = snapshot.data;
if (state is BlocPopulatedState) {
return PodcastList(results: state.results as search.SearchResult);
} else {
if (state is BlocLoadingState) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
PlatformProgressIndicator(),
],
),
);
} else if (state is BlocErrorState) {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
L.of(context)!.no_search_results_message,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
return SliverFillRemaining(
hasScrollBody: false,
child: Container(),
);
}
},
);
}
}

View File

@@ -0,0 +1,128 @@
// 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 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
/// A widget that allows users to reorder the bottom navigation bar items
class BottomBarOrderWidget extends StatefulWidget {
const BottomBarOrderWidget({super.key});
@override
State<BottomBarOrderWidget> createState() => _BottomBarOrderWidgetState();
}
class _BottomBarOrderWidgetState extends State<BottomBarOrderWidget> {
late List<String> _currentOrder;
@override
void initState() {
super.initState();
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
_currentOrder = List.from(settingsBloc.currentSettings.bottomBarOrder);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Reorganize Bottom Bar'),
actions: [
TextButton(
onPressed: () async {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setBottomBarOrder(_currentOrder);
// Show a brief confirmation
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bottom bar order saved!'),
duration: Duration(seconds: 1),
),
);
// Small delay to let the user see the changes take effect
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
Navigator.pop(context);
}
},
child: Text(
'Save',
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Drag and drop to reorder the bottom navigation items. The first items will be easier to access.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
),
Expanded(
child: ReorderableListView(
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final item = _currentOrder.removeAt(oldIndex);
_currentOrder.insert(newIndex, item);
});
},
children: _currentOrder.map((item) {
return ListTile(
key: Key(item),
leading: Icon(_getIconForItem(item)),
title: Text(item),
trailing: const Icon(Icons.drag_handle),
);
}).toList(),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() {
_currentOrder = ['Home', 'Feed', 'Saved', 'Podcasts', 'Downloads', 'History', 'Playlists', 'Search'];
});
},
child: const Text('Reset to Default'),
),
),
],
),
),
],
),
);
}
IconData _getIconForItem(String item) {
switch (item) {
case 'Home': return Icons.home;
case 'Feed': return Icons.rss_feed;
case 'Saved': return Icons.bookmark;
case 'Podcasts': return Icons.podcasts;
case 'Downloads': return Icons.download;
case 'History': return Icons.history;
case 'Playlists': return Icons.playlist_play;
case 'Search': return Icons.search;
default: return Icons.home;
}
}
}

View File

@@ -0,0 +1,183 @@
// 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 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
class EpisodeRefreshWidget extends StatefulWidget {
const EpisodeRefreshWidget({super.key});
@override
State<EpisodeRefreshWidget> createState() => _EpisodeRefreshWidgetState();
}
class _EpisodeRefreshWidgetState extends State<EpisodeRefreshWidget> {
@override
Widget build(BuildContext context) {
var settingsBloc = Provider.of<SettingsBloc>(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(L.of(context)!.settings_auto_update_episodes),
subtitle: updateSubtitle(snapshot.data!),
onTap: () {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text(
L.of(context)!.settings_auto_update_episodes_heading,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
scrollable: true,
content: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Column(children: <Widget>[
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_never),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: -1,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? -1);
Navigator.pop(context);
});
},
),
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_always),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: 0,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? 0);
Navigator.pop(context);
});
},
),
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_30min),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: 30,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? 30);
Navigator.pop(context);
});
},
),
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_1hour),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: 60,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? 60);
Navigator.pop(context);
});
},
),
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_3hour),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: 180,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? 180);
Navigator.pop(context);
});
},
),
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_6hour),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: 360,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? 360);
Navigator.pop(context);
});
},
),
RadioListTile<int>(
title: Text(L.of(context)!.settings_auto_update_episodes_12hour),
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
value: 720,
groupValue: snapshot.data!.autoUpdateEpisodePeriod,
onChanged: (int? value) {
setState(() {
settingsBloc.autoUpdatePeriod(value ?? 720);
Navigator.pop(context);
});
},
),
]);
},
));
},
);
},
),
],
);
});
}
Text updateSubtitle(AppSettings settings) {
switch (settings.autoUpdateEpisodePeriod) {
case -1:
return Text(L.of(context)!.settings_auto_update_episodes_never);
case 0:
return Text(L.of(context)!.settings_auto_update_episodes_always);
case 10:
return Text(L.of(context)!.settings_auto_update_episodes_10min);
case 30:
return Text(L.of(context)!.settings_auto_update_episodes_30min);
case 60:
return Text(L.of(context)!.settings_auto_update_episodes_1hour);
case 180:
return Text(L.of(context)!.settings_auto_update_episodes_3hour);
case 360:
return Text(L.of(context)!.settings_auto_update_episodes_6hour);
case 720:
return Text(L.of(context)!.settings_auto_update_episodes_12hour);
}
return const Text('Never');
}
}

View File

@@ -0,0 +1,311 @@
// lib/ui/settings/pinepods_login.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/services/pinepods/login_service.dart';
import 'package:pinepods_mobile/ui/widgets/restart_widget.dart';
import 'package:pinepods_mobile/ui/settings/settings_section_label.dart';
import 'package:provider/provider.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class PinepodsLoginWidget extends StatefulWidget {
const PinepodsLoginWidget({Key? key}) : super(key: key);
@override
State<PinepodsLoginWidget> createState() => _PinepodsLoginWidgetState();
}
class _PinepodsLoginWidgetState extends State<PinepodsLoginWidget> {
final _serverController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _mfaController = TextEditingController();
bool _isLoading = false;
bool _showMfaField = false;
String _errorMessage = '';
bool _isLoggedIn = false;
String? _connectedServer;
String? _tempServerUrl;
String? _tempUsername;
int? _tempUserId;
String? _tempMfaSessionToken;
@override
void initState() {
super.initState();
// Initialize UI based on saved settings
_loadSavedSettings();
}
void _loadSavedSettings() {
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
var settings = settingsBloc.currentSettings;
// Check if we have PinePods settings
setState(() {
_isLoggedIn = false;
_connectedServer = null;
// We'll add these properties to AppSettings in the next step
if (settings.pinepodsServer != null &&
settings.pinepodsServer!.isNotEmpty &&
settings.pinepodsApiKey != null &&
settings.pinepodsApiKey!.isNotEmpty) {
_isLoggedIn = true;
_connectedServer = settings.pinepodsServer;
}
});
}
Future<void> _connectToPinepods() async {
if (!_showMfaField && (_serverController.text.isEmpty ||
_usernameController.text.isEmpty ||
_passwordController.text.isEmpty)) {
setState(() {
_errorMessage = 'Please fill in all fields';
});
return;
}
if (_showMfaField && _mfaController.text.isEmpty) {
setState(() {
_errorMessage = 'Please enter your MFA code';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
if (_showMfaField && _tempMfaSessionToken != null) {
// Complete MFA login flow
final mfaCode = _mfaController.text.trim();
final result = await PinepodsLoginService.completeMfaLogin(
serverUrl: _tempServerUrl!,
username: _tempUsername!,
mfaSessionToken: _tempMfaSessionToken!,
mfaCode: mfaCode,
);
if (result.isSuccess) {
// Save the connection details including user ID
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
setState(() {
_isLoggedIn = true;
_connectedServer = _tempServerUrl;
_showMfaField = false;
_tempServerUrl = null;
_tempUsername = null;
_tempUserId = null;
_tempMfaSessionToken = null;
_isLoading = false;
});
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'MFA verification failed';
_isLoading = false;
});
}
} else {
// Initial login flow
final serverUrl = _serverController.text.trim();
final username = _usernameController.text.trim();
final password = _passwordController.text;
final result = await PinepodsLoginService.login(
serverUrl,
username,
password,
);
if (result.isSuccess) {
// Save the connection details including user ID
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
settingsBloc.setPinepodsServer(result.serverUrl!);
settingsBloc.setPinepodsApiKey(result.apiKey!);
settingsBloc.setPinepodsUserId(result.userId!);
setState(() {
_isLoggedIn = true;
_connectedServer = serverUrl;
_isLoading = false;
});
} else if (result.requiresMfa) {
// Store MFA session info and show MFA field
setState(() {
_tempServerUrl = result.serverUrl;
_tempUsername = result.username;
_tempUserId = result.userId;
_tempMfaSessionToken = result.mfaSessionToken;
_showMfaField = true;
_isLoading = false;
_errorMessage = 'Please enter your MFA code';
});
} else {
setState(() {
_errorMessage = result.errorMessage ?? 'Login failed';
_isLoading = false;
});
}
}
} catch (e) {
setState(() {
_errorMessage = 'Error: ${e.toString()}';
_isLoading = false;
});
}
}
void _resetMfa() {
setState(() {
_showMfaField = false;
_tempServerUrl = null;
_tempUsername = null;
_tempUserId = null;
_tempMfaSessionToken = null;
_mfaController.clear();
_errorMessage = '';
});
}
void _logOut() async {
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
// Clear all PinePods user data
settingsBloc.setPinepodsServer(null);
settingsBloc.setPinepodsApiKey(null);
settingsBloc.setPinepodsUserId(null);
settingsBloc.setPinepodsUsername(null);
settingsBloc.setPinepodsEmail(null);
setState(() {
_isLoggedIn = false;
_connectedServer = null;
});
// Wait for the settings to be processed and then restart the app
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 100));
if (mounted) {
// Restart the entire app to reset all state
RestartWidget.restartApp(context);
}
});
}
@override
Widget build(BuildContext context) {
// Add a divider label for the PinePods section
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsDividerLabel(label: 'PinePods Server'),
const Divider(),
if (_isLoggedIn) ...[
// Show connected status
ListTile(
title: const Text('PinePods Connection'),
subtitle: Text(_connectedServer ?? ''),
trailing: TextButton(
onPressed: _logOut,
child: const Text('Log Out'),
),
),
] else ...[
// Show login form
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _serverController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'https://your-pinepods-server.com',
),
),
const SizedBox(height: 16),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
),
obscureText: true,
enabled: !_showMfaField,
),
// MFA Field (shown when MFA is required)
if (_showMfaField) ...[
const SizedBox(height: 16),
TextField(
controller: _mfaController,
decoration: InputDecoration(
labelText: 'MFA Code',
hintText: 'Enter 6-digit code',
suffixIcon: IconButton(
icon: const Icon(Icons.close),
onPressed: _resetMfa,
tooltip: 'Cancel MFA',
),
),
keyboardType: TextInputType.number,
maxLength: 6,
),
],
if (_errorMessage.isNotEmpty) ...[
const SizedBox(height: 16),
Text(
_errorMessage,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _connectToPinepods,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(_showMfaField ? 'Verify MFA Code' : 'Connect'),
),
),
],
),
),
],
],
);
}
@override
void dispose() {
_serverController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_mfaController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,118 @@
// 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 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
class SearchProviderWidget extends StatefulWidget {
final ValueChanged<String?>? onChanged;
const SearchProviderWidget({
super.key,
this.onChanged,
});
@override
State<SearchProviderWidget> createState() => _SearchProviderWidgetState();
}
class _SearchProviderWidgetState extends State<SearchProviderWidget> {
@override
Widget build(BuildContext context) {
var settingsBloc = Provider.of<SettingsBloc>(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return snapshot.data!.searchProviders.length > 1
? Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(L.of(context)!.search_provider_label),
subtitle: Text(snapshot.data!.searchProvider == 'itunes' ? 'iTunes' : 'PodcastIndex'),
onTap: () {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return AlertDialog(
title: Semantics(
header: true,
child: Text(L.of(context)!.search_provider_label,
style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
),
content: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
RadioListTile<String>(
title: const Text('iTunes'),
value: 'itunes',
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
groupValue: snapshot.data!.searchProvider,
onChanged: (String? value) {
setState(() {
settingsBloc.setSearchProvider(value ?? 'itunes');
if (widget.onChanged != null) {
widget.onChanged!(value);
}
Navigator.pop(context);
});
},
),
RadioListTile<String>(
title: const Text('PodcastIndex'),
value: 'podcastindex',
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 0.0),
groupValue: snapshot.data!.searchProvider,
onChanged: (String? value) {
setState(() {
settingsBloc.setSearchProvider(value ?? 'podcastindex');
if (widget.onChanged != null) {
widget.onChanged!(value);
}
Navigator.pop(context);
});
},
),
SimpleDialogOption(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
// child: Text(L.of(context)!.close_button_label),
child: Align(
alignment: Alignment.centerRight,
child: TextButton(
child: ActionText(L.of(context)!.close_button_label),
onPressed: () {
Navigator.pop(context, '');
},
),
),
),
]);
},
));
},
);
},
),
],
)
: Container();
});
}
}

View File

@@ -0,0 +1,339 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/core/utils.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/settings/episode_refresh.dart';
import 'package:pinepods_mobile/ui/settings/search_provider.dart';
import 'package:pinepods_mobile/ui/settings/settings_section_label.dart';
import 'package:pinepods_mobile/ui/settings/bottom_bar_order.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/settings/pinepods_login.dart';
import 'package:pinepods_mobile/ui/debug/debug_logs_page.dart';
import 'package:pinepods_mobile/ui/themes.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:provider/provider.dart';
/// This is the settings page and allows the user to select various
/// options for the app.
///
/// This is a self contained page and so, unlike the other forms, talks directly
/// to a settings service rather than a BLoC. Whilst this deviates slightly from
/// the overall architecture, adding a BLoC to simply be consistent with the rest
/// of the application would add unnecessary complexity.
///
/// This page is built with both Android & iOS in mind. However, the
/// rest of the application is not prepared for iOS design; this
/// is in preparation for the iOS version.
class Settings extends StatefulWidget {
const Settings({
super.key,
});
@override
State<Settings> createState() => _SettingsState();
}
class _SettingsState extends State<Settings> {
bool sdcard = false;
Widget _buildList(BuildContext context) {
var settingsBloc = Provider.of<SettingsBloc>(context);
var podcastBloc = Provider.of<PodcastBloc>(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: settingsBloc.currentSettings,
builder: (context, snapshot) {
return ListView(
children: [
SettingsDividerLabel(label: L.of(context)!.settings_personalisation_divider_label),
const Divider(),
MergeSemantics(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
L.of(context)!.settings_theme_switch_label,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
ThemeRegistry.getTheme(snapshot.data!.theme).description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.palette, size: 20),
const SizedBox(width: 12),
Expanded(
child: DropdownButton<String>(
value: snapshot.data!.theme,
isExpanded: true,
underline: Container(),
items: ThemeRegistry.themeList.map((theme) {
return DropdownMenuItem<String>(
value: theme.key,
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: theme.isDark ? Colors.grey[800] : Colors.grey[200],
border: Border.all(
color: theme.themeData.colorScheme.primary,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
theme.name,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
onChanged: (String? newTheme) {
if (newTheme != null) {
settingsBloc.setTheme(newTheme);
}
},
),
),
],
),
],
),
),
),
sdcard
? MergeSemantics(
child: ListTile(
title: Text(L.of(context)!.settings_download_sd_card_label),
trailing: Switch.adaptive(
value: snapshot.data!.storeDownloadsSDCard,
onChanged: (value) => sdcard
? setState(() {
if (value) {
_showStorageDialog(enableExternalStorage: true);
} else {
_showStorageDialog(enableExternalStorage: false);
}
settingsBloc.storeDownloadonSDCard(value);
})
: null,
),
),
)
: const SizedBox(
height: 0,
width: 0,
),
SettingsDividerLabel(label: 'Navigation'),
const Divider(),
ListTile(
title: const Text('Reorganize Bottom Bar'),
subtitle: const Text('Customize the order of bottom navigation items'),
leading: const Icon(Icons.reorder),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BottomBarOrderWidget(),
),
);
},
),
SettingsDividerLabel(label: L.of(context)!.settings_playback_divider_label),
const Divider(),
MergeSemantics(
child: ListTile(
title: Text(L.of(context)!.settings_auto_open_now_playing),
trailing: Switch.adaptive(
value: snapshot.data!.autoOpenNowPlaying,
onChanged: (value) => setState(() => settingsBloc.setAutoOpenNowPlaying(value)),
),
),
),
const SearchProviderWidget(),
SettingsDividerLabel(label: 'Debug'),
const Divider(),
ListTile(
title: const Text('App Logs'),
subtitle: const Text('View debug logs and device information'),
leading: const Icon(Icons.bug_report),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DebugLogsPage(),
),
);
},
),
const PinepodsLoginWidget(),
const _WebAppInfoWidget(),
],
);
});
}
Widget _buildAndroid(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).appBarTheme.systemOverlayStyle!,
child: Scaffold(
appBar: AppBar(
elevation: 0.0,
title: Text(
L.of(context)!.settings_label,
),
),
body: _buildList(context),
),
);
}
Widget _buildIos(BuildContext context) {
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
padding: const EdgeInsetsDirectional.all(0.0),
leading: CupertinoButton(
child: const Icon(Icons.arrow_back_ios),
onPressed: () {
Navigator.pop(context);
}),
middle: Text(
L.of(context)!.settings_label,
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
backgroundColor: Theme.of(context).colorScheme.surface,
),
child: Material(child: _buildList(context)),
);
}
void _showStorageDialog({required bool enableExternalStorage}) {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(L.of(context)!.settings_download_switch_label),
content: Text(
enableExternalStorage
? L.of(context)!.settings_download_switch_card
: L.of(context)!.settings_download_switch_internal,
),
actions: <Widget>[
BasicDialogAction(
title: Text(
L.of(context)!.ok_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
],
),
);
}
@override
Widget build(context) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return _buildAndroid(context);
case TargetPlatform.iOS:
return _buildIos(context);
default:
assert(false, 'Unexpected platform $defaultTargetPlatform');
return _buildAndroid(context);
}
}
@override
void initState() {
super.initState();
hasExternalStorage().then((value) {
setState(() {
sdcard = value;
});
});
}
}
class _WebAppInfoWidget extends StatelessWidget {
const _WebAppInfoWidget();
@override
Widget build(BuildContext context) {
return Consumer<SettingsBloc>(
builder: (context, settingsBloc, child) {
final settings = settingsBloc.currentSettings;
final serverUrl = settings.pinepodsServer;
// Only show if user is connected to a server
if (serverUrl == null || serverUrl.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.web,
color: Theme.of(context).primaryColor,
size: 20,
),
const SizedBox(width: 8),
Text(
'Web App Settings',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
],
),
const SizedBox(height: 8),
Text(
'Many more server side and user settings available from the PinePods web app. Please head to $serverUrl to adjust much more',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
);
},
);
}
}

View File

@@ -0,0 +1,33 @@
// 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 'package:flutter/material.dart';
class SettingsDividerLabel extends StatelessWidget {
final String label;
final EdgeInsetsGeometry padding;
const SettingsDividerLabel({
super.key,
required this.label,
this.padding = const EdgeInsets.fromLTRB(16.0, 24.0, 0.0, 0.0),
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Semantics(
header: true,
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontSize: 12.0,
color: Theme.of(context).primaryColor,
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,225 @@
// lib/ui/utils/local_download_utils.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:provider/provider.dart';
/// Utility class for managing local downloads of PinePods episodes
class LocalDownloadUtils {
static final Map<String, bool> _localDownloadStatusCache = {};
/// Generate consistent GUID for PinePods episodes for local downloads
static String generateEpisodeGuid(PinepodsEpisode episode) {
return 'pinepods_${episode.episodeId}';
}
/// Clear the local download status cache (call on refresh)
static void clearCache() {
_localDownloadStatusCache.clear();
}
/// Check if episode is downloaded locally with caching
static Future<bool> isEpisodeDownloadedLocally(
BuildContext context,
PinepodsEpisode episode
) async {
final guid = generateEpisodeGuid(episode);
final logger = AppLogger();
logger.debug('LocalDownload', 'Checking download status for episode: ${episode.episodeTitle}, GUID: $guid');
// Check cache first
if (_localDownloadStatusCache.containsKey(guid)) {
logger.debug('LocalDownload', 'Found cached status for $guid: ${_localDownloadStatusCache[guid]}');
return _localDownloadStatusCache[guid]!;
}
try {
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
// Get all episodes and find matches with both new and old GUID formats
final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes();
final matchingEpisodes = allEpisodes.where((ep) =>
ep.guid == guid || ep.guid.startsWith('${guid}_')
).toList();
logger.debug('LocalDownload', 'Repository lookup for $guid: found ${matchingEpisodes.length} matching episodes');
// Found matching episodes
// Consider downloaded if ANY matching episode is downloaded
final isDownloaded = matchingEpisodes.any((ep) =>
ep.downloaded || ep.downloadState == DownloadState.downloaded
);
logger.debug('LocalDownload', 'Final download status for $guid: $isDownloaded');
// Cache the result
_localDownloadStatusCache[guid] = isDownloaded;
return isDownloaded;
} catch (e) {
final logger = AppLogger();
logger.error('LocalDownload', 'Error checking local download status for episode: ${episode.episodeTitle}', e.toString());
return false;
}
}
/// Update local download status cache
static void updateLocalDownloadStatus(PinepodsEpisode episode, bool isDownloaded) {
final guid = generateEpisodeGuid(episode);
_localDownloadStatusCache[guid] = isDownloaded;
}
/// Proactively load local download status for a list of episodes
static Future<void> loadLocalDownloadStatuses(
BuildContext context,
List<PinepodsEpisode> episodes
) async {
final logger = AppLogger();
logger.debug('LocalDownload', 'Loading local download statuses for ${episodes.length} episodes');
try {
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
// Get all downloaded episodes from repository
final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes();
logger.debug('LocalDownload', 'Found ${allEpisodes.length} total episodes in repository');
// Filter to PinePods episodes only and log them
final pinepodsEpisodes = allEpisodes.where((ep) => ep.guid.startsWith('pinepods_')).toList();
logger.debug('LocalDownload', 'Found ${pinepodsEpisodes.length} PinePods episodes in repository');
// Found pinepods episodes in repository
// Now check each episode against the repository
for (final episode in episodes) {
final guid = generateEpisodeGuid(episode);
// Look for episodes with either new format (pinepods_123) or old format (pinepods_123_timestamp)
final matchingEpisodes = allEpisodes.where((ep) =>
ep.guid == guid || ep.guid.startsWith('${guid}_')
).toList();
// Checking for matching episodes
// Consider downloaded if ANY matching episode is downloaded
final isDownloaded = matchingEpisodes.any((ep) =>
ep.downloaded || ep.downloadState == DownloadState.downloaded
);
_localDownloadStatusCache[guid] = isDownloaded;
// Episode status checked
}
// Download statuses cached
} catch (e) {
logger.error('LocalDownload', 'Error loading local download statuses', e.toString());
}
}
/// Download episode locally
static Future<bool> localDownloadEpisode(
BuildContext context,
PinepodsEpisode episode
) async {
final logger = AppLogger();
try {
// Convert PinepodsEpisode to Episode for local download
final localEpisode = Episode(
guid: generateEpisodeGuid(episode),
pguid: 'pinepods_${episode.podcastName.replaceAll(' ', '_').toLowerCase()}',
podcast: episode.podcastName,
title: episode.episodeTitle,
description: episode.episodeDescription,
imageUrl: episode.episodeArtwork,
contentUrl: episode.episodeUrl,
duration: episode.episodeDuration,
publicationDate: DateTime.tryParse(episode.episodePubDate),
author: episode.podcastName,
season: 0,
episode: 0,
position: episode.listenDuration ?? 0,
played: episode.completed,
chapters: [],
transcriptUrls: [],
);
logger.debug('LocalDownload', 'Created local episode with GUID: ${localEpisode.guid}');
logger.debug('LocalDownload', 'Episode title: ${localEpisode.title}');
logger.debug('LocalDownload', 'Episode URL: ${localEpisode.contentUrl}');
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
// First save the episode to the repository so it can be tracked
await podcastBloc.podcastService.saveEpisode(localEpisode);
logger.debug('LocalDownload', 'Episode saved to repository');
// Use the download service from podcast bloc
final success = await podcastBloc.downloadService.downloadEpisode(localEpisode);
logger.debug('LocalDownload', 'Download service result: $success');
if (success) {
updateLocalDownloadStatus(episode, true);
}
return success;
} catch (e) {
logger.error('LocalDownload', 'Error in local download for episode: ${episode.episodeTitle}', e.toString());
return false;
}
}
/// Delete local download(s) for episode
static Future<int> deleteLocalDownload(
BuildContext context,
PinepodsEpisode episode
) async {
final logger = AppLogger();
try {
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
final guid = generateEpisodeGuid(episode);
// Get all episodes and find matches with both new and old GUID formats
final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes();
final matchingEpisodes = allEpisodes.where((ep) =>
ep.guid == guid || ep.guid.startsWith('${guid}_')
).toList();
logger.debug('LocalDownload', 'Found ${matchingEpisodes.length} episodes to delete for $guid');
if (matchingEpisodes.isNotEmpty) {
// Delete ALL matching episodes (handles duplicates from old timestamp GUIDs)
for (final localEpisode in matchingEpisodes) {
logger.debug('LocalDownload', 'Deleting episode: ${localEpisode.guid}');
await podcastBloc.podcastService.repository.deleteEpisode(localEpisode);
}
// Update cache
updateLocalDownloadStatus(episode, false);
return matchingEpisodes.length;
} else {
return 0;
}
} catch (e) {
logger.error('LocalDownload', 'Error deleting local download for episode: ${episode.episodeTitle}', e.toString());
return 0;
}
}
/// Show snackbar with message
static void showSnackBar(BuildContext context, String message, Color backgroundColor) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
duration: const Duration(seconds: 2),
),
);
}
}

View File

@@ -0,0 +1,43 @@
// 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 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/ui/podcast/now_playing.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_audio_service.dart';
import 'package:provider/provider.dart';
/// If we have the 'show now playing upon play' option set to true, launch
/// the [NowPlaying] widget automatically.
void optionalShowNowPlaying(BuildContext context, AppSettings settings) {
if (settings.autoOpenNowPlaying) {
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (context) => const NowPlaying(),
settings: const RouteSettings(name: 'nowplaying'),
fullscreenDialog: false,
),
);
}
}
/// Helper function to play a PinePods episode and automatically show the full screen player if enabled
Future<void> playPinepodsEpisodeWithOptionalFullScreen(
BuildContext context,
PinepodsAudioService audioService,
PinepodsEpisode episode, {
bool resume = true,
}) async {
await audioService.playPinepodsEpisode(
pinepodsEpisode: episode,
resume: resume,
);
// Show full screen player if setting is enabled
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
optionalShowNowPlaying(context, settingsBloc.currentSettings);
}

View File

@@ -0,0 +1,151 @@
// lib/ui/utils/position_utils.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/services/logging/app_logger.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:provider/provider.dart';
/// Utility class for managing episode position synchronization and display
class PositionUtils {
static final AppLogger _logger = AppLogger();
/// Generate consistent GUID for PinePods episodes
static String generateEpisodeGuid(PinepodsEpisode episode) {
return 'pinepods_${episode.episodeId}';
}
/// Get local position for episode from repository
static Future<double?> getLocalPosition(BuildContext context, PinepodsEpisode episode) async {
try {
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
final guid = generateEpisodeGuid(episode);
// Get all episodes and find matches with both new and old GUID formats
final allEpisodes = await podcastBloc.podcastService.repository.findAllEpisodes();
final matchingEpisodes = allEpisodes.where((ep) =>
ep.guid == guid || ep.guid.startsWith('${guid}_')
).toList();
if (matchingEpisodes.isNotEmpty) {
// Return the highest position from any matching episode (in case of duplicates)
final positions = matchingEpisodes.map((ep) => ep.position / 1000.0).toList();
return positions.reduce((a, b) => a > b ? a : b);
}
return null;
} catch (e) {
_logger.error('PositionUtils', 'Error getting local position for episode: ${episode.episodeTitle}', e.toString());
return null;
}
}
/// Get server position for episode (use existing data from feed)
static Future<double?> getServerPosition(PinepodsService pinepodsService, PinepodsEpisode episode, int userId) async {
return episode.listenDuration?.toDouble();
}
/// Get the best available position (furthest of local vs server)
static Future<PositionInfo> getBestPosition(
BuildContext context,
PinepodsService pinepodsService,
PinepodsEpisode episode,
int userId,
) async {
// Get both positions in parallel
final futures = await Future.wait([
getLocalPosition(context, episode),
getServerPosition(pinepodsService, episode, userId),
]);
final localPosition = futures[0] ?? 0.0;
final serverPosition = futures[1] ?? episode.listenDuration?.toDouble() ?? 0.0;
final bestPosition = localPosition > serverPosition ? localPosition : serverPosition;
final isLocal = localPosition >= serverPosition;
return PositionInfo(
position: bestPosition,
isLocal: isLocal,
localPosition: localPosition,
serverPosition: serverPosition,
);
}
/// Enrich a single episode with the best available position
static Future<PinepodsEpisode> enrichEpisodeWithBestPosition(
BuildContext context,
PinepodsService pinepodsService,
PinepodsEpisode episode,
int userId,
) async {
final positionInfo = await getBestPosition(context, pinepodsService, episode, userId);
// Create a new episode with updated position
return PinepodsEpisode(
podcastName: episode.podcastName,
episodeTitle: episode.episodeTitle,
episodePubDate: episode.episodePubDate,
episodeDescription: episode.episodeDescription,
episodeArtwork: episode.episodeArtwork,
episodeUrl: episode.episodeUrl,
episodeDuration: episode.episodeDuration,
listenDuration: positionInfo.position.round(),
episodeId: episode.episodeId,
completed: episode.completed,
saved: episode.saved,
queued: episode.queued,
downloaded: episode.downloaded,
isYoutube: episode.isYoutube,
podcastId: episode.podcastId,
);
}
/// Enrich a list of episodes with the best available positions
static Future<List<PinepodsEpisode>> enrichEpisodesWithBestPositions(
BuildContext context,
PinepodsService pinepodsService,
List<PinepodsEpisode> episodes,
int userId,
) async {
_logger.info('PositionUtils', 'Enriching ${episodes.length} episodes with best positions');
final enrichedEpisodes = <PinepodsEpisode>[];
for (final episode in episodes) {
try {
final enrichedEpisode = await enrichEpisodeWithBestPosition(
context,
pinepodsService,
episode,
userId,
);
enrichedEpisodes.add(enrichedEpisode);
} catch (e) {
_logger.warning('PositionUtils', 'Failed to enrich episode ${episode.episodeTitle}, using original: ${e.toString()}');
enrichedEpisodes.add(episode);
}
}
_logger.info('PositionUtils', 'Successfully enriched ${enrichedEpisodes.length} episodes');
return enrichedEpisodes;
}
}
/// Information about episode position
class PositionInfo {
final double position;
final bool isLocal;
final double localPosition;
final double serverPosition;
PositionInfo({
required this.position,
required this.isLocal,
required this.localPosition,
required this.serverPosition,
});
}

View File

@@ -0,0 +1,26 @@
// 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:io';
import 'package:flutter/widgets.dart';
/// This is a simple wrapper for the [Text] widget that is intended to
/// be used with action dialogs.
///
/// It should be supplied with a text value in sentence case. If running on
/// Android this will be shifted to all upper case to meet the Material Design
/// guidelines; otherwise it will be displayed as is to fit in the with iOS
/// developer guidelines.
class ActionText extends StatelessWidget {
/// The text to display which will be shifted to all upper-case on Android.
final String text;
const ActionText(this.text, {super.key});
@override
Widget build(BuildContext context) {
return Platform.isAndroid ? Text(text.toUpperCase()) : Text(text);
}
}

View File

@@ -0,0 +1,46 @@
// 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 'package:flutter/material.dart';
/// An [IconButton] cannot have a background or border.
///
/// This class wraps an IconButton in a shape so that it can have a background.
class DecoratedIconButton extends StatelessWidget {
final Color decorationColour;
final Color iconColour;
final IconData icon;
final VoidCallback onPressed;
const DecoratedIconButton({
super.key,
required this.iconColour,
required this.decorationColour,
required this.icon,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Center(
child: Ink(
width: 42.0,
height: 42.0,
decoration: ShapeDecoration(
color: decorationColour,
shape: const CircleBorder(),
),
child: IconButton(
icon: Icon(icon),
padding: const EdgeInsets.all(0.0),
color: iconColour,
onPressed: onPressed,
),
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
// 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 'package:pinepods_mobile/ui/widgets/platform_progress_indicator.dart';
import 'package:flutter/material.dart';
/// This class returns a platform-specific spinning indicator after a time specified
/// in milliseconds.
///
/// Defaults to 1 second. This can be used as a place holder for cached images. By
/// delaying for several milliseconds it can reduce the occurrences of placeholders
/// flashing on screen as the cached image is loaded. Images that take longer to fetch
/// or process from the cache will result in a [PlatformProgressIndicator] indicator
/// being displayed.
class DelayedCircularProgressIndicator extends StatelessWidget {
final f = Future.delayed(const Duration(milliseconds: 1000), () => Container());
DelayedCircularProgressIndicator({
super.key,
});
@override
Widget build(BuildContext context) {
return FutureBuilder<Widget>(
future: f,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return const Center(
child: PlatformProgressIndicator(),
);
} else {
return Container();
}
});
}
}

View File

@@ -0,0 +1,62 @@
// 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 'package:flutter/material.dart';
import 'package:percent_indicator/percent_indicator.dart';
/// Displays a download button for an episode.
///
/// Can be passed a percentage representing the download progress which
/// the button will then animate to show progress.
class DownloadButton extends StatelessWidget {
final String label;
final String title;
final IconData icon;
final int percent;
final VoidCallback onPressed;
const DownloadButton({
super.key,
required this.label,
required this.title,
required this.icon,
required this.percent,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
var progress = percent.toDouble() / 100;
return Semantics(
label: '$label $title',
child: InkWell(
onTap: onPressed,
child: CircularPercentIndicator(
radius: 19.0,
lineWidth: 1.5,
backgroundColor: Theme.of(context).primaryColor,
progressColor: Theme.of(context).indicatorColor,
animation: true,
animateFromLastPercent: true,
percent: progress,
center: percent > 0
? Text(
'$percent%',
style: const TextStyle(
fontSize: 12.0,
),
)
: Icon(
icon,
size: 22.0,
/// Why is this not picking up the theme like other widgets?!?!?!
color: Theme.of(context).primaryColor,
),
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Renders an episode within the queue which can be dragged to re-order the queue.
class DraggableEpisodeTile extends StatelessWidget {
final Episode episode;
final int index;
final bool draggable;
final bool playable;
const DraggableEpisodeTile({
super.key,
required this.episode,
this.index = 0,
this.draggable = true,
this.playable = false,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return ListTile(
key: Key('DT${episode.guid}'),
enabled: playable,
leading: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl ?? '',
size: 56.0,
highlight: episode.highlight,
),
title: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
subtitle: EpisodeSubtitle(episode),
trailing: draggable
? ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
)
: const SizedBox(
width: 0.0,
height: 0.0,
),
onTap: () {
if (playable) {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(episode);
optionalShowNowPlaying(context, settings);
}
},
);
}
}

View File

@@ -0,0 +1,265 @@
// lib/ui/widgets/draggable_queue_episode_card.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
class DraggableQueueEpisodeCard extends StatelessWidget {
final PinepodsEpisode episode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPressed;
final int index; // Add index for drag listener
const DraggableQueueEpisodeCard({
Key? key,
required this.episode,
required this.index,
this.onTap,
this.onLongPress,
this.onPlayPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
elevation: 1,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
ReorderableDragStartListener(
index: index,
child: Container(
width: 24,
height: 50,
margin: const EdgeInsets.only(right: 12),
child: Center(
child: Icon(
Icons.drag_indicator,
color: Colors.grey[600],
size: 20,
),
),
),
),
// Episode artwork
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: episode.episodeArtwork.isNotEmpty
? Image.network(
episode.episodeArtwork,
width: 50,
height: 50,
fit: BoxFit.cover,
cacheWidth: 100,
cacheHeight: 100,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.podcasts,
color: Colors.grey[600],
size: 24,
),
);
},
)
: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.podcasts,
color: Colors.grey[600],
size: 24,
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.episodeTitle,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
episode.podcastName,
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.episodePubDate.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.calendar_today,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
_formatDate(episode.episodePubDate),
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
if (episode.episodeDuration > 0) ...[
const SizedBox(width: 12),
Icon(
Icons.access_time,
size: 12,
color: Colors.grey[500],
),
const SizedBox(width: 4),
Text(
_formatDuration(episode.episodeDuration),
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
),
),
],
],
),
],
// Progress bar if episode has been started
if (episode.listenDuration != null && episode.listenDuration! > 0 && episode.episodeDuration > 0) ...[
const SizedBox(height: 8),
LinearProgressIndicator(
value: episode.listenDuration! / episode.episodeDuration,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor.withOpacity(0.7),
),
),
],
],
),
),
// Status indicators and play button
Column(
children: [
if (onPlayPressed != null)
IconButton(
onPressed: onPlayPressed,
icon: Icon(
episode.completed
? Icons.check_circle
: ((episode.listenDuration != null && episode.listenDuration! > 0) ? Icons.play_circle_filled : Icons.play_circle_outline),
color: episode.completed
? Colors.green
: Theme.of(context).primaryColor,
size: 28,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episode.saved)
Icon(
Icons.bookmark,
size: 16,
color: Colors.orange[600],
),
if (episode.downloaded)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.download_done,
size: 16,
color: Colors.green[600],
),
),
if (episode.queued)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.queue_music,
size: 16,
color: Colors.blue[600],
),
),
],
),
],
),
],
),
),
),
);
}
String _formatDate(String dateString) {
try {
final date = DateTime.parse(dateString);
final now = DateTime.now();
final difference = now.difference(date).inDays;
if (difference == 0) {
return 'Today';
} else if (difference == 1) {
return 'Yesterday';
} else if (difference < 7) {
return '${difference}d ago';
} else if (difference < 30) {
return '${(difference / 7).floor()}w ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
} catch (e) {
return dateString;
}
}
String _formatDuration(int seconds) {
if (seconds <= 0) return '';
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
} else {
return '${minutes}m';
}
}
}

View File

@@ -0,0 +1,164 @@
// lib/ui/widgets/episode_context_menu.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
class EpisodeContextMenu extends StatelessWidget {
final PinepodsEpisode episode;
final VoidCallback? onSave;
final VoidCallback? onRemoveSaved;
final VoidCallback? onDownload;
final VoidCallback? onLocalDownload;
final VoidCallback? onDeleteLocalDownload;
final VoidCallback? onQueue;
final VoidCallback? onMarkComplete;
final VoidCallback? onDismiss;
final bool isDownloadedLocally;
const EpisodeContextMenu({
Key? key,
required this.episode,
this.onSave,
this.onRemoveSaved,
this.onDownload,
this.onLocalDownload,
this.onDeleteLocalDownload,
this.onQueue,
this.onMarkComplete,
this.onDismiss,
this.isDownloadedLocally = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onDismiss, // Dismiss when tapping outside
child: Container(
color: Colors.black.withOpacity(0.3), // Semi-transparent overlay
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: GestureDetector(
onTap: () {}, // Prevent dismissal when tapping the menu itself
child: Material(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
elevation: 10,
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 400,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode title
Text(
episode.episodeTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Text(
episode.podcastName,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
const Divider(height: 1),
const SizedBox(height: 8),
// Menu options
_buildMenuOption(
context,
icon: episode.saved ? Icons.bookmark_remove : Icons.bookmark_add,
text: episode.saved ? 'Remove from Saved' : 'Save Episode',
onTap: episode.saved ? onRemoveSaved : onSave,
),
_buildMenuOption(
context,
icon: episode.downloaded ? Icons.delete_outline : Icons.cloud_download_outlined,
text: episode.downloaded ? 'Delete from Server' : 'Download to Server',
onTap: onDownload,
),
_buildMenuOption(
context,
icon: isDownloadedLocally ? Icons.delete_forever_outlined : Icons.file_download_outlined,
text: isDownloadedLocally ? 'Delete Local Download' : 'Download Locally',
onTap: isDownloadedLocally ? onDeleteLocalDownload : onLocalDownload,
),
_buildMenuOption(
context,
icon: episode.queued ? Icons.queue_music : Icons.add_to_queue,
text: episode.queued ? 'Remove from Queue' : 'Add to Queue',
onTap: onQueue,
),
_buildMenuOption(
context,
icon: episode.completed ? Icons.check_circle : Icons.check_circle_outline,
text: episode.completed ? 'Mark as Incomplete' : 'Mark as Complete',
onTap: onMarkComplete,
),
],
),
),
),
),
),
),
),
);
}
Widget _buildMenuOption(
BuildContext context, {
required IconData icon,
required String text,
VoidCallback? onTap,
bool enabled = true,
}) {
return InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Row(
children: [
Icon(
icon,
size: 20,
color: enabled
? Theme.of(context).iconTheme.color
: Theme.of(context).disabledColor,
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 14,
color: enabled
? Theme.of(context).textTheme.bodyLarge?.color
: Theme.of(context).disabledColor,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
// lib/ui/widgets/episode_description.dart
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_html_svg/flutter_html_svg.dart';
import 'package:flutter_html_table/flutter_html_table.dart';
import 'package:url_launcher/url_launcher.dart';
/// A specialized widget for displaying episode descriptions with clickable timestamps.
///
/// This widget extends the basic HTML display functionality to parse timestamp patterns
/// like "43:53" or "1:23:45" and make them clickable for navigation within the episode.
class EpisodeDescription extends StatelessWidget {
final String content;
final FontSize? fontSize;
final Function(Duration)? onTimestampTap;
const EpisodeDescription({
super.key,
required this.content,
this.fontSize,
this.onTimestampTap,
});
@override
Widget build(BuildContext context) {
// For now, let's use a simpler approach - just display the HTML with custom link handling
// We'll parse timestamps in the onLinkTap handler
return Html(
data: _processTimestamps(content),
extensions: const [
SvgHtmlExtension(),
TableHtmlExtension(),
],
style: {
'html': Style(
fontSize: FontSize(16.25),
lineHeight: LineHeight.percent(110),
),
'p': Style(
margin: Margins.only(
top: 0,
bottom: 12,
),
),
'.timestamp': Style(
color: const Color(0xFF539e8a),
textDecoration: TextDecoration.underline,
),
},
onLinkTap: (url, _, __) {
if (url != null && url.startsWith('timestamp:') && onTimestampTap != null) {
// Handle timestamp links
final secondsStr = url.substring(10); // Remove 'timestamp:' prefix
final seconds = int.tryParse(secondsStr);
if (seconds != null) {
final duration = Duration(seconds: seconds);
onTimestampTap!(duration);
}
} else if (url != null) {
// Handle regular links
canLaunchUrl(Uri.parse(url)).then((value) => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
));
}
},
);
}
/// Parses content and wraps timestamps with clickable links
String _processTimestamps(String htmlContent) {
// Regex pattern to match timestamp formats:
// - MM:SS (e.g., 43:53)
// - H:MM:SS (e.g., 1:23:45)
// - HH:MM:SS (e.g., 12:34:56)
final timestampRegex = RegExp(r'\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b');
return htmlContent.replaceAllMapped(timestampRegex, (match) {
final fullMatch = match.group(0)!;
final hours = match.group(1);
final minutes = match.group(2)!;
final seconds = match.group(3)!;
// Calculate total seconds for the timestamp
int totalSeconds = int.parse(seconds);
totalSeconds += int.parse(minutes) * 60;
if (hours != null) {
totalSeconds += int.parse(hours) * 3600;
}
// Return the timestamp wrapped in a clickable link
return '<a href="timestamp:$totalSeconds" style="color: #539e8a; text-decoration: underline;">$fullMatch</a>';
});
}
}

View File

@@ -0,0 +1,212 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to filter the episodes.
class EpisodeFilterSelectorWidget extends StatefulWidget {
final Podcast? podcast;
const EpisodeFilterSelectorWidget({
required this.podcast,
super.key,
});
@override
State<EpisodeFilterSelectorWidget> createState() => _EpisodeFilterSelectorWidgetState();
}
class _EpisodeFilterSelectorWidgetState extends State<EpisodeFilterSelectorWidget> {
@override
Widget build(BuildContext context) {
var podcastBloc = Provider.of<PodcastBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<BlocState<Podcast>>(
stream: podcastBloc.details,
initialData: BlocEmptyState<Podcast>(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: IconButton(
icon: Icon(
widget.podcast == null || widget.podcast!.filter == PodcastEpisodeFilter.none
? Icons.filter_alt_outlined
: Icons.filter_alt_off_outlined,
semanticLabel: L.of(context)!.episode_filter_semantic_label,
),
visualDensity: VisualDensity.compact,
onPressed: widget.podcast != null && widget.podcast!.subscribed
? () {
showModalBottomSheet<void>(
isScrollControlled: true,
barrierLabel: L.of(context)!.scrim_episode_filter_selector,
context: context,
backgroundColor: theme.secondaryHeaderColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return EpisodeFilterSlider(
podcast: widget.podcast!,
);
});
}
: null,
),
),
),
],
);
});
}
}
class EpisodeFilterSlider extends StatefulWidget {
final Podcast podcast;
const EpisodeFilterSlider({
required this.podcast,
super.key,
});
@override
State<EpisodeFilterSlider> createState() => _EpisodeFilterSliderState();
}
class _EpisodeFilterSliderState extends State<EpisodeFilterSlider> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Semantics(
header: true,
child: Text(
'Episode Filter',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
shrinkWrap: true,
children: [
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_none_label,
filter: PodcastEpisodeFilter.none,
selectedFilter: widget.podcast.filter,
),
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_started_label,
filter: PodcastEpisodeFilter.started,
selectedFilter: widget.podcast.filter,
),
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_played_label,
filter: PodcastEpisodeFilter.played,
selectedFilter: widget.podcast.filter,
),
const Divider(),
EpisodeFilterSelectorEntry(
label: L.of(context)!.episode_filter_unplayed_label,
filter: PodcastEpisodeFilter.notPlayed,
selectedFilter: widget.podcast.filter,
),
const Divider(),
],
),
)
]);
}
}
class EpisodeFilterSelectorEntry extends StatelessWidget {
const EpisodeFilterSelectorEntry({
super.key,
required this.label,
required this.filter,
required this.selectedFilter,
});
final String label;
final PodcastEpisodeFilter filter;
final PodcastEpisodeFilter selectedFilter;
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
switch (filter) {
case PodcastEpisodeFilter.none:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterNone);
break;
case PodcastEpisodeFilter.started:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterStarted);
break;
case PodcastEpisodeFilter.played:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterFinished);
break;
case PodcastEpisodeFilter.notPlayed:
podcastBloc.podcastEvent(PodcastEvent.episodeFilterNotFinished);
break;
}
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Semantics(
selected: filter == selectedFilter,
child: Text(
label,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (filter == selectedFilter)
const Icon(
Icons.check,
size: 18.0,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,219 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to filter the episodes.
class EpisodeSortSelectorWidget extends StatefulWidget {
final Podcast? podcast;
const EpisodeSortSelectorWidget({
required this.podcast,
super.key,
});
@override
State<EpisodeSortSelectorWidget> createState() => _EpisodeSortSelectorWidgetState();
}
class _EpisodeSortSelectorWidgetState extends State<EpisodeSortSelectorWidget> {
@override
Widget build(BuildContext context) {
var podcastBloc = Provider.of<PodcastBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<BlocState<Podcast>>(
stream: podcastBloc.details,
initialData: BlocEmptyState<Podcast>(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: IconButton(
icon: Icon(
Icons.sort,
semanticLabel: L.of(context)!.episode_sort_semantic_label,
),
visualDensity: VisualDensity.compact,
onPressed: widget.podcast != null && widget.podcast!.subscribed
? () {
showModalBottomSheet<void>(
barrierLabel: L.of(context)!.scrim_episode_sort_selector,
isScrollControlled: true,
context: context,
backgroundColor: theme.secondaryHeaderColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return EpisodeSortSlider(
podcast: widget.podcast!,
);
});
}
: null,
),
),
),
],
);
});
}
}
class EpisodeSortSlider extends StatefulWidget {
final Podcast podcast;
const EpisodeSortSlider({
required this.podcast,
super.key,
});
@override
State<EpisodeSortSlider> createState() => _EpisodeSortSliderState();
}
class _EpisodeSortSliderState extends State<EpisodeSortSlider> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Semantics(
header: true,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.episode_sort_semantic_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
shrinkWrap: true,
children: [
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_none_label,
sort: PodcastEpisodeSort.none,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_latest_first_label,
sort: PodcastEpisodeSort.latestFirst,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_earliest_first_label,
sort: PodcastEpisodeSort.earliestFirst,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_alphabetical_ascending_label,
sort: PodcastEpisodeSort.alphabeticalAscending,
selectedSort: widget.podcast.sort,
),
const Divider(),
EpisodeSortSelectorEntry(
label: L.of(context)!.episode_sort_alphabetical_descending_label,
sort: PodcastEpisodeSort.alphabeticalDescending,
selectedSort: widget.podcast.sort,
),
const Divider(),
],
),
)
]);
}
}
class EpisodeSortSelectorEntry extends StatelessWidget {
const EpisodeSortSelectorEntry({
super.key,
required this.label,
required this.sort,
required this.selectedSort,
});
final String label;
final PodcastEpisodeSort sort;
final PodcastEpisodeSort selectedSort;
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
switch (sort) {
case PodcastEpisodeSort.none:
podcastBloc.podcastEvent(PodcastEvent.episodeSortDefault);
break;
case PodcastEpisodeSort.latestFirst:
podcastBloc.podcastEvent(PodcastEvent.episodeSortLatest);
break;
case PodcastEpisodeSort.earliestFirst:
podcastBloc.podcastEvent(PodcastEvent.episodeSortEarliest);
break;
case PodcastEpisodeSort.alphabeticalAscending:
podcastBloc.podcastEvent(PodcastEvent.episodeSortAlphabeticalAscending);
break;
case PodcastEpisodeSort.alphabeticalDescending:
podcastBloc.podcastEvent(PodcastEvent.episodeSortAlphabeticalDescending);
break;
}
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
Semantics(
selected: sort == selectedSort,
child: Text(
label,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (sort == selectedSort)
const Icon(
Icons.check,
size: 18.0,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,957 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/episode_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/podcast/queue_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/downloadable.dart';
import 'package:pinepods_mobile/entities/episode.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/ui/podcast/episode_details.dart';
import 'package:pinepods_mobile/ui/podcast/transport_controls.dart';
import 'package:pinepods_mobile/ui/widgets/action_text.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/ui/utils/player_utils.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dialogs/flutter_dialogs.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
/// This class builds a tile for each episode in the podcast feed.
class EpisodeTile extends StatelessWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const EpisodeTile({
super.key,
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
Widget build(BuildContext context) {
final mediaQueryData = MediaQuery.of(context);
if (mediaQueryData.accessibleNavigation) {
if (defaultTargetPlatform == TargetPlatform.iOS) {
return _CupertinoAccessibleEpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
} else {
return _AccessibleEpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
}
} else {
return ExpandableEpisodeTile(
episode: episode,
download: download,
play: play,
playing: playing,
queued: queued,
);
}
}
}
/// An EpisodeTitle is built with an [ExpansionTile] widget and displays the episode's
/// basic details, thumbnail and play button.
///
/// It can then be expanded to present addition information about the episode and further
/// controls.
///
/// TODO: Replace [Opacity] with [Container] with a transparent colour.
class ExpandableEpisodeTile extends StatefulWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const ExpandableEpisodeTile({
super.key,
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
State<ExpandableEpisodeTile> createState() => _ExpandableEpisodeTileState();
}
class _ExpandableEpisodeTileState extends State<ExpandableEpisodeTile> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = Theme.of(context).textTheme;
final episodeBloc = Provider.of<EpisodeBloc>(context);
final queueBloc = Provider.of<QueueBloc>(context);
return ExpansionTile(
tilePadding: const EdgeInsets.fromLTRB(16.0, 0.0, 8.0, 0.0),
key: Key('PT${widget.episode.guid}'),
onExpansionChanged: (isExpanded) {
setState(() {
expanded = isExpanded;
});
},
trailing: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeTransportControls(
episode: widget.episode,
download: widget.download,
play: widget.play,
),
),
leading: ExcludeSemantics(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.passthrough,
children: <Widget>[
Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: TileImage(
url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!,
size: 56.0,
highlight: widget.episode.highlight,
),
),
SizedBox(
height: 5.0,
width: 56.0 * (widget.episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
),
subtitle: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeSubtitle(widget.episode),
),
title: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: Text(
widget.episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
),
children: <Widget>[
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 4.0,
),
child: Text(
widget.episode.descriptionText!,
overflow: TextOverflow.ellipsis,
softWrap: false,
maxLines: 5,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)),
),
onPressed: widget.episode.downloaded
? () {
showPlatformDialog<void>(
context: context,
useRootNavigator: false,
builder: (_) => BasicDialogAlert(
title: Text(
L.of(context)!.delete_episode_title,
),
content: Text(L.of(context)!.delete_episode_confirmation),
actions: <Widget>[
BasicDialogAction(
title: ActionText(
L.of(context)!.cancel_button_label,
),
onPressed: () {
Navigator.pop(context);
},
),
BasicDialogAction(
title: ActionText(
L.of(context)!.delete_button_label,
),
iosIsDefaultAction: true,
iosIsDestructiveAction: true,
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context);
},
),
],
),
);
}
: null,
child: Column(
children: <Widget>[
Icon(
Icons.delete_outline,
semanticLabel: L.of(context)!.delete_episode_button_label,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
ExcludeSemantics(
child: Text(
L.of(context)!.delete_label,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
),
],
),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
),
onPressed: widget.playing
? null
: () {
if (widget.queued) {
queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode));
} else {
queueBloc.queueEvent(QueueAddEvent(episode: widget.episode));
}
},
child: Column(
children: <Widget>[
Icon(
widget.queued ? Icons.playlist_add_check_outlined : Icons.playlist_add_outlined,
semanticLabel: widget.queued
? L.of(context)!.semantics_remove_from_queue
: L.of(context)!.semantics_add_to_queue,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
ExcludeSemantics(
child: Text(
widget.queued ? 'Remove' : 'Add',
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
),
],
),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
),
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
widget.episode.played ? Icons.unpublished_outlined : Icons.check_circle_outline,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
Text(
widget.episode.played ? L.of(context)!.mark_unplayed_label : L.of(context)!.mark_played_label,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
],
),
),
),
Expanded(
child: TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(0.0)),
),
onPressed: () {
showModalBottomSheet<void>(
barrierLabel: L.of(context)!.scrim_episode_details_selector,
context: context,
backgroundColor: theme.bottomAppBarTheme.color,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
),
builder: (context) {
return EpisodeDetails(
episode: widget.episode,
);
});
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Icon(
Icons.unfold_more_outlined,
size: 22,
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 2.0),
),
Text(
L.of(context)!.more_label,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.normal,
),
),
],
),
),
),
],
),
),
],
);
}
}
/// This is an accessible version of the episode tile that uses Apple theming.
/// When the tile is tapped, an iOS menu will appear with the relevant options.
class _CupertinoAccessibleEpisodeTile extends StatefulWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const _CupertinoAccessibleEpisodeTile({
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
State<_CupertinoAccessibleEpisodeTile> createState() => _CupertinoAccessibleEpisodeTileState();
}
class _CupertinoAccessibleEpisodeTileState extends State<_CupertinoAccessibleEpisodeTile> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final episodeBloc = Provider.of<EpisodeBloc>(context);
final podcastBloc = Provider.of<PodcastBloc>(context);
final queueBloc = Provider.of<QueueBloc>(context);
return StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
final currentlyPlaying = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.playing;
final currentlyPaused = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.pausing;
return Semantics(
button: true,
child: ListTile(
key: Key('PT${widget.episode.guid}'),
leading: ExcludeSemantics(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.passthrough,
children: <Widget>[
Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: TileImage(
url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!,
size: 56.0,
highlight: widget.episode.highlight,
),
),
SizedBox(
height: 5.0,
width: 56.0 * (widget.episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
),
subtitle: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeSubtitle(widget.episode),
),
title: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: Text(
widget.episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
),
onTap: () {
showCupertinoModalPopup<void>(
context: context,
builder: (BuildContext context) {
return CupertinoActionSheet(
actions: <Widget>[
if (currentlyPlaying)
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
audioBloc.transitionState(TransitionState.pause);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.pause_button_label),
),
if (currentlyPaused)
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
audioBloc.transitionState(TransitionState.play);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.resume_button_label),
),
if (!currentlyPlaying && !currentlyPaused)
CupertinoActionSheetAction(
isDefaultAction: true,
onPressed: () {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(widget.episode);
optionalShowNowPlaying(context, settings);
Navigator.pop(context, 'Cancel');
},
child: widget.episode.downloaded
? Text(L.of(context)!.play_download_button_label)
: Text(L.of(context)!.play_button_label),
),
if (widget.episode.downloadState == DownloadState.queued ||
widget.episode.downloadState == DownloadState.downloading)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.cancel_download_button_label),
),
if (widget.episode.downloadState != DownloadState.downloading)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
if (widget.episode.downloaded) {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, 'Cancel');
} else {
podcastBloc.downloadEpisode(widget.episode);
Navigator.pop(context, 'Cancel');
}
},
child: widget.episode.downloaded
? Text(L.of(context)!.delete_episode_button_label)
: Text(L.of(context)!.download_episode_button_label),
),
if (!currentlyPlaying && !widget.queued)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
queueBloc.queueEvent(QueueAddEvent(episode: widget.episode));
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_add_to_queue),
),
if (!currentlyPlaying && widget.queued)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode));
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_remove_from_queue),
),
if (widget.episode.played)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_mark_episode_unplayed),
),
if (!widget.episode.played)
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, 'Cancel');
},
child: Text(L.of(context)!.semantics_mark_episode_played),
),
CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
Navigator.pop(context, 'Cancel');
showModalBottomSheet<void>(
context: context,
barrierLabel: L.of(context)!.scrim_episode_details_selector,
backgroundColor: theme.bottomAppBarTheme.color,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
),
builder: (context) {
return EpisodeDetails(
episode: widget.episode,
);
});
},
child: Text(L.of(context)!.episode_details_button_label),
),
],
cancelButton: CupertinoActionSheetAction(
isDefaultAction: false,
onPressed: () {
Navigator.pop(context, 'Close');
},
child: Text(L.of(context)!.close_button_label),
),
);
},
);
},
),
);
});
}
}
/// This is an accessible version of the episode tile that uses Android theming.
/// When the tile is tapped, an Android dialog menu will appear with the relevant
/// options.
class _AccessibleEpisodeTile extends StatefulWidget {
final Episode episode;
final bool download;
final bool play;
final bool playing;
final bool queued;
const _AccessibleEpisodeTile({
required this.episode,
required this.download,
required this.play,
this.playing = false,
this.queued = false,
});
@override
State<_AccessibleEpisodeTile> createState() => _AccessibleEpisodeTileState();
}
class _AccessibleEpisodeTileState extends State<_AccessibleEpisodeTile> {
bool expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = Theme.of(context).textTheme;
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final episodeBloc = Provider.of<EpisodeBloc>(context);
final podcastBloc = Provider.of<PodcastBloc>(context);
final queueBloc = Provider.of<QueueBloc>(context);
return StreamBuilder<_PlayerControlState>(
stream: Rx.combineLatest2(audioBloc.playingState!, audioBloc.nowPlaying!,
(AudioState audioState, Episode? episode) => _PlayerControlState(audioState, episode)),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container();
}
final audioState = snapshot.data!.audioState;
final nowPlaying = snapshot.data!.episode;
final currentlyPlaying = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.playing;
final currentlyPaused = nowPlaying?.guid == widget.episode.guid && audioState == AudioState.pausing;
return ListTile(
key: Key('PT${widget.episode.guid}'),
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return Semantics(
header: true,
child: SimpleDialog(
//TODO: Fix this - should not be hardcoded text
title: const Text('Episode Actions'),
children: <Widget>[
if (currentlyPlaying)
SimpleDialogOption(
onPressed: () {
audioBloc.transitionState(TransitionState.pause);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.pause_button_label),
),
if (currentlyPaused)
SimpleDialogOption(
onPressed: () {
audioBloc.transitionState(TransitionState.play);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.resume_button_label),
),
if (!currentlyPlaying && !currentlyPaused && widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(widget.episode);
optionalShowNowPlaying(context, settings);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.play_download_button_label),
),
if (!currentlyPlaying && !currentlyPaused && !widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
final settings = Provider.of<SettingsBloc>(context, listen: false).currentSettings;
audioBloc.play(widget.episode);
optionalShowNowPlaying(context, settings);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.play_button_label),
),
if (widget.episode.downloadState == DownloadState.queued ||
widget.episode.downloadState == DownloadState.downloading)
SimpleDialogOption(
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.cancel_download_button_label),
),
if (widget.episode.downloadState != DownloadState.downloading && widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
episodeBloc.deleteDownload(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.delete_episode_button_label),
),
if (widget.episode.downloadState != DownloadState.downloading && !widget.episode.downloaded)
SimpleDialogOption(
onPressed: () {
podcastBloc.downloadEpisode(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.download_episode_button_label),
),
if (!currentlyPlaying && !widget.queued)
SimpleDialogOption(
onPressed: () {
queueBloc.queueEvent(QueueAddEvent(episode: widget.episode));
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_add_to_queue),
),
if (!currentlyPlaying && widget.queued)
SimpleDialogOption(
onPressed: () {
queueBloc.queueEvent(QueueRemoveEvent(episode: widget.episode));
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_remove_from_queue),
),
if (widget.episode.played)
SimpleDialogOption(
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_mark_episode_unplayed),
),
if (!widget.episode.played)
SimpleDialogOption(
onPressed: () {
episodeBloc.togglePlayed(widget.episode);
Navigator.pop(context, '');
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.semantics_mark_episode_played),
),
SimpleDialogOption(
onPressed: () {
Navigator.pop(context, '');
showModalBottomSheet<void>(
context: context,
barrierLabel: L.of(context)!.scrim_episode_details_selector,
backgroundColor: theme.bottomAppBarTheme.color,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
),
builder: (context) {
return EpisodeDetails(
episode: widget.episode,
);
});
},
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
child: Text(L.of(context)!.episode_details_button_label),
),
SimpleDialogOption(
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
// child: Text(L.of(context)!.close_button_label),
child: Align(
alignment: Alignment.centerRight,
child: TextButton(
child: ActionText(L.of(context)!.close_button_label),
onPressed: () {
Navigator.pop(context, '');
},
),
),
),
],
),
);
},
);
},
leading: ExcludeSemantics(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.passthrough,
children: <Widget>[
Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: TileImage(
url: widget.episode.thumbImageUrl ?? widget.episode.imageUrl!,
size: 56.0,
highlight: widget.episode.highlight,
),
),
SizedBox(
height: 5.0,
width: 56.0 * (widget.episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
),
subtitle: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: EpisodeSubtitle(widget.episode),
),
title: Opacity(
opacity: widget.episode.played ? 0.5 : 1.0,
child: Text(
widget.episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: false,
style: textTheme.bodyMedium,
),
),
);
});
}
}
class EpisodeTransportControls extends StatelessWidget {
final Episode episode;
final bool download;
final bool play;
const EpisodeTransportControls({
super.key,
required this.episode,
required this.download,
required this.play,
});
@override
Widget build(BuildContext context) {
final buttons = <Widget>[];
if (download) {
buttons.add(Semantics(
container: true,
child: DownloadControl(
episode: episode,
),
));
}
if (play) {
buttons.add(Semantics(
container: true,
child: PlayControl(
episode: episode,
),
));
}
return SizedBox(
width: (buttons.length * 48.0),
child: Row(
children: <Widget>[...buttons],
),
);
}
}
class EpisodeSubtitle extends StatelessWidget {
final Episode episode;
final String date;
final Duration length;
EpisodeSubtitle(this.episode, {super.key})
: date = episode.publicationDate == null
? ''
: DateFormat(episode.publicationDate!.year == DateTime.now().year ? 'd MMM' : 'd MMM yyyy')
.format(episode.publicationDate!),
length = Duration(seconds: episode.duration);
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
var timeRemaining = episode.timeRemaining;
String title;
if (length.inSeconds > 0) {
if (length.inSeconds < 60) {
title = '$date${length.inSeconds} sec';
} else {
title = '$date${length.inMinutes} min';
}
} else {
title = date;
}
if (timeRemaining.inSeconds > 0) {
if (timeRemaining.inSeconds < 60) {
title = '$title / ${timeRemaining.inSeconds} sec left';
} else {
title = '$title / ${timeRemaining.inMinutes} min left';
}
}
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
title,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: textTheme.bodySmall,
),
);
}
}
/// This class acts as a wrapper between the current audio state and
/// downloadables. Saves all that nesting of StreamBuilders.
class _PlayerControlState {
final AudioState audioState;
final Episode? episode;
_PlayerControlState(this.audioState, this.episode);
}

View File

@@ -0,0 +1,148 @@
// 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 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// Allows the user to select the layout for the library and discovery panes.
/// Can select from a list or different sized grids.
class LayoutSelectorWidget extends StatefulWidget {
const LayoutSelectorWidget({
super.key,
});
@override
State<LayoutSelectorWidget> createState() => _LayoutSelectorWidgetState();
}
class _LayoutSelectorWidgetState extends State<LayoutSelectorWidget> {
var speed = 1.0;
@override
void initState() {
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
speed = settingsBloc.currentSettings.playbackSpeed;
super.initState();
}
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
final mode = snapshot.data!.layout;
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.fromLTRB(8.0, 24.0, 8.0, 24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
const Padding(
padding: EdgeInsets.only(left: 8.0, right: 8.0),
child: Icon(
Icons.grid_view,
size: 18,
),
),
ExcludeSemantics(
child: Text(
L.of(context)!.layout_label,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
)),
Padding(
padding: const EdgeInsets.only(
left: 8.0,
right: 8.0,
),
child: OutlinedButton(
onPressed: () {
settingsBloc.layoutMode(0);
},
style: OutlinedButton.styleFrom(
backgroundColor: mode == 0 ? Theme.of(context).primaryColor : null,
),
child: Semantics(
selected: mode == 0,
label: L.of(context)!.semantics_layout_option_list,
child: Icon(
Icons.list,
color: mode == 0 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
right: 8.0,
),
child: OutlinedButton(
onPressed: () {
settingsBloc.layoutMode(1);
},
style: OutlinedButton.styleFrom(
backgroundColor: mode == 1 ? Theme.of(context).primaryColor : null,
),
child: Semantics(
selected: mode == 1,
label: L.of(context)!.semantics_layout_option_compact_grid,
child: Icon(
Icons.grid_on,
color: mode == 1 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
right: 8.0,
),
child: OutlinedButton(
onPressed: () {
settingsBloc.layoutMode(2);
},
style: OutlinedButton.styleFrom(
backgroundColor: mode == 2 ? Theme.of(context).primaryColor : null,
),
child: Semantics(
selected: mode == 2,
label: L.of(context)!.semantics_layout_option_grid,
child: Icon(
Icons.grid_view,
color: mode == 2 ? Theme.of(context).canvasColor : Theme.of(context).primaryColor,
),
),
),
),
]),
),
const SizedBox(
height: 8.0,
),
],
);
});
}
}

View File

@@ -0,0 +1,116 @@
// lib/ui/widgets/lazy_network_image.dart
import 'package:flutter/material.dart';
class LazyNetworkImage extends StatefulWidget {
final String imageUrl;
final double width;
final double height;
final BoxFit fit;
final Widget? placeholder;
final Widget? errorWidget;
final BorderRadius? borderRadius;
const LazyNetworkImage({
super.key,
required this.imageUrl,
required this.width,
required this.height,
this.fit = BoxFit.cover,
this.placeholder,
this.errorWidget,
this.borderRadius,
});
@override
State<LazyNetworkImage> createState() => _LazyNetworkImageState();
}
class _LazyNetworkImageState extends State<LazyNetworkImage> {
bool _shouldLoad = false;
Widget get _defaultPlaceholder => Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: widget.borderRadius,
),
child: const Icon(
Icons.music_note,
color: Colors.grey,
size: 24,
),
);
Widget get _defaultErrorWidget => Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: widget.borderRadius,
),
child: const Icon(
Icons.broken_image,
color: Colors.grey,
size: 24,
),
);
@override
void initState() {
super.initState();
// Delay loading slightly to allow the widget to be built first
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_shouldLoad = true;
});
}
});
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: widget.borderRadius ?? BorderRadius.zero,
child: _shouldLoad && widget.imageUrl.isNotEmpty
? Image.network(
widget.imageUrl,
width: widget.width,
height: widget.height,
fit: widget.fit,
cacheWidth: (widget.width * 2).round(), // 2x for better quality on high-DPI
cacheHeight: (widget.height * 2).round(),
errorBuilder: (context, error, stackTrace) {
return widget.errorWidget ?? _defaultErrorWidget;
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: widget.borderRadius,
),
child: Center(
child: SizedBox(
width: widget.width * 0.4,
height: widget.height * 0.4,
child: CircularProgressIndicator(
strokeWidth: 2,
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
),
);
},
)
: widget.placeholder ?? _defaultPlaceholder,
);
}
}

View File

@@ -0,0 +1,162 @@
// lib/ui/widgets/offline_episode_tile.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:intl/intl.dart' show DateFormat;
/// A custom episode tile specifically for offline downloaded episodes.
/// This bypasses the legacy PlayControl system and uses a custom play callback.
class OfflineEpisodeTile extends StatelessWidget {
final Episode episode;
final VoidCallback? onPlayPressed;
final VoidCallback? onTap;
const OfflineEpisodeTile({
super.key,
required this.episode,
this.onPlayPressed,
this.onTap,
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: ListTile(
onTap: onTap,
leading: Stack(
alignment: Alignment.bottomLeft,
children: [
Opacity(
opacity: episode.played ? 0.5 : 1.0,
child: TileImage(
url: episode.thumbImageUrl ?? episode.imageUrl!,
size: 56.0,
highlight: episode.highlight,
),
),
// Progress indicator
SizedBox(
height: 5.0,
width: 56.0 * (episode.percentagePlayed / 100),
child: Container(
color: Theme.of(context).primaryColor,
),
),
],
),
title: Opacity(
opacity: episode.played ? 0.5 : 1.0,
child: Text(
episode.title!,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: textTheme.bodyMedium,
),
),
subtitle: Opacity(
opacity: episode.played ? 0.5 : 1.0,
child: _EpisodeSubtitle(episode),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Offline indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green[100],
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.offline_pin,
size: 12,
color: Colors.green[700],
),
const SizedBox(width: 4),
Text(
'Offline',
style: TextStyle(
fontSize: 10,
color: Colors.green[700],
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 8),
// Custom play button that bypasses legacy audio system
SizedBox(
width: 48,
height: 48,
child: IconButton(
onPressed: onPlayPressed,
icon: Icon(
Icons.play_arrow,
color: Theme.of(context).primaryColor,
),
tooltip: L.of(context)?.play_button_label ?? 'Play',
),
),
],
),
),
);
}
}
class _EpisodeSubtitle extends StatelessWidget {
final Episode episode;
final String date;
final Duration length;
_EpisodeSubtitle(this.episode)
: date = episode.publicationDate == null
? ''
: DateFormat(episode.publicationDate!.year == DateTime.now().year ? 'd MMM' : 'd MMM yyyy')
.format(episode.publicationDate!),
length = Duration(seconds: episode.duration);
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
var timeRemaining = episode.timeRemaining;
String title;
if (length.inSeconds > 0) {
if (length.inSeconds < 60) {
title = '$date${length.inSeconds} sec';
} else {
title = '$date${length.inMinutes} min';
}
} else {
title = date;
}
if (timeRemaining.inSeconds > 0) {
if (timeRemaining.inSeconds < 60) {
title = '$title / ${timeRemaining.inSeconds} sec left';
} else {
title = '$title / ${timeRemaining.inMinutes} min left';
}
}
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
title,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: textTheme.bodySmall,
),
);
}
}

View File

@@ -0,0 +1,174 @@
// lib/ui/widgets/paginated_episode_list.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/entities/episode.dart';
import 'package:pinepods_mobile/ui/widgets/pinepods_episode_card.dart';
import 'package:pinepods_mobile/ui/widgets/episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/offline_episode_tile.dart';
import 'package:pinepods_mobile/ui/widgets/shimmer_episode_tile.dart';
class PaginatedEpisodeList extends StatefulWidget {
final List<dynamic> episodes; // Can be PinepodsEpisode or Episode
final bool isServerEpisodes;
final bool isOfflineMode; // New flag for offline mode
final Function(dynamic episode)? onEpisodeTap;
final Function(dynamic episode, int globalIndex)? onEpisodeLongPress;
final Function(dynamic episode)? onPlayPressed;
final int pageSize;
const PaginatedEpisodeList({
super.key,
required this.episodes,
required this.isServerEpisodes,
this.isOfflineMode = false,
this.onEpisodeTap,
this.onEpisodeLongPress,
this.onPlayPressed,
this.pageSize = 20, // Show 20 episodes at a time
});
@override
State<PaginatedEpisodeList> createState() => _PaginatedEpisodeListState();
}
class _PaginatedEpisodeListState extends State<PaginatedEpisodeList> {
int _currentPage = 0;
bool _isLoadingMore = false;
int get _totalPages => (widget.episodes.length / widget.pageSize).ceil();
int get _currentEndIndex => (_currentPage + 1) * widget.pageSize;
int get _displayedCount => _currentEndIndex.clamp(0, widget.episodes.length);
List<dynamic> get _displayedEpisodes =>
widget.episodes.take(_displayedCount).toList();
Future<void> _loadMoreEpisodes() async {
if (_isLoadingMore || _currentPage + 1 >= _totalPages) return;
setState(() {
_isLoadingMore = true;
});
// Simulate a small delay to show loading state
await Future.delayed(const Duration(milliseconds: 500));
setState(() {
_currentPage++;
_isLoadingMore = false;
});
}
Widget _buildEpisodeWidget(dynamic episode, int globalIndex) {
if (widget.isServerEpisodes && episode is PinepodsEpisode) {
return PinepodsEpisodeCard(
episode: episode,
onTap: widget.onEpisodeTap != null
? () => widget.onEpisodeTap!(episode)
: null,
onLongPress: widget.onEpisodeLongPress != null
? () => widget.onEpisodeLongPress!(episode, globalIndex)
: null,
onPlayPressed: widget.onPlayPressed != null
? () => widget.onPlayPressed!(episode)
: null,
);
} else if (!widget.isServerEpisodes && episode is Episode) {
// Use offline episode tile when in offline mode to bypass legacy audio system
if (widget.isOfflineMode) {
return OfflineEpisodeTile(
episode: episode,
onTap: widget.onEpisodeTap != null
? () => widget.onEpisodeTap!(episode)
: null,
onPlayPressed: widget.onPlayPressed != null
? () => widget.onPlayPressed!(episode)
: null,
);
} else {
return EpisodeTile(
episode: episode,
download: false,
play: true,
);
}
}
return const SizedBox.shrink(); // Fallback
}
@override
Widget build(BuildContext context) {
if (widget.episodes.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
// Display current episodes
..._displayedEpisodes.asMap().entries.map((entry) {
final index = entry.key;
final episode = entry.value;
final globalIndex = widget.episodes.indexOf(episode);
return _buildEpisodeWidget(episode, globalIndex);
}).toList(),
// Loading shimmer for more episodes
if (_isLoadingMore) ...[
...List.generate(3, (index) => const ShimmerEpisodeTile()),
],
// Load more button or loading indicator
if (_currentPage + 1 < _totalPages && !_isLoadingMore) ...[
const SizedBox(height: 8),
if (_isLoadingMore)
const Padding(
padding: EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Loading more episodes...'),
],
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _loadMoreEpisodes,
icon: const Icon(Icons.expand_more),
label: Text(
'Load ${(_displayedCount + widget.pageSize).clamp(0, widget.episodes.length) - _displayedCount} more episodes '
'(${widget.episodes.length - _displayedCount} remaining)',
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
),
] else if (widget.episodes.length > widget.pageSize) ...[
// Show completion message for large lists
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'All ${widget.episodes.length} episodes loaded',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,168 @@
// lib/ui/widgets/pinepods_episode_card.dart
import 'package:flutter/material.dart';
import 'package:pinepods_mobile/entities/pinepods_episode.dart';
import 'package:pinepods_mobile/ui/widgets/lazy_network_image.dart';
class PinepodsEpisodeCard extends StatelessWidget {
final PinepodsEpisode episode;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onPlayPressed;
const PinepodsEpisodeCard({
Key? key,
required this.episode,
this.onTap,
this.onLongPress,
this.onPlayPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
elevation: 1,
child: InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Episode artwork with lazy loading
LazyNetworkImage(
imageUrl: episode.episodeArtwork,
width: 50,
height: 50,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(6),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.episodeTitle,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
episode.podcastName,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Text(
episode.formattedPubDate,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
const SizedBox(width: 8),
Text(
episode.formattedDuration,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
),
// Progress bar if episode has been started
if (episode.isStarted) ...[
const SizedBox(height: 6),
LinearProgressIndicator(
value: episode.progressPercentage / 100,
backgroundColor: Colors.grey[300],
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
minHeight: 2,
),
],
],
),
),
// Status indicators and play button
Column(
children: [
if (onPlayPressed != null)
IconButton(
onPressed: onPlayPressed,
icon: Icon(
episode.completed
? Icons.check_circle
: ((episode.listenDuration != null && episode.listenDuration! > 0)
? Icons.play_circle_filled
: Icons.play_circle_outline),
color: episode.completed
? Colors.green
: Theme.of(context).primaryColor,
size: 28,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (episode.saved)
Icon(
Icons.bookmark,
size: 16,
color: Colors.orange[600],
),
if (episode.downloaded)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.download_done,
size: 16,
color: Colors.green[600],
),
),
if (episode.queued)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(
Icons.queue_music,
size: 16,
color: Colors.blue[600],
),
),
],
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
// 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 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
class PinepodsPodcastGridTile extends StatelessWidget {
final Podcast podcast;
const PinepodsPodcastGridTile({
super.key,
required this.podcast,
});
UnifiedPinepodsPodcast _convertToUnifiedPodcast() {
return UnifiedPinepodsPodcast(
id: podcast.id ?? 0,
indexId: 0, // Default value for subscribed podcasts
title: podcast.title,
url: podcast.url,
originalUrl: podcast.url,
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0, // Default value
categories: null,
explicit: false, // Default value
episodeCount: podcast.episodes.length, // Use actual episode count
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
final unifiedPodcast = _convertToUnifiedPodcast();
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true, // These are subscribed podcasts
),
),
);
},
child: Semantics(
label: podcast.title,
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 18.0,
),
),
),
),
);
}
}
class PinepodsPodcastTitledGridTile extends StatelessWidget {
final Podcast podcast;
const PinepodsPodcastTitledGridTile({
super.key,
required this.podcast,
});
UnifiedPinepodsPodcast _convertToUnifiedPodcast() {
return UnifiedPinepodsPodcast(
id: podcast.id ?? 0,
indexId: 0, // Default value for subscribed podcasts
title: podcast.title,
url: podcast.url,
originalUrl: podcast.url,
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0, // Default value
categories: null,
explicit: false, // Default value
episodeCount: podcast.episodes.length, // Use actual episode count
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: () {
final unifiedPodcast = _convertToUnifiedPodcast();
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true, // These are subscribed podcasts
),
),
);
},
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: Column(
children: [
TileImage(
url: podcast.imageUrl!,
size: 128.0,
),
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Text(
podcast.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,77 @@
// 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 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:flutter/material.dart';
class PinepodsPodcastTile extends StatelessWidget {
final Podcast podcast;
const PinepodsPodcastTile({
super.key,
required this.podcast,
});
UnifiedPinepodsPodcast _convertToUnifiedPodcast() {
return UnifiedPinepodsPodcast(
id: podcast.id ?? 0,
indexId: 0, // Default value for subscribed podcasts
title: podcast.title,
url: podcast.url,
originalUrl: podcast.url,
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0, // Default value
categories: null,
explicit: false, // Default value
episodeCount: podcast.episodes.length, // Use actual episode count
);
}
@override
Widget build(BuildContext context) {
return ListTile(
onTap: () {
final unifiedPodcast = _convertToUnifiedPodcast();
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepods_podcast_details'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true, // These are subscribed podcasts
),
),
);
},
minVerticalPadding: 9,
leading: ExcludeSemantics(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 60,
),
),
),
title: Text(
podcast.title,
maxLines: 1,
),
subtitle: Text(
'${podcast.copyright ?? ''}\n',
maxLines: 2,
),
isThreeLine: false,
);
}
}

View File

@@ -0,0 +1,25 @@
// 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 'package:flutter/material.dart';
class PlaceholderBuilder extends InheritedWidget {
final WidgetBuilder Function() builder;
final WidgetBuilder Function() errorBuilder;
const PlaceholderBuilder({
super.key,
required this.builder,
required this.errorBuilder,
required super.child,
});
static PlaceholderBuilder? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<PlaceholderBuilder>();
}
@override
bool updateShouldNotify(PlaceholderBuilder oldWidget) {
return builder != oldWidget.builder || errorBuilder != oldWidget.errorBuilder;
}
}

View File

@@ -0,0 +1,58 @@
// 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:io';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:flutter/material.dart';
/// Simple widget for rendering either the standard Android close or iOS Back button.
class PlatformBackButton extends StatelessWidget {
final Color decorationColour;
final Color iconColour;
final VoidCallback onPressed;
const PlatformBackButton({
super.key,
required this.iconColour,
required this.decorationColour,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Semantics(
button: true,
child: Center(
child: SizedBox(
height: 48.0,
width: 48.0,
child: InkWell(
onTap: onPressed,
child: Container(
margin: const EdgeInsets.all(6.0),
height: 48.0,
width: 48.0,
decoration: ShapeDecoration(
color: decorationColour,
shape: const CircleBorder(),
),
child: Padding(
padding: EdgeInsets.only(left: Platform.isIOS ? 8.0 : 0.0),
child: Icon(
Platform.isIOS ? Icons.arrow_back_ios : Icons.close,
size: Platform.isIOS ? 20.0 : 26.0,
semanticLabel: L.of(context)?.go_back_button_label,
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,32 @@
// 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 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
/// The class returns a circular progress indicator that is appropriate for the platform
/// it is running on.
///
/// This boils down to a [CupertinoActivityIndicator] when running on iOS or MacOS
/// and a [CircularProgressIndicator] for everything else.
class PlatformProgressIndicator extends StatelessWidget {
const PlatformProgressIndicator({
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return const CircularProgressIndicator();
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return const CupertinoActivityIndicator();
}
}
}

View File

@@ -0,0 +1,77 @@
// 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 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'package:percent_indicator/percent_indicator.dart';
class PlayPauseButton extends StatelessWidget {
final IconData icon;
final String label;
final String title;
const PlayPauseButton({
super.key,
required this.icon,
required this.label,
required this.title,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: '$label $title',
child: CircularPercentIndicator(
radius: 19.0,
lineWidth: 1.5,
backgroundColor: Theme.of(context).primaryColor,
percent: 0.0,
center: Icon(
icon,
size: 22.0,
/// Why is this not picking up the theme like other widgets?!?!?!
color: Theme.of(context).primaryColor,
),
),
);
}
}
class PlayPauseBusyButton extends StatelessWidget {
final IconData icon;
final String label;
final String title;
const PlayPauseBusyButton({
super.key,
required this.icon,
required this.label,
required this.title,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: '$label $title',
child: Stack(
children: <Widget>[
SizedBox(
height: 48.0,
width: 48.0,
child: Icon(
icon,
size: 22.0,
color: Theme.of(context).primaryColor,
),
),
SpinKitRing(
lineWidth: 1.5,
color: Theme.of(context).primaryColor,
size: 38.0,
),
],
));
}
}

View File

@@ -0,0 +1,250 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/podcast/podcast_details.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PodcastGridTile extends StatelessWidget {
final Podcast podcast;
const PodcastGridTile({
super.key,
required this.podcast,
});
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return GestureDetector(
onTap: () async {
await _navigateToPodcastDetails(context, podcastBloc);
},
child: Semantics(
label: podcast.title,
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 18.0,
),
),
),
),
);
}
Future<void> _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async {
// Check if this is a PinePods setup and if the podcast is already subscribed
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
// Check if podcast is already subscribed
final pinepodsService = PinepodsService();
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final isSubscribed = await pinepodsService.checkPodcastExists(
podcast.title,
podcast.url!,
settings.pinepodsUserId!
);
if (isSubscribed) {
// Get the internal PinePods database ID
final internalPodcastId = await pinepodsService.getPodcastId(
settings.pinepodsUserId!,
podcast.url!,
podcast.title
);
// Use PinePods podcast details for subscribed podcasts
final unifiedPodcast = UnifiedPinepodsPodcast(
id: internalPodcastId ?? 0,
indexId: 0, // Default for subscribed podcasts
title: podcast.title,
url: podcast.url ?? '',
originalUrl: podcast.url ?? '',
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0,
explicit: false,
episodeCount: 0, // Will be loaded
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true,
),
),
);
}
return;
}
} catch (e) {
// If check fails, fall through to standard podcast details
print('Error checking subscription status: $e');
}
}
// Use standard podcast details for non-subscribed or non-PinePods setups
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) => PodcastDetails(podcast, podcastBloc),
),
);
}
}
}
class PodcastTitledGridTile extends StatelessWidget {
final Podcast podcast;
const PodcastTitledGridTile({
super.key,
required this.podcast,
});
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
final theme = Theme.of(context);
return GestureDetector(
onTap: () async {
await _navigateToPodcastDetails(context, podcastBloc);
},
child: GridTile(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: Column(
children: [
TileImage(
url: podcast.imageUrl!,
size: 128.0,
),
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Text(
podcast.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
),
),
],
),
),
),
);
}
Future<void> _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async {
// Check if this is a PinePods setup and if the podcast is already subscribed
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
// Check if podcast is already subscribed
final pinepodsService = PinepodsService();
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final isSubscribed = await pinepodsService.checkPodcastExists(
podcast.title,
podcast.url!,
settings.pinepodsUserId!
);
if (isSubscribed) {
// Get the internal PinePods database ID
final internalPodcastId = await pinepodsService.getPodcastId(
settings.pinepodsUserId!,
podcast.url!,
podcast.title
);
// Use PinePods podcast details for subscribed podcasts
final unifiedPodcast = UnifiedPinepodsPodcast(
id: internalPodcastId ?? 0,
indexId: 0, // Default for subscribed podcasts
title: podcast.title,
url: podcast.url ?? '',
originalUrl: podcast.url ?? '',
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0,
explicit: false,
episodeCount: 0, // Will be loaded
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true,
),
),
);
}
return;
}
} catch (e) {
// If check fails, fall through to standard podcast details
print('Error checking subscription status: $e');
}
}
// Use standard podcast details for non-subscribed or non-PinePods setups
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) => PodcastDetails(podcast, podcastBloc),
),
);
}
}
}

View File

@@ -0,0 +1,50 @@
// 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 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_html_svg/flutter_html_svg.dart';
import 'package:flutter_html_table/flutter_html_table.dart';
import 'package:url_launcher/url_launcher.dart';
/// This class is a simple, common wrapper around the flutter_html Html widget.
///
/// This wrapper allows us to remove some of the HTML tags which can cause rendering
/// issues when viewing podcast descriptions on a mobile device.
class PodcastHtml extends StatelessWidget {
final String content;
final FontSize? fontSize;
const PodcastHtml({
super.key,
required this.content,
this.fontSize,
});
@override
Widget build(BuildContext context) {
return Html(
data: content,
extensions: const [
SvgHtmlExtension(),
TableHtmlExtension(),
],
style: {
'html': Style(
fontSize: FontSize(16.25),
lineHeight: LineHeight.percent(110),
),
'p': Style(
margin: Margins.only(
top: 0,
bottom: 12,
),
),
},
onLinkTap: (url, _, __) => canLaunchUrl(Uri.parse(url!)).then((value) => launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
)),
);
}
}

View File

@@ -0,0 +1,263 @@
// 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 'package:pinepods_mobile/core/environment.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
/// This class handles rendering of podcast images from a url.
/// Images will be cached for quicker fetching on subsequent requests. An optional placeholder
/// and error placeholder can be specified which will be rendered whilst the image is loading
/// or has failed to load.
///
/// We cache the image at a fixed sized of 480 regardless of render size. By doing this, large
/// podcast artwork will not slow the application down and the same image rendered at different
/// sizes will return the same cache hit reducing the need for fetching the image several times
/// for differing render sizes.
// ignore: must_be_immutable
class PodcastImage extends StatefulWidget {
final String url;
final double height;
final double width;
final BoxFit fit;
final bool highlight;
final double borderRadius;
final Widget? placeholder;
final Widget? errorPlaceholder;
const PodcastImage({
super.key,
required this.url,
this.height = double.infinity,
this.width = double.infinity,
this.fit = BoxFit.cover,
this.placeholder,
this.errorPlaceholder,
this.highlight = false,
this.borderRadius = 0.0,
});
@override
State<PodcastImage> createState() => _PodcastImageState();
}
class _PodcastImageState extends State<PodcastImage> with TickerProviderStateMixin {
static const cacheWidth = 480;
/// There appears to be a bug in extended image that causes images to
/// be re-fetched if headers have been set. We'll leave headers for now.
final headers = <String, String>{'User-Agent': Environment.userAgent()};
@override
Widget build(BuildContext context) {
return ExtendedImage.network(
widget.url,
key: widget.key,
width: widget.height,
height: widget.width,
cacheWidth: cacheWidth,
fit: widget.fit,
cache: true,
loadStateChanged: (ExtendedImageState state) {
Widget renderWidget;
if (state.extendedImageLoadState == LoadState.failed) {
renderWidget = ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: widget.errorPlaceholder ??
SizedBox(
width: widget.width,
height: widget.height,
),
);
} else {
renderWidget = AnimatedCrossFade(
crossFadeState: state.wasSynchronouslyLoaded || state.extendedImageLoadState == LoadState.completed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
firstChild: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: widget.placeholder ??
SizedBox(
width: widget.width,
height: widget.height,
),
),
secondChild: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: ExtendedRawImage(
image: state.extendedImageInfo?.image,
fit: widget.fit,
),
),
layoutBuilder: (
Widget topChild,
Key topChildKey,
Widget bottomChild,
Key bottomChildKey,
) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: widget.highlight
? [
PositionedDirectional(
key: bottomChildKey,
child: bottomChild,
),
PositionedDirectional(
key: topChildKey,
child: topChild,
),
Positioned(
top: -1.5,
right: -1.5,
child: Container(
width: 13,
height: 13,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).canvasColor,
),
),
),
Positioned(
top: 0.0,
right: 0.0,
child: Container(
width: 10.0,
height: 10.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).indicatorColor,
),
),
),
]
: [
PositionedDirectional(
key: bottomChildKey,
child: bottomChild,
),
PositionedDirectional(
key: topChildKey,
child: topChild,
),
],
);
},
);
}
return renderWidget;
},
);
}
}
class PodcastBannerImage extends StatefulWidget {
final String url;
final double height;
final double width;
final BoxFit fit;
final double borderRadius;
final Widget? placeholder;
final Widget? errorPlaceholder;
const PodcastBannerImage({
super.key,
required this.url,
this.height = double.infinity,
this.width = double.infinity,
this.fit = BoxFit.cover,
this.placeholder,
this.errorPlaceholder,
this.borderRadius = 0.0,
});
@override
State<PodcastBannerImage> createState() => _PodcastBannerImageState();
}
class _PodcastBannerImageState extends State<PodcastBannerImage> with TickerProviderStateMixin {
static const cacheWidth = 480;
/// There appears to be a bug in extended image that causes images to
/// be re-fetched if headers have been set. We'll leave headers for now.
final headers = <String, String>{'User-Agent': Environment.userAgent()};
@override
Widget build(BuildContext context) {
return ExtendedImage.network(
widget.url,
key: widget.key,
width: widget.height,
height: widget.width,
cacheWidth: cacheWidth,
fit: widget.fit,
cache: true,
loadStateChanged: (ExtendedImageState state) {
Widget renderWidget;
if (state.extendedImageLoadState == LoadState.failed) {
renderWidget = Container(
alignment: Alignment.topCenter,
width: widget.width - 2.0,
height: widget.height - 2.0,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)),
child: widget.errorPlaceholder ??
SizedBox(
width: widget.width - 2.0,
height: widget.height - 2.0,
),
),
);
} else {
renderWidget = AnimatedCrossFade(
crossFadeState: state.wasSynchronouslyLoaded || state.extendedImageLoadState == LoadState.completed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(seconds: 1),
firstChild: widget.placeholder ??
SizedBox(
width: widget.width,
height: widget.height,
),
secondChild: ExtendedRawImage(
width: widget.width,
height: widget.height,
image: state.extendedImageInfo?.image,
fit: widget.fit,
),
layoutBuilder: (
Widget topChild,
Key topChildKey,
Widget bottomChild,
Key bottomChildKey,
) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
PositionedDirectional(
key: bottomChildKey,
child: bottomChild,
),
PositionedDirectional(
key: topChildKey,
child: topChild,
),
],
);
},
);
}
return renderWidget;
},
);
}
}

View File

@@ -0,0 +1,99 @@
// 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 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_grid_tile.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_tile.dart';
import 'package:flutter/material.dart';
import 'package:podcast_search/podcast_search.dart' as search;
import 'package:provider/provider.dart';
class PodcastList extends StatelessWidget {
const PodcastList({
super.key,
required this.results,
});
final search.SearchResult results;
@override
Widget build(BuildContext context) {
final settingsBloc = Provider.of<SettingsBloc>(context);
if (results.items.isNotEmpty) {
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
builder: (context, settingsSnapshot) {
if (settingsSnapshot.hasData) {
var mode = settingsSnapshot.data!.layout;
var size = mode == 1 ? 100.0 : 160.0;
if (mode == 0) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final i = results.items[index];
final p = Podcast.fromSearchResultItem(i);
return PodcastTile(podcast: p);
},
childCount: results.items.length,
addAutomaticKeepAlives: false,
));
}
return SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: size,
mainAxisSpacing: 10.0,
crossAxisSpacing: 10.0,
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
final i = results.items[index];
final p = Podcast.fromSearchResultItem(i);
return PodcastGridTile(podcast: p);
},
childCount: results.items.length,
),
);
} else {
return const SliverFillRemaining(
hasScrollBody: false,
child: SizedBox(
height: 0,
width: 0,
),
);
}
});
} else {
return SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
Icons.search,
size: 75,
color: Theme.of(context).primaryColor,
),
Text(
L.of(context)!.no_search_results_message,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,136 @@
// 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 'package:pinepods_mobile/bloc/podcast/podcast_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/podcast.dart';
import 'package:pinepods_mobile/entities/pinepods_search.dart';
import 'package:pinepods_mobile/ui/podcast/podcast_details.dart';
import 'package:pinepods_mobile/ui/pinepods/podcast_details.dart';
import 'package:pinepods_mobile/ui/widgets/tile_image.dart';
import 'package:pinepods_mobile/services/pinepods/pinepods_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class PodcastTile extends StatelessWidget {
final Podcast podcast;
const PodcastTile({
super.key,
required this.podcast,
});
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context);
return ListTile(
onTap: () async {
await _navigateToPodcastDetails(context, podcastBloc);
},
minVerticalPadding: 9,
leading: ExcludeSemantics(
child: Hero(
key: Key('tilehero${podcast.imageUrl}:${podcast.link}'),
tag: '${podcast.imageUrl}:${podcast.link}',
child: TileImage(
url: podcast.imageUrl!,
size: 60,
),
),
),
title: Text(
podcast.title,
maxLines: 1,
),
/// A ListTile's density changes depending upon whether we have 2 or more lines of text. We
/// manually add a newline character here to ensure the density is consistent whether the
/// podcast subtitle spans 1 or more lines. Bit of a hack, but a simple solution.
subtitle: Text(
'${podcast.copyright ?? ''}\n',
maxLines: 2,
),
isThreeLine: false,
);
}
Future<void> _navigateToPodcastDetails(BuildContext context, PodcastBloc podcastBloc) async {
// Check if this is a PinePods setup and if the podcast is already subscribed
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final settings = settingsBloc.currentSettings;
if (settings.pinepodsServer != null &&
settings.pinepodsApiKey != null &&
settings.pinepodsUserId != null) {
// Check if podcast is already subscribed
final pinepodsService = PinepodsService();
pinepodsService.setCredentials(settings.pinepodsServer!, settings.pinepodsApiKey!);
try {
final isSubscribed = await pinepodsService.checkPodcastExists(
podcast.title,
podcast.url!,
settings.pinepodsUserId!
);
if (isSubscribed) {
// Get the internal PinePods database ID
final internalPodcastId = await pinepodsService.getPodcastId(
settings.pinepodsUserId!,
podcast.url!,
podcast.title
);
// Use PinePods podcast details for subscribed podcasts
final unifiedPodcast = UnifiedPinepodsPodcast(
id: internalPodcastId ?? 0,
indexId: 0, // Default for subscribed podcasts
title: podcast.title,
url: podcast.url ?? '',
originalUrl: podcast.url ?? '',
link: podcast.link ?? '',
description: podcast.description ?? '',
author: podcast.copyright ?? '',
ownerName: podcast.copyright ?? '',
image: podcast.imageUrl ?? '',
artwork: podcast.imageUrl ?? '',
lastUpdateTime: 0,
explicit: false,
episodeCount: 0, // Will be loaded
);
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'pinepodspodcastdetails'),
builder: (context) => PinepodsPodcastDetails(
podcast: unifiedPodcast,
isFollowing: true,
),
),
);
}
return;
}
} catch (e) {
// If check fails, fall through to standard podcast details
print('Error checking subscription status: $e');
}
}
// Use standard podcast details for non-subscribed or non-PinePods setups
if (context.mounted) {
Navigator.push(
context,
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'podcastdetails'),
builder: (context) => PodcastDetails(podcast, podcastBloc),
),
);
}
}
}

View File

@@ -0,0 +1,34 @@
// lib/ui/widgets/restart_widget.dart
import 'package:flutter/material.dart';
class RestartWidget extends StatefulWidget {
final Widget child;
const RestartWidget({Key? key, required this.child}) : super(key: key);
static void restartApp(BuildContext context) {
context.findAncestorStateOfType<_RestartWidgetState>()?.restartApp();
}
@override
_RestartWidgetState createState() => _RestartWidgetState();
}
class _RestartWidgetState extends State<RestartWidget> {
Key key = UniqueKey();
void restartApp() {
setState(() {
key = UniqueKey();
});
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: key,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,44 @@
// 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 'package:flutter/material.dart';
/// A transitioning route that slides the child in from the
/// right.
class SlideRightRoute extends PageRouteBuilder<void> {
final Widget widget;
@override
final RouteSettings settings;
SlideRightRoute({
required this.widget,
required this.settings,
}) : super(
pageBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return widget;
},
settings: settings,
transitionsBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(
1.0,
0.0,
),
end: Offset.zero,
).animate(animation),
child: child,
);
});
}

View File

@@ -0,0 +1,242 @@
// lib/ui/widgets/server_error_page.dart
import 'package:flutter/material.dart';
class ServerErrorPage extends StatelessWidget {
final String? errorMessage;
final VoidCallback? onRetry;
final String? title;
final String? subtitle;
final bool showLogo;
const ServerErrorPage({
Key? key,
this.errorMessage,
this.onRetry,
this.title,
this.subtitle,
this.showLogo = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// PinePods Logo
if (showLogo) ...[
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/images/pinepods-logo.png',
width: 120,
height: 120,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// Fallback if logo image fails to load
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.podcasts,
size: 64,
color: Theme.of(context).primaryColor,
),
);
},
),
),
const SizedBox(height: 32),
],
// Error Icon
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.cloud_off_rounded,
size: 48,
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 24),
// Title
Text(
title ?? 'Server Unavailable',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Subtitle
Text(
subtitle ?? 'Unable to connect to the PinePods server',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
// Error Message (if provided)
if (errorMessage != null && errorMessage!.isNotEmpty) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.error.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 6),
Text(
'Error Details',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.error,
),
),
],
),
const SizedBox(height: 8),
Text(
errorMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
],
),
),
const SizedBox(height: 24),
],
// Troubleshooting suggestions
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.lightbulb_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 6),
Text(
'Troubleshooting Tips',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
_buildTroubleshootingTip(context, '• Check your internet connection'),
_buildTroubleshootingTip(context, '• Verify server settings in the app'),
_buildTroubleshootingTip(context, '• Ensure the PinePods server is running'),
_buildTroubleshootingTip(context, '• Contact your administrator if the issue persists'),
],
),
),
const SizedBox(height: 32),
// Action Buttons
if (onRetry != null)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
);
}
Widget _buildTroubleshootingTip(BuildContext context, String tip) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
tip,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
}
/// A specialized server error page for SliverFillRemaining usage
class SliverServerErrorPage extends StatelessWidget {
final String? errorMessage;
final VoidCallback? onRetry;
final String? title;
final String? subtitle;
final bool showLogo;
const SliverServerErrorPage({
Key? key,
this.errorMessage,
this.onRetry,
this.title,
this.subtitle,
this.showLogo = true,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverFillRemaining(
hasScrollBody: false,
child: ServerErrorPage(
errorMessage: errorMessage,
onRetry: onRetry,
title: title,
subtitle: subtitle,
showLogo: showLogo,
),
);
}
}

View File

@@ -0,0 +1,138 @@
// lib/ui/widgets/shimmer_episode_tile.dart
import 'package:flutter/material.dart';
class ShimmerEpisodeTile extends StatefulWidget {
const ShimmerEpisodeTile({super.key});
@override
State<ShimmerEpisodeTile> createState() => _ShimmerEpisodeTileState();
}
class _ShimmerEpisodeTileState extends State<ShimmerEpisodeTile>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
@override
void initState() {
super.initState();
_shimmerController = AnimationController.unbounded(vsync: this)
..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000));
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shimmer image placeholder
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
gradient: LinearGradient(
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
stops: const [0.1, 0.3, 0.4],
begin: const Alignment(-1.0, -0.3),
end: const Alignment(1.0, 0.3),
transform: _SlidingGradientTransform(_shimmerController.value),
),
),
);
},
),
const SizedBox(width: 12),
// Shimmer text content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title placeholder
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
stops: const [0.1, 0.3, 0.4],
begin: const Alignment(-1.0, -0.3),
end: const Alignment(1.0, 0.3),
transform: _SlidingGradientTransform(_shimmerController.value),
),
),
);
},
),
const SizedBox(height: 8),
// Subtitle placeholder
AnimatedBuilder(
animation: _shimmerController,
builder: (context, child) {
return Container(
width: 120,
height: 12,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
colors: [
Colors.grey[300]!,
Colors.grey[100]!,
Colors.grey[300]!,
],
stops: const [0.1, 0.3, 0.4],
begin: const Alignment(-1.0, -0.3),
end: const Alignment(1.0, 0.3),
transform: _SlidingGradientTransform(_shimmerController.value),
),
),
);
},
),
],
),
),
],
),
),
);
}
}
class _SlidingGradientTransform extends GradientTransform {
const _SlidingGradientTransform(this.slidePercent);
final double slidePercent;
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}

View File

@@ -0,0 +1,298 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/entities/sleep.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to change the playback speed and toggle audio effects.
///
/// The two audio effects, trim silence and volume boost, are currently Android only.
class SleepSelectorWidget extends StatefulWidget {
const SleepSelectorWidget({
super.key,
});
@override
State<SleepSelectorWidget> createState() => _SleepSelectorWidgetState();
}
class _SleepSelectorWidgetState extends State<SleepSelectorWidget> {
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final settingsBloc = Provider.of<SettingsBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: StreamBuilder<Sleep>(
stream: audioBloc.sleepStream,
initialData: Sleep(type: SleepType.none),
builder: (context, sleepSnapshot) {
var sl = '';
if (sleepSnapshot.hasData) {
var s = sleepSnapshot.data!;
switch(s.type) {
case SleepType.none:
sl = '';
case SleepType.time:
sl = '${L.of(context)!.now_playing_episode_time_remaining} ${SleepSlider.formatDuration(s.timeRemaining)}';
case SleepType.episode:
sl = '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_episode_label}';
}
}
return IconButton(
icon: sleepSnapshot.data?.type != SleepType.none ? Icon(
Icons.bedtime,
semanticLabel: '${L.of(context)!.sleep_timer_label}. $sl',
size: 20.0,
) : Icon(
Icons.bedtime_outlined,
semanticLabel: L.of(context)!.sleep_timer_label,
size: 20.0,
),
onPressed: () {
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
backgroundColor: theme.secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_sleep_timer_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return const SleepSlider();
});
},
);
}
),
),
),
],
);
});
}
}
class SleepSlider extends StatefulWidget {
const SleepSlider({super.key});
static String formatDuration(Duration duration) {
String twoDigits(int n) {
if (n >= 10) return '$n';
return '0$n';
}
var twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).toInt());
var twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).toInt());
return '${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds';
}
@override
State<SleepSlider> createState() => _SleepSliderState();
}
class _SleepSliderState extends State<SleepSlider> {
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return StreamBuilder<Sleep>(
stream: audioBloc.sleepStream,
initialData: Sleep(type: SleepType.none),
builder: (context, snapshot) {
var s = snapshot.data;
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Semantics(
header: true,
child: Text(
L.of(context)!.sleep_timer_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
if (s != null && s.type == SleepType.none)
Text(
'(${L.of(context)!.sleep_off_label})',
semanticsLabel: '${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_off_label}',
style: Theme.of(context).textTheme.bodyLarge,
),
if (s != null && s.type == SleepType.time)
Text(
'(${SleepSlider.formatDuration(s.timeRemaining)})',
semanticsLabel:
'${L.of(context)!.semantic_current_value_label} ${SleepSlider.formatDuration(s.timeRemaining)}',
style: Theme.of(context).textTheme.bodyLarge,
),
if (s != null && s.type == SleepType.episode)
Text(
'(${L.of(context)!.sleep_episode_label})',
semanticsLabel:
'${L.of(context)!.semantic_current_value_label} ${L.of(context)!.sleep_episode_label}',
style: Theme.of(context).textTheme.bodyLarge,
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
shrinkWrap: true,
children: [
SleepSelectorEntry(
sleep: Sleep(type: SleepType.none),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 5),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 10),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 15),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 30),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 45),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.time,
duration: const Duration(minutes: 60),
),
current: s,
),
const Divider(),
SleepSelectorEntry(
sleep: Sleep(
type: SleepType.episode,
),
current: s,
),
],
),
)
]);
});
}
}
class SleepSelectorEntry extends StatelessWidget {
const SleepSelectorEntry({
super.key,
required this.sleep,
required this.current,
});
final Sleep sleep;
final Sleep? current;
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
audioBloc.sleep(Sleep(
type: sleep.type,
duration: sleep.duration,
));
Navigator.pop(context);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
if (sleep.type == SleepType.none)
Text(
L.of(context)!.sleep_off_label,
style: Theme.of(context).textTheme.bodyLarge,
),
if (sleep.type == SleepType.time)
Text(
L.of(context)!.sleep_minute_label(sleep.duration.inMinutes.toString()),
style: Theme.of(context).textTheme.bodyLarge,
),
if (sleep.type == SleepType.episode)
Text(
L.of(context)!.sleep_episode_label,
style: Theme.of(context).textTheme.bodyLarge,
),
if (sleep == current)
const Icon(
Icons.check,
size: 18.0,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,43 @@
// 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 'package:flutter/material.dart';
/// This class generates a simple 'handle' icon that can be added on widgets such as
/// scrollable sheets and bottom dialogs.
///
/// When running with a screen reader, the handle icon becomes selectable with an
/// optional label and tap callback. This makes it easier to open/close.
class SliderHandle extends StatelessWidget {
final GestureTapCallback? onTap;
final String label;
const SliderHandle({
super.key,
this.onTap,
this.label = '',
});
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).hintColor,
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,241 @@
// 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 'package:pinepods_mobile/bloc/podcast/audio_bloc.dart';
import 'package:pinepods_mobile/bloc/settings/settings_bloc.dart';
import 'package:pinepods_mobile/core/extensions.dart';
import 'package:pinepods_mobile/entities/app_settings.dart';
import 'package:pinepods_mobile/l10n/L.dart';
import 'package:pinepods_mobile/ui/widgets/slider_handle.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
/// This widget allows the user to change the playback speed and toggle audio effects.
///
/// The two audio effects, trim silence and volume boost, are currently Android only.
class SpeedSelectorWidget extends StatefulWidget {
const SpeedSelectorWidget({
super.key,
});
@override
State<SpeedSelectorWidget> createState() => _SpeedSelectorWidgetState();
}
class _SpeedSelectorWidgetState extends State<SpeedSelectorWidget> {
var speed = 1.0;
@override
void initState() {
var settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
speed = settingsBloc.currentSettings.playbackSpeed;
super.initState();
}
@override
Widget build(BuildContext context) {
var settingsBloc = Provider.of<SettingsBloc>(context);
var theme = Theme.of(context);
return StreamBuilder<AppSettings>(
stream: settingsBloc.settings,
initialData: AppSettings.sensibleDefaults(),
builder: (context, snapshot) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () {
showModalBottomSheet<void>(
context: context,
backgroundColor: theme.secondaryHeaderColor,
barrierLabel: L.of(context)!.scrim_speed_selector,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
),
),
builder: (context) {
return const SpeedSlider();
});
},
child: SizedBox(
height: 48.0,
width: 48.0,
child: Center(
child: Semantics(
button: true,
child: Text(
semanticsLabel: '${L.of(context)!.playback_speed_label} ${snapshot.data!.playbackSpeed.toTenth}',
snapshot.data!.playbackSpeed == 1.0 ? 'x1' : 'x${snapshot.data!.playbackSpeed.toTenth}',
style: TextStyle(
fontSize: 16.0,
color: Theme.of(context).iconTheme.color,
),
),
),
),
),
),
],
);
});
}
}
class SpeedSlider extends StatefulWidget {
const SpeedSlider({super.key});
@override
State<SpeedSlider> createState() => _SpeedSliderState();
}
class _SpeedSliderState extends State<SpeedSlider> {
var speed = 1.0;
var trimSilence = false;
var volumeBoost = false;
@override
void initState() {
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
speed = settingsBloc.currentSettings.playbackSpeed;
trimSilence = settingsBloc.currentSettings.trimSilence;
volumeBoost = settingsBloc.currentSettings.volumeBoost;
super.initState();
}
@override
Widget build(BuildContext context) {
final audioBloc = Provider.of<AudioBloc>(context, listen: false);
final settingsBloc = Provider.of<SettingsBloc>(context, listen: false);
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const SliderHandle(),
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Text(
L.of(context)!.audio_settings_playback_speed_label,
style: Theme.of(context).textTheme.titleLarge,
),
),
const Divider(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'${speed.toStringAsFixed(1)}x',
style: Theme.of(context).textTheme.headlineSmall,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: IconButton(
tooltip: L.of(context)!.semantics_decrease_playback_speed,
iconSize: 28.0,
icon: const Icon(Icons.remove_circle_outline),
onPressed: (speed <= 0.5)
? null
: () {
setState(() {
speed -= 0.1;
speed = speed.toTenth;
audioBloc.playbackSpeed(speed);
settingsBloc.setPlaybackSpeed(speed);
});
},
),
),
Expanded(
flex: 4,
child: Slider(
value: speed.toTenth,
min: 0.5,
max: 2.0,
divisions: 15,
onChanged: (value) {
setState(() {
speed = value;
});
},
onChangeEnd: (value) {
audioBloc.playbackSpeed(speed);
settingsBloc.setPlaybackSpeed(value);
},
),
),
Expanded(
child: IconButton(
tooltip: L.of(context)!.semantics_increase_playback_speed,
iconSize: 28.0,
icon: const Icon(Icons.add_circle_outline),
onPressed: (speed > 1.9)
? null
: () {
setState(() {
speed += 0.1;
speed = speed.toTenth;
audioBloc.playbackSpeed(speed);
settingsBloc.setPlaybackSpeed(speed);
});
},
),
),
],
),
const SizedBox(
height: 8.0,
),
const Divider(),
if (theme.platform == TargetPlatform.android) ...[
/// Disable the trim silence option for now until the positioning bug
/// in just_audio is resolved.
// ListTile(
// title: Text(L.of(context).audio_effect_trim_silence_label),
// trailing: Switch.adaptive(
// value: trimSilence,
// onChanged: (value) {
// setState(() {
// trimSilence = value;
// audioBloc.trimSilence(value);
// settingsBloc.trimSilence(value);
// });
// },
// ),
// ),
ListTile(
title: Text(L.of(context)!.audio_effect_volume_boost_label),
trailing: Switch.adaptive(
value: volumeBoost,
onChanged: (boost) {
setState(() {
volumeBoost = boost;
audioBloc.volumeBoost(boost);
settingsBloc.volumeBoost(boost);
});
},
),
),
] else
const SizedBox(
width: 0.0,
height: 0.0,
),
],
);
}
}

View File

@@ -0,0 +1,76 @@
// 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/podcast_bloc.dart';
import 'package:pinepods_mobile/state/bloc_state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SyncSpinner extends StatefulWidget {
const SyncSpinner({super.key});
@override
State<SyncSpinner> createState() => _SyncSpinnerState();
}
class _SyncSpinnerState extends State<SyncSpinner> with SingleTickerProviderStateMixin {
late AnimationController _controller;
StreamSubscription<BlocState<void>>? subscription;
Widget? _child;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_child = const Icon(
Icons.refresh,
size: 16.0,
);
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
subscription = podcastBloc.backgroundLoading.listen((event) {
if (event is BlocSuccessfulState<void> || event is BlocErrorState<void>) {
_controller.stop();
}
});
}
@override
void dispose() {
_controller.dispose();
subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final podcastBloc = Provider.of<PodcastBloc>(context, listen: false);
return StreamBuilder<BlocState<void>>(
initialData: BlocEmptyState<void>(),
stream: podcastBloc.backgroundLoading,
builder: (context, snapshot) {
final state = snapshot.data;
return state is BlocLoadingState<void>
? RotationTransition(
turns: _controller,
child: _child,
)
: const SizedBox(
width: 0.0,
height: 0.0,
);
});
}
}

View File

@@ -0,0 +1,51 @@
// 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 'package:pinepods_mobile/ui/widgets/placeholder_builder.dart';
import 'package:pinepods_mobile/ui/widgets/podcast_image.dart';
import 'package:flutter/material.dart';
class TileImage extends StatelessWidget {
const TileImage({
super.key,
required this.url,
required this.size,
this.highlight = false,
});
/// The URL of the image to display.
final String url;
/// The size of the image container; both height and width.
final double size;
final bool highlight;
@override
Widget build(BuildContext context) {
final placeholderBuilder = PlaceholderBuilder.of(context);
return PodcastImage(
key: Key('tile$url'),
highlight: highlight,
url: url,
height: size,
width: size,
borderRadius: 4.0,
fit: BoxFit.contain,
placeholder: placeholderBuilder != null
? placeholderBuilder.builder()(context)
: const Image(
fit: BoxFit.contain,
image: AssetImage('assets/images/favicon.png'),
),
errorPlaceholder: placeholderBuilder != null
? placeholderBuilder.errorBuilder()(context)
: const Image(
fit: BoxFit.contain,
image: AssetImage('assets/images/favicon.png'),
),
);
}
}