mirror of
https://github.com/madari-media/madari-oss.git
synced 2026-04-20 18:22:04 +00:00
Project import generated by Copybara.
GitOrigin-RevId: 41a06075bfe3d1efe0dce3bfb24a4a77b557be64
This commit is contained in:
parent
49d8ac59d1
commit
7a4759a940
31 changed files with 4355 additions and 355 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
1525
lib/features/doc_viewer/container/video_viewer/tv_controls.dart
Normal file
1525
lib/features/doc_viewer/container/video_viewer/tv_controls.dart
Normal file
File diff suppressed because it is too large
Load diff
329
lib/features/settings/screen/trakt_integration_screen.dart
Normal file
329
lib/features/settings/screen/trakt_integration_screen.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
157
lib/features/trakt/containers/up_next.container.dart
Normal file
157
lib/features/trakt/containers/up_next.container.dart
Normal 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!,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
653
lib/features/trakt/service/trakt.service.dart
Normal file
653
lib/features/trakt/service/trakt.service.dart
Normal 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 {}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
91
lib/pages/home_tv.page.dart
Normal file
91
lib/pages/home_tv.page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
11
lib/utils/auth_refresh.dart
Normal file
11
lib/utils/auth_refresh.dart
Normal 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);
|
||||
}
|
||||
5
lib/utils/cached_storage_static.dart
Normal file
5
lib/utils/cached_storage_static.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import 'package:cached_storage/cached_storage.dart';
|
||||
|
||||
class StaticCachedStorage {
|
||||
static CachedStorage? storage;
|
||||
}
|
||||
5
lib/utils/tv_detector.dart
Normal file
5
lib/utils/tv_detector.dart
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class DeviceDetector {
|
||||
static bool isTV() {
|
||||
return const String.fromEnvironment('is_tv') == 'true';
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
11
test/trakt_service_test.dart
Normal file
11
test/trakt_service_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue