Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/config/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class AppConstants {
static const String wsTicket = '$apiBase/auth/ws-ticket';
static const String youtubeResolve = '$apiBase/youtube/resolve';
static const String youtubeSuggestions = '$apiBase/youtube/suggestions';
static const String settingsYoutubeCookies =
'$apiBase/settings/youtube-cookies';
static const String channels = '$apiBase/channels';
static const String channelSubscribe = '$apiBase/channels/subscribe';
static const String channelSubscribeBulk = '$apiBase/channels/subscribe-bulk';
Expand Down
225 changes: 224 additions & 1 deletion lib/screens/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
const SizedBox(height: 24),

// YouTube account (admin only) — enables age-restricted videos
// for everyone on this server.
if (authState.currentUser?.isAdmin == true) ...[
const _SectionHeader(title: 'YouTube Account'),
const _YoutubeCookiesSection(),
const SizedBox(height: 24),
],

// About section
const _SectionHeader(title: 'About'),
Card(
Expand All @@ -284,7 +292,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('NullFeed'),
subtitle: Text('Version 1.0.0'),
subtitle: Text('Version 0.1.0'),
),
ListTile(
leading: const Icon(Icons.code),
Expand Down Expand Up @@ -319,3 +327,218 @@ class _SectionHeader extends StatelessWidget {
);
}
}

/// Admin panel to paste/refresh a YouTube cookies.txt so age-restricted /
/// members-only videos play. Set once; applies to every profile on the server.
class _YoutubeCookiesSection extends ConsumerStatefulWidget {
const _YoutubeCookiesSection();

@override
ConsumerState<_YoutubeCookiesSection> createState() =>
_YoutubeCookiesSectionState();
}

class _YoutubeCookiesSectionState
extends ConsumerState<_YoutubeCookiesSection> {
final _controller = TextEditingController();
({bool configured, bool stale, String? updatedAt})? _status;
bool _loading = true;
bool _busy = false;
String? _error;

@override
void initState() {
super.initState();
_load();
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}

Future<void> _load() async {
try {
final s = await ref.read(apiServiceProvider).getYoutubeCookiesStatus();
if (mounted) {
setState(() {
_status = s;
_loading = false;
});
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}

Future<void> _save() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
setState(() {
_busy = true;
_error = null;
});
try {
final s = await ref.read(apiServiceProvider).saveYoutubeCookies(text);
if (!mounted) return;
_controller.clear();
setState(() {
_status = s;
_busy = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('YouTube cookies saved')));
} on ApiException catch (e) {
if (mounted) {
setState(() {
_error = e.message;
_busy = false;
});
}
}
}

Future<void> _remove() async {
setState(() => _busy = true);
try {
final api = ref.read(apiServiceProvider);
await api.clearYoutubeCookies();
final s = await api.getYoutubeCookiesStatus();
if (mounted) {
setState(() {
_status = s;
_busy = false;
});
}
} catch (_) {
if (mounted) setState(() => _busy = false);
}
}

@override
Widget build(BuildContext context) {
final status = _status;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
_statusRow(status),
const SizedBox(height: 12),
Text(
'Paste a cookies.txt from a browser signed in to YouTube (e.g. the '
'"Get cookies.txt LOCALLY" extension) to play age-restricted '
'videos. Set once — it applies to every profile on this server.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: NullFeedTheme.textMuted),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
maxLines: 4,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
decoration: const InputDecoration(
hintText: '# Netscape HTTP Cookie File …',
border: OutlineInputBorder(),
isDense: true,
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: const TextStyle(
color: NullFeedTheme.errorColor,
fontSize: 12,
),
),
],
const SizedBox(height: 12),
Row(
children: [
FilledButton(
onPressed: _busy ? null : _save,
child: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
if (status?.configured == true) ...[
const SizedBox(width: 12),
TextButton(
onPressed: _busy ? null : _remove,
child: const Text(
'Remove',
style: TextStyle(color: NullFeedTheme.errorColor),
),
),
],
],
),
],
),
),
);
}

Widget _statusRow(({bool configured, bool stale, String? updatedAt})? s) {
if (s == null || !s.configured) {
return const Row(
children: [
Icon(Icons.cancel_outlined, color: NullFeedTheme.textMuted, size: 18),
SizedBox(width: 8),
Text('Not connected'),
],
);
}
if (s.stale) {
return const Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: NullFeedTheme.errorColor,
size: 18,
),
SizedBox(width: 8),
Expanded(
child: Text('Cookies expired — paste fresh ones to keep playing'),
),
],
);
}
return Row(
children: [
const Icon(
Icons.check_circle,
color: NullFeedTheme.successColor,
size: 18,
),
const SizedBox(width: 8),
Expanded(child: Text('Connected${_formatUpdated(s.updatedAt)}')),
],
);
}

String _formatUpdated(String? iso) {
final dt = iso == null ? null : DateTime.tryParse(iso);
if (dt == null) return '';
return ' · updated ${dt.toLocal().toString().split('.').first}';
}
}
29 changes: 29 additions & 0 deletions lib/services/api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,35 @@ class ApiService {
await _dio.delete('$_baseUrl${AppConstants.videoDetail(videoId)}');
});

// YouTube cookies (admin only) — enables age-restricted / members-only videos.
({bool configured, bool stale, String? updatedAt}) _parseCookieStatus(
Map<String, dynamic> data,
) => (
configured: data['configured'] == true,
stale: data['stale'] == true,
updatedAt: data['updated_at'] as String?,
);

Future<({bool configured, bool stale, String? updatedAt})>
getYoutubeCookiesStatus() => _guard(() async {
final r = await _dio.get('$_baseUrl${AppConstants.settingsYoutubeCookies}');
return _parseCookieStatus(r.data as Map<String, dynamic>);
});

Future<({bool configured, bool stale, String? updatedAt})> saveYoutubeCookies(
String cookies,
) => _guard(() async {
final r = await _dio.put(
'$_baseUrl${AppConstants.settingsYoutubeCookies}',
data: {'cookies': cookies},
);
return _parseCookieStatus(r.data as Map<String, dynamic>);
});

Future<void> clearYoutubeCookies() => _guard(() async {
await _dio.delete('$_baseUrl${AppConstants.settingsYoutubeCookies}');
});

/// Records an evictable cache claim on [videoId] and (server-side) kicks off
/// its HQ download, without it showing in the Downloads tab. Called when the
/// user starts instant playback of a not-yet-downloaded video so the player
Expand Down
Loading