Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
472
doc/research/reference-sources/floaty_login_screen.dart
Normal file
472
doc/research/reference-sources/floaty_login_screen.dart
Normal file
@@ -0,0 +1,472 @@
|
||||
import 'package:floaty/features/authentication/services/oauth2_service.dart';
|
||||
import 'package:floaty/features/api/repositories/fpapi.dart';
|
||||
import 'package:floaty/whitelabels.dart';
|
||||
import 'package:floaty/settings.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:floaty/shared/components/switcher.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
|
||||
class LoginManager {
|
||||
final OAuth2Service _oauth2Service = OAuth2Service.instance;
|
||||
|
||||
/// Manual cookie login for testing
|
||||
Future<void> loginWithCookie(
|
||||
BuildContext context, String cookieString, Function logincomplete) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
try {
|
||||
if (cookieString.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Please enter a cookie.', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the cookie in the cookie jar
|
||||
final whiteLabel = await whitelabels.getSelectedWhitelabel();
|
||||
final uri = Uri.parse('https://${whiteLabel.domain}/');
|
||||
|
||||
// Create cookie with far-future expiration (1 year from now)
|
||||
// Use the actual domain from the whitelabel (not the URI domain)
|
||||
// For multi-subdomain support, we could use .floatplane.com, but for now use exact domain
|
||||
final cookie = Cookie(whiteLabel.cookieName, cookieString)
|
||||
..expires = DateTime.now().add(const Duration(days: 365))
|
||||
..domain = whiteLabel.domain // Use the exact domain from whitelabel
|
||||
..path = '/'
|
||||
..httpOnly = true;
|
||||
|
||||
// Save cookie to the domain's URI
|
||||
await fpApiRequests.cookieJar.saveFromResponse(
|
||||
uri,
|
||||
[cookie],
|
||||
);
|
||||
|
||||
// Mark this whitelabel as using cookie auth
|
||||
await settings.setKey('${whiteLabel.friendlyName}_auth_method', 'cookie');
|
||||
|
||||
await whitelabels
|
||||
.addLoggedInLabel('${whiteLabel.friendlyName}-aaaaaaaaaa');
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Cookie saved successfully!', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
logincomplete();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showCloseIcon: true,
|
||||
closeIconColor: Colors.white,
|
||||
content: Text('Cookie error: $e', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth2 login flow
|
||||
Future<void> loginWithOAuth2(
|
||||
BuildContext context, Function logincomplete) async {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 30),
|
||||
content: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text('Opening browser for authentication...',
|
||||
style: textTheme.bodyLarge),
|
||||
],
|
||||
),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _oauth2Service.login();
|
||||
|
||||
// Clear loading indicator
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
}
|
||||
|
||||
if (result.isSuccess && result.accessToken != null) {
|
||||
// Get whitelabel info
|
||||
final whiteLabel = await whitelabels.getSelectedWhitelabel();
|
||||
|
||||
// Store tokens with whitelabel
|
||||
await _oauth2Service.storeTokens(result,
|
||||
whitelabel: whiteLabel.friendlyName);
|
||||
|
||||
// Mark as logged in
|
||||
final userId = result.userInfo?['sub'] ?? 'oauth2_user';
|
||||
await whitelabels
|
||||
.addLoggedInLabel('${whiteLabel.friendlyName}-$userId');
|
||||
// Sync offline progress after successful login
|
||||
try {
|
||||
await fpApiRequests.syncOfflineProgress(whiteLabel.friendlyName);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to sync offline progress after login: \$e');
|
||||
}
|
||||
logincomplete();
|
||||
} else if (result.isCancelled) {
|
||||
// User cancelled authentication
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Authentication was cancelled.',
|
||||
style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Authentication failed
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showCloseIcon: true,
|
||||
closeIconColor: Colors.white,
|
||||
content: Text(result.error ?? 'OAuth2 authentication failed.',
|
||||
style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Clear loading indicator
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showCloseIcon: true,
|
||||
closeIconColor: Colors.white,
|
||||
content:
|
||||
Text('Authentication error: $e', style: textTheme.bodyLarge),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController _cookieController = TextEditingController();
|
||||
bool _showCookieInput = false;
|
||||
String? sitekey;
|
||||
String? token;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
// Feature flag for cookie testing
|
||||
const bool useCookieAuth =
|
||||
bool.fromEnvironment('USE_COOKIE_AUTH', defaultValue: false);
|
||||
|
||||
return Scaffold(
|
||||
body: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: colorScheme.surface,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Image.asset(
|
||||
'assets/app_foreground.png',
|
||||
width: 80,
|
||||
height: 80,
|
||||
filterQuality: FilterQuality.high,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Welcome to Floaty',
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Sign in to access your Floatplane content',
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (!_showCookieInput) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
LoginManager().loginWithOAuth2(
|
||||
context,
|
||||
() {
|
||||
if (context.mounted) {
|
||||
context.pushReplacement('/home');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Sign In with OAuth2'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (useCookieAuth) ...[
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showCookieInput = true;
|
||||
});
|
||||
},
|
||||
child: const Text('Use Cookie (Testing)'),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _cookieController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cookie',
|
||||
hintText: 'Paste sails.sid cookie value',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'For testing purposes only',
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
LoginManager().loginWithCookie(
|
||||
context,
|
||||
_cookieController.text,
|
||||
() {
|
||||
if (context.mounted) {
|
||||
context.pushReplacement('/home');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.cookie),
|
||||
label: const Text('Login with Cookie'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showCookieInput = false;
|
||||
});
|
||||
},
|
||||
child: const Text('Back to OAuth2'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Switcher(
|
||||
onSwitch: (String value) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 5),
|
||||
showCloseIcon: true,
|
||||
content: Text(
|
||||
'After you complete login you can sign into the other service and you will get the options of viewing each service individually.',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
backgroundColor:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
whitelabels: whitelabels.getWhitelabels(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoginFields extends StatelessWidget {
|
||||
const LoginFields({
|
||||
required this.passwordController,
|
||||
required this.usernameController,
|
||||
required this.onSubmitted,
|
||||
required this.needstwofa,
|
||||
required this.logincomplete,
|
||||
this.onOAuth2Login,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TextEditingController passwordController;
|
||||
final TextEditingController usernameController;
|
||||
final Function(String, String, BuildContext, Function, Function) onSubmitted;
|
||||
final Function() needstwofa;
|
||||
final Function() logincomplete;
|
||||
final Function()? onOAuth2Login;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: true,
|
||||
onSubmitted: (_) => FocusScope.of(context).nextFocus(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'Enter your email',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
textInputAction: TextInputAction.done,
|
||||
obscureText: true,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onSubmitted: (_) => onSubmitted(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
context,
|
||||
needstwofa,
|
||||
logincomplete,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => onSubmitted(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
context,
|
||||
needstwofa,
|
||||
logincomplete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
if (onOAuth2Login != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Row(
|
||||
children: [
|
||||
Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('or'),
|
||||
),
|
||||
Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onOAuth2Login,
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Sign in with OAuth2'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user