added cargo files
This commit is contained in:
163
PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart
Normal file
163
PinePods-0.8.2/mobile/lib/ui/auth/auth_wrapper.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
147
PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart
Normal file
147
PinePods-0.8.2/mobile/lib/ui/auth/oidc_browser.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
777
PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
Normal file
777
PinePods-0.8.2/mobile/lib/ui/auth/pinepods_startup_login.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
656
PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart
Normal file
656
PinePods-0.8.2/mobile/lib/ui/debug/debug_logs_page.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
19
PinePods-0.8.2/mobile/lib/ui/library/downloads.dart
Normal file
19
PinePods-0.8.2/mobile/lib/ui/library/downloads.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
115
PinePods-0.8.2/mobile/lib/ui/library/library.dart
Normal file
115
PinePods-0.8.2/mobile/lib/ui/library/library.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
390
PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart
Normal file
390
PinePods-0.8.2/mobile/lib/ui/pinepods/create_playlist.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
968
PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
Normal file
968
PinePods-0.8.2/mobile/lib/ui/pinepods/downloads.dart
Normal 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),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
963
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart
Normal file
963
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_details.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal file
817
PinePods-0.8.2/mobile/lib/ui/pinepods/episode_search.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
1050
PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart
Normal file
1050
PinePods-0.8.2/mobile/lib/ui/pinepods/feed.dart
Normal file
File diff suppressed because it is too large
Load Diff
745
PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart
Normal file
745
PinePods-0.8.2/mobile/lib/ui/pinepods/history.dart
Normal 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
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1377
PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart
Normal file
1377
PinePods-0.8.2/mobile/lib/ui/pinepods/home.dart
Normal file
File diff suppressed because it is too large
Load Diff
116
PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart
Normal file
116
PinePods-0.8.2/mobile/lib/ui/pinepods/more_menu.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
572
PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
Normal file
572
PinePods-0.8.2/mobile/lib/ui/pinepods/playlist_episodes.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
546
PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart
Normal file
546
PinePods-0.8.2/mobile/lib/ui/pinepods/playlists.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
1227
PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart
Normal file
1227
PinePods-0.8.2/mobile/lib/ui/pinepods/podcast_details.dart
Normal file
File diff suppressed because it is too large
Load Diff
337
PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart
Normal file
337
PinePods-0.8.2/mobile/lib/ui/pinepods/podcasts.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
805
PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart
Normal file
805
PinePods-0.8.2/mobile/lib/ui/pinepods/queue.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
730
PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart
Normal file
730
PinePods-0.8.2/mobile/lib/ui/pinepods/saved.dart
Normal 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
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
674
PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart
Normal file
674
PinePods-0.8.2/mobile/lib/ui/pinepods/search.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
503
PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart
Normal file
503
PinePods-0.8.2/mobile/lib/ui/pinepods/user_stats.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1329
PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart
Normal file
1329
PinePods-0.8.2/mobile/lib/ui/pinepods_podcast_app.dart
Normal file
File diff suppressed because it is too large
Load Diff
170
PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
Normal file
170
PinePods-0.8.2/mobile/lib/ui/podcast/chapter_selector.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
49
PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart
Normal file
49
PinePods-0.8.2/mobile/lib/ui/podcast/dot_decoration.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
101
PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart
Normal file
101
PinePods-0.8.2/mobile/lib/ui/podcast/episode_details.dart
Normal 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!),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
222
PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart
Normal file
222
PinePods-0.8.2/mobile/lib/ui/podcast/funding_menu.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
360
PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart
Normal file
360
PinePods-0.8.2/mobile/lib/ui/podcast/mini_player.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
654
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart
Normal file
654
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
317
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart
Normal file
317
PinePods-0.8.2/mobile/lib/ui/podcast/now_playing_options.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
82
PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart
Normal file
82
PinePods-0.8.2/mobile/lib/ui/podcast/person_avatar.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
390
PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart
Normal file
390
PinePods-0.8.2/mobile/lib/ui/podcast/pinepods_up_next_view.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
206
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart
Normal file
206
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_context_menu.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
1000
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart
Normal file
1000
PinePods-0.8.2/mobile/lib/ui/podcast/podcast_details.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart
Normal file
54
PinePods-0.8.2/mobile/lib/ui/podcast/show_notes.dart
Normal 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!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
532
PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart
Normal file
532
PinePods-0.8.2/mobile/lib/ui/podcast/transcript_view.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
277
PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart
Normal file
277
PinePods-0.8.2/mobile/lib/ui/podcast/transport_controls.dart
Normal 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);
|
||||
}
|
||||
185
PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart
Normal file
185
PinePods-0.8.2/mobile/lib/ui/podcast/up_next_view.dart
Normal 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,
|
||||
));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
111
PinePods-0.8.2/mobile/lib/ui/search/search.dart
Normal file
111
PinePods-0.8.2/mobile/lib/ui/search/search.dart
Normal 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!),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
PinePods-0.8.2/mobile/lib/ui/search/search_bar.dart
Normal file
78
PinePods-0.8.2/mobile/lib/ui/search/search_bar.dart
Normal 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');
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
PinePods-0.8.2/mobile/lib/ui/search/search_results.dart
Normal file
74
PinePods-0.8.2/mobile/lib/ui/search/search_results.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
128
PinePods-0.8.2/mobile/lib/ui/settings/bottom_bar_order.dart
Normal file
128
PinePods-0.8.2/mobile/lib/ui/settings/bottom_bar_order.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
183
PinePods-0.8.2/mobile/lib/ui/settings/episode_refresh.dart
Normal file
183
PinePods-0.8.2/mobile/lib/ui/settings/episode_refresh.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
311
PinePods-0.8.2/mobile/lib/ui/settings/pinepods_login.dart
Normal file
311
PinePods-0.8.2/mobile/lib/ui/settings/pinepods_login.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
118
PinePods-0.8.2/mobile/lib/ui/settings/search_provider.dart
Normal file
118
PinePods-0.8.2/mobile/lib/ui/settings/search_provider.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
339
PinePods-0.8.2/mobile/lib/ui/settings/settings.dart
Normal file
339
PinePods-0.8.2/mobile/lib/ui/settings/settings.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2294
PinePods-0.8.2/mobile/lib/ui/themes.dart
Normal file
2294
PinePods-0.8.2/mobile/lib/ui/themes.dart
Normal file
File diff suppressed because it is too large
Load Diff
225
PinePods-0.8.2/mobile/lib/ui/utils/local_download_utils.dart
Normal file
225
PinePods-0.8.2/mobile/lib/ui/utils/local_download_utils.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
PinePods-0.8.2/mobile/lib/ui/utils/player_utils.dart
Normal file
43
PinePods-0.8.2/mobile/lib/ui/utils/player_utils.dart
Normal 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);
|
||||
}
|
||||
151
PinePods-0.8.2/mobile/lib/ui/utils/position_utils.dart
Normal file
151
PinePods-0.8.2/mobile/lib/ui/utils/position_utils.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
26
PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart
Normal file
26
PinePods-0.8.2/mobile/lib/ui/widgets/action_text.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
62
PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart
Normal file
62
PinePods-0.8.2/mobile/lib/ui/widgets/download_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
164
PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart
Normal file
164
PinePods-0.8.2/mobile/lib/ui/widgets/episode_context_menu.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
219
PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart
Normal file
219
PinePods-0.8.2/mobile/lib/ui/widgets/episode_sort_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
957
PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart
Normal file
957
PinePods-0.8.2/mobile/lib/ui/widgets/episode_tile.dart
Normal 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);
|
||||
}
|
||||
148
PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart
Normal file
148
PinePods-0.8.2/mobile/lib/ui/widgets/layout_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
116
PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart
Normal file
116
PinePods-0.8.2/mobile/lib/ui/widgets/lazy_network_image.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
162
PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart
Normal file
162
PinePods-0.8.2/mobile/lib/ui/widgets/offline_episode_tile.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
174
PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart
Normal file
174
PinePods-0.8.2/mobile/lib/ui/widgets/paginated_episode_list.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
168
PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart
Normal file
168
PinePods-0.8.2/mobile/lib/ui/widgets/pinepods_episode_card.dart
Normal 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],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart
Normal file
77
PinePods-0.8.2/mobile/lib/ui/widgets/play_pause_button.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
250
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart
Normal file
250
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_grid_tile.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart
Normal file
50
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_html.dart
Normal 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,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart
Normal file
263
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_image.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
99
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart
Normal file
99
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_list.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart
Normal file
136
PinePods-0.8.2/mobile/lib/ui/widgets/podcast_tile.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart
Normal file
34
PinePods-0.8.2/mobile/lib/ui/widgets/restart_widget.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart
Normal file
44
PinePods-0.8.2/mobile/lib/ui/widgets/search_slide_route.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
242
PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart
Normal file
242
PinePods-0.8.2/mobile/lib/ui/widgets/server_error_page.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart
Normal file
138
PinePods-0.8.2/mobile/lib/ui/widgets/shimmer_episode_tile.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
298
PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart
Normal file
298
PinePods-0.8.2/mobile/lib/ui/widgets/sleep_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart
Normal file
43
PinePods-0.8.2/mobile/lib/ui/widgets/slider_handle.dart
Normal 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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
241
PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart
Normal file
241
PinePods-0.8.2/mobile/lib/ui/widgets/speed_selector.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
76
PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart
Normal file
76
PinePods-0.8.2/mobile/lib/ui/widgets/sync_spinner.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
51
PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart
Normal file
51
PinePods-0.8.2/mobile/lib/ui/widgets/tile_image.dart
Normal 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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user