mirror of
https://github.com/madari-media/madari-oss.git
synced 2026-04-21 19:21:56 +00:00
fix: small changes
Some checks failed
Build and Deploy / build_windows (push) Has been cancelled
Build and Deploy / build_android (push) Has been cancelled
Build and Deploy / build_ipa (push) Has been cancelled
Build and Deploy / build_linux (push) Has been cancelled
Build and Deploy / build_macos (push) Has been cancelled
Some checks failed
Build and Deploy / build_windows (push) Has been cancelled
Build and Deploy / build_android (push) Has been cancelled
Build and Deploy / build_ipa (push) Has been cancelled
Build and Deploy / build_linux (push) Has been cancelled
Build and Deploy / build_macos (push) Has been cancelled
This commit is contained in:
parent
87a9f0bf76
commit
46571852de
24 changed files with 568 additions and 324 deletions
2
Makefile
2
Makefile
|
|
@ -9,7 +9,7 @@ schema:
|
||||||
dart run drift_dev schema dump lib/database/database.dart drift_schemas/drift_schema_v1.json
|
dart run drift_dev schema dump lib/database/database.dart drift_schemas/drift_schema_v1.json
|
||||||
|
|
||||||
build_web:
|
build_web:
|
||||||
flutter build web --target lib/main_web.dart --release --pwa-strategy none --wasm --dart-define=BUILD_ID=$(BUILD_ID)
|
flutter build web --target lib/main_web.dart --release --pwa-strategy none --web-renderer html --dart-define=BUILD_ID=$(BUILD_ID)
|
||||||
|
|
||||||
build_mac:
|
build_mac:
|
||||||
flutter build macos --target lib/main.dart --release --dart-define=BUILD_ID=$(BUILD_ID)
|
flutter build macos --target lib/main.dart --release --dart-define=BUILD_ID=$(BUILD_ID)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class AppWeb extends StatelessWidget {
|
|
||||||
const AppWeb({
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const MaterialApp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -35,6 +35,10 @@ class AppDatabase extends _$AppDatabase {
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
return driftDatabase(
|
return driftDatabase(
|
||||||
name: 'app_db',
|
name: 'app_db',
|
||||||
|
web: DriftWebOptions(
|
||||||
|
sqlite3Wasm: Uri.parse("wasm"),
|
||||||
|
driftWorker: Uri.parse("worker"),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,181 +1,231 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
import '../../common/utils/refresh_auth.dart';
|
import '../service/zeku_service.dart';
|
||||||
import '../../pocketbase/service/pocketbase.service.dart';
|
|
||||||
|
|
||||||
class TraktContainer extends StatefulWidget {
|
class ServicesGrid extends StatefulWidget {
|
||||||
const TraktContainer({super.key});
|
const ServicesGrid({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TraktContainer> createState() => _TraktContainerState();
|
State<ServicesGrid> createState() => _ServicesGridState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TraktContainerState extends State<TraktContainer> {
|
class _ServicesGridState extends State<ServicesGrid> {
|
||||||
final pb = AppPocketBaseService.instance.pb;
|
final _zekuService = ZekuService();
|
||||||
bool isLoggedIn = false;
|
late Future<List<ZekuServiceItem>> _servicesFuture;
|
||||||
bool isLoading = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
checkIsLoggedIn();
|
_servicesFuture = _zekuService.getServices();
|
||||||
}
|
|
||||||
|
|
||||||
void checkIsLoggedIn() {
|
|
||||||
final traktToken = pb.authStore.record!.getStringValue("trakt_token");
|
|
||||||
setState(() {
|
|
||||||
isLoggedIn = traktToken != "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeAccount() async {
|
|
||||||
setState(() => isLoading = true);
|
|
||||||
try {
|
|
||||||
final record = pb.authStore.record!;
|
|
||||||
record.set("trakt_token", "");
|
|
||||||
|
|
||||||
await pb.collection('users').update(
|
|
||||||
record.id,
|
|
||||||
body: record.toJson(),
|
|
||||||
);
|
|
||||||
|
|
||||||
await refreshAuth();
|
|
||||||
setState(() {
|
|
||||||
isLoggedIn = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString()),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loginWithTrakt() async {
|
|
||||||
setState(() => isLoading = true);
|
|
||||||
try {
|
|
||||||
await pb.collection("users").authWithOAuth2(
|
|
||||||
"oidc",
|
|
||||||
(url) async {
|
|
||||||
final newUrl = Uri.parse(
|
|
||||||
url.toString().replaceFirst(
|
|
||||||
"scope=openid&",
|
|
||||||
"",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await launchUrl(newUrl);
|
|
||||||
},
|
|
||||||
scopes: ["openid"],
|
|
||||||
);
|
|
||||||
|
|
||||||
await refreshAuth();
|
|
||||||
checkIsLoggedIn();
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString()),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
|
|
||||||
final isDesktopOrTV = screenWidth > 1024;
|
return FutureBuilder<List<ZekuServiceItem>>(
|
||||||
final isTablet = screenWidth > 600 && screenWidth <= 1024;
|
future: _servicesFuture,
|
||||||
final horizontalPadding = isDesktopOrTV
|
builder: (context, snapshot) {
|
||||||
? screenWidth * 0.2
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
: isTablet
|
return Center(
|
||||||
? 48.0
|
child: CircularProgressIndicator(
|
||||||
: 24.0;
|
color: theme.colorScheme.primary,
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: horizontalPadding,
|
|
||||||
vertical: 24,
|
|
||||||
),
|
),
|
||||||
child: ConstrainedBox(
|
);
|
||||||
constraints: const BoxConstraints(maxWidth: 600),
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Icon(
|
||||||
isLoggedIn ? 'Connected to Trakt' : 'Connect with Trakt',
|
Icons.error_outline,
|
||||||
style: theme.textTheme.displaySmall?.copyWith(
|
color: theme.colorScheme.error,
|
||||||
fontWeight: FontWeight.bold,
|
size: 48,
|
||||||
color: colorScheme.onSurface,
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Failed to load services',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
isLoggedIn
|
'${snapshot.error}',
|
||||||
? 'Your Trakt account is connected'
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
: 'Sign in to track your movies and shows',
|
color: theme.colorScheme.error,
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
ElevatedButton(
|
||||||
height: 50,
|
onPressed: () {
|
||||||
child: FilledButton(
|
setState(() {
|
||||||
onPressed: isLoading
|
_servicesFuture = _zekuService.getServices();
|
||||||
? null
|
});
|
||||||
: (isLoggedIn ? removeAccount : loginWithTrakt),
|
},
|
||||||
style: FilledButton.styleFrom(
|
child: const Text('Retry'),
|
||||||
backgroundColor:
|
|
||||||
isLoggedIn ? colorScheme.error : colorScheme.primary,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
).copyWith(
|
],
|
||||||
overlayColor: WidgetStateProperty.resolveWith((states) {
|
),
|
||||||
if (states.contains(WidgetState.hovered)) {
|
);
|
||||||
return colorScheme.onPrimary.withValues(alpha: 0.08);
|
|
||||||
}
|
}
|
||||||
if (states.contains(WidgetState.pressed)) {
|
|
||||||
return colorScheme.onPrimary.withValues(alpha: 0.12);
|
final services = snapshot.data ?? [];
|
||||||
}
|
|
||||||
return null;
|
if (services.isEmpty) {
|
||||||
}),
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'No services available',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
child: isLoading
|
),
|
||||||
? const SizedBox(
|
);
|
||||||
height: 20,
|
}
|
||||||
width: 20,
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(0),
|
||||||
|
itemCount: services.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final service = services[index];
|
||||||
|
return ServiceCard(
|
||||||
|
service: service,
|
||||||
|
onRefresh: () {
|
||||||
|
setState(() {
|
||||||
|
_servicesFuture = _zekuService.getServices();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServiceCard extends StatefulWidget {
|
||||||
|
final ZekuServiceItem service;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
const ServiceCard({
|
||||||
|
super.key,
|
||||||
|
required this.service,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ServiceCard> createState() => _ServiceCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceCardState extends State<ServiceCard> {
|
||||||
|
authenticate() async {
|
||||||
|
await ZekuService.instance.authenticate();
|
||||||
|
|
||||||
|
showAdaptiveDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Authenticated?"),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text("Cancel"),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: const Text("Refresh"),
|
||||||
|
onPressed: () {
|
||||||
|
widget.onRefresh();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(String service) async {
|
||||||
|
await ZekuService.instance.removeSession(service);
|
||||||
|
widget.onRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: widget.service.logo,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor:
|
color: theme.colorScheme.primary,
|
||||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: Text(
|
errorWidget: (context, url, error) => Container(
|
||||||
isLoggedIn
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
? 'Disconnect Account'
|
child: Icon(
|
||||||
: 'Connect with Trakt',
|
Icons.image_not_supported,
|
||||||
style: const TextStyle(
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
fontSize: 16,
|
size: 24,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.service.name,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
widget.service.website,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (widget.service.enabled) {
|
||||||
|
disconnect(widget.service.name);
|
||||||
|
} else {
|
||||||
|
authenticate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: widget.service.enabled
|
||||||
|
? const Text("Disconnect")
|
||||||
|
: const Text("Authenticate"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -14,40 +14,8 @@ class ExternalAccount extends StatelessWidget {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("External Accounts"),
|
title: const Text("External Accounts"),
|
||||||
),
|
),
|
||||||
body: SettingWrapper(
|
body: const SettingWrapper(
|
||||||
child: ListView(
|
child: ServicesGrid(),
|
||||||
children: [
|
|
||||||
_buildSection(
|
|
||||||
"Trakt",
|
|
||||||
[
|
|
||||||
const TraktContainer(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSection(String title, List<Widget> children) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
...children,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
lib/features/accounts/service/zeku_service.dart
Normal file
114
lib/features/accounts/service/zeku_service.dart
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:madari_client/features/pocketbase/service/pocketbase.service.dart';
|
||||||
|
import 'package:madari_client/features/settings/service/selected_profile.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
part 'zeku_service.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class ZekuServiceItem {
|
||||||
|
final String name;
|
||||||
|
final String logo;
|
||||||
|
final String website;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
ZekuServiceItem({
|
||||||
|
required this.name,
|
||||||
|
required this.logo,
|
||||||
|
required this.website,
|
||||||
|
required this.enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ZekuServiceItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return _$ZekuServiceItemFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$ZekuServiceItemToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZekuService {
|
||||||
|
static final ZekuService _instance = ZekuService._internal();
|
||||||
|
final pocketbase = AppPocketBaseService.instance.pb;
|
||||||
|
final String endpoint =
|
||||||
|
kDebugMode ? 'http://100.64.0.1:3001' : 'https://zeku.madari.media';
|
||||||
|
|
||||||
|
authenticate() async {
|
||||||
|
final result = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
"$endpoint/${SelectedProfileService.instance.selectedProfileId}/session",
|
||||||
|
),
|
||||||
|
headers: {
|
||||||
|
"Authorization":
|
||||||
|
"Bearer ${AppPocketBaseService.instance.pb.authStore.token}",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final res = jsonDecode(result.body);
|
||||||
|
|
||||||
|
final id = res["data"]["id"];
|
||||||
|
|
||||||
|
await launchUrlString(
|
||||||
|
"$endpoint/$id/trakt/auth",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() async {}
|
||||||
|
|
||||||
|
factory ZekuService() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
ZekuService._internal();
|
||||||
|
|
||||||
|
static ZekuService get instance => _instance;
|
||||||
|
|
||||||
|
final List<ZekuServiceItem> _services = [];
|
||||||
|
|
||||||
|
Future<List<ZekuServiceItem>> getServices() async {
|
||||||
|
try {
|
||||||
|
final result = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
"$endpoint/${SelectedProfileService.instance.selectedProfileId}/services",
|
||||||
|
),
|
||||||
|
headers: {
|
||||||
|
"Authorization":
|
||||||
|
"Bearer ${AppPocketBaseService.instance.pb.authStore.token}",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final bodyParsed = jsonDecode(result.body);
|
||||||
|
|
||||||
|
final List<ZekuServiceItem> returnValue = [];
|
||||||
|
|
||||||
|
for (final item in bodyParsed["data"]) {
|
||||||
|
returnValue.add(ZekuServiceItem.fromJson(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to fetch services: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> removeSession(String service) async {
|
||||||
|
final result = await http.get(
|
||||||
|
Uri.parse(
|
||||||
|
"$endpoint/${SelectedProfileService.instance.selectedProfileId}/${service.toLowerCase()}/revoke",
|
||||||
|
),
|
||||||
|
headers: {
|
||||||
|
"Authorization":
|
||||||
|
"Bearer ${AppPocketBaseService.instance.pb.authStore.token}",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.statusCode != 200) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,19 @@ Future startupApp() async {
|
||||||
await AppTheme().ensureInitialized();
|
await AppTheme().ensureInitialized();
|
||||||
await SelectedProfileService.instance.initialize();
|
await SelectedProfileService.instance.initialize();
|
||||||
|
|
||||||
|
final pb = AppPocketBaseService.instance.pb;
|
||||||
|
final userCollection = pb.collection("users");
|
||||||
|
|
||||||
|
if (pb.authStore.isValid) {
|
||||||
|
try {
|
||||||
|
final user = await userCollection.authRefresh();
|
||||||
|
|
||||||
|
pb.authStore.save(user.token, user.record);
|
||||||
|
} catch (e) {
|
||||||
|
pb.authStore.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (UniversalPlatform.isDesktop) {
|
if (UniversalPlatform.isDesktop) {
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +52,9 @@ Future startupApp() async {
|
||||||
config: QueryConfigFlutter(
|
config: QueryConfigFlutter(
|
||||||
refetchDuration: const Duration(minutes: 60),
|
refetchDuration: const Duration(minutes: 60),
|
||||||
cacheDuration: const Duration(minutes: 60),
|
cacheDuration: const Duration(minutes: 60),
|
||||||
|
refetchOnResume: false,
|
||||||
|
refetchOnConnection: false,
|
||||||
|
refetchOnResumeMinBackgroundDuration: const Duration(days: 30),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ class StremioManifestCatalog {
|
||||||
String type;
|
String type;
|
||||||
String id;
|
String id;
|
||||||
String? name;
|
String? name;
|
||||||
@JsonKey(name: "itemCount", defaultValue: 50)
|
@JsonKey(name: "itemCount")
|
||||||
final int itemCount;
|
final int itemCount;
|
||||||
final List<StremioManifestCatalogExtra>? extra;
|
final List<StremioManifestCatalogExtra>? extra;
|
||||||
@JsonKey(name: "extraRequired")
|
@JsonKey(name: "extraRequired")
|
||||||
|
|
|
||||||
3
lib/features/video_player/container/native.dart
Normal file
3
lib/features/video_player/container/native.dart
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export 'set_delay_stub.dart'
|
||||||
|
if (dart.library.html) 'set_delay_web.dart'
|
||||||
|
if (dart.library.io) 'set_delay_native.dart';
|
||||||
|
|
@ -24,7 +24,7 @@ class _AudioTrackSelectorState extends State<AudioTrackSelector> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trakt.id == "no") {
|
if (trakt.id == "no") {
|
||||||
return "No subtitles";
|
return "No audio";
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = trakt.language ?? trakt.id;
|
final result = trakt.language ?? trakt.id;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
|
void setDelay(NativePlayer platform, VideoSettingsProvider settings) {
|
||||||
|
platform.setProperty('sub-delay', "${-settings.subtitleDelay}");
|
||||||
|
platform.setProperty('audio-delay', "${-settings.audioDelay}");
|
||||||
|
}
|
||||||
4
lib/features/video_player/container/set_delay_stub.dart
Normal file
4
lib/features/video_player/container/set_delay_stub.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
|
void setDelay(NativePlayer platform, VideoSettingsProvider settings) {}
|
||||||
4
lib/features/video_player/container/set_delay_web.dart
Normal file
4
lib/features/video_player/container/set_delay_web.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
|
void setDelay(NativePlayer platform, VideoSettingsProvider settings) {}
|
||||||
|
|
@ -2,11 +2,15 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:madari_client/features/video_player/container/options/settings_sheet.dart';
|
import 'package:madari_client/features/video_player/container/options/settings_sheet.dart';
|
||||||
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
||||||
|
import 'package:madari_client/features/video_player/container/video_mobile.dart';
|
||||||
|
import 'package:madari_client/features/video_player/container/video_play.dart';
|
||||||
import 'package:media_kit_video/media_kit_video.dart';
|
import 'package:media_kit_video/media_kit_video.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:rxdart/rxdart.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import '../../streamio_addons/models/stremio_base_types.dart' as types;
|
import '../../streamio_addons/models/stremio_base_types.dart' as types;
|
||||||
|
import '../widgets/video_selector.dart';
|
||||||
import 'options/always_on_top.dart';
|
import 'options/always_on_top.dart';
|
||||||
import 'options/audio_track_selector.dart';
|
import 'options/audio_track_selector.dart';
|
||||||
import 'options/scale_option.dart';
|
import 'options/scale_option.dart';
|
||||||
|
|
@ -15,11 +19,15 @@ import 'options/subtitle_selector.dart';
|
||||||
class VideoDesktop extends StatefulWidget {
|
class VideoDesktop extends StatefulWidget {
|
||||||
final VideoController controller;
|
final VideoController controller;
|
||||||
final types.Meta? meta;
|
final types.Meta? meta;
|
||||||
|
final BehaviorSubject<int> updateSubject;
|
||||||
|
final OnVideoChangeCallback onVideoChange;
|
||||||
|
|
||||||
const VideoDesktop({
|
const VideoDesktop({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.meta,
|
required this.meta,
|
||||||
|
required this.updateSubject,
|
||||||
|
required this.onVideoChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -34,11 +42,6 @@ class _VideoDesktopState extends State<VideoDesktop> {
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleLock(BuildContext context) {
|
|
||||||
final settings = context.read<VideoSettingsProvider>();
|
|
||||||
settings.toggleLock();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _showPopupMenu({
|
Future<void> _showPopupMenu({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required String title,
|
required String title,
|
||||||
|
|
@ -49,7 +52,7 @@ class _VideoDesktopState extends State<VideoDesktop> {
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 400, // Fixed width for desktop
|
width: 400,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.black87,
|
backgroundColor: Colors.black87,
|
||||||
|
|
@ -130,14 +133,9 @@ class _VideoDesktopState extends State<VideoDesktop> {
|
||||||
),
|
),
|
||||||
if (widget.meta?.currentVideo != null)
|
if (widget.meta?.currentVideo != null)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: VideoTitle(
|
||||||
"${widget.meta?.name} - ${widget.meta?.currentVideo?.name ?? widget.meta?.currentVideo?.title} - S${widget.meta!.currentVideo?.season} E${widget.meta?.currentVideo?.episode}",
|
meta: widget.meta!,
|
||||||
style: const TextStyle(
|
updateSubject: widget.updateSubject,
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.meta?.currentVideo == null)
|
if (widget.meta?.currentVideo == null)
|
||||||
|
|
@ -152,6 +150,13 @@ class _VideoDesktopState extends State<VideoDesktop> {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.meta is types.Meta && widget.meta?.type == "series")
|
||||||
|
SeasonSource(
|
||||||
|
onVideoChange: widget.onVideoChange,
|
||||||
|
meta: widget.meta!,
|
||||||
|
isMobile: true,
|
||||||
|
updateSubject: widget.updateSubject,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
seekBarThumbColor: Theme.of(context).primaryColorLight,
|
seekBarThumbColor: Theme.of(context).primaryColorLight,
|
||||||
seekBarColor: Theme.of(context).primaryColor,
|
seekBarColor: Theme.of(context).primaryColor,
|
||||||
|
|
@ -159,6 +164,12 @@ class _VideoDesktopState extends State<VideoDesktop> {
|
||||||
bottomButtonBar: [
|
bottomButtonBar: [
|
||||||
const MaterialPlayOrPauseButton(),
|
const MaterialPlayOrPauseButton(),
|
||||||
const MaterialDesktopVolumeButton(),
|
const MaterialDesktopVolumeButton(),
|
||||||
|
if (widget.meta is types.Meta && widget.meta?.type == "series")
|
||||||
|
NextVideo(
|
||||||
|
updateSubject: widget.updateSubject,
|
||||||
|
onVideoChange: widget.onVideoChange,
|
||||||
|
meta: widget.meta!,
|
||||||
|
),
|
||||||
const MaterialSkipNextButton(),
|
const MaterialSkipNextButton(),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const MaterialPositionIndicator(),
|
const MaterialPositionIndicator(),
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,6 @@ class _VideoMobileState extends State<VideoMobile> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: VideoTitle(
|
child: VideoTitle(
|
||||||
meta: widget.meta!,
|
meta: widget.meta!,
|
||||||
index: widget.index,
|
|
||||||
updateSubject: widget.updateSubject,
|
updateSubject: widget.updateSubject,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -255,13 +254,11 @@ class _VideoMobileState extends State<VideoMobile> {
|
||||||
|
|
||||||
class VideoTitle extends StatefulWidget {
|
class VideoTitle extends StatefulWidget {
|
||||||
final types.Meta meta;
|
final types.Meta meta;
|
||||||
final int index;
|
|
||||||
final BehaviorSubject<int> updateSubject;
|
final BehaviorSubject<int> updateSubject;
|
||||||
|
|
||||||
const VideoTitle({
|
const VideoTitle({
|
||||||
super.key,
|
super.key,
|
||||||
required this.meta,
|
required this.meta,
|
||||||
required this.index,
|
|
||||||
required this.updateSubject,
|
required this.updateSubject,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -270,7 +267,7 @@ class VideoTitle extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoTitleState extends State<VideoTitle> {
|
class _VideoTitleState extends State<VideoTitle> {
|
||||||
late int index = widget.index;
|
late int index = widget.updateSubject.value;
|
||||||
|
|
||||||
late StreamSubscription<int> _updateStatus;
|
late StreamSubscription<int> _updateStatus;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:madari_client/features/settings/model/playback_settings_model.dart';
|
import 'package:madari_client/features/settings/model/playback_settings_model.dart';
|
||||||
|
import 'package:madari_client/features/video_player/container/native.dart';
|
||||||
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
import 'package:madari_client/features/video_player/container/state/video_settings.dart';
|
||||||
import 'package:madari_client/features/video_player/container/video_desktop.dart';
|
import 'package:madari_client/features/video_player/container/video_desktop.dart';
|
||||||
import 'package:madari_client/features/video_player/container/video_mobile.dart';
|
import 'package:madari_client/features/video_player/container/video_mobile.dart';
|
||||||
|
|
@ -100,8 +101,9 @@ class _VideoPlayState extends State<VideoPlay> {
|
||||||
final platform = player.platform;
|
final platform = player.platform;
|
||||||
if (platform is NativePlayer) {
|
if (platform is NativePlayer) {
|
||||||
_debouncer.run(() {
|
_debouncer.run(() {
|
||||||
platform.setProperty('sub-delay', "${-_settings.subtitleDelay}");
|
if (!UniversalPlatform.isWeb) {
|
||||||
platform.setProperty('audio-delay', "${-_settings.audioDelay}");
|
setDelay(platform, _settings);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +149,8 @@ class _VideoPlayState extends State<VideoPlay> {
|
||||||
return VideoDesktop(
|
return VideoDesktop(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
meta: widget.meta,
|
meta: widget.meta,
|
||||||
|
onVideoChange: widget.onVideoChange,
|
||||||
|
updateSubject: widget.updateSubject,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
lib/features/video_player/service/zeku_player.dart
Normal file
43
lib/features/video_player/service/zeku_player.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'zeku_player.g.dart';
|
||||||
|
|
||||||
|
class ZekuSyncer {
|
||||||
|
ZekuSyncer();
|
||||||
|
|
||||||
|
Future<WatchPoint> getPlayingTimestamp(PlayingTimestampRequest input) async {
|
||||||
|
return WatchPoint(
|
||||||
|
duration: const Duration(
|
||||||
|
seconds: 10,
|
||||||
|
),
|
||||||
|
request: input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<WatchPoint>> getPlaying(PlayingTimestampRequest input) async {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WatchPoint {
|
||||||
|
final Duration duration;
|
||||||
|
final PlayingTimestampRequest request;
|
||||||
|
|
||||||
|
WatchPoint({
|
||||||
|
required this.duration,
|
||||||
|
required this.request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class PlayingTimestampRequest {
|
||||||
|
final String id;
|
||||||
|
final int? episode;
|
||||||
|
final int? season;
|
||||||
|
|
||||||
|
PlayingTimestampRequest({
|
||||||
|
required this.id,
|
||||||
|
required this.episode,
|
||||||
|
required this.season,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
final _logger = Logger('StreamioShimmer');
|
final _logger = Logger('StreamioShimmer');
|
||||||
|
|
||||||
|
|
@ -62,7 +63,10 @@ class StreamioShimmer extends StatelessWidget {
|
||||||
image: image != null
|
image: image != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
image: CachedNetworkImageProvider(
|
image: CachedNetworkImageProvider(
|
||||||
image!),
|
UniversalPlatform.isWeb
|
||||||
|
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(image!)}@webp"
|
||||||
|
: image!,
|
||||||
|
),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart';
|
import 'package:madari_client/features/streamio_addons/models/stremio_base_types.dart';
|
||||||
import 'package:madari_client/utils/array-extension.dart';
|
import 'package:madari_client/utils/array-extension.dart';
|
||||||
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import '../../../../library/container/add_to_list_button.dart';
|
import '../../../../library/container/add_to_list_button.dart';
|
||||||
import '../../../../streamio_addons/service/stremio_addon_service.dart';
|
import '../../../../streamio_addons/service/stremio_addon_service.dart';
|
||||||
|
|
@ -64,7 +65,10 @@ Future<String?> openVideoStream(
|
||||||
class StreamioBackground extends StatelessWidget {
|
class StreamioBackground extends StatelessWidget {
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
|
|
||||||
const StreamioBackground({super.key, this.imageUrl});
|
const StreamioBackground({
|
||||||
|
super.key,
|
||||||
|
this.imageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -75,7 +79,9 @@ class StreamioBackground extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: imageUrl!,
|
imageUrl: UniversalPlatform.isWeb
|
||||||
|
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(imageUrl!)}@webp"
|
||||||
|
: imageUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
_logger.warning('Error loading background image', error);
|
_logger.warning('Error loading background image', error);
|
||||||
|
|
@ -469,7 +475,9 @@ class _StreamioEpisodeListState extends State<StreamioEpisodeList> {
|
||||||
Hero(
|
Hero(
|
||||||
tag: 'episode_thumb_${episode.id}',
|
tag: 'episode_thumb_${episode.id}',
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: episode.thumbnail!,
|
imageUrl: UniversalPlatform.isWeb
|
||||||
|
? "https://proxy-image.syncws.com/insecure/plain/${episode.thumbnail!}@webp"
|
||||||
|
: episode.thumbnail!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import '../../../../streamio_addons/models/stremio_base_types.dart';
|
import '../../../../streamio_addons/models/stremio_base_types.dart';
|
||||||
import 'cast_info.dart';
|
import 'cast_info.dart';
|
||||||
|
|
@ -59,6 +60,8 @@ class StreamioCastSection extends StatelessWidget {
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: actor.profilePath!.startsWith("/")
|
imageUrl: actor.profilePath!.startsWith("/")
|
||||||
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://image.tmdb.org/t/p/original/${actor.profilePath}")}@webp"
|
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://image.tmdb.org/t/p/original/${actor.profilePath}")}@webp"
|
||||||
|
: UniversalPlatform.isWeb
|
||||||
|
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(actor.profilePath!)}@webp"
|
||||||
: actor.profilePath!,
|
: actor.profilePath!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../../../streamio_addons/models/stremio_base_types.dart';
|
import '../../../../streamio_addons/models/stremio_base_types.dart';
|
||||||
|
|
@ -68,7 +69,9 @@ class StreamioTrailerSection extends StatelessWidget {
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: getYoutubeThumbnail(trailer.ytId),
|
imageUrl: UniversalPlatform.isWeb
|
||||||
|
? "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(getYoutubeThumbnail(trailer.ytId))}@webp"
|
||||||
|
: getYoutubeThumbnail(trailer.ytId),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorWidget: (context, url, error) {
|
errorWidget: (context, url, error) {
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,24 @@ class _CatalogFeaturedState extends State<CatalogFeatured> {
|
||||||
_loadImage();
|
_loadImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _nextPage() {
|
||||||
|
if (_selectedIndex < widget.meta.length - 1) {
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _previousPage() {
|
||||||
|
if (_selectedIndex > 0) {
|
||||||
|
_pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screenSize = MediaQuery.of(context).size;
|
final screenSize = MediaQuery.of(context).size;
|
||||||
|
|
@ -178,7 +196,6 @@ class _CatalogFeaturedState extends State<CatalogFeatured> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Dark overlay gradient
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
|
|
@ -194,10 +211,10 @@ class _CatalogFeaturedState extends State<CatalogFeatured> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Dim overlay for consistent text readability
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withOpacity(0.2),
|
color:
|
||||||
|
Colors.black.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|
@ -286,6 +303,66 @@ class _CatalogFeaturedState extends State<CatalogFeatured> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!isMobile)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration:
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
opacity:
|
||||||
|
_selectedIndex > 0 ? 1.0 : 0.3,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.chevron_left_rounded,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _previousPage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isMobile)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration:
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
opacity: _selectedIndex <
|
||||||
|
widget.meta.length - 1
|
||||||
|
? 1.0
|
||||||
|
: 0.3,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
size: 36,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _nextPage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import 'dart:math';
|
||||||
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
||||||
import 'package:flex_color_picker/flex_color_picker.dart';
|
import 'package:flex_color_picker/flex_color_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
|
import 'package:madari_client/features/streamio_addons/extension/query_extension.dart';
|
||||||
|
|
@ -46,7 +45,6 @@ class CatalogGrid extends StatefulWidget {
|
||||||
class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
||||||
late InfiniteQuery<List<Meta>, int> _query;
|
late InfiniteQuery<List<Meta>, int> _query;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
late FocusNode _gridFocusNode;
|
|
||||||
final service = StremioAddonService.instance;
|
final service = StremioAddonService.instance;
|
||||||
static const int pageSize = 20;
|
static const int pageSize = 20;
|
||||||
int _focusedIndex = 0;
|
int _focusedIndex = 0;
|
||||||
|
|
@ -66,10 +64,6 @@ class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
||||||
|
|
||||||
return InfiniteQuery(
|
return InfiniteQuery(
|
||||||
key: (id ?? this.id) + state.search.trim(),
|
key: (id ?? this.id) + state.search.trim(),
|
||||||
config: QueryConfig(
|
|
||||||
cacheDuration: const Duration(days: 30),
|
|
||||||
refetchDuration: const Duration(hours: 8),
|
|
||||||
),
|
|
||||||
getNextArg: (state) {
|
getNextArg: (state) {
|
||||||
final lastPage = state.lastPage;
|
final lastPage = state.lastPage;
|
||||||
if (lastPage == null) return 1;
|
if (lastPage == null) return 1;
|
||||||
|
|
@ -123,45 +117,6 @@ class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
|
||||||
if (!_isFocused) return KeyEventResult.ignored;
|
|
||||||
|
|
||||||
if (event is KeyDownEvent) {
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight ||
|
|
||||||
event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
|
||||||
final allItems =
|
|
||||||
_query.state.data?.expand((page) => page).toList() ?? [];
|
|
||||||
final itemCount =
|
|
||||||
allItems.take(15).length + (allItems.isNotEmpty ? 1 : 0);
|
|
||||||
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
||||||
setState(() {
|
|
||||||
_focusedIndex = (_focusedIndex + 1).clamp(0, itemCount - 1);
|
|
||||||
});
|
|
||||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
|
||||||
setState(() {
|
|
||||||
_focusedIndex = (_focusedIndex - 1).clamp(0, itemCount - 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_scrollToFocusedItem();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scrollToFocusedItem() {
|
|
||||||
final cardSize = StremioCardSize.getSize(context);
|
|
||||||
final offset = _focusedIndex * (cardSize.width + 8.0);
|
|
||||||
|
|
||||||
_scrollController.animateTo(
|
|
||||||
offset,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -170,31 +125,11 @@ class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
||||||
_refresh = HomeLayoutService.instance.refreshWidgets.listen((value) {
|
_refresh = HomeLayoutService.instance.refreshWidgets.listen((value) {
|
||||||
_query.refetch();
|
_query.refetch();
|
||||||
});
|
});
|
||||||
|
|
||||||
_gridFocusNode = FocusNode(
|
|
||||||
debugLabel: 'CatalogGrid-$id',
|
|
||||||
onKeyEvent: _handleKeyEvent,
|
|
||||||
);
|
|
||||||
|
|
||||||
_gridFocusNode.addListener(() {
|
|
||||||
if (mounted) {
|
|
||||||
Scrollable.ensureVisible(
|
|
||||||
context,
|
|
||||||
alignment: 0.3,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
_isFocused = _gridFocusNode.hasFocus;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_gridFocusNode.dispose();
|
|
||||||
_refresh.cancel();
|
_refresh.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -398,9 +333,7 @@ class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Focus(
|
return ListView.builder(
|
||||||
focusNode: _gridFocusNode,
|
|
||||||
child: ListView.builder(
|
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
|
|
@ -422,7 +355,6 @@ class _CatalogGridState extends State<CatalogGrid> implements Refreshable {
|
||||||
index == _focusedIndex && _isFocused,
|
index == _focusedIndex && _isFocused,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:madari_client/app/app_web.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'app/app.dart';
|
||||||
import 'features/common/utils/startup_app.dart';
|
import 'features/common/utils/startup_app.dart';
|
||||||
import 'features/logger/service/logger.service.dart';
|
import 'features/logger/service/logger.service.dart';
|
||||||
import 'features/theme/theme/app_theme.dart';
|
import 'features/theme/theme/app_theme.dart';
|
||||||
|
import 'features/widgetter/state/widget_state_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -15,7 +16,10 @@ void main() async {
|
||||||
runApp(
|
runApp(
|
||||||
ChangeNotifierProvider.value(
|
ChangeNotifierProvider.value(
|
||||||
value: AppTheme().themeProvider,
|
value: AppTheme().themeProvider,
|
||||||
child: const AppWeb(),
|
child: ChangeNotifierProvider(
|
||||||
|
create: (context) => StateProvider(),
|
||||||
|
child: const AppDefault(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue