Project import generated by Copybara.

GitOrigin-RevId: 41a06075bfe3d1efe0dce3bfb24a4a77b557be64
This commit is contained in:
Madari Developers 2025-01-07 17:34:58 +00:00
parent 49d8ac59d1
commit 7a4759a940
31 changed files with 4355 additions and 355 deletions

View file

@ -37,7 +37,7 @@ flutter run
Contributions, issues, and feature requests are welcome!
Feel free to fork the repository and submit a pull request.
# Legal Disclaimer
## Legal Disclaimer
This application is designed to be an open source media player that can process URLs and add-ons.

View file

@ -97,12 +97,12 @@ class _ConfigureNeoConnectionState extends State<ConfigureNeoConnection> {
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter username';
return 'Please enter email';
}
return null;
},

View file

@ -10,8 +10,10 @@ import '../types/base/base.dart';
import '../widget/stremio/stremio_create.dart';
abstract class BaseConnectionService {
Widget renderCard(LibraryRecord library, LibraryItem item, String heroPrefix);
Widget renderList(LibraryRecord library, LibraryItem item, String heroPrefix);
Widget renderCard(LibraryItem item, String heroPrefix);
Widget renderList(LibraryItem item, String heroPrefix);
static final Map<String, RecordModel> _item = {};
final String connectionId;
@ -46,14 +48,21 @@ abstract class BaseConnectionService {
static Future<ConnectionResponse> connectionByIdRaw(
String connectionId,
) async {
final result = await AppEngine.engine.pb
.collection("connection")
.getOne(connectionId, expand: "type");
RecordModel model_;
if (_item.containsKey(connectionId)) {
model_ = _item[connectionId]!;
} else {
model_ = await AppEngine.engine.pb
.collection("connection")
.getOne(connectionId, expand: "type");
_item[connectionId] = model_;
}
return ConnectionResponse(
connection: Connection.fromRecord(result),
connection: Connection.fromRecord(model_),
connectionTypeRecord: ConnectionTypeRecord.fromRecord(
result.get<RecordModel>("expand.type"),
model_.get<RecordModel>("expand.type"),
),
);
}
@ -84,6 +93,10 @@ abstract class BaseConnectionService {
String? cursor,
});
Future<List<LibraryItem>> getBulkItem(
List<LibraryItem> ids,
);
Future<List<ConnectionFilter<T>>> getFilters<T>(
LibraryRecord library,
);
@ -91,7 +104,6 @@ abstract class BaseConnectionService {
Future<LibraryItem?> getItemById(LibraryItem id);
Future<void> getStreams(
LibraryRecord library,
LibraryItem id, {
String? season,
String? episode,
@ -191,6 +203,10 @@ class ConnectionFilterItem {
abstract class LibraryItem extends Jsonable {
late final String id;
LibraryItem({
required this.id,
});
@override
Map<String, dynamic> toJson();
}

View file

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:cached_query/cached_query.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:json_annotation/json_annotation.dart';
@ -29,47 +30,71 @@ class StremioConnectionService extends BaseConnectionService {
@override
Future<LibraryItem?> getItemById(LibraryItem id) async {
for (final addon in config.addons) {
final manifest = await _getManifest(addon);
return Query<LibraryItem?>(
key: "meta_${id.id}",
config: QueryConfig(
cacheDuration: const Duration(days: 30),
refetchDuration: (id as Meta).type == "movie"
? const Duration(days: 30)
: const Duration(
minutes: 10,
),
),
queryFn: () async {
for (final addon in config.addons) {
final manifest = await _getManifest(addon);
if (manifest.resources == null) {
continue;
}
if (manifest.resources == null) {
continue;
}
List<String> idPrefixes = [];
List<String> idPrefixes = [];
bool isMeta = false;
for (final item in manifest.resources!) {
if (item.name == "meta") {
idPrefixes.addAll((item.idPrefix ?? []) + (item.idPrefixes ?? []));
isMeta = true;
break;
}
}
bool isMeta = false;
for (final item in manifest.resources!) {
if (item.name == "meta") {
idPrefixes.addAll(
(item.idPrefix ?? []) + (item.idPrefixes ?? []));
isMeta = true;
break;
}
}
if (isMeta == false) {
continue;
}
if (isMeta == false) {
continue;
}
final ids = ((manifest.idPrefixes ?? []) + idPrefixes)
.firstWhere((item) => id.id.startsWith(item), orElse: () => "");
final ids = ((manifest.idPrefixes ?? []) + idPrefixes)
.firstWhere((item) => id.id.startsWith(item),
orElse: () => "");
if (ids.isEmpty) {
continue;
}
if (ids.isEmpty) {
continue;
}
final result = await http.get(
Uri.parse(
"${_getAddonBaseURL(addon)}/meta/${(id as Meta).type}/${id.id}.json",
),
);
final result = await http.get(
Uri.parse(
"${_getAddonBaseURL(addon)}/meta/${(id as Meta).type}/${id.id}.json",
),
);
print("${_getAddonBaseURL(addon)}/meta/${(id).type}/${id.id}.json");
return StreamMetaResponse.fromJson(jsonDecode(result.body))
.meta;
}
return StreamMetaResponse.fromJson(jsonDecode(result.body)).meta;
}
return null;
return null;
})
.stream
.where((item) {
return item.status != QueryStatus.loading;
})
.first
.then((docs) {
if (docs.error != null) {
throw docs.error;
}
return docs.data;
});
}
List<InternalManifestItemConfig> getConfig(dynamic configOutput) {
@ -120,7 +145,6 @@ class StremioConnectionService extends BaseConnectionService {
return "${filter.title}=${Uri.encodeComponent(filter.value.toString())}";
}).join('&');
// Add filters to URL
if (filterPath.isNotEmpty) {
url += "/$filterPath";
}
@ -128,11 +152,32 @@ class StremioConnectionService extends BaseConnectionService {
url += ".json";
final httpBody = await http.get(
Uri.parse(url),
);
final result = await Query(
config: QueryConfig(
cacheDuration: const Duration(
hours: 8,
),
),
queryFn: () async {
final httpBody = await http.get(
Uri.parse(url),
);
final result = StrmioMeta.fromJson(jsonDecode(httpBody.body));
return StrmioMeta.fromJson(jsonDecode(httpBody.body));
},
key: url,
)
.stream
.where((item) {
return item.status != QueryStatus.loading;
})
.first
.then((docs) {
if (docs.error != null) {
throw docs.error;
}
return docs.data!;
});
hasMore = result.hasMore ?? false;
returnValue.addAll(result.metas ?? []);
@ -147,34 +192,76 @@ class StremioConnectionService extends BaseConnectionService {
}
@override
Widget renderCard(
LibraryRecord library, LibraryItem item, String heroPrefix) {
Widget renderCard(LibraryItem item, String heroPrefix) {
return StremioCard(
item: item,
prefix: heroPrefix,
connectionId: connectionId,
libraryId: library.id,
service: this,
);
}
@override
Widget renderList(
LibraryRecord library, LibraryItem item, String heroPrefix) {
Future<List<LibraryItem>> getBulkItem(List<LibraryItem> ids) async {
if (ids.isEmpty) {
return [];
}
return (await Future.wait(
ids.map(
(res) async {
return getItemById(res).then((item) {
return (item as Meta).copyWith(
progress: (res as Meta).progress,
nextSeason: res.nextSeason,
nextEpisode: res.nextEpisode,
nextEpisodeTitle: res.nextEpisodeTitle,
);
});
},
),
))
.whereType<Meta>()
.toList();
}
@override
Widget renderList(LibraryItem item, String heroPrefix) {
return StremioListItem(item: item);
}
Future<StremioManifest> _getManifest(String url) async {
final String result;
if (manifestCache.containsKey(url)) {
result = manifestCache[url]!;
} else {
result = (await http.get(Uri.parse(url))).body;
manifestCache[url] = result;
}
return Query(
key: url,
config: QueryConfig(
cacheDuration: const Duration(days: 30),
refetchDuration: const Duration(days: 1),
),
queryFn: () async {
final String result;
if (manifestCache.containsKey(url)) {
result = manifestCache[url]!;
} else {
result = (await http.get(Uri.parse(url))).body;
manifestCache[url] = result;
}
final body = jsonDecode(result);
final resultFinal = StremioManifest.fromJson(body);
return resultFinal;
final body = jsonDecode(result);
final resultFinal = StremioManifest.fromJson(body);
return resultFinal;
})
.stream
.where((item) {
return item.status != QueryStatus.loading;
})
.first
.then((docs) {
if (docs.error != null) {
throw docs.error;
}
return docs.data!;
});
;
}
_getAddonBaseURL(String input) {
@ -232,7 +319,6 @@ class StremioConnectionService extends BaseConnectionService {
@override
Future<void> getStreams(
LibraryRecord library,
LibraryItem id, {
String? season,
String? episode,
@ -257,29 +343,50 @@ class StremioConnectionService extends BaseConnectionService {
final url =
"${_getAddonBaseURL(addon)}/stream/${meta.type}/${Uri.encodeComponent(id.id)}.json";
print(url);
final result = await Query(
key: url,
queryFn: () async {
final result = await http.get(Uri.parse(url), headers: {});
final result = await http.get(Uri.parse(url), headers: {});
if (result.statusCode == 404) {
if (callback != null) {
callback(
null,
ArgumentError(
"Invalid status code for the addon ${addonManifest.name} with id ${addonManifest.id}",
),
);
}
}
if (result.statusCode == 404) {
if (callback != null) {
callback(
null,
ArgumentError(
"Invalid status code for the addon ${addonManifest.name} with id ${addonManifest.id}",
),
);
}
return result.body;
},
)
.stream
.where((item) {
return item.status != QueryStatus.loading;
})
.first
.then((docs) {
return docs.data;
});
if (result == null) {
continue;
}
final body = StreamResponse.fromJson(jsonDecode(result.body));
final body = StreamResponse.fromJson(jsonDecode(result));
streams.addAll(
body.streams
.map(
(item) => videoStreamToStreamList(
item, meta, season, episode, addonManifest),
item,
meta,
season,
episode,
addonManifest,
),
)
.whereType<StreamList>()
.toList(),

View file

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:pocketbase/pocketbase.dart';
@ -299,6 +298,21 @@ class Meta extends LibraryItem {
@JsonKey(name: "dvdRelease")
final DateTime? dvdRelease;
@JsonKey(includeFromJson: false, includeToJson: false)
final double? progress;
@JsonKey(includeFromJson: false, includeToJson: false)
final int? nextSeason;
@JsonKey(includeFromJson: false, includeToJson: false)
final int? nextEpisode;
@JsonKey(includeFromJson: false, includeToJson: false)
final String? nextEpisodeTitle;
@JsonKey(includeFromJson: false, includeToJson: false)
final int? traktId;
String get imdbRating {
return (imdbRating_ ?? "").toString();
}
@ -313,16 +327,20 @@ class Meta extends LibraryItem {
this.popularities,
required this.type,
this.cast,
this.traktId,
this.country,
this.description,
this.genre,
this.imdbRating_,
this.poster,
this.nextEpisode,
this.nextSeason,
this.released,
this.slug,
this.year,
this.status,
this.tvdbId,
this.nextEpisodeTitle,
this.director,
this.writer,
this.background,
@ -343,7 +361,8 @@ class Meta extends LibraryItem {
this.creditsCrew,
this.language,
this.dvdRelease,
});
this.progress,
}) : super(id: id);
Meta copyWith({
String? imdbId,
@ -381,6 +400,10 @@ class Meta extends LibraryItem {
List<CreditsCrew>? creditsCrew,
String? language,
DateTime? dvdRelease,
int? nextSeason,
int? nextEpisode,
String? nextEpisodeTitle,
double? progress,
}) =>
Meta(
imdbId: imdbId ?? this.imdbId,
@ -418,23 +441,15 @@ class Meta extends LibraryItem {
creditsCrew: creditsCrew ?? this.creditsCrew,
language: language ?? this.language,
dvdRelease: dvdRelease ?? this.dvdRelease,
nextEpisode: nextEpisode ?? this.nextEpisode,
nextEpisodeTitle: nextEpisodeTitle ?? this.nextEpisodeTitle,
nextSeason: nextSeason ?? this.nextSeason,
progress: progress ?? this.progress,
);
factory Meta.fromJson(Map<String, dynamic> json) {
final result = _$MetaFromJson(json);
if (kIsWeb) {
result.poster = result.poster?.replaceFirst(
"images.metahub.space/",
"madari-proxy.b-cdn.net/",
);
result.background = result.poster?.replaceFirst(
"images.metahub.space/",
"madari-proxy.b-cdn.net/",
);
}
return result;
}

View file

@ -55,7 +55,7 @@ class _RenderLibraryListState extends State<RenderLibraryList> {
);
return SizedBox(
height: _getListHeight(context),
height: getListHeight(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
@ -135,7 +135,6 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
query = getQuery();
}
@ -143,7 +142,6 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
loadFilters();
}
@ -273,8 +271,7 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
}
_buildBody() {
final itemWidth = _getItemWidth(context);
final listHeight = _getListHeight(context);
final listHeight = getListHeight(context);
if (isUnsupported) {
return SizedBox(
@ -288,7 +285,9 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
child: InfiniteQueryBuilder(
query: query,
builder: (context, data, query) {
final items = data.data?.expand((e) => e).toList() ?? [];
final items = (data.data?.expand((e) => e).toList() ?? [])
.whereType<LibraryItem>()
.toList();
if (data.status == QueryStatus.loading && items.isEmpty) {
return const CustomScrollView(
@ -300,96 +299,15 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
);
}
return CustomScrollView(
controller: _scrollController,
physics:
widget.isGrid ? null : const NeverScrollableScrollPhysics(),
slivers: [
if (data.status == QueryStatus.error)
SliverToBoxAdapter(
child: SizedBox(
height: listHeight,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Something went wrong while loading the library \n${data.error}",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 10,
),
TextButton.icon(
label: const Text("Retry"),
onPressed: () {
query.refetch();
},
icon: const Icon(
Icons.refresh,
),
)
],
),
),
),
),
),
if (widget.isGrid)
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: getGridResponsiveColumnCount(context),
mainAxisSpacing: getGridResponsiveSpacing(context),
crossAxisSpacing: getGridResponsiveSpacing(context),
childAspectRatio: 2 / 3,
),
itemCount: items.length,
itemBuilder: (ctx, index) {
final item = items[index];
return service.renderCard(
widget.item,
item,
"${index}_${widget.item.id}",
);
},
),
if (!widget.isGrid)
SliverToBoxAdapter(
child: SizedBox(
height: listHeight,
child: ListView.builder(
itemBuilder: (ctx, index) {
final item = items[index];
return SizedBox(
width: itemWidth,
child: service.renderCard(
widget.item,
item,
"${index}_${widget.item.id}",
),
);
},
scrollDirection: Axis.horizontal,
itemCount: items.length,
),
),
),
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
),
],
return RenderListItems(
hasError: data.status == QueryStatus.error,
onRefresh: () {
query.refetch();
},
isGrid: widget.isGrid,
items: items,
heroPrefix: widget.item.id,
service: service,
);
},
),
@ -397,15 +315,146 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
}
}
class SpinnerCards extends StatelessWidget {
const SpinnerCards({
class RenderListItems extends StatelessWidget {
final ScrollController? controller;
final ScrollController? itemScrollController;
final bool isGrid;
final bool hasError;
final VoidCallback? onRefresh;
final BaseConnectionService service;
final List<LibraryItem> items;
final String heroPrefix;
final dynamic error;
final bool isWide;
const RenderListItems({
super.key,
this.controller,
this.isGrid = false,
this.hasError = false,
this.onRefresh,
required this.items,
required this.service,
required this.heroPrefix,
this.itemScrollController,
this.error,
this.isWide = false,
});
@override
Widget build(BuildContext context) {
final itemWidth = _getItemWidth(context);
final itemHeight = _getListHeight(context);
final listHeight = getListHeight(context);
final itemWidth = getItemWidth(
context,
isWide: isWide,
);
return CustomScrollView(
controller: controller,
physics: isGrid ? null : const NeverScrollableScrollPhysics(),
slivers: [
if (hasError)
SliverToBoxAdapter(
child: SizedBox(
height: listHeight,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Something went wrong while loading the library \n$error",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(
height: 10,
),
TextButton.icon(
label: const Text("Retry"),
onPressed: onRefresh,
icon: const Icon(
Icons.refresh,
),
)
],
),
),
),
),
),
if (isGrid)
SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: getGridResponsiveColumnCount(context),
mainAxisSpacing: getGridResponsiveSpacing(context),
crossAxisSpacing: getGridResponsiveSpacing(context),
childAspectRatio: 2 / 3,
),
itemCount: items.length,
itemBuilder: (ctx, index) {
final item = items[index];
return service.renderCard(
item,
"${index}_$heroPrefix",
);
},
),
if (!isGrid)
SliverToBoxAdapter(
child: SizedBox(
height: listHeight,
child: ListView.builder(
itemBuilder: (ctx, index) {
final item = items[index];
return SizedBox(
width: itemWidth,
child: Container(
decoration: const BoxDecoration(),
child: service.renderCard(
item,
"${index}_${heroPrefix}",
),
),
);
},
scrollDirection: Axis.horizontal,
itemCount: items.length,
),
),
),
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
),
],
);
}
}
class SpinnerCards extends StatelessWidget {
final bool isWide;
const SpinnerCards({
super.key,
this.isWide = false,
});
@override
Widget build(BuildContext context) {
final itemWidth = getItemWidth(
context,
isWide: isWide,
);
final itemHeight = getListHeight(context);
return SizedBox(
height: itemHeight,
@ -438,12 +487,14 @@ class SpinnerCards extends StatelessWidget {
}
}
double _getItemWidth(BuildContext context) {
double getItemWidth(BuildContext context, {bool isWide = false}) {
double screenWidth = MediaQuery.of(context).size.width;
return screenWidth > 800 ? 200.0 : 120.0;
return screenWidth > 800
? (isWide ? 400.0 : 200.0)
: (isWide ? 280.0 : 120.0);
}
double _getListHeight(BuildContext context) {
double getListHeight(BuildContext context) {
double screenWidth = MediaQuery.of(context).size.width;
return screenWidth > 800 ? 300.0 : 180.0;
}

View file

@ -17,19 +17,19 @@ const kIsWeb = bool.fromEnvironment('dart.library.js_util');
class RenderStreamList extends StatefulWidget {
final BaseConnectionService service;
final String library;
final LibraryItem id;
final String? episode;
final String? season;
final bool shouldPop;
final double? progress;
const RenderStreamList({
super.key,
required this.service,
required this.library,
required this.id,
this.season,
this.episode,
this.progress,
required this.shouldPop,
});
@ -170,10 +170,9 @@ class _RenderStreamListState extends State<RenderStreamList> {
final Map<String, StreamSource> _sources = {};
Future getLibrary() async {
final library = await BaseConnectionService.getLibraries();
await BaseConnectionService.getLibraries();
final result = await widget.service.getStreams(
library.data.firstWhere((i) => i.id == widget.library),
await widget.service.getStreams(
widget.id,
episode: widget.episode,
season: widget.season,
@ -310,9 +309,14 @@ class _RenderStreamListState extends State<RenderStreamList> {
builder: (ctx) => DocViewer(
source: item.source,
service: widget.service,
library: widget.library,
meta: widget.id,
meta: widget.season != null && widget.episode != null
? (widget.id as Meta).copyWith(
nextSeason: int.parse(widget.season!),
nextEpisode: int.parse(widget.episode!),
)
: widget.id,
season: widget.season,
progress: widget.progress,
),
),
);

View file

@ -9,14 +9,14 @@ class StremioCard extends StatelessWidget {
final LibraryItem item;
final String prefix;
final String connectionId;
final String libraryId;
final BaseConnectionService service;
const StremioCard({
super.key,
required this.item,
required this.prefix,
required this.connectionId,
required this.libraryId,
required this.service,
});
@override
@ -35,68 +35,267 @@ class StremioCard extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
onTap: () {
context.push(
"/info/stremio/$connectionId/$libraryId/${meta.type}/${meta.id}?hero=$prefix${meta.type}${item.id}",
extra: meta,
"/info/stremio/$connectionId/${meta.type}/${meta.id}?hero=$prefix${meta.type}${item.id}",
extra: {
'meta': meta,
'service': service,
},
);
},
child: Hero(
tag: "$prefix${meta.type}${item.id}",
child: AspectRatio(
aspectRatio: 2 / 3, // Typical poster aspect ratio
child: (meta.poster == null)
? Container()
: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(meta.poster!)}@webp",
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.cover,
),
),
child: meta.imdbRating != null && meta.imdbRating != ""
? Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text(
meta.imdbRating!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
),
),
child: meta.nextSeason == null || meta.progress != null
? _buildRegular(context, meta)
: _buildWideCard(context, meta),
),
),
);
}
_buildWideCard(BuildContext context, Meta meta) {
if (meta.background == null) {
return Container();
}
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(meta.background!)}@webp",
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.cover,
),
),
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black,
Colors.transparent,
],
begin: Alignment.bottomLeft,
end: Alignment.center,
),
),
),
),
Positioned(
bottom: 0,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text("S${meta.nextSeason} E${meta.nextEpisode}"),
Text(
"${meta.nextEpisodeTitle}".trim(),
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
),
const Positioned(
child: Center(
child: IconButton.filled(
onPressed: null,
icon: Icon(
Icons.play_arrow,
size: 24,
),
),
),
),
meta.imdbRating != ""
? Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text(
meta.imdbRating,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
),
)
: const SizedBox.shrink(),
],
),
);
}
String? getBackgroundImage(Meta meta) {
String? backgroundImage;
if (meta.nextEpisode != null &&
meta.nextSeason != null &&
meta.videos != null) {
for (final video in meta.videos!) {
if (video.season == meta.nextSeason &&
video.episode == meta.nextEpisode) {
return video.thumbnail ?? meta.poster;
}
}
}
if (meta.poster != null) {
backgroundImage = meta.poster;
}
return backgroundImage;
}
_buildRegular(BuildContext context, Meta meta) {
final backgroundImage = getBackgroundImage(meta);
return Hero(
tag: "$prefix${meta.type}${item.id}",
child: AspectRatio(
aspectRatio: 2 / 3,
child: (backgroundImage == null)
? Text("${meta.name}")
: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(backgroundImage)}@webp",
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.cover,
),
),
child: meta.imdbRating != ""
? Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text(
meta.imdbRating!,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
if (meta.progress != null)
const Positioned.fill(
child: IconButton(
onPressed: null,
icon: Icon(
Icons.play_arrow,
size: 24,
),
),
),
if (meta.progress != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: LinearProgressIndicator(
value: meta.progress,
),
),
if (meta.nextEpisode != null && meta.nextSeason != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.grey,
Colors.transparent,
],
begin: Alignment.bottomLeft,
end: Alignment.topRight,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 4, horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
meta.name ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(fontWeight: FontWeight.w600),
),
Text(
"S${meta.nextSeason} E${meta.nextEpisode}",
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
],
),
),
),
)
],
),
),
);
}
}

View file

@ -13,7 +13,7 @@ class StremioItemViewer extends StatefulWidget {
final Meta? original;
final String? hero;
final BaseConnectionService? service;
final String library;
final num? progress;
const StremioItemViewer({
super.key,
@ -21,7 +21,7 @@ class StremioItemViewer extends StatefulWidget {
this.original,
this.hero,
this.service,
required this.library,
this.progress,
});
@override
@ -70,7 +70,6 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
)
: RenderStreamList(
service: widget.service!,
library: widget.library,
id: widget.meta as LibraryItem,
shouldPop: false,
),
@ -116,7 +115,7 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
children: [
Expanded(
child: Text(
item!.name!,
(item!.name ?? "No name"),
style: Theme.of(context).textTheme.titleLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
@ -149,7 +148,9 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
_onPlayPressed(context);
},
label: Text(
"Play",
widget.progress != null && widget.progress != 0
? "Resume"
: "Play",
style: Theme.of(context)
.primaryTextTheme
.bodyMedium
@ -278,8 +279,7 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
widget.original?.type == "series" &&
widget.original?.videos?.isNotEmpty == true)
StremioItemSeasonSelector(
meta: item!,
library: widget.library,
meta: (item as Meta),
service: widget.service,
),
SliverPadding(

View file

@ -0,0 +1,476 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../types/stremio/stremio_base.types.dart';
class StremioItemViewerTV extends StatefulWidget {
final Meta? meta;
final Meta? original;
final String? hero;
final BaseConnectionService? service;
final String library;
const StremioItemViewerTV({
super.key,
this.meta,
this.original,
this.hero,
this.service,
required this.library,
});
@override
State<StremioItemViewerTV> createState() => _StremioItemViewerTVState();
}
class _StremioItemViewerTVState extends State<StremioItemViewerTV> {
String? _errorMessage;
final FocusNode _playButtonFocusNode = FocusNode();
final FocusNode _trailersFocusNode = FocusNode();
bool _showTrailers = false;
@override
void initState() {
super.initState();
// Set initial focus to the Play button
_playButtonFocusNode.requestFocus();
}
@override
void dispose() {
_playButtonFocusNode.dispose();
_trailersFocusNode.dispose();
super.dispose();
}
bool get _isLoading {
return widget.original == null;
}
Meta? _item;
Meta? get item {
return _item ?? widget.meta;
}
void _onPlayPressed(BuildContext context) {
if (item == null) {
return;
}
showModalBottomSheet(
context: context,
builder: (context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.close),
),
title: const Text("Streams"),
),
body: widget.service == null
? const Center(
child: CircularProgressIndicator(),
)
: RenderStreamList(
service: widget.service!,
id: widget.meta as LibraryItem,
shouldPop: false,
),
);
},
);
}
@override
Widget build(BuildContext context) {
if (_errorMessage != null) {
return Center(
child: Text("Failed $_errorMessage"),
);
}
if (item == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Scaffold(
body: Stack(
children: [
// Static Background
if (item!.background != null)
Positioned.fill(
child: Image.network(
item!.background!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
if (item!.poster == null) {
return Container();
}
return Image.network(item!.poster!, fit: BoxFit.cover);
},
),
),
// Gradient Overlay
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.8),
],
),
),
),
),
// Content
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Title
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
item!.name ?? "No Title",
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
// Poster and Details Section
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 900,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poster
Hero(
tag: "${widget.hero}",
child: Container(
width: 150,
height: 225,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
image: item!.poster == null
? null
: DecorationImage(
image:
NetworkImage(item!.poster!),
fit: BoxFit.cover,
),
boxShadow: [
BoxShadow(
color:
Colors.black.withOpacity(0.3),
spreadRadius: 2,
blurRadius: 8,
),
],
),
),
),
const SizedBox(width: 16),
// Details
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Year and Rating
Row(
children: [
if (item!.year != null)
Chip(
label:
Text("${item!.year ?? ""}"),
backgroundColor: Colors.white24,
labelStyle: const TextStyle(
color: Colors.white),
),
const SizedBox(width: 8),
if (item!.imdbRating != "")
Row(
children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 20,
),
const SizedBox(width: 4),
Text(
item!.imdbRating,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color:
Colors.white),
),
],
),
],
),
const SizedBox(height: 16),
// Description
Text(
'Description',
style: Theme.of(context)
.textTheme
.titleLarge,
),
if (item!.description != null)
const SizedBox(height: 8),
if (item!.description != null)
Text(
item!.description!,
style: Theme.of(context)
.textTheme
.bodyMedium,
),
const SizedBox(height: 16),
// Additional Details
_buildDetailSection(
context, 'Additional Information', [
if (item!.genre != null)
_buildDetailRow('Genres',
item!.genre!.join(', ')),
if (item!.country != null)
_buildDetailRow(
'Country', item!.country!),
if (item!.runtime != null)
_buildDetailRow(
'Runtime', item!.runtime!),
if (item!.language != null)
_buildDetailRow(
'Language', item!.language!),
]),
],
),
),
],
),
const SizedBox(height: 16),
// Play Button
Focus(
focusNode: _playButtonFocusNode,
onKey: (node, event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey ==
LogicalKeyboardKey.arrowDown) {
// Show Trailers
setState(() {
_showTrailers = true;
});
FocusScope.of(context)
.requestFocus(_trailersFocusNode);
return KeyEventResult.handled;
} else if (event.logicalKey ==
LogicalKeyboardKey.enter) {
// Play the item
_onPlayPressed(context);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: ElevatedButton.icon(
icon: _isLoading
? Container(
margin:
const EdgeInsets.only(right: 6),
child: const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(),
),
)
: const Icon(
Icons.play_arrow_rounded,
size: 24,
color: Colors.black87,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
),
onPressed: () {
if (item!.type == "series" && _isLoading) {
return;
}
_onPlayPressed(context);
},
label: Text(
"Play",
style: Theme.of(context)
.primaryTextTheme
.bodyMedium
?.copyWith(
color: Colors.black87,
),
),
),
),
],
),
),
),
),
),
),
if (_showTrailers &&
item!.trailerStreams != null &&
item!.trailerStreams!.isNotEmpty)
Focus(
focusNode: _trailersFocusNode,
onKey: (node, event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
// Hide Trailers and move focus back to Play Button
setState(() {
_showTrailers = false;
});
FocusScope.of(context)
.requestFocus(_playButtonFocusNode);
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child:
_buildTrailersSection(context, item!.trailerStreams!),
),
],
),
),
],
),
);
}
Widget _buildDetailSection(
BuildContext context, String title, List<Widget> details) {
if (details.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
...details,
const SizedBox(height: 16),
],
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(child: Text(value)),
],
),
);
}
Widget _buildTrailersSection(
BuildContext context, List<TrailerStream> trailers) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trailers',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: trailers.length,
itemBuilder: (context, index) {
final trailer = trailers[index];
return GestureDetector(
onTap: () async {
final url = Uri.parse(
"https://www.youtube-nocookie.com/embed/${trailer.ytId}?autoplay=1&color=red&disablekb=1&enablejsapi=1&fs=1",
);
launchUrl(
url,
);
},
child: Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.black26,
image: DecorationImage(
image: CachedNetworkImageProvider(
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent("https://i.ytimg.com/vi/${trailer.ytId}/mqdefault.jpg")}@webp",
imageRenderMethodForWeb:
ImageRenderMethodForWeb.HttpGet,
),
fit: BoxFit.contain,
),
),
child: Center(
child: Text(
trailer.title,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white),
),
),
),
),
);
},
),
),
],
),
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:intl/intl.dart' as intl;
import 'package:madari_client/features/connection/types/stremio.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
import 'package:madari_client/features/trakt/service/trakt.service.dart';
import '../../../doc_viewer/types/doc_source.dart';
import '../../../watch_history/service/base_watch_history.dart';
@ -13,7 +14,6 @@ import '../../../watch_history/service/zeee_watch_history.dart';
class StremioItemSeasonSelector extends StatefulWidget {
final Meta meta;
final int? season;
final String library;
final BaseConnectionService? service;
final bool shouldPop;
@ -21,7 +21,6 @@ class StremioItemSeasonSelector extends StatefulWidget {
super.key,
required this.meta,
this.season,
required this.library,
required this.service,
this.shouldPop = false,
});
@ -39,6 +38,7 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
final Map<String, double> _progress = {};
final Map<int, Map<int, double>> _traktProgress = {};
@override
void initState() {
@ -67,6 +67,32 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
}
getWatchHistory() async {
final traktService = TraktService.instance;
try {
if (traktService!.isEnabled()) {
final result = await traktService.getProgress(widget.meta);
for (final item in result) {
if (!_traktProgress.containsKey(item.season)) {
_traktProgress.addAll(<int, Map<int, double>>{
item.season!: {},
});
}
_traktProgress[item.season!] = _traktProgress[item.season] ?? {};
_traktProgress[item.season]![item.episode!] = item.progress;
}
setState(() {});
return;
}
} catch (e, stack) {
print(e);
print(stack);
print("Unable to get trakt progress");
}
final docs = await zeeeWatchHistory!.getItemWatchHistory(
ids: widget.meta.videos!.map((item) {
return WatchHistoryGetRequest(id: item.id);
@ -111,7 +137,6 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
),
body: RenderStreamList(
service: widget.service!,
library: widget.library,
id: meta,
season: currentSeason.toString(),
shouldPop: widget.shouldPop,
@ -234,6 +259,12 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
final episodes = seasonMap[currentSeason]!;
final episode = episodes[index];
final progress = _traktProgress[episode.season]
?[episode.episode] ==
null
? (_progress[episode.id] ?? 0) / 100
: (_traktProgress[episode.season]![episode.episode]! / 100);
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
@ -303,13 +334,45 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
),
Center(
child: CircularProgressIndicator(
value:
(_progress[episode.id] ?? 0) / 100,
value: progress,
),
)
],
),
),
if (progress > .9)
Positioned(
bottom: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.teal,
),
child: Padding(
padding: const EdgeInsets.only(
right: 4.0,
bottom: 2.0,
left: 4.0,
top: 2.0,
),
child: Center(
child: Text(
"Watched",
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Colors.black,
),
),
),
),
),
),
),
],
),
),

View file

@ -16,6 +16,8 @@ class DocViewer extends StatefulWidget {
final String? season;
final BaseConnectionService? service;
final double? progress;
const DocViewer({
super.key,
required this.source,
@ -23,6 +25,7 @@ class DocViewer extends StatefulWidget {
this.library,
this.meta,
this.season,
this.progress,
});
@override

View file

@ -6,13 +6,16 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart';
import 'package:madari_client/features/watch_history/service/base_watch_history.dart';
import 'package:madari_client/utils/tv_detector.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../utils/load_language.dart';
import '../../connections/types/stremio/stremio_base.types.dart' as types;
import '../../connections/widget/stremio/stremio_season_selector.dart';
import '../../trakt/service/trakt.service.dart';
import '../../watch_history/service/zeee_watch_history.dart';
import '../types/doc_source.dart';
import 'video_viewer/desktop_video_player.dart';
@ -42,13 +45,21 @@ class _VideoViewerState extends State<VideoViewer> {
StreamSubscription? _subTracks;
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
Timer? _timer;
late final player = Player(
late final Player player = Player(
configuration: const PlayerConfiguration(
title: "Madari",
),
);
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
double get currentProgressInPercentage {
final duration = player.state.duration.inSeconds;
final position = player.state.position.inSeconds;
return duration > 0 ? (position / duration * 100) : 0;
}
Future<List<TraktProgress>>? traktProgress;
saveWatchHistory() {
final duration = player.state.duration.inSeconds;
final position = player.state.position.inSeconds;
@ -134,6 +145,71 @@ class _VideoViewerState extends State<VideoViewer> {
});
}
setDurationFromTrakt() async {
if (player.state.duration.inSeconds < 2) {
return;
}
if (!TraktService.instance!.isEnabled() || traktProgress == null) {
player.play();
return;
}
final progress = await traktProgress;
if ((progress ?? []).isEmpty) {
player.play();
}
final duration = Duration(
seconds: calculateSecondsFromProgress(
player.state.duration.inSeconds.toDouble(),
progress!.first.progress,
),
);
player.seek(duration);
player.play();
addListenerForTrakt();
}
List<StreamSubscription> listener = [];
bool traktIntegration = false;
addListenerForTrakt() {
if (traktIntegration == true) {
return;
}
traktIntegration = true;
final streams = player.stream.playing.listen((item) {
if (item) {
TraktService.instance!.startScrobbling(
meta: widget.meta as types.Meta,
progress: currentProgressInPercentage,
);
} else {
TraktService.instance!.pauseScrobbling(
meta: widget.meta as types.Meta,
progress: currentProgressInPercentage,
);
}
});
final oneMore = player.stream.completed.listen((item) {
TraktService.instance!.stopScrobbling(
meta: widget.meta as types.Meta,
progress: currentProgressInPercentage,
);
});
listener.add(streams);
listener.add(oneMore);
}
PlaybackConfig config = getPlaybackConfig();
bool defaultConfigSelected = false;
@ -156,6 +232,12 @@ class _VideoViewerState extends State<VideoViewer> {
}
}
_duration = player.stream.duration.listen((item) {
if (item.inSeconds != 0) {
setDurationFromTrakt();
}
});
_streamComplete = player.stream.completed.listen((completed) {
if (completed) {
onLibrarySelect();
@ -189,11 +271,17 @@ class _VideoViewerState extends State<VideoViewer> {
saveWatchHistory();
});
this._streamListen = player.stream.playing.listen((playing) {
_streamListen = player.stream.playing.listen((playing) {
if (playing) {
saveWatchHistory();
}
});
if (widget.meta is types.Meta) {
traktProgress = TraktService.instance!.getProgress(
widget.meta as types.Meta,
);
}
}
loadFile() async {
@ -226,7 +314,7 @@ class _VideoViewerState extends State<VideoViewer> {
(_source as FileSource).filePath,
start: duration,
),
play: true,
play: false,
);
case const (URLSource):
case const (MediaURLSource):
@ -237,7 +325,7 @@ class _VideoViewerState extends State<VideoViewer> {
httpHeaders: (_source as URLSource).headers,
start: duration,
),
play: true,
play: false,
);
}
}
@ -246,6 +334,7 @@ class _VideoViewerState extends State<VideoViewer> {
late StreamSubscription<bool> _streamComplete;
late StreamSubscription<bool> _streamListen;
late StreamSubscription<dynamic> _duration;
onLibrarySelect() async {
controller.player.pause();
@ -261,7 +350,6 @@ class _VideoViewerState extends State<VideoViewer> {
slivers: [
StremioItemSeasonSelector(
service: widget.service,
library: widget.library!,
meta: widget.meta as types.Meta,
shouldPop: true,
season: int.tryParse(widget.currentSeason!),
@ -281,12 +369,22 @@ class _VideoViewerState extends State<VideoViewer> {
@override
void dispose() {
if (traktIntegration && widget.meta is types.Meta) {
TraktService.instance!.stopScrobbling(
meta: widget.meta as types.Meta,
progress: currentProgressInPercentage,
);
}
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
for (final item in listener) {
item.cancel();
}
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: [],
@ -295,6 +393,7 @@ class _VideoViewerState extends State<VideoViewer> {
_subTracks?.cancel();
_streamComplete.cancel();
_streamListen.cancel();
_duration.cancel();
player.dispose();
super.dispose();
}
@ -306,66 +405,88 @@ class _VideoViewerState extends State<VideoViewer> {
);
}
_buildMobileView(BuildContext context) {
final mobile = getMobileVideoPlayer(
context,
onLibrarySelect: onLibrarySelect,
hasLibrary: widget.service != null &&
widget.library != null &&
widget.meta != null,
audioTracks: audioTracks,
player: player,
source: _source,
subtitles: subtitles,
onSubtitleClick: onSubtitleSelect,
onAudioClick: onAudioSelect,
toggleScale: () {
setState(() {
isScaled = !isScaled;
});
},
);
return MaterialVideoControlsTheme(
fullscreen: mobile,
normal: mobile,
child: Video(
fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight,
pauseUponEnteringBackgroundMode: true,
key: key,
onExitFullscreen: () async {
await defaultExitNativeFullscreen();
if (context.mounted) Navigator.of(context).pop();
},
controller: controller,
controls: MaterialVideoControls,
),
);
}
_buildDesktop(BuildContext context) {
final desktop = getDesktopControls(
context,
audioTracks: audioTracks,
player: player,
source: _source,
subtitles: subtitles,
onAudioSelect: onAudioSelect,
onSubtitleSelect: onSubtitleSelect,
);
return MaterialDesktopVideoControlsTheme(
normal: desktop,
fullscreen: desktop,
child: Video(
key: key,
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
controller: controller,
controls: MaterialDesktopVideoControls,
),
);
}
_buildBody(BuildContext context) {
if (DeviceDetector.isTV()) {
return MaterialTvVideoControlsTheme(
fullscreen: const MaterialTvVideoControlsThemeData(),
normal: const MaterialTvVideoControlsThemeData(),
child: Video(
key: key,
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
controller: controller,
controls: MaterialTvVideoControls,
),
);
}
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
final mobile = getMobileVideoPlayer(
context,
onLibrarySelect: onLibrarySelect,
hasLibrary: widget.service != null &&
widget.library != null &&
widget.meta != null,
audioTracks: audioTracks,
player: player,
source: _source,
subtitles: subtitles,
onSubtitleClick: onSubtitleSelect,
onAudioClick: onAudioSelect,
toggleScale: () {
setState(() {
isScaled = !isScaled;
});
},
);
return MaterialVideoControlsTheme(
fullscreen: mobile,
normal: mobile,
child: Video(
fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight,
pauseUponEnteringBackgroundMode: true,
key: key,
onExitFullscreen: () async {
await defaultExitNativeFullscreen();
if (context.mounted) Navigator.of(context).pop();
},
controller: controller,
controls: MaterialVideoControls,
),
);
return _buildMobileView(context);
default:
final desktop = getDesktopControls(
context,
audioTracks: audioTracks,
player: player,
source: _source,
subtitles: subtitles,
onAudioSelect: onAudioSelect,
onSubtitleSelect: onSubtitleSelect,
);
return MaterialDesktopVideoControlsTheme(
normal: desktop,
fullscreen: desktop,
child: Video(
key: key,
width: MediaQuery.of(context).size.width,
fit: BoxFit.fitWidth,
controller: controller,
controls: MaterialDesktopVideoControls,
),
);
return _buildDesktop(context);
}
}

View file

@ -21,6 +21,7 @@ MaterialDesktopVideoControlsThemeData getDesktopControls(
}) {
return MaterialDesktopVideoControlsThemeData(
toggleFullscreenOnDoublePress: false,
displaySeekBar: true,
topButtonBar: [
SafeArea(
child: MaterialDesktopCustomButton(
@ -51,6 +52,7 @@ MaterialDesktopVideoControlsThemeData getDesktopControls(
);
}
: null,
playAndPauseOnTap: true,
bottomButtonBar: [
const MaterialDesktopSkipPreviousButton(),
const MaterialDesktopPlayOrPauseButton(),

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,329 @@
import 'package:flutter/material.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../utils/auth_refresh.dart';
class TraktIntegration extends StatefulWidget {
const TraktIntegration({
super.key,
});
@override
State<TraktIntegration> createState() => _TraktIntegrationState();
}
class _TraktIntegrationState extends State<TraktIntegration> {
final pb = AppEngine.engine.pb;
bool isLoggedIn = false;
List<TraktCategories> selectedLists = [];
List<TraktCategories> availableLists = [...traktCategories];
@override
void initState() {
super.initState();
checkIsLoggedIn();
_loadSelectedCategories();
}
// Check if the user is logged in
checkIsLoggedIn() {
final traktToken = pb.authStore.record!.getStringValue("trakt_token");
setState(() {
isLoggedIn = traktToken != "";
});
}
// Load selected categories from the database
void _loadSelectedCategories() async {
final record = pb.authStore.record!;
final config = record.get("config") ?? {};
final savedCategories =
config["selected_categories"] as List<dynamic>? ?? [];
setState(() {
selectedLists = traktCategories
.where((category) => savedCategories.contains(category.key))
.toList();
availableLists = traktCategories
.where((category) => !savedCategories.contains(category.key))
.toList();
});
}
// Save selected categories to the database
void _saveSelectedCategories() async {
final record = pb.authStore.record!;
final config = record.get("config") ?? {};
config["selected_categories"] =
selectedLists.map((category) => category.key).toList();
await pb.collection('users').update(
record.id,
body: {
"config": config,
},
);
await refreshAuth();
}
// Remove a category
void _removeCategory(TraktCategories category) {
setState(() {
selectedLists.remove(category);
availableLists.add(category);
});
_saveSelectedCategories();
}
// Add a category
void _addCategory(TraktCategories category) {
setState(() {
availableLists.remove(category);
selectedLists.add(category);
});
_saveSelectedCategories();
}
removeAccount() async {
final record = pb.authStore.record!;
record.set("trakt_token", "");
pb.collection('users').update(
record.id,
body: record.toJson(),
);
await refreshAuth();
}
loginWithTrakt() async {
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();
}
// Show the "Add Category" dialog
Future<void> _showAddCategoryDialog() async {
return showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
"Add Category",
style: TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: availableLists.length,
itemBuilder: (context, index) {
final category = availableLists[index];
return ListTile(
title: Text(
category.title,
style: const TextStyle(fontSize: 16),
),
trailing: const Icon(Icons.add, color: Colors.blue),
onTap: () {
_addCategory(category);
Navigator.of(context).pop();
},
);
},
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text(
"Close",
style: TextStyle(color: Colors.red),
),
),
],
);
},
);
}
// Reorder categories
void _onReorder(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {
newIndex -= 1;
}
final TraktCategories item = selectedLists.removeAt(oldIndex);
selectedLists.insert(newIndex, item);
});
_saveSelectedCategories();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
"Trakt Integration",
style: TextStyle(fontWeight: FontWeight.bold),
),
centerTitle: true,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Padding(
padding: const EdgeInsets.only(
bottom: 16.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isLoggedIn)
ElevatedButton(
onPressed: () async {
await removeAccount();
setState(() {
isLoggedIn = false;
});
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
"Disconnect Account",
),
)
else
ElevatedButton(
onPressed: () {
loginWithTrakt();
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
"Login with Trakt",
),
),
const SizedBox(height: 20),
if (isLoggedIn) ...[
const Text(
"Selected Categories to show in home",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Expanded(
child: ReorderableListView.builder(
itemCount: selectedLists.length,
onReorder: _onReorder,
itemBuilder: (context, index) {
final category = selectedLists[index];
return Card(
key: ValueKey(category.key),
elevation: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
title: Text(
category.title,
style: const TextStyle(fontSize: 16),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _removeCategory(category),
),
),
);
},
),
),
const SizedBox(height: 20),
if (availableLists.isNotEmpty)
ElevatedButton(
onPressed: _showAddCategoryDialog,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green.shade600,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
"Add Category",
style: TextStyle(fontSize: 16, color: Colors.white),
),
),
],
],
),
),
),
);
}
}
List<TraktCategories> traktCategories = [
TraktCategories(
title: "Up Next - Trakt",
key: "up_next_series",
),
TraktCategories(
title: "Continue watching",
key: "continue_watching",
),
TraktCategories(
title: "Upcoming Schedule",
key: "upcoming_schedule",
),
TraktCategories(
title: "Watchlist",
key: "watchlist",
),
TraktCategories(
title: "Show Recommendations",
key: "show_recommendations",
),
TraktCategories(
title: "Movie Recommendations",
key: "movie_recommendations",
),
];
class TraktCategories {
final String title;
final String key;
TraktCategories({
required this.title,
required this.key,
});
}

View file

@ -0,0 +1,157 @@
import 'package:cached_query_flutter/cached_query_flutter.dart';
import 'package:flutter/material.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/trakt/service/trakt.service.dart';
import '../../connections/widget/base/render_library_list.dart';
import '../../settings/screen/trakt_integration_screen.dart';
class TraktContainer extends StatefulWidget {
final String loadId;
const TraktContainer({
super.key,
required this.loadId,
});
@override
State<TraktContainer> createState() => _TraktContainerState();
}
class _TraktContainerState extends State<TraktContainer> {
late Query<List<LibraryItem>> _query;
@override
void initState() {
super.initState();
_query = Query(
key: widget.loadId,
config: QueryConfig(
cacheDuration: const Duration(days: 30),
refetchDuration: const Duration(minutes: 1),
storageDuration: const Duration(days: 30),
),
queryFn: () {
switch (widget.loadId) {
case "up_next":
case "up_next_series":
return TraktService.instance!.getUpNextSeries();
case "continue_watching":
return TraktService.instance!.getContinueWatching();
case "upcoming_schedule":
return TraktService.instance!.getUpcomingSchedule();
case "watchlist":
return TraktService.instance!.getWatchlist();
case "show_recommendations":
return TraktService.instance!.getShowRecommendations();
case "movie_recommendations":
return TraktService.instance!.getMovieRecommendations();
default:
throw Exception("Invalid loadId: ${widget.loadId}");
}
},
);
}
String get title {
return traktCategories
.firstWhere((item) => item.key == widget.loadId)
.title;
}
@override
Widget build(BuildContext context) {
return QueryBuilder(
query: _query,
builder: (context, snapshot) {
final theme = Theme.of(context);
final item = snapshot.data;
return Container(
margin: const EdgeInsets.only(bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
title,
style: theme.textTheme.bodyLarge,
),
const Spacer(),
SizedBox(
height: 30,
child: TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: Text("Trakt - $title"),
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: RenderListItems(
items: item ?? [],
error: snapshot.error,
hasError:
snapshot.status == QueryStatus.error,
heroPrefix: "trakt_up_next${widget.loadId}",
service: TraktService.stremioService!,
isGrid: true,
isWide: false,
),
),
);
},
),
);
},
child: Text(
"Show more",
style: theme.textTheme.labelMedium?.copyWith(
color: Colors.white70,
),
),
),
),
],
),
const SizedBox(
height: 8,
),
Stack(
children: [
if ((item ?? []).isEmpty &&
snapshot.status != QueryStatus.loading)
const Positioned.fill(
child: Center(
child: Text("Nothing to see here"),
),
),
SizedBox(
height: getListHeight(context),
child: snapshot.status == QueryStatus.loading
? SpinnerCards(
isWide: widget.loadId == "up_next_series",
)
: RenderListItems(
isWide: widget.loadId == "up_next_series",
items: item ?? [],
error: snapshot.error,
hasError: snapshot.status == QueryStatus.error,
heroPrefix: "trakt_up_next${widget.loadId}",
service: TraktService.stremioService!,
),
),
],
)
],
),
);
},
);
}
}

View file

@ -0,0 +1,653 @@
import 'dart:convert';
import 'package:cached_storage/cached_storage.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:pocketbase/pocketbase.dart';
import '../../../engine/connection_type.dart';
import '../../../engine/engine.dart';
import '../../connections/service/base_connection_service.dart';
import '../../connections/types/stremio/stremio_base.types.dart';
import '../../settings/types/connection.dart';
class TraktService {
static const String _baseUrl = 'https://api.trakt.tv';
static const String _apiVersion = '2';
static TraktService? _instance;
static TraktService? get instance => _instance;
static BaseConnectionService? stremioService;
static ensureInitialized() async {
if (_instance != null) {
return _instance;
}
final traktService = TraktService();
await traktService.initStremioService();
_instance = traktService;
}
Future<BaseConnectionService> initStremioService() async {
if (stremioService != null) {
return stremioService!;
}
final model_ =
await AppEngine.engine.pb.collection("connection").getFirstListItem(
"type.type = 'stremio_addons'",
expand: "type",
);
final connection = ConnectionResponse(
connection: Connection.fromRecord(model_),
connectionTypeRecord: ConnectionTypeRecord.fromRecord(
model_.get<RecordModel>("expand.type"),
),
);
stremioService = BaseConnectionService.connectionById(connection);
return stremioService!;
}
static String get _traktClient {
final client = "" ?? DotEnv().get("trakt_client_id");
if (client == "") {
return "b47864365ac88ecc253c3b0bdf1c82a619c1833e8806f702895a7e8cb06b536a";
}
return client;
}
get _token {
return AppEngine.engine.pb.authStore.record!.getStringValue("trakt_token");
}
Map<String, String> get headers => {
'Content-Type': 'application/json',
'Accept-Content': 'application/json',
'trakt-api-version': _apiVersion,
'trakt-api-key': _traktClient,
'Authorization': 'Bearer $_token',
};
Future<List<LibraryItem>> getUpNextSeries({bool noUpNext = false}) async {
await initStremioService();
if (!isEnabled()) {
return [];
}
try {
final watchedResponse = await http.get(
Uri.parse('$_baseUrl/sync/watched/shows'),
headers: headers,
);
if (watchedResponse.statusCode != 200) {
throw ArgumentError('Failed to fetch watched shows');
}
final watchedShows = json.decode(watchedResponse.body) as List;
final progressFutures = watchedShows.map((show) async {
final showId = show['show']['ids']['trakt'];
final imdb = show['show']['ids']['imdb'];
if (noUpNext == true) {
final meta = await stremioService!.getItemById(
Meta(
type: "series",
id: imdb,
),
);
return (meta as Meta).copyWith();
}
try {
final progressResponse = await http.get(
Uri.parse('$_baseUrl/shows/$showId/progress/watched'),
headers: headers,
);
if (progressResponse.statusCode != 200) {
return null;
}
final progress = json.decode(progressResponse.body);
final nextEpisode = progress['next_episode'];
print(nextEpisode);
if (nextEpisode != null && imdb != null) {
final item = await stremioService!.getItemById(
Meta(type: "series", id: imdb),
);
item as Meta;
return item.copyWith(
nextEpisode: nextEpisode['number'],
nextSeason: nextEpisode['season'],
nextEpisodeTitle: nextEpisode['title'],
);
}
} catch (e) {
print('Error fetching progress for show $showId: $e');
return null;
}
return null;
}).toList();
final results = await Future.wait(progressFutures);
return results.whereType<Meta>().toList();
} catch (e, stack) {
print('Error fetching up next episodes: $e');
print(stack);
return [];
}
}
Future<List<LibraryItem>> getContinueWatching() async {
await initStremioService();
if (!isEnabled()) {
return [];
}
try {
final watchedResponse = await http.get(
Uri.parse('$_baseUrl/sync/playback'),
headers: headers,
);
if (watchedResponse.statusCode != 200) {
throw Exception('Failed to fetch watched movies');
}
final continueWatching = json.decode(watchedResponse.body) as List;
final Map<String, double> progress = {};
final result = await stremioService!.getBulkItem(
continueWatching.map((movie) {
if (movie['type'] == 'episode') {
progress[movie['show']['ids']['imdb']] = movie['progress'];
return Meta(
type: "series",
id: movie['show']['ids']['imdb'],
progress: movie['progress'],
nextSeason: movie['episode']['season'],
nextEpisode: movie['episode']['number'],
nextEpisodeTitle: movie['episode']['title'],
);
}
final imdb = movie['movie']['ids']['imdb'];
progress[imdb] = movie['progress'];
return Meta(
type: "movie",
id: imdb,
progress: movie['progress'],
);
}).toList(),
);
return result.map((res) {
Meta returnValue = res as Meta;
if (progress.containsKey(res.id)) {
returnValue = res.copyWith(
progress: progress[res.id],
);
}
if (res.type == "series") {
return returnValue.copyWith();
}
return returnValue;
}).toList();
} catch (e, stack) {
print('Error fetching up next movies: $e');
print(stack);
return [];
}
}
Future<List<LibraryItem>> getUpcomingSchedule() async {
await initStremioService();
if (!isEnabled()) {
return [];
}
try {
final scheduleResponse = await http.get(
Uri.parse(
'$_baseUrl/calendars/my/shows/${DateFormat('yyyy-MM-dd').format(DateTime.now())}/7',
),
headers: headers,
);
if (scheduleResponse.statusCode != 200) {
print(scheduleResponse.body);
print(scheduleResponse.statusCode);
print('Failed to fetch upcoming schedule');
throw Error();
}
final scheduleShows = json.decode(scheduleResponse.body) as List;
final result = await stremioService!.getBulkItem(
scheduleShows.map((show) {
final imdb = show['show']['ids']['imdb'];
return Meta(
type: "series",
id: imdb,
);
}).toList(),
);
return result;
} catch (e, stack) {
print('Error fetching upcoming schedule: $e');
print(stack);
return [];
}
}
Future<List<LibraryItem>> getWatchlist() async {
await initStremioService();
if (!isEnabled()) {
return [];
}
try {
final watchlistResponse = await http.get(
Uri.parse('$_baseUrl/sync/watchlist'),
headers: headers,
);
if (watchlistResponse.statusCode != 200) {
throw Exception('Failed to fetch watchlist');
}
final watchlistItems = json.decode(watchlistResponse.body) as List;
final result = await stremioService!.getBulkItem(
watchlistItems.map((item) {
final type = item['type'];
final imdb = item[type]['ids']['imdb'];
return Meta(
type: type,
id: imdb,
);
}).toList(),
);
return result;
} catch (e, stack) {
print('Error fetching watchlist: $e');
print(stack);
return [];
}
}
Future<List<LibraryItem>> getShowRecommendations() async {
await initStremioService();
if (!isEnabled()) {
return [];
}
try {
final recommendationsResponse = await http.get(
Uri.parse('$_baseUrl/recommendations/shows'),
headers: headers,
);
if (recommendationsResponse.statusCode != 200) {
throw Exception('Failed to fetch show recommendations');
}
final recommendedShows =
json.decode(recommendationsResponse.body) as List;
final result = await stremioService!.getBulkItem(
recommendedShows.map((show) {
final imdb = show['ids']['imdb'];
return Meta(
type: "series",
id: imdb,
);
}).toList(),
);
return result;
} catch (e, stack) {
print('Error fetching show recommendations: $e');
print(stack);
return [];
}
}
Future<List<LibraryItem>> getMovieRecommendations() async {
await initStremioService();
if (!isEnabled()) {
return [];
}
try {
final recommendationsResponse = await http.get(
Uri.parse('$_baseUrl/recommendations/movies'),
headers: headers,
);
if (recommendationsResponse.statusCode != 200) {
throw Exception('Failed to fetch movie recommendations');
}
final recommendedMovies =
json.decode(recommendationsResponse.body) as List;
final result = await stremioService!.getBulkItem(
recommendedMovies.map((movie) {
final imdb = movie['ids']['imdb'];
return Meta(
type: "movie",
id: imdb,
);
}).toList(),
);
return result;
} catch (e, stack) {
print('Error fetching movie recommendations: $e');
print(stack);
return [];
}
}
List<String> getHomePageContent() {
final List<String> config = ((AppEngine.engine.pb.authStore.record
?.get("config")?["selected_categories"] ??
[]) as List<dynamic>)
.whereType<String>()
.toList();
if (!isEnabled()) {
return [];
}
return config;
}
bool isEnabled() {
return AppEngine.engine.pb.authStore.record!
.getStringValue("trakt_token") !=
"";
}
Future<int?> getTraktIdForMovie(String imdb) async {
final id = await http.get(
Uri.parse("$_baseUrl/search/imdb/$imdb"),
headers: headers,
);
if (id.statusCode != 200) {
throw ArgumentError("failed to get trakt id");
}
final body = jsonDecode(id.body) as List<dynamic>;
if (body.isEmpty) {
return null;
}
final firstItem = body.first;
if (firstItem["type"] == "show") {
return body[0]['show']['ids']['trakt'];
}
if (firstItem["type"] == "movie") {
return body[0]['movie']['ids']['trakt'];
}
return null;
}
Future<void> startScrobbling({
required Meta meta,
required double progress,
}) async {
if (!isEnabled()) {
return;
}
try {
final response = await http.post(
Uri.parse('$_baseUrl/scrobble/start'),
headers: headers,
body: json.encode({
'progress': progress,
..._buildObjectForMeta(meta),
}),
);
if (response.statusCode != 201) {
print(response.statusCode);
print(response.body);
throw Exception('Failed to start scrobbling');
}
} catch (e, stack) {
print('Error starting scrobbling: $e');
print(stack);
rethrow;
}
}
Future<void> pauseScrobbling({
required Meta meta,
required double progress,
}) async {
if (!isEnabled()) {
return;
}
try {
final response = await http.post(
Uri.parse('$_baseUrl/scrobble/pause'),
headers: headers,
body: json.encode({
'progress': progress,
..._buildObjectForMeta(meta),
}),
);
if (response.statusCode != 201) {
print(response.statusCode);
throw Exception('Failed to pause scrobbling');
}
} catch (e, stack) {
print('Error pausing scrobbling: $e');
print(stack);
rethrow;
}
}
Map<String, dynamic> _buildObjectForMeta(Meta meta) {
if (meta.type == "movie") {
return {
'movie': {
'title': meta.name,
'year': meta.year,
'ids': {
'imdb': meta.imdbId ?? meta.id,
},
},
};
} else {
if (meta.nextEpisode == null && meta.nextSeason == null) {
throw ArgumentError("");
}
return {
"show": {
"title": meta.name,
"year": meta.year,
"ids": {
"imdb": meta.imdbId ?? meta.id,
}
},
"episode": {
"season": meta.nextSeason,
"number": meta.nextEpisode,
},
};
}
}
Future<void> stopScrobbling({
required Meta meta,
required double progress,
}) async {
if (!isEnabled()) {
return;
}
try {
final response = await http.post(
Uri.parse('$_baseUrl/scrobble/stop'),
headers: headers,
body: json.encode({
'progress': progress,
..._buildObjectForMeta(meta),
}),
);
if (response.statusCode != 201) {
print(response.statusCode);
throw Exception('Failed to stop scrobbling');
}
} catch (e, stack) {
print('Error stopping scrobbling: $e');
print(stack);
rethrow;
}
}
Future<List<TraktProgress>> getProgress(Meta meta) async {
if (!isEnabled()) {
return [];
}
if (meta.type == "series") {
final response = await http.get(
Uri.parse("$_baseUrl/sync/playback/episodes"),
headers: headers,
);
if (response.statusCode != 200) {
return [];
}
final body = jsonDecode(response.body) as List;
final List<TraktProgress> result = [];
for (final item in body) {
if (item["type"] != "episode") {
continue;
}
final isShow = item["show"]["ids"]["imdb"] == (meta.imdbId ?? meta.id);
final currentEpisode = item["episode"]["number"];
final currentSeason = item["episode"]["season"];
if (isShow && meta.nextEpisode != null && meta.nextSeason != null) {
if (meta.nextSeason == currentSeason &&
meta.nextEpisode == currentEpisode) {
result.add(
TraktProgress(
id: meta.id,
progress: item["progress"]!,
episode: currentEpisode,
season: currentSeason,
),
);
}
} else if (isShow) {
result.add(
TraktProgress(
id: meta.id,
progress: item["progress"]!,
episode: currentEpisode,
season: currentSeason,
),
);
}
}
return result;
} else {
final response = await http.get(
Uri.parse("$_baseUrl/sync/playback/movies"),
headers: headers,
);
if (response.statusCode != 200) {
return [];
}
final body = jsonDecode(response.body) as List;
for (final item in body) {
if (item["type"] != "movie") {
continue;
}
if (item["movie"]["ids"]["imdb"] == (meta.imdbId ?? meta.id)) {
return [
TraktProgress(
id: item["movie"]["ids"]["imdb"],
progress: item["progress"],
),
];
}
}
}
return [];
}
}
class TraktProgress {
final String id;
final int? episode;
final int? season;
final double progress;
TraktProgress({
required this.id,
this.episode,
this.season,
required this.progress,
});
}
extension StaticInstance on CachedStorage {}

View file

@ -12,6 +12,7 @@ import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/doc_viewer/container/doc_viewer.dart';
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
import 'package:madari_client/routes.dart';
import 'package:madari_client/utils/cached_storage_static.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path/path.dart' as path;
import 'package:window_manager/window_manager.dart';
@ -28,9 +29,11 @@ void main() async {
print("Unable");
}
StaticCachedStorage.storage = await CachedStorage.ensureInitialized();
try {
CachedQuery.instance.configFlutter(
storage: await CachedStorage.ensureInitialized(),
storage: StaticCachedStorage.storage,
config: QueryConfigFlutter(
refetchDuration: const Duration(minutes: 60),
cacheDuration: const Duration(minutes: 60),
@ -138,7 +141,7 @@ class _MadariAppState extends State<MadariApp> {
debugShowCheckedModeBanner: false, // comes in the way of the search
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
seedColor: Colors.red,
),
useMaterial3: true,
),

View file

@ -4,9 +4,12 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:madari_client/engine/library.dart';
import 'package:madari_client/features/connections/service/base_connection_service.dart';
import 'package:madari_client/features/trakt/containers/up_next.container.dart';
import 'package:madari_client/features/trakt/service/trakt.service.dart';
import '../features/connections/widget/base/render_library_list.dart';
import '../features/getting_started/container/getting_started.dart';
import '../utils/auth_refresh.dart';
class HomeTabPage extends StatefulWidget {
final String? search;
@ -28,14 +31,16 @@ class HomeTabPage extends StatefulWidget {
class _HomeTabPageState extends State<HomeTabPage> {
late final query = Query(
queryFn: () {
queryFn: () async {
await TraktService.ensureInitialized();
if (widget.defaultLibraries != null) {
return Future.value(
widget.defaultLibraries,
);
}
return BaseConnectionService.getLibraries();
return await BaseConnectionService.getLibraries();
},
key: [
"home${widget.defaultLibraries?.data.length ?? 0}${widget.search ?? ""}",
@ -49,6 +54,20 @@ class _HomeTabPageState extends State<HomeTabPage> {
});
super.initState();
traktLibraries = getTraktLibraries();
}
List<String> traktLibraries = [];
final traktService = TraktService();
List<String> getTraktLibraries() {
if (widget.defaultLibraries?.data.isNotEmpty == true) {
return [];
}
return traktService.getHomePageContent();
}
@override
@ -67,7 +86,11 @@ class _HomeTabPageState extends State<HomeTabPage> {
),
body: RefreshIndicator(
onRefresh: () async {
await refreshAuth();
await query.refetch();
setState(() {
traktLibraries = getTraktLibraries();
});
return;
},
child: QueryBuilder(
@ -93,8 +116,13 @@ class _HomeTabPageState extends State<HomeTabPage> {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: GettingStartedScreen(
onCallback: () {
onCallback: () async {
await refreshAuth();
query.refetch();
setState(() {
traktLibraries = getTraktLibraries();
});
},
),
),
@ -108,7 +136,15 @@ class _HomeTabPageState extends State<HomeTabPage> {
),
child: ListView.builder(
itemBuilder: (item, index) {
final item = data.data[index];
if (traktLibraries.length > index) {
final category = traktLibraries[index];
return TraktContainer(
loadId: category,
);
}
final item = data.data[index - traktLibraries.length];
return Container(
margin: const EdgeInsets.only(bottom: 6),
@ -165,7 +201,7 @@ class _HomeTabPageState extends State<HomeTabPage> {
),
);
},
itemCount: data.data.length,
itemCount: data.data.length + traktLibraries.length,
),
);
},

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
class TVHomePage extends StatefulWidget {
static String get routeName => "/tv";
final StatefulNavigationShell navigationShell;
const TVHomePage({
super.key,
required this.navigationShell,
});
@override
State<TVHomePage> createState() => _TVHomePageState();
}
class _TVHomePageState extends State<TVHomePage> {
int _selectedIndex = 0;
final FocusNode _contentFocusNode = FocusNode();
final FocusNode _navigationFocusNode = FocusNode();
// Handle keyboard navigation
void _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {}
}
@override
void dispose() {
_contentFocusNode.dispose();
_navigationFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Focus(
focusNode: _navigationFocusNode,
onKeyEvent: (node, event) {
_handleKeyEvent(event);
return KeyEventResult.handled;
},
child: Row(
children: [
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
widget.navigationShell
.goBranch(index); // Navigate to the selected branch
_contentFocusNode.unfocus(); // Unfocus the content area
});
},
labelType: NavigationRailLabelType.selected,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Home'),
),
NavigationRailDestination(
icon: Icon(Icons.search),
label: Text('Search'),
),
NavigationRailDestination(
icon: Icon(Icons.download),
label: Text('Downloads'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Settings'),
),
],
),
Expanded(
child: Focus(
focusNode: _contentFocusNode,
onKeyEvent: (node, event) {
_handleKeyEvent(event);
return KeyEventResult.handled;
},
child: widget.navigationShell,
),
),
],
),
),
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/features/settings/screen/trakt_integration_screen.dart';
import 'package:madari_client/features/watch_history/service/zeee_watch_history.dart';
import 'package:madari_client/pages/sign_in.page.dart';
@ -62,6 +63,17 @@ class MoreContainer extends StatelessWidget {
),
),
),
_buildListItem(
context,
icon: Icons.connect_without_contact,
title: "Trakt",
subtitle: "Configure your Trakt account with Madari",
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TraktIntegration(),
),
),
),
_buildListItem(
context,
icon: Icons.logout,

View file

@ -153,9 +153,12 @@ class _SignInPageState extends State<SignInPage> with TickerProviderStateMixin {
// Username field
_buildTextField(
controller: _usernameController,
hintText: 'Username',
hintText: 'Email',
prefixIcon: Icons.person_outline,
autofocus: true,
autoFillHints: [
AutofillHints.email,
],
),
const SizedBox(height: 16),
@ -165,6 +168,9 @@ class _SignInPageState extends State<SignInPage> with TickerProviderStateMixin {
hintText: 'Password',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
autoFillHints: [
AutofillHints.password,
],
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
@ -227,6 +233,7 @@ class _SignInPageState extends State<SignInPage> with TickerProviderStateMixin {
bool obscureText = false,
Widget? suffixIcon,
bool autofocus = false,
List<String>? autoFillHints = const [],
}) {
return Container(
decoration: BoxDecoration(
@ -245,6 +252,9 @@ class _SignInPageState extends State<SignInPage> with TickerProviderStateMixin {
color: Colors.white,
fontSize: 15,
),
autofillHints: autoFillHints,
textInputAction: TextInputAction.next,
onEditingComplete: () => FocusScope.of(context).nextFocus(),
decoration: InputDecoration(
hintText: hintText,
hintStyle: GoogleFonts.exo2(

View file

@ -162,6 +162,9 @@ class _SignUpPageState extends State<SignUpPage> with TickerProviderStateMixin {
autofocus: true,
controller: _usernameController,
hintText: "Name",
autoFillHints: [
AutofillHints.name,
],
prefixIcon: Icons.drive_file_rename_outline,
),
const SizedBox(height: 16),
@ -172,6 +175,9 @@ class _SignUpPageState extends State<SignUpPage> with TickerProviderStateMixin {
controller: _emailController,
hintText: "Email",
prefixIcon: Icons.email_outlined,
autoFillHints: [
AutofillHints.email,
],
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your email';
@ -186,12 +192,14 @@ class _SignUpPageState extends State<SignUpPage> with TickerProviderStateMixin {
const SizedBox(height: 16),
_buildTextField(
autofocus: true,
obscureText: true,
controller: _passwordController,
hintText: "Password",
prefixIcon: Icons.password,
),
autofocus: true,
obscureText: true,
controller: _passwordController,
hintText: "Password",
prefixIcon: Icons.password,
autoFillHints: [
AutofillHints.password,
]),
const SizedBox(height: 16),
@ -200,6 +208,9 @@ class _SignUpPageState extends State<SignUpPage> with TickerProviderStateMixin {
obscureText: true,
controller: _confirmPasswordController,
hintText: "Confirm Password",
autoFillHints: [
AutofillHints.password,
],
prefixIcon: Icons.password,
validator: (value) {
if (value == null || value.trim().isEmpty) {
@ -269,6 +280,7 @@ class _SignUpPageState extends State<SignUpPage> with TickerProviderStateMixin {
Widget? suffixIcon,
bool autofocus = false,
final FormFieldValidator? validator,
List<String> autoFillHints = const [],
}) {
return Container(
decoration: BoxDecoration(
@ -283,10 +295,13 @@ class _SignUpPageState extends State<SignUpPage> with TickerProviderStateMixin {
controller: controller,
obscureText: obscureText,
autofocus: autofocus,
textInputAction: TextInputAction.next,
onEditingComplete: () => FocusScope.of(context).nextFocus(),
style: GoogleFonts.exo2(
color: Colors.white,
fontSize: 15,
),
autofillHints: autoFillHints,
decoration: InputDecoration(
hintText: hintText,
hintStyle: GoogleFonts.exo2(

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../features/connections/service/base_connection_service.dart';
import '../features/connections/types/stremio/stremio_base.types.dart';
import '../features/connections/widget/base/render_stream_list.dart';
import '../features/connections/widget/stremio/stremio_item_viewer.dart';
class StremioItemPage extends StatefulWidget {
@ -11,16 +12,16 @@ class StremioItemPage extends StatefulWidget {
final Meta? meta;
final String? hero;
final String connection;
final String library;
final BaseConnectionService? service;
const StremioItemPage({
super.key,
required this.type,
required this.id,
required this.connection,
required this.library,
this.hero,
this.meta,
this.service,
});
@override
@ -30,6 +31,7 @@ class StremioItemPage extends StatefulWidget {
class _StremioItemPageState extends State<StremioItemPage> {
late Query<ConnectionRaw> query = Query(
key: "item${widget.type}${widget.id}",
onSuccess: (data) {},
queryFn: () async {
try {
final result =
@ -60,6 +62,64 @@ class _StremioItemPageState extends State<StremioItemPage> {
},
);
@override
void initState() {
super.initState();
if (widget.meta?.progress != null || widget.meta?.nextEpisode != null) {
Future.delayed(
const Duration(milliseconds: 500),
() {
if (widget.meta != null && widget.service != null) {
if (mounted) {
final season = widget.meta?.nextSeason == null
? ""
: "S${widget.meta?.nextSeason}";
final episode = widget.meta?.nextEpisode == null
? ""
: "E${widget.meta?.nextEpisode}";
showModalBottomSheet(
context: context,
builder: (context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
Navigator.of(context).pop();
},
icon: const Icon(Icons.close),
),
title: Text(
"Streams $season $episode".trim(),
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 14.0),
child: RenderStreamList(
progress: widget.meta!.progress != null
? widget.meta!.progress! * 100
: null,
service: widget.service!,
id: widget.meta as LibraryItem,
season: widget.meta?.nextSeason?.toString(),
episode: widget.meta?.nextEpisode?.toString(),
shouldPop: false,
),
),
),
);
},
);
}
}
},
);
}
}
@override
Widget build(BuildContext context) {
return QueryBuilder(
@ -84,11 +144,25 @@ class _StremioItemPageState extends State<StremioItemPage> {
meta = state.data?.item as Meta;
}
// if (DeviceDetector.isTV()) {
// return StremioItemViewerTV(
// hero: widget.hero,
// meta: meta ?? widget.meta,
// original: meta,
// library: widget.library,
// service: state.data == null
// ? null
// : BaseConnectionService.connectionById(
// state.data!.connectionResponse,
// ),
// );
// }
return StremioItemViewer(
hero: widget.hero,
meta: meta ?? widget.meta,
original: meta,
library: widget.library,
progress: widget.meta?.progress != null ? widget.meta!.progress : 0,
service: state.data == null
? null
: BaseConnectionService.connectionById(

View file

@ -5,7 +5,6 @@ import 'package:madari_client/engine/engine.dart';
import 'package:madari_client/pages/library_view.page.dart';
import 'package:madari_client/pages/stremio_item.page.dart';
import 'features/connections/types/stremio/stremio_base.types.dart';
import 'pages/download.page.dart';
import 'pages/home.page.dart';
import 'pages/home_tab.page.dart';
@ -86,18 +85,19 @@ GoRouter createRouter() {
],
),
GoRoute(
path: "/info/stremio/:connection/:library/:type/:id",
path: "/info/stremio/:connection/:type/:id",
builder: (context, state) {
final params = state.pathParameters;
final meta = state.extra as Meta?;
final meta = state.extra as Map<String, dynamic>?;
return StremioItemPage(
hero: state.uri.queryParameters["hero"],
type: params["type"]!,
id: params["id"]!,
connection: params["connection"]!,
meta: meta,
library: params["library"]!,
meta: meta?.containsKey("meta") == true ? meta!['meta'] : null,
service:
meta?.containsKey("service") == true ? meta!['service'] : null,
);
},
),

View file

@ -0,0 +1,11 @@
import '../engine/engine.dart';
Future<void> refreshAuth() async {
final pb = AppEngine.engine.pb;
final userCollection = pb.collection("users");
final user = await userCollection.getOne(
AppEngine.engine.pb.authStore.record!.id,
);
pb.authStore.save(pb.authStore.token, user);
}

View file

@ -0,0 +1,5 @@
import 'package:cached_storage/cached_storage.dart';
class StaticCachedStorage {
static CachedStorage? storage;
}

View file

@ -0,0 +1,5 @@
class DeviceDetector {
static bool isTV() {
return const String.fromEnvironment('is_tv') == 'true';
}
}

View file

@ -5,6 +5,8 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- flutter_inappwebview_macos (0.0.1):
@ -63,6 +65,7 @@ PODS:
DEPENDENCIES:
- bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`)
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
@ -90,6 +93,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_inappwebview_macos:
@ -126,6 +131,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
connectivity_plus: 18382e7311ba19efcaee94442b23b32507b20695
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24

View file

@ -0,0 +1,11 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:madari_client/features/trakt/service/trakt.service.dart';
void main() {
testWidgets('Test trakt integration', (WidgetTester tester) async {
final service = TraktService();
await DotEnv().load(isOptional: true);
});
}