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

- 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:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View 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),
],
);
}
}