diff --git a/README.md b/README.md index a4f0362..474de3b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/features/connection/containers/configure_neo_connection.dart b/lib/features/connection/containers/configure_neo_connection.dart index ad6a233..bfc01a3 100644 --- a/lib/features/connection/containers/configure_neo_connection.dart +++ b/lib/features/connection/containers/configure_neo_connection.dart @@ -97,12 +97,12 @@ class _ConfigureNeoConnectionState extends State { 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; }, diff --git a/lib/features/connections/service/base_connection_service.dart b/lib/features/connections/service/base_connection_service.dart index 823eb8d..89cd11e 100644 --- a/lib/features/connections/service/base_connection_service.dart +++ b/lib/features/connections/service/base_connection_service.dart @@ -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 _item = {}; final String connectionId; @@ -46,14 +48,21 @@ abstract class BaseConnectionService { static Future 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("expand.type"), + model_.get("expand.type"), ), ); } @@ -84,6 +93,10 @@ abstract class BaseConnectionService { String? cursor, }); + Future> getBulkItem( + List ids, + ); + Future>> getFilters( LibraryRecord library, ); @@ -91,7 +104,6 @@ abstract class BaseConnectionService { Future getItemById(LibraryItem id); Future 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 toJson(); } diff --git a/lib/features/connections/service/stremio_connection_service.dart b/lib/features/connections/service/stremio_connection_service.dart index f600d0f..e9b439f 100644 --- a/lib/features/connections/service/stremio_connection_service.dart +++ b/lib/features/connections/service/stremio_connection_service.dart @@ -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 getItemById(LibraryItem id) async { - for (final addon in config.addons) { - final manifest = await _getManifest(addon); - - if (manifest.resources == null) { - continue; - } - - List idPrefixes = []; - - 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; - } - - final ids = ((manifest.idPrefixes ?? []) + idPrefixes) - .firstWhere((item) => id.id.startsWith(item), orElse: () => ""); - - if (ids.isEmpty) { - continue; - } - - 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 null; + return Query( + 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; + } + + List idPrefixes = []; + + 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; + } + + final ids = ((manifest.idPrefixes ?? []) + idPrefixes) + .firstWhere((item) => id.id.startsWith(item), + orElse: () => ""); + + if (ids.isEmpty) { + continue; + } + + final result = await http.get( + Uri.parse( + "${_getAddonBaseURL(addon)}/meta/${(id as Meta).type}/${id.id}.json", + ), + ); + + return StreamMetaResponse.fromJson(jsonDecode(result.body)) + .meta; + } + + return null; + }) + .stream + .where((item) { + return item.status != QueryStatus.loading; + }) + .first + .then((docs) { + if (docs.error != null) { + throw docs.error; + } + return docs.data; + }); } List 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> getBulkItem(List 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() + .toList(); + } + + @override + Widget renderList(LibraryItem item, String heroPrefix) { return StremioListItem(item: item); } Future _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; - } - - final body = jsonDecode(result); - final resultFinal = StremioManifest.fromJson(body); - return resultFinal; + 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; + }) + .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 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 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}", - ), - ); - } + final result = await Query( + key: url, + queryFn: () async { + 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}", + ), + ); + } + } + + 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() .toList(), diff --git a/lib/features/connections/types/stremio/stremio_base.types.dart b/lib/features/connections/types/stremio/stremio_base.types.dart index c4ae6f2..d6000bf 100644 --- a/lib/features/connections/types/stremio/stremio_base.types.dart +++ b/lib/features/connections/types/stremio/stremio_base.types.dart @@ -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, 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 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; } diff --git a/lib/features/connections/widget/base/render_library_list.dart b/lib/features/connections/widget/base/render_library_list.dart index f7c672b..ebe8299 100644 --- a/lib/features/connections/widget/base/render_library_list.dart +++ b/lib/features/connections/widget/base/render_library_list.dart @@ -55,7 +55,7 @@ class _RenderLibraryListState extends State { ); 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() + .toList(); if (data.status == QueryStatus.loading && items.isEmpty) { return const CustomScrollView( @@ -300,112 +299,162 @@ 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), + return RenderListItems( + hasError: data.status == QueryStatus.error, + onRefresh: () { + query.refetch(); + }, + isGrid: widget.isGrid, + items: items, + heroPrefix: widget.item.id, + service: service, + ); + }, + ), + ); + } +} + +class RenderListItems extends StatelessWidget { + final ScrollController? controller; + final ScrollController? itemScrollController; + final bool isGrid; + final bool hasError; + final VoidCallback? onRefresh; + final BaseConnectionService service; + final List 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 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, ), - 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, - ), - ) - ], - ), + const SizedBox( + height: 10, ), - ), - ), - ), - if (widget.isGrid) - SliverGrid.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: getGridResponsiveColumnCount(context), - mainAxisSpacing: getGridResponsiveSpacing(context), - crossAxisSpacing: getGridResponsiveSpacing(context), - childAspectRatio: 2 / 3, + TextButton.icon( + label: const Text("Retry"), + onPressed: onRefresh, + icon: const Icon( + Icons.refresh, + ), + ) + ], ), - 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, + ), + ), + ), + 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}", + ), ), - ), - ), - SliverPadding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom, - ), + ); + }, + 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); - final itemHeight = _getListHeight(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; } diff --git a/lib/features/connections/widget/base/render_stream_list.dart b/lib/features/connections/widget/base/render_stream_list.dart index dc7f251..d8a8f6c 100644 --- a/lib/features/connections/widget/base/render_stream_list.dart +++ b/lib/features/connections/widget/base/render_stream_list.dart @@ -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 { final Map _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 { 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, ), ), ); diff --git a/lib/features/connections/widget/stremio/stremio_card.dart b/lib/features/connections/widget/stremio/stremio_card.dart index 79cc355..56b66c4 100644 --- a/lib/features/connections/widget/stremio/stremio_card.dart +++ b/lib/features/connections/widget/stremio/stremio_card.dart @@ -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,67 +35,266 @@ 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( + 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( - image: DecorationImage( - image: CachedNetworkImageProvider( - "https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(meta.poster!)}@webp", - imageRenderMethodForWeb: - ImageRenderMethodForWeb.HttpGet, + 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, + ), ), - fit: BoxFit.cover, + ], + ), + ), + ), + ) + : 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 != 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, - ), + ), + 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(), + ), + ) + : 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), + ), + ], + ), + ), + ), + ) + ], + ), ), ); } diff --git a/lib/features/connections/widget/stremio/stremio_item_viewer.dart b/lib/features/connections/widget/stremio/stremio_item_viewer.dart index dc32f35..4e78aea 100644 --- a/lib/features/connections/widget/stremio/stremio_item_viewer.dart +++ b/lib/features/connections/widget/stremio/stremio_item_viewer.dart @@ -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 { ) : RenderStreamList( service: widget.service!, - library: widget.library, id: widget.meta as LibraryItem, shouldPop: false, ), @@ -116,7 +115,7 @@ class _StremioItemViewerState extends State { 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 { _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 { widget.original?.type == "series" && widget.original?.videos?.isNotEmpty == true) StremioItemSeasonSelector( - meta: item!, - library: widget.library, + meta: (item as Meta), service: widget.service, ), SliverPadding( diff --git a/lib/features/connections/widget/stremio/stremio_item_viewer_card.dart b/lib/features/connections/widget/stremio/stremio_item_viewer_card.dart new file mode 100644 index 0000000..4ad1c17 --- /dev/null +++ b/lib/features/connections/widget/stremio/stremio_item_viewer_card.dart @@ -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 createState() => _StremioItemViewerTVState(); +} + +class _StremioItemViewerTVState extends State { + 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 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 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), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/connections/widget/stremio/stremio_season_selector.dart b/lib/features/connections/widget/stremio/stremio_season_selector.dart index ab15fd5..ef17e4f 100644 --- a/lib/features/connections/widget/stremio/stremio_season_selector.dart +++ b/lib/features/connections/widget/stremio/stremio_season_selector.dart @@ -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 final zeeeWatchHistory = ZeeeWatchHistoryStatic.service; final Map _progress = {}; + final Map> _traktProgress = {}; @override void initState() { @@ -67,6 +67,32 @@ class _StremioItemSeasonSelectorState extends State } 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(>{ + 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 ), body: RenderStreamList( service: widget.service!, - library: widget.library, id: meta, season: currentSeason.toString(), shouldPop: widget.shouldPop, @@ -234,6 +259,12 @@ class _StremioItemSeasonSelectorState extends State 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 ), 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, + ), + ), + ), + ), + ), + ), + ), ], ), ), diff --git a/lib/features/doc_viewer/container/doc_viewer.dart b/lib/features/doc_viewer/container/doc_viewer.dart index 078defc..fa1d9e2 100644 --- a/lib/features/doc_viewer/container/doc_viewer.dart +++ b/lib/features/doc_viewer/container/doc_viewer.dart @@ -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 diff --git a/lib/features/doc_viewer/container/video_viewer.dart b/lib/features/doc_viewer/container/video_viewer.dart index c8ab3c6..650e582 100644 --- a/lib/features/doc_viewer/container/video_viewer.dart +++ b/lib/features/doc_viewer/container/video_viewer.dart @@ -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 { 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 key = GlobalKey(); + double get currentProgressInPercentage { + final duration = player.state.duration.inSeconds; + final position = player.state.position.inSeconds; + return duration > 0 ? (position / duration * 100) : 0; + } + + Future>? traktProgress; + saveWatchHistory() { final duration = player.state.duration.inSeconds; final position = player.state.position.inSeconds; @@ -134,6 +145,71 @@ class _VideoViewerState extends State { }); } + 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 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 { } } + _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 { 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 { (_source as FileSource).filePath, start: duration, ), - play: true, + play: false, ); case const (URLSource): case const (MediaURLSource): @@ -237,7 +325,7 @@ class _VideoViewerState extends State { httpHeaders: (_source as URLSource).headers, start: duration, ), - play: true, + play: false, ); } } @@ -246,6 +334,7 @@ class _VideoViewerState extends State { late StreamSubscription _streamComplete; late StreamSubscription _streamListen; + late StreamSubscription _duration; onLibrarySelect() async { controller.player.pause(); @@ -261,7 +350,6 @@ class _VideoViewerState extends State { 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 { @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 { _subTracks?.cancel(); _streamComplete.cancel(); _streamListen.cancel(); + _duration.cancel(); player.dispose(); super.dispose(); } @@ -306,66 +405,88 @@ class _VideoViewerState extends State { ); } + _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); } } diff --git a/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart b/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart index 11c97c2..6dc8a70 100644 --- a/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart +++ b/lib/features/doc_viewer/container/video_viewer/desktop_video_player.dart @@ -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(), diff --git a/lib/features/doc_viewer/container/video_viewer/tv_controls.dart b/lib/features/doc_viewer/container/video_viewer/tv_controls.dart new file mode 100644 index 0000000..5161ba8 --- /dev/null +++ b/lib/features/doc_viewer/container/video_viewer/tv_controls.dart @@ -0,0 +1,1525 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +// ignore_for_file: non_constant_identifier_names +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; + +/// {@template material_desktop_video_controls} +/// +/// [Video] controls which use Material design. +/// +/// {@endtemplate} +Widget MaterialTvVideoControls(VideoState state) { + return const VideoControlsThemeDataInjector( + child: _MaterialTvVideoControls(), + ); +} + +/// [MaterialTvVideoControlsThemeData] available in this [context]. +MaterialTvVideoControlsThemeData _theme(BuildContext context) => + FullscreenInheritedWidget.maybeOf(context) == null + ? MaterialTvVideoControlsTheme.maybeOf(context)?.normal ?? + kDefaultMaterialDesktopVideoControlsThemeData + : MaterialTvVideoControlsTheme.maybeOf(context)?.fullscreen ?? + kDefaultMaterialDesktopVideoControlsThemeDataFullscreen; + +/// Default [MaterialTvVideoControlsThemeData]. +const kDefaultMaterialDesktopVideoControlsThemeData = +MaterialTvVideoControlsThemeData(); + +/// Default [MaterialTvVideoControlsThemeData] for fullscreen. +const kDefaultMaterialDesktopVideoControlsThemeDataFullscreen = +MaterialTvVideoControlsThemeData(); + +/// {@template material_desktop_video_controls_theme_data} +/// +/// Theming related data for [MaterialTvVideoControls]. These values are used to theme the descendant [MaterialTvVideoControls]. +/// +/// {@endtemplate} +class MaterialTvVideoControlsThemeData { + // BEHAVIOR + + /// Whether to display seek bar. + final bool displaySeekBar; + + /// Whether a skip next button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipNextButton; + + /// Whether a skip previous button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipPreviousButton; + + /// Modify volume on mouse scroll. + final bool modifyVolumeOnScroll; + + /// Whether to toggle fullscreen on double press. + final bool toggleFullscreenOnDoublePress; + + /// Whether to hide mouse on controls removal.(will need to move the mouse to be hidden check issue: https://github.com/flutter/flutter/issues/76622) works on macos without moving the mouse + final bool hideMouseOnControlsRemoval; + + /// Whether to toggle play and pause on tap. + final bool playAndPauseOnTap; + + /// Keyboards shortcuts. + final Map? keyboardShortcuts; + + /// Whether the controls are initially visible. + final bool visibleOnMount; + + // GENERIC + + /// Padding around the controls. + /// + /// * Default: `EdgeInsets.zero` + /// * Fullscreen: `MediaQuery.of(context).padding` + final EdgeInsets? padding; + + /// [Duration] after which the controls will be hidden when there is no mouse movement. + final Duration controlsHoverDuration; + + /// [Duration] for which the controls will be animated when shown or hidden. + final Duration controlsTransitionDuration; + + /// Builder for the buffering indicator. + final Widget Function(BuildContext)? bufferingIndicatorBuilder; + + // BUTTON BAR + + /// Buttons to be displayed in the primary button bar. + final List primaryButtonBar; + + /// Buttons to be displayed in the top button bar. + final List topButtonBar; + + /// Margin around the top button bar. + final EdgeInsets topButtonBarMargin; + + /// Buttons to be displayed in the bottom button bar. + final List bottomButtonBar; + + /// Margin around the bottom button bar. + final EdgeInsets bottomButtonBarMargin; + + /// Height of the button bar. + final double buttonBarHeight; + + /// Size of the button bar buttons. + final double buttonBarButtonSize; + + /// Color of the button bar buttons. + final Color buttonBarButtonColor; + + // SEEK BAR + + /// [Duration] for which the seek bar will be animated when the user seeks. + final Duration seekBarTransitionDuration; + + /// [Duration] for which the seek bar thumb will be animated when the user seeks. + final Duration seekBarThumbTransitionDuration; + + /// Margin around the seek bar. + final EdgeInsets seekBarMargin; + + /// Height of the seek bar. + final double seekBarHeight; + + /// Height of the seek bar when hovered. + final double seekBarHoverHeight; + + /// Height of the seek bar [Container]. + final double seekBarContainerHeight; + + /// [Color] of the seek bar. + final Color seekBarColor; + + /// [Color] of the hovered section in the seek bar. + final Color seekBarHoverColor; + + /// [Color] of the playback position section in the seek bar. + final Color seekBarPositionColor; + + /// [Color] of the playback buffer section in the seek bar. + final Color seekBarBufferColor; + + /// Size of the seek bar thumb. + final double seekBarThumbSize; + + /// [Color] of the seek bar thumb. + final Color seekBarThumbColor; + + // VOLUME BAR + + /// [Color] of the volume bar. + final Color volumeBarColor; + + /// [Color] of the active region in the volume bar. + final Color volumeBarActiveColor; + + /// Size of the volume bar thumb. + final double volumeBarThumbSize; + + /// [Color] of the volume bar thumb. + final Color volumeBarThumbColor; + + /// [Duration] for which the volume bar will be animated when the user hovers. + final Duration volumeBarTransitionDuration; + + // SUBTITLE + + /// Whether to shift the subtitles upwards when the controls are visible. + final bool shiftSubtitlesOnControlsVisibilityChange; + + /// {@macro material_desktop_video_controls_theme_data} + const MaterialTvVideoControlsThemeData({ + this.displaySeekBar = true, + this.automaticallyImplySkipNextButton = true, + this.automaticallyImplySkipPreviousButton = true, + this.toggleFullscreenOnDoublePress = true, + this.playAndPauseOnTap = false, + this.modifyVolumeOnScroll = true, + this.keyboardShortcuts, + this.visibleOnMount = false, + this.hideMouseOnControlsRemoval = false, + this.padding, + this.controlsHoverDuration = const Duration(seconds: 3), + this.controlsTransitionDuration = const Duration(milliseconds: 150), + this.bufferingIndicatorBuilder, + this.primaryButtonBar = const [], + this.topButtonBar = const [], + this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.bottomButtonBar = const [ + MaterialTvSkipPreviousButton(), + MaterialTvPlayOrPauseButton(), + MaterialTvSkipNextButton(), + MaterialTvVolumeButton(), + MaterialTvPositionIndicator(), + Spacer(), + MaterialTvFullscreenButton(), + ], + this.bottomButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.buttonBarHeight = 56.0, + this.buttonBarButtonSize = 28.0, + this.buttonBarButtonColor = const Color(0xFFFFFFFF), + this.seekBarTransitionDuration = const Duration(milliseconds: 300), + this.seekBarThumbTransitionDuration = const Duration(milliseconds: 150), + this.seekBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.seekBarHeight = 3.2, + this.seekBarHoverHeight = 5.6, + this.seekBarContainerHeight = 36.0, + this.seekBarColor = const Color(0x3DFFFFFF), + this.seekBarHoverColor = const Color(0x3DFFFFFF), + this.seekBarPositionColor = const Color(0xFFFF0000), + this.seekBarBufferColor = const Color(0x3DFFFFFF), + this.seekBarThumbSize = 12.0, + this.seekBarThumbColor = const Color(0xFFFF0000), + this.volumeBarColor = const Color(0x3DFFFFFF), + this.volumeBarActiveColor = const Color(0xFFFFFFFF), + this.volumeBarThumbSize = 12.0, + this.volumeBarThumbColor = const Color(0xFFFFFFFF), + this.volumeBarTransitionDuration = const Duration(milliseconds: 150), + this.shiftSubtitlesOnControlsVisibilityChange = true, + }); + + /// Creates a copy of this [MaterialTvVideoControlsThemeData] with the given fields replaced by the non-null parameter values. + MaterialTvVideoControlsThemeData copyWith({ + bool? displaySeekBar, + bool? automaticallyImplySkipNextButton, + bool? automaticallyImplySkipPreviousButton, + bool? toggleFullscreenOnDoublePress, + bool? playAndPauseOnTap, + bool? modifyVolumeOnScroll, + Map? keyboardShortcuts, + bool? visibleOnMount, + bool? hideMouseOnControlsRemoval, + Duration? controlsHoverDuration, + Duration? controlsTransitionDuration, + Widget Function(BuildContext)? bufferingIndicatorBuilder, + List? topButtonBar, + EdgeInsets? topButtonBarMargin, + List? bottomButtonBar, + EdgeInsets? bottomButtonBarMargin, + double? buttonBarHeight, + double? buttonBarButtonSize, + Color? buttonBarButtonColor, + Duration? seekBarTransitionDuration, + Duration? seekBarThumbTransitionDuration, + EdgeInsets? seekBarMargin, + double? seekBarHeight, + double? seekBarHoverHeight, + double? seekBarContainerHeight, + Color? seekBarColor, + Color? seekBarHoverColor, + Color? seekBarPositionColor, + Color? seekBarBufferColor, + double? seekBarThumbSize, + Color? seekBarThumbColor, + Color? volumeBarColor, + Color? volumeBarActiveColor, + double? volumeBarThumbSize, + Color? volumeBarThumbColor, + Duration? volumeBarTransitionDuration, + bool? shiftSubtitlesOnControlsVisibilityChange, + }) { + return MaterialTvVideoControlsThemeData( + displaySeekBar: displaySeekBar ?? this.displaySeekBar, + automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? + this.automaticallyImplySkipNextButton, + automaticallyImplySkipPreviousButton: + automaticallyImplySkipPreviousButton ?? + this.automaticallyImplySkipPreviousButton, + toggleFullscreenOnDoublePress: + toggleFullscreenOnDoublePress ?? this.toggleFullscreenOnDoublePress, + playAndPauseOnTap: playAndPauseOnTap ?? this.playAndPauseOnTap, + modifyVolumeOnScroll: modifyVolumeOnScroll ?? this.modifyVolumeOnScroll, + keyboardShortcuts: keyboardShortcuts ?? this.keyboardShortcuts, + visibleOnMount: visibleOnMount ?? this.visibleOnMount, + hideMouseOnControlsRemoval: + hideMouseOnControlsRemoval ?? this.hideMouseOnControlsRemoval, + controlsHoverDuration: + controlsHoverDuration ?? this.controlsHoverDuration, + bufferingIndicatorBuilder: + bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, + controlsTransitionDuration: + controlsTransitionDuration ?? this.controlsTransitionDuration, + topButtonBar: topButtonBar ?? this.topButtonBar, + topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin, + bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar, + bottomButtonBarMargin: + bottomButtonBarMargin ?? this.bottomButtonBarMargin, + buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight, + buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize, + buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor, + seekBarTransitionDuration: + seekBarTransitionDuration ?? this.seekBarTransitionDuration, + seekBarThumbTransitionDuration: + seekBarThumbTransitionDuration ?? this.seekBarThumbTransitionDuration, + seekBarMargin: seekBarMargin ?? this.seekBarMargin, + seekBarHeight: seekBarHeight ?? this.seekBarHeight, + seekBarHoverHeight: seekBarHoverHeight ?? this.seekBarHoverHeight, + seekBarContainerHeight: + seekBarContainerHeight ?? this.seekBarContainerHeight, + seekBarColor: seekBarColor ?? this.seekBarColor, + seekBarHoverColor: seekBarHoverColor ?? this.seekBarHoverColor, + seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, + seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, + seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, + seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, + volumeBarColor: volumeBarColor ?? this.volumeBarColor, + volumeBarActiveColor: volumeBarActiveColor ?? this.volumeBarActiveColor, + volumeBarThumbSize: volumeBarThumbSize ?? this.volumeBarThumbSize, + volumeBarThumbColor: volumeBarThumbColor ?? this.volumeBarThumbColor, + volumeBarTransitionDuration: + volumeBarTransitionDuration ?? this.volumeBarTransitionDuration, + shiftSubtitlesOnControlsVisibilityChange: + shiftSubtitlesOnControlsVisibilityChange ?? + this.shiftSubtitlesOnControlsVisibilityChange, + ); + } +} + +/// {@template material_desktop_video_controls_theme} +/// +/// Inherited widget which provides [MaterialTvVideoControlsThemeData] to descendant widgets. +/// +/// {@endtemplate} +class MaterialTvVideoControlsTheme extends InheritedWidget { + final MaterialTvVideoControlsThemeData normal; + final MaterialTvVideoControlsThemeData fullscreen; + const MaterialTvVideoControlsTheme({ + super.key, + required this.normal, + required this.fullscreen, + required super.child, + }); + + static MaterialTvVideoControlsTheme? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType(); + } + + static MaterialTvVideoControlsTheme of(BuildContext context) { + final MaterialTvVideoControlsTheme? result = maybeOf(context); + assert( + result != null, + 'No [MaterialDesktopVideoControlsTheme] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(MaterialTvVideoControlsTheme oldWidget) => + identical(normal, oldWidget.normal) && + identical(fullscreen, oldWidget.fullscreen); +} + +/// {@macro material_desktop_video_controls} +class _MaterialTvVideoControls extends StatefulWidget { + const _MaterialTvVideoControls(); + + @override + State<_MaterialTvVideoControls> createState() => + _MaterialTvVideoControlsState(); +} + +/// {@macro material_desktop_video_controls} +class _MaterialTvVideoControlsState extends State<_MaterialTvVideoControls> { + late bool mount = _theme(context).visibleOnMount; + late bool visible = _theme(context).visibleOnMount; + + Timer? _timer; + + late /* private */ var playlist = controller(context).player.state.playlist; + late bool buffering = controller(context).player.state.buffering; + + DateTime last = DateTime.now(); + + final List subscriptions = []; + + FocusNode _focusNode = FocusNode(); + + double get subtitleVerticalShiftOffset => + (_theme(context).padding?.bottom ?? 0.0) + + (_theme(context).bottomButtonBarMargin.vertical) + + (_theme(context).bottomButtonBar.isNotEmpty + ? _theme(context).buttonBarHeight + : 0.0); + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.playlist.listen( + (event) { + setState(() { + playlist = event; + }); + }, + ), + controller(context).player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + }); + }, + ), + ], + ); + + if (_theme(context).visibleOnMount) { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + } + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void shiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding + + EdgeInsets.fromLTRB( + 0.0, + 0.0, + 0.0, + subtitleVerticalShiftOffset, + ), + ); + } + } + + void unshiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding, + ); + } + } + + void onHover() { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } + + void onEnter() { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } + + void onExit() { + setState(() { + visible = false; + }); + unshiftSubtitle(); + _timer?.cancel(); + } + + @override + Widget build(BuildContext context) { + return FocusScope( + autofocus: true, + onKeyEvent: (node, event) { + onEnter(); + + if (event is KeyDownEvent) { + print('Key pressed: ${event.logicalKey.debugName}'); + + if (event.logicalKey == LogicalKeyboardKey.mediaPlayPause) { + controller(context).player.playOrPause(); + } + } + + return KeyEventResult.ignored; + }, + child: Theme( + data: Theme.of(context).copyWith( + focusColor: const Color(0x00000000), + hoverColor: const Color(0x00000000), + splashColor: const Color(0x00000000), + highlightColor: const Color(0x00000000), + ), + child: Material( + elevation: 0.0, + borderOnForeground: false, + animationDuration: Duration.zero, + color: const Color(0x00000000), + shadowColor: const Color(0x00000000), + surfaceTintColor: const Color(0x00000000), + child: Stack( + children: [ + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + onEnd: () { + if (!visible) { + setState(() { + mount = false; + }); + } + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + // Top gradient. + if (_theme(context).topButtonBar.isNotEmpty) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [ + 0.0, + 0.2, + ], + colors: [ + Color(0x61000000), + Color(0x00000000), + ], + ), + ), + ), + // Bottom gradient. + if (_theme(context).bottomButtonBar.isNotEmpty) + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: [ + 0.5, + 1.0, + ], + colors: [ + Color(0x00000000), + Color(0x61000000), + ], + ), + ), + ), + if (mount) + Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).topButtonBar, + ), + ), + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering ? 0.0 : 1.0, + duration: + _theme(context).controlsTransitionDuration, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.center, + children: _theme(context).primaryButtonBar, + ), + ), + ), + ), + if (_theme(context).displaySeekBar) + Transform.translate( + offset: + _theme(context).bottomButtonBar.isNotEmpty + ? const Offset(0.0, 16.0) + : Offset.zero, + child: MaterialTvSeekBar( + onSeekStart: () { + _timer?.cancel(); + }, + onSeekEnd: () { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + }, + ), + ), + if (_theme(context).bottomButtonBar.isNotEmpty) + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).bottomButtonBar, + ), + ), + ], + ), + ), + ], + ), + ), + // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) + ? MediaQuery.of(context).padding + : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: Center( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: + _theme(context).controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: _theme(context) + .bufferingIndicatorBuilder + ?.call(context) ?? + child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +// SEEK BAR + +/// Material design seek bar. +class MaterialTvSeekBar extends StatefulWidget { + final VoidCallback? onSeekStart; + final VoidCallback? onSeekEnd; + + const MaterialTvSeekBar({ + Key? key, + this.onSeekStart, + this.onSeekEnd, + }) : super(key: key); + + @override + MaterialTvSeekBarState createState() => MaterialTvSeekBarState(); +} + +class MaterialTvSeekBarState extends State { + bool hover = false; + bool click = false; + double slider = 0.0; + + late bool playing = controller(context).player.state.playing; + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + late Duration buffer = controller(context).player.state.buffer; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.playing.listen((event) { + setState(() { + playing = event; + }); + }), + controller(context).player.stream.completed.listen((event) { + setState(() { + position = Duration.zero; + }); + }), + controller(context).player.stream.position.listen((event) { + setState(() { + if (!click) position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + controller(context).player.stream.buffer.listen((event) { + setState(() { + buffer = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + hover = true; + slider = percent.clamp(0.0, 1.0); + }); + controller(context).player.seek(duration * slider); + } + + void onPointerDown() { + widget.onSeekStart?.call(); + setState(() { + click = true; + }); + } + + void onPointerUp() { + widget.onSeekEnd?.call(); + setState(() { + // Explicitly set the position to prevent the slider from jumping. + click = false; + position = duration * slider; + }); + controller(context).player.seek(duration * slider); + } + + void onHover(PointerHoverEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + hover = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onEnter(PointerEnterEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + hover = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onExit(PointerExitEvent e, BoxConstraints constraints) { + setState(() { + hover = false; + slider = 0.0; + }); + } + + /// Returns the current playback position in percentage. + double get positionPercent { + if (position == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = position.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + /// Returns the current playback buffer position in percentage. + double get bufferPercent { + if (buffer == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = buffer.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + FocusNode focusNode = FocusNode(); + FocusNode focusNode2 = FocusNode(); + + @override + Widget build(BuildContext context) { + return FocusableActionDetector( + focusNode: focusNode, // Create a FocusNode to manage focus + autofocus: false, // Automatically focus when the widget appears + onFocusChange: (focused) { + // Handle focus changes + setState(() { + hover = focused; // Example: show hover effect when focused + }); + + if (focused) { + focusNode2.requestFocus(); + } + }, + child: Focus( + focusNode: focusNode2, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + double percent = 0.01; + + double sliderPercent = + (positionPercent + percent).clamp(0.0, 1.0); + + setState(() { + hover = true; + slider = sliderPercent; + }); + controller(context).player.seek(duration * slider); + + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + double percent = 0.01; + + double sliderPercent = + (positionPercent - percent).clamp(0.0, 1.0); + + setState(() { + hover = true; + slider = sliderPercent; + }); + controller(context).player.seek(duration * slider); + + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + }, + child: Container( + clipBehavior: Clip.none, + margin: _theme(context).seekBarMargin, + child: LayoutBuilder( + builder: (context, constraints) => Container( + color: const Color(0x00000000), + width: constraints.maxWidth, + height: _theme(context).seekBarContainerHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + AnimatedContainer( + width: constraints.maxWidth, + height: hover + ? _theme(context).seekBarHoverHeight + : _theme(context).seekBarHeight, + alignment: Alignment.centerLeft, + duration: _theme(context).seekBarThumbTransitionDuration, + color: _theme(context).seekBarColor, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + Container( + width: constraints.maxWidth * slider, + color: _theme(context).seekBarHoverColor, + ), + Container( + width: constraints.maxWidth * bufferPercent, + color: _theme(context).seekBarBufferColor, + ), + Container( + width: click + ? constraints.maxWidth * slider + : constraints.maxWidth * positionPercent, + color: _theme(context).seekBarPositionColor, + ), + ], + ), + ), + Positioned( + left: click + ? (constraints.maxWidth - + _theme(context).seekBarThumbSize / 2) * + slider + : (constraints.maxWidth - + _theme(context).seekBarThumbSize / 2) * + positionPercent, + child: AnimatedContainer( + width: hover || click + ? _theme(context).seekBarThumbSize + : 0.0, + height: hover || click + ? _theme(context).seekBarThumbSize + : 0.0, + duration: _theme(context).seekBarThumbTransitionDuration, + decoration: BoxDecoration( + color: _theme(context).seekBarThumbColor, + borderRadius: BorderRadius.circular( + _theme(context).seekBarThumbSize / 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class MaterialTvPlayOrPauseButton extends StatefulWidget { + /// Overriden icon size for [MaterialTvSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialTvSkipPreviousButton]. + final Color? iconColor; + + const MaterialTvPlayOrPauseButton({ + super.key, + this.iconSize, + this.iconColor, + }); + + @override + MaterialTvPlayOrPauseButtonState createState() => + MaterialTvPlayOrPauseButtonState(); +} + +class MaterialTvPlayOrPauseButtonState + extends State + with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: controller(context).player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: controller(context).player.playOrPause, + iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + ), + ); + } +} + +// BUTTON: SKIP NEXT + +/// MaterialDesktop design skip next button. +class MaterialTvSkipNextButton extends StatelessWidget { + /// Icon for [MaterialTvSkipNextButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialTvSkipNextButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialTvSkipNextButton]. + final Color? iconColor; + + const MaterialTvSkipNextButton({ + Key? key, + this.icon, + this.iconSize, + this.iconColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipNextButton || + (controller(context).player.state.playlist.medias.length > 1 && + _theme(context).automaticallyImplySkipNextButton)) { + return IconButton( + onPressed: controller(context).player.next, + icon: icon ?? const Icon(Icons.skip_next), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: SKIP PREVIOUS + +/// MaterialDesktop design skip previous button. +class MaterialTvSkipPreviousButton extends StatelessWidget { + /// Icon for [MaterialTvSkipPreviousButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialTvSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialTvSkipPreviousButton]. + final Color? iconColor; + + const MaterialTvSkipPreviousButton({ + Key? key, + this.icon, + this.iconSize, + this.iconColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipPreviousButton || + (controller(context).player.state.playlist.medias.length > 1 && + _theme(context).automaticallyImplySkipPreviousButton)) { + return IconButton( + onPressed: controller(context).player.previous, + icon: icon ?? const Icon(Icons.skip_previous), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: FULL SCREEN + +/// MaterialDesktop design fullscreen button. +class MaterialTvFullscreenButton extends StatelessWidget { + /// Icon for [MaterialTvFullscreenButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialTvFullscreenButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialTvFullscreenButton]. + final Color? iconColor; + + const MaterialTvFullscreenButton({ + Key? key, + this.icon, + this.iconSize, + this.iconColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => toggleFullscreen(context), + icon: icon ?? + (isFullscreen(context) + ? const Icon(Icons.fullscreen_exit) + : const Icon(Icons.fullscreen)), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: CUSTOM + +/// MaterialDesktop design custom button. +class MaterialTvCustomButton extends StatelessWidget { + /// Icon for [MaterialTvCustomButton]. + final Widget? icon; + + /// Icon size for [MaterialTvCustomButton]. + final double? iconSize; + + /// Icon color for [MaterialTvCustomButton]. + final Color? iconColor; + + /// The callback that is called when the button is tapped or otherwise activated. + final VoidCallback onPressed; + + const MaterialTvCustomButton({ + Key? key, + this.icon, + this.iconSize, + this.iconColor, + required this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: icon ?? const Icon(Icons.settings), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: VOLUME + +/// MaterialDesktop design volume button & slider. +class MaterialTvVolumeButton extends StatefulWidget { + /// Icon size for the volume button. + final double? iconSize; + + /// Icon color for the volume button. + final Color? iconColor; + + /// Mute icon. + final Widget? volumeMuteIcon; + + /// Low volume icon. + final Widget? volumeLowIcon; + + /// High volume icon. + final Widget? volumeHighIcon; + + /// Width for the volume slider. + final double? sliderWidth; + + const MaterialTvVolumeButton({ + super.key, + this.iconSize, + this.iconColor, + this.volumeMuteIcon, + this.volumeLowIcon, + this.volumeHighIcon, + this.sliderWidth, + }); + + @override + MaterialTvVolumeButtonState createState() => MaterialTvVolumeButtonState(); +} + +class MaterialTvVolumeButtonState extends State + with SingleTickerProviderStateMixin { + late double volume = controller(context).player.state.volume; + + StreamSubscription? subscription; + + bool hover = false; + + bool mute = false; + double _volume = 0.0; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.volume.listen((event) { + setState(() { + volume = event; + }); + }); + } + + @override + void dispose() { + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (e) { + setState(() { + hover = true; + }); + }, + onExit: (e) { + setState(() { + hover = false; + }); + }, + child: Listener( + onPointerSignal: (event) { + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy < 0) { + controller(context).player.setVolume( + (volume + 5.0).clamp(0.0, 100.0), + ); + } + if (event.scrollDelta.dy > 0) { + controller(context).player.setVolume( + (volume - 5.0).clamp(0.0, 100.0), + ); + } + } + }, + child: Row( + children: [ + const SizedBox(width: 4.0), + IconButton( + onPressed: () async { + if (mute) { + await controller(context).player.setVolume(_volume); + mute = !mute; + } + // https://github.com/media-kit/media-kit/pull/250#issuecomment-1605588306 + else if (volume == 0.0) { + _volume = 100.0; + await controller(context).player.setVolume(100.0); + mute = false; + } else { + _volume = volume; + await controller(context).player.setVolume(0.0); + mute = !mute; + } + + setState(() {}); + }, + iconSize: widget.iconSize ?? + (_theme(context).buttonBarButtonSize * 0.8), + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: AnimatedSwitcher( + duration: _theme(context).volumeBarTransitionDuration, + child: volume == 0.0 + ? (widget.volumeMuteIcon ?? + const Icon( + Icons.volume_off, + key: ValueKey(Icons.volume_off), + )) + : volume < 50.0 + ? (widget.volumeLowIcon ?? + const Icon( + Icons.volume_down, + key: ValueKey(Icons.volume_down), + )) + : (widget.volumeHighIcon ?? + const Icon( + Icons.volume_up, + key: ValueKey(Icons.volume_up), + )), + ), + ), + AnimatedOpacity( + opacity: hover ? 1.0 : 0.0, + duration: _theme(context).volumeBarTransitionDuration, + child: AnimatedContainer( + width: + hover ? (12.0 + (widget.sliderWidth ?? 52.0) + 18.0) : 12.0, + duration: _theme(context).volumeBarTransitionDuration, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const SizedBox(width: 12.0), + SizedBox( + width: widget.sliderWidth ?? 52.0, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 1.2, + inactiveTrackColor: _theme(context).volumeBarColor, + activeTrackColor: + _theme(context).volumeBarActiveColor, + thumbColor: _theme(context).volumeBarThumbColor, + thumbShape: RoundSliderThumbShape( + enabledThumbRadius: + _theme(context).volumeBarThumbSize / 2, + elevation: 0.0, + pressedElevation: 0.0, + ), + trackShape: _CustomTrackShape(), + overlayColor: const Color(0x00000000), + ), + child: Slider( + value: volume.clamp(0.0, 100.0), + min: 0.0, + max: 100.0, + onChanged: (value) async { + await controller(context).player.setVolume(value); + mute = false; + setState(() {}); + }, + ), + ), + ), + const SizedBox(width: 18.0), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +// POSITION INDICATOR + +/// MaterialDesktop design position indicator. +class MaterialTvPositionIndicator extends StatefulWidget { + /// Overriden [TextStyle] for the [MaterialTvPositionIndicator]. + final TextStyle? style; + const MaterialTvPositionIndicator({super.key, this.style}); + + @override + MaterialTvPositionIndicatorState createState() => + MaterialTvPositionIndicatorState(); +} + +class MaterialTvPositionIndicatorState + extends State { + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.position.listen((event) { + setState(() { + position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + '${position.label(reference: duration)} / ${duration.label(reference: duration)}', + style: widget.style ?? + TextStyle( + height: 1.0, + fontSize: 12.0, + color: _theme(context).buttonBarButtonColor, + ), + ); + } +} + +class _CustomTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final height = sliderTheme.trackHeight; + final left = offset.dx; + final top = offset.dy + (parentBox.size.height - height!) / 2; + final width = parentBox.size.width; + return Rect.fromLTWH( + left, + top, + width, + height, + ); + } +} diff --git a/lib/features/settings/screen/trakt_integration_screen.dart b/lib/features/settings/screen/trakt_integration_screen.dart new file mode 100644 index 0000000..7d4dc0b --- /dev/null +++ b/lib/features/settings/screen/trakt_integration_screen.dart @@ -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 createState() => _TraktIntegrationState(); +} + +class _TraktIntegrationState extends State { + final pb = AppEngine.engine.pb; + bool isLoggedIn = false; + List selectedLists = []; + List 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? ?? []; + + 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 _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( + 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, + }); +} diff --git a/lib/features/trakt/containers/up_next.container.dart b/lib/features/trakt/containers/up_next.container.dart new file mode 100644 index 0000000..2fc43f3 --- /dev/null +++ b/lib/features/trakt/containers/up_next.container.dart @@ -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 createState() => _TraktContainerState(); +} + +class _TraktContainerState extends State { + late Query> _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!, + ), + ), + ], + ) + ], + ), + ); + }, + ); + } +} diff --git a/lib/features/trakt/service/trakt.service.dart b/lib/features/trakt/service/trakt.service.dart new file mode 100644 index 0000000..0414a9c --- /dev/null +++ b/lib/features/trakt/service/trakt.service.dart @@ -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 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("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 get headers => { + 'Content-Type': 'application/json', + 'Accept-Content': 'application/json', + 'trakt-api-version': _apiVersion, + 'trakt-api-key': _traktClient, + 'Authorization': 'Bearer $_token', + }; + + Future> 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().toList(); + } catch (e, stack) { + print('Error fetching up next episodes: $e'); + print(stack); + return []; + } + } + + Future> 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 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> 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> 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> 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> 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 getHomePageContent() { + final List config = ((AppEngine.engine.pb.authStore.record + ?.get("config")?["selected_categories"] ?? + []) as List) + .whereType() + .toList(); + + if (!isEnabled()) { + return []; + } + + return config; + } + + bool isEnabled() { + return AppEngine.engine.pb.authStore.record! + .getStringValue("trakt_token") != + ""; + } + + Future 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; + + 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 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 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 _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 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> 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 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 {} diff --git a/lib/main.dart b/lib/main.dart index 5d00f95..5e07ae9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { debugShowCheckedModeBanner: false, // comes in the way of the search theme: ThemeData( colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, + seedColor: Colors.red, ), useMaterial3: true, ), diff --git a/lib/pages/home_tab.page.dart b/lib/pages/home_tab.page.dart index 31e0b06..bdaadc5 100644 --- a/lib/pages/home_tab.page.dart +++ b/lib/pages/home_tab.page.dart @@ -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 { 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 { }); super.initState(); + + traktLibraries = getTraktLibraries(); + } + + List traktLibraries = []; + + final traktService = TraktService(); + + List getTraktLibraries() { + if (widget.defaultLibraries?.data.isNotEmpty == true) { + return []; + } + + return traktService.getHomePageContent(); } @override @@ -67,7 +86,11 @@ class _HomeTabPageState extends State { ), body: RefreshIndicator( onRefresh: () async { + await refreshAuth(); await query.refetch(); + setState(() { + traktLibraries = getTraktLibraries(); + }); return; }, child: QueryBuilder( @@ -93,8 +116,13 @@ class _HomeTabPageState extends State { 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 { ), 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 { ), ); }, - itemCount: data.data.length, + itemCount: data.data.length + traktLibraries.length, ), ); }, diff --git a/lib/pages/home_tv.page.dart b/lib/pages/home_tv.page.dart new file mode 100644 index 0000000..3c763b4 --- /dev/null +++ b/lib/pages/home_tv.page.dart @@ -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 createState() => _TVHomePageState(); +} + +class _TVHomePageState extends State { + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/more_tab.page.dart b/lib/pages/more_tab.page.dart index e02032b..e250049 100644 --- a/lib/pages/more_tab.page.dart +++ b/lib/pages/more_tab.page.dart @@ -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, diff --git a/lib/pages/sign_in.page.dart b/lib/pages/sign_in.page.dart index 382c6df..98684e0 100644 --- a/lib/pages/sign_in.page.dart +++ b/lib/pages/sign_in.page.dart @@ -153,9 +153,12 @@ class _SignInPageState extends State 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 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 with TickerProviderStateMixin { bool obscureText = false, Widget? suffixIcon, bool autofocus = false, + List? autoFillHints = const [], }) { return Container( decoration: BoxDecoration( @@ -245,6 +252,9 @@ class _SignInPageState extends State with TickerProviderStateMixin { color: Colors.white, fontSize: 15, ), + autofillHints: autoFillHints, + textInputAction: TextInputAction.next, + onEditingComplete: () => FocusScope.of(context).nextFocus(), decoration: InputDecoration( hintText: hintText, hintStyle: GoogleFonts.exo2( diff --git a/lib/pages/sign_up.page.dart b/lib/pages/sign_up.page.dart index 501e37b..911597f 100644 --- a/lib/pages/sign_up.page.dart +++ b/lib/pages/sign_up.page.dart @@ -162,6 +162,9 @@ class _SignUpPageState extends State 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 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 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 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 with TickerProviderStateMixin { Widget? suffixIcon, bool autofocus = false, final FormFieldValidator? validator, + List autoFillHints = const [], }) { return Container( decoration: BoxDecoration( @@ -283,10 +295,13 @@ class _SignUpPageState extends State 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( diff --git a/lib/pages/stremio_item.page.dart b/lib/pages/stremio_item.page.dart index 007eb22..17a4144 100644 --- a/lib/pages/stremio_item.page.dart +++ b/lib/pages/stremio_item.page.dart @@ -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 { late Query query = Query( key: "item${widget.type}${widget.id}", + onSuccess: (data) {}, queryFn: () async { try { final result = @@ -60,6 +62,64 @@ class _StremioItemPageState extends State { }, ); + @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 { 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( diff --git a/lib/routes.dart b/lib/routes.dart index 9cb192a..adbcb50 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -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?; 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, ); }, ), diff --git a/lib/utils/auth_refresh.dart b/lib/utils/auth_refresh.dart new file mode 100644 index 0000000..8af0685 --- /dev/null +++ b/lib/utils/auth_refresh.dart @@ -0,0 +1,11 @@ +import '../engine/engine.dart'; + +Future 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); +} diff --git a/lib/utils/cached_storage_static.dart b/lib/utils/cached_storage_static.dart new file mode 100644 index 0000000..7dca893 --- /dev/null +++ b/lib/utils/cached_storage_static.dart @@ -0,0 +1,5 @@ +import 'package:cached_storage/cached_storage.dart'; + +class StaticCachedStorage { + static CachedStorage? storage; +} diff --git a/lib/utils/tv_detector.dart b/lib/utils/tv_detector.dart new file mode 100644 index 0000000..894f09f --- /dev/null +++ b/lib/utils/tv_detector.dart @@ -0,0 +1,5 @@ +class DeviceDetector { + static bool isTV() { + return const String.fromEnvironment('is_tv') == 'true'; + } +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ac5706a..16bcf35 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -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 diff --git a/test/trakt_service_test.dart b/test/trakt_service_test.dart new file mode 100644 index 0000000..5ac1241 --- /dev/null +++ b/test/trakt_service_test.dart @@ -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); + }); +}