diff --git a/lib/features/connections/service/stremio_connection_service.dart b/lib/features/connections/service/stremio_connection_service.dart index 8865128..44f78fd 100644 --- a/lib/features/connections/service/stremio_connection_service.dart +++ b/lib/features/connections/service/stremio_connection_service.dart @@ -10,6 +10,7 @@ import 'package:madari_client/features/connections/types/base/base.dart'; import 'package:madari_client/features/connections/widget/stremio/stremio_card.dart'; import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.dart'; import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; +import 'package:madari_client/utils/common.dart'; import 'package:pocketbase/pocketbase.dart'; import '../../connection/services/stremio_service.dart'; @@ -127,6 +128,63 @@ class StremioConnectionService extends BaseConnectionService { return configItems; } + Stream> getSubtitles(Meta record) async* { + final List subtitles = []; + + _logger.info('getting subtitles'); + + for (final addon in config.addons) { + final manifest = await _getManifest(addon); + + final resource = manifest.resources + ?.firstWhereOrNull((res) => res.name == "subtitles"); + + if (resource == null) { + continue; + } + + final types = resource.types ?? manifest.types ?? []; + final idPrefixes = + resource.idPrefixes ?? resource.idPrefix ?? manifest.idPrefixes; + + if (!types.contains(record.type)) { + continue; + } + + final hasPrefixMatch = idPrefixes?.firstWhereOrNull((item) { + return record.id.startsWith(item); + }); + + if (hasPrefixMatch == null) { + continue; + } + + final addonBase = _getAddonBaseURL(addon); + + final url = + "$addonBase/subtitles/${record.type}/${Uri.encodeQueryComponent(record.currentVideo?.id ?? record.id)}.json"; + + _logger.info('loading subtitles from $url'); + + final body = await http.get(Uri.parse(url)); + + if (body.statusCode != 200) { + _logger.warning('failed due to status code ${body.statusCode}'); + continue; + } + + final dataBody = jsonDecode(body.body); + + try { + final responses = SubtitleResponse.fromJson(dataBody); + subtitles.addAll(responses.subtitles); + yield subtitles; + } catch (e) { + _logger.warning("failed to parse subtitle response"); + } + } + } + @override Future> getItems( LibraryRecord library, { @@ -586,3 +644,69 @@ class StremioConfig { Map toJson() => _$StremioConfigToJson(this); } + +class Subtitle { + final String id; + final String url; + final String subEncoding; + final String lang; + final String m; + final String? g; // Making g optional since some entries have empty string + + const Subtitle({ + required this.id, + required this.url, + required this.subEncoding, + required this.lang, + required this.m, + this.g, + }); + + factory Subtitle.fromJson(Map json) { + return Subtitle( + id: json['id'] as String, + url: json['url'] as String, + subEncoding: json['SubEncoding'] as String, + lang: json['lang'] as String, + m: json['m'] as String, + g: json['g'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'url': url, + 'SubEncoding': subEncoding, + 'lang': lang, + 'm': m, + 'g': g, + }; + } +} + +class SubtitleResponse { + final List subtitles; + final int? cacheMaxAge; + + const SubtitleResponse({ + required this.subtitles, + required this.cacheMaxAge, + }); + + factory SubtitleResponse.fromJson(Map json) { + return SubtitleResponse( + subtitles: (json['subtitles'] as List) + .map((e) => Subtitle.fromJson(e as Map)) + .toList(), + cacheMaxAge: json['cacheMaxAge'] as int?, + ); + } + + Map toJson() { + return { + 'subtitles': subtitles.map((e) => e.toJson()).toList(), + 'cacheMaxAge': cacheMaxAge, + }; + } +} diff --git a/lib/features/connections/widget/base/render_library_list.dart b/lib/features/connections/widget/base/render_library_list.dart index b5ae74a..21e9419 100644 --- a/lib/features/connections/widget/base/render_library_list.dart +++ b/lib/features/connections/widget/base/render_library_list.dart @@ -298,8 +298,7 @@ class __RenderLibraryListState extends State<_RenderLibraryList> { query.getNextPage(); }, itemScrollController: _scrollController, - isLoadingMore: data.status == QueryStatus.loading || - data.status == QueryStatus.loading && items.isEmpty, + isLoadingMore: data.status == QueryStatus.loading && items.isEmpty, isGrid: widget.isGrid, items: items, heroPrefix: widget.item.id, @@ -440,10 +439,6 @@ class RenderListItems extends StatelessWidget { ), ), ] else ...[ - if (isLoadingMore) - const SliverToBoxAdapter( - child: SpinnerCards(), - ), if (!isLoadingMore) SliverToBoxAdapter( child: SizedBox( @@ -469,6 +464,12 @@ class RenderListItems extends StatelessWidget { ), ), ), + if (isLoadingMore) + SliverToBoxAdapter( + child: SpinnerCards( + isWide: isWide, + ), + ), ], SliverPadding( padding: EdgeInsets.only( diff --git a/lib/features/connections/widget/stremio/stremio_season_selector.dart b/lib/features/connections/widget/stremio/stremio_season_selector.dart index 0321ce5..ac83923 100644 --- a/lib/features/connections/widget/stremio/stremio_season_selector.dart +++ b/lib/features/connections/widget/stremio/stremio_season_selector.dart @@ -95,7 +95,11 @@ class _StremioItemSeasonSelectorState extends State final docs = await zeeeWatchHistory!.getItemWatchHistory( ids: widget.meta.videos!.map((item) { - return WatchHistoryGetRequest(id: item.id); + return WatchHistoryGetRequest( + id: item.id, + episode: item.episode.toString(), + season: item.season.toString(), + ); }).toList(), ); @@ -138,11 +142,13 @@ class _StremioItemSeasonSelectorState extends State ), body: RenderStreamList( service: widget.service!, - id: meta.copyWith( - episodeExternalIds: { - "tvdb": episode.tvdbId, - }, - ), + id: episode.tvdbId != null + ? meta.copyWith( + episodeExternalIds: { + "tvdb": episode.tvdbId, + }, + ) + : meta, season: currentSeason.toString(), episode: episode.number?.toString(), shouldPop: widget.shouldPop, diff --git a/lib/features/doc_viewer/container/video_viewer.dart b/lib/features/doc_viewer/container/video_viewer.dart index 9384a26..e67e32e 100644 --- a/lib/features/doc_viewer/container/video_viewer.dart +++ b/lib/features/doc_viewer/container/video_viewer.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -7,9 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.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'; @@ -20,8 +17,7 @@ import '../../trakt/service/trakt.service.dart'; import '../../trakt/types/common.dart'; import '../../watch_history/service/zeee_watch_history.dart'; import '../types/doc_source.dart'; -import 'video_viewer/desktop_video_player.dart'; -import 'video_viewer/mobile_video_player.dart'; +import 'video_viewer/video_viewer_ui.dart'; class VideoViewer extends StatefulWidget { final DocSource source; @@ -44,7 +40,6 @@ class VideoViewer extends StatefulWidget { } class _VideoViewerState extends State { - StreamSubscription? _subTracks; final zeeeWatchHistory = ZeeeWatchHistoryStatic.service; Timer? _timer; late final Player player = Player( @@ -52,7 +47,6 @@ class _VideoViewerState extends State { title: "Madari", ), ); - late final GlobalKey key = GlobalKey(); final Logger _logger = Logger('VideoPlayer'); double get currentProgressInPercentage { @@ -120,64 +114,8 @@ class _VideoViewerState extends State { ), ); - List subtitles = []; - List audioTracks = []; - Map languages = {}; - late DocSource _source; - void setDefaultAudioTracks(Tracks tracks) { - if (defaultConfigSelected == true && - (tracks.audio.length <= 1 || tracks.audio.length <= 1)) { - return; - } - - defaultConfigSelected = true; - - controller.player.setRate(config.playbackSpeed); - - final defaultSubtitle = config.defaultSubtitleTrack; - final defaultAudio = config.defaultAudioTrack; - - for (final item in tracks.audio) { - if (defaultAudio == item.id || - defaultAudio == item.language || - defaultAudio == item.title) { - controller.player.setAudioTrack(item); - break; - } - } - - if (config.disableSubtitle) { - for (final item in tracks.subtitle) { - if (item.id == "no" || item.language == "no" || item.title == "no") { - controller.player.setSubtitleTrack(item); - } - } - } else { - for (final item in tracks.subtitle) { - if (defaultSubtitle == item.id || - defaultSubtitle == item.language || - defaultSubtitle == item.title) { - controller.player.setSubtitleTrack(item); - break; - } - } - } - } - - void onPlaybackReady(Tracks tracks) { - setState(() { - audioTracks = tracks.audio.where((item) { - return item.id != "auto" && item.id != "no"; - }).toList(); - - subtitles = tracks.subtitle.where((item) { - return item.id != "auto"; - }).toList(); - }); - } - bool canCallOnce = false; int? traktId; @@ -222,8 +160,6 @@ class _VideoViewerState extends State { PlaybackConfig config = getPlaybackConfig(); - bool defaultConfigSelected = false; - @override void initState() { super.initState(); @@ -234,14 +170,6 @@ class _VideoViewerState extends State { overlays: [], ); - if (!kIsWeb) { - if (Platform.isAndroid || Platform.isIOS) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - key.currentState?.enterFullscreen(); - }); - } - } - _duration = player.stream.duration.listen((item) async { if (item.inSeconds != 0) { await setDurationFromTrakt(); @@ -249,27 +177,6 @@ class _VideoViewerState extends State { } }); - _streamComplete = player.stream.completed.listen((completed) { - if (completed) { - onLibrarySelect(); - } - }); - - _subTracks = player.stream.tracks.listen((tracks) { - if (mounted) { - setDefaultAudioTracks(tracks); - onPlaybackReady(tracks); - } - }); - - loadLanguages(context).then((language) { - if (mounted) { - setState(() { - languages = language; - }); - } - }); - loadFile(); if (player.platform is NativePlayer && !kIsWeb) { @@ -288,7 +195,7 @@ class _VideoViewerState extends State { } }); - if (widget.meta is types.Meta) { + if (widget.meta is types.Meta && TraktService.isEnabled()) { traktProgress = TraktService.instance!.getProgress( widget.meta as types.Meta, ); @@ -343,8 +250,6 @@ class _VideoViewerState extends State { } } - bool isScaled = false; - late StreamSubscription _streamComplete; late StreamSubscription _streamListen; late StreamSubscription _duration; @@ -400,8 +305,6 @@ class _VideoViewerState extends State { overlays: [], ); _timer?.cancel(); - _subTracks?.cancel(); - _streamComplete.cancel(); _streamListen.cancel(); _duration.cancel(); @@ -422,226 +325,15 @@ class _VideoViewerState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: _buildBody(context), - ); - } - - _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; - }); - }, - ); - String subtitleStyleName = config.subtitleStyle ?? 'Normal'; - String subtitleStyleColor = config.subtitleColor ?? 'white'; - double subtitleSize = config.subtitleSize; - - Color hexToColor(String hexColor) { - final hexCode = hexColor.replaceAll('#', ''); - try { - return Color(int.parse('0x$hexCode')); - } catch (e) { - return Colors.white; - } - } - - FontStyle getFontStyleFromString(String styleName) { - switch (styleName.toLowerCase()) { - case 'italic': - return FontStyle.italic; - case 'normal': - default: - return FontStyle.normal; - } - } - - FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName); - return MaterialVideoControlsTheme( - fullscreen: mobile, - normal: mobile, - child: Video( - subtitleViewConfiguration: SubtitleViewConfiguration( - style: TextStyle( - color: hexToColor(subtitleStyleColor), - fontSize: subtitleSize, - fontStyle: currentFontStyle, - fontWeight: FontWeight.bold), - ), - fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight, - pauseUponEnteringBackgroundMode: true, - key: key, - onExitFullscreen: () async { - await defaultExitNativeFullscreen(); - if (context.mounted) Navigator.of(context).pop(); - }, + body: VideoViewerUi( 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: - return _buildMobileView(context); - default: - return _buildDesktop(context); - } - } - - onSubtitleSelect() { - _logger.info('Subtitle selection triggered.'); - - showCupertinoModalPopup( - context: context, - builder: (ctx) => Card( - child: Container( - height: MediaQuery.of(context).size.height * 0.4, - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Subtitle', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: ListView.builder( - itemCount: subtitles.length, - itemBuilder: (context, index) { - final currentItem = subtitles[index]; - - final title = currentItem.language ?? - currentItem.title ?? - currentItem.id; - - return ListTile( - title: Text( - languages.containsKey(title) - ? languages[title]! - : title, - ), - selected: - player.state.track.subtitle.id == currentItem.id, - onTap: () { - player.setSubtitleTrack(currentItem); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - } - - onAudioSelect() { - _logger.info('Audio track selection triggered.'); - - showCupertinoModalPopup( - context: context, - builder: (ctx) => Card( - child: Container( - height: MediaQuery.of(context).size.height * 0.4, - decoration: BoxDecoration( - color: Theme.of(context).dialogBackgroundColor, - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'Select Audio Track', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - Expanded( - child: ListView.builder( - itemCount: audioTracks.length, - itemBuilder: (context, index) { - final currentItem = audioTracks[index]; - final title = currentItem.language ?? - currentItem.title ?? - currentItem.id; - return ListTile( - title: Text( - languages.containsKey(title) - ? languages[title]! - : title, - ), - selected: player.state.track.audio.id == currentItem.id, - onTap: () { - player.setAudioTrack(currentItem); - Navigator.pop(context); - }, - ); - }, - ), - ), - ], - ), - ), + player: player, + config: config, + source: _source, + onLibrarySelect: onLibrarySelect, + title: _source.title, + service: widget.service, + meta: widget.meta, ), ); } diff --git a/lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart b/lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart new file mode 100644 index 0000000..860a20d --- /dev/null +++ b/lib/features/doc_viewer/container/video_viewer/audio_track_selector.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:madari_client/utils/load_language.dart'; +import 'package:media_kit/media_kit.dart'; + +class AudioTrackSelector extends StatefulWidget { + final Player player; + final PlaybackConfig config; + + const AudioTrackSelector({ + super.key, + required this.player, + required this.config, + }); + + @override + State createState() => _AudioTrackSelectorState(); +} + +class _AudioTrackSelectorState extends State { + List audioTracks = []; + Map languages = {}; + + @override + void initState() { + super.initState(); + + audioTracks = widget.player.state.tracks.audio.where((item) { + return item.id != "auto" && item.id != "no"; + }).toList(); + + loadLanguages(context).then((language) { + if (mounted) { + setState(() { + languages = language; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return Card( + child: Container( + height: MediaQuery.of(context).size.height * 0.4, + decoration: BoxDecoration( + color: Theme.of(context).dialogBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Select Audio Track', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Expanded( + child: ListView.builder( + itemCount: audioTracks.length, + itemBuilder: (context, index) { + final currentItem = audioTracks[index]; + final title = currentItem.language ?? + currentItem.title ?? + currentItem.id; + return ListTile( + title: Text( + languages.containsKey(title) ? languages[title]! : title, + ), + selected: + widget.player.state.track.audio.id == currentItem.id, + onTap: () { + widget.player.setAudioTrack(currentItem); + Navigator.pop(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 6dc8a70..2d7b77b 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 @@ -12,8 +12,6 @@ import 'package:window_manager/window_manager.dart'; MaterialDesktopVideoControlsThemeData getDesktopControls( BuildContext context, { required DocSource source, - required List subtitles, - required List audioTracks, required Player player, Widget? library, required Function() onSubtitleSelect, diff --git a/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart b/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart index 8e902d3..d8d2cb9 100644 --- a/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart +++ b/lib/features/doc_viewer/container/video_viewer/mobile_video_player.dart @@ -9,13 +9,10 @@ import '../../types/doc_source.dart'; MaterialVideoControlsThemeData getMobileVideoPlayer( BuildContext context, { required DocSource source, - required List subtitles, - required List audioTracks, required Player player, required VoidCallback onSubtitleClick, required VoidCallback onAudioClick, required VoidCallback toggleScale, - required bool hasLibrary, required VoidCallback onLibrarySelect, }) { final mediaQuery = MediaQuery.of(context); @@ -37,13 +34,6 @@ MaterialVideoControlsThemeData getMobileVideoPlayer( style: Theme.of(context).textTheme.bodyLarge, ), const Spacer(), - if (hasLibrary) - MaterialCustomButton( - icon: const Icon(Icons.library_books), - onPressed: () { - onLibrarySelect(); - }, - ), ], bufferingIndicatorBuilder: (source is TorrentSource) ? (ctx) { diff --git a/lib/features/doc_viewer/container/video_viewer/season_selector.dart b/lib/features/doc_viewer/container/video_viewer/season_selector.dart new file mode 100644 index 0000000..c8c60a3 --- /dev/null +++ b/lib/features/doc_viewer/container/video_viewer/season_selector.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class SeasonSelector extends StatelessWidget { + const SeasonSelector({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container(); + } +} diff --git a/lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart b/lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart new file mode 100644 index 0000000..673212e --- /dev/null +++ b/lib/features/doc_viewer/container/video_viewer/subtitle_selector.dart @@ -0,0 +1,207 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:madari_client/features/connections/service/stremio_connection_service.dart'; +import 'package:madari_client/utils/load_language.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../../../connections/service/base_connection_service.dart'; +import '../../../connections/types/stremio/stremio_base.types.dart'; + +Map> externalSubtitlesCache = {}; + +class SubtitleSelector extends StatefulWidget { + final Player player; + final PlaybackConfig config; + final BaseConnectionService? service; + final LibraryItem? meta; + + const SubtitleSelector({ + super.key, + required this.player, + required this.config, + required this.service, + this.meta, + }); + + @override + State createState() => _SubtitleSelectorState(); +} + +class _SubtitleSelectorState extends State { + List subtitles = []; + Map languages = {}; + Stream>? externalSubtitles; + + late StreamSubscription> _subtitles; + + @override + void initState() { + super.initState(); + + if (widget.service is StremioConnectionService && widget.meta is Meta) { + final meta = widget.meta as Meta; + + if (externalSubtitlesCache.containsKey(meta.id)) { + externalSubtitles = Stream.value(externalSubtitlesCache[meta.id]!); + } else { + externalSubtitles = (widget.service as StremioConnectionService) + .getSubtitles(meta) + .map((item) { + externalSubtitlesCache[meta.id] = item; + + return item; + }); + } + } + + onPlaybackReady(widget.player.state.tracks); + _subtitles = widget.player.stream.subtitle.listen((item) { + onPlaybackReady(widget.player.state.tracks); + }); + + loadLanguages(context).then((language) { + if (mounted) { + setState(() { + languages = language; + }); + } + }); + } + + @override + void dispose() { + super.dispose(); + + _subtitles.cancel(); + } + + void onPlaybackReady(Tracks tracks) { + setState(() { + subtitles = tracks.subtitle.where((item) { + return item.id != "auto"; + }).toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints( + maxWidth: 520, + ), + child: Card( + child: Container( + height: max(MediaQuery.of(context).size.height * 0.4, 400), + decoration: BoxDecoration( + color: Theme.of(context).dialogBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Select Subtitle', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Expanded( + child: StreamBuilder>( + stream: externalSubtitles, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Shimmer.fromColors( + baseColor: Colors.black54, + highlightColor: Colors.black54, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) { + return ListTile( + title: Container( + height: 20, + color: Colors.white, + ), + ); + }, + ), + ); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else if (!snapshot.hasData || snapshot.data!.isEmpty) { + return ListView.builder( + itemCount: subtitles.length, + itemBuilder: (context, index) { + final currentItem = subtitles[index]; + final title = currentItem.language ?? + currentItem.title ?? + currentItem.id; + + return ListTile( + title: Text( + languages.containsKey(title) + ? languages[title]! + : title, + ), + selected: widget.player.state.track.subtitle.id == + currentItem.id, + onTap: () { + widget.player.setSubtitleTrack(currentItem); + Navigator.pop(context); + }, + ); + }, + ); + } else { + final externalSubtitlesList = snapshot.data!; + final allSubtitles = [ + SubtitleTrack.no(), + ...subtitles, + ...externalSubtitlesList.map( + (subtitle) { + return SubtitleTrack.uri( + subtitle.url, + language: subtitle.lang, + title: + "${languages[subtitle.lang] ?? subtitle.lang} ${subtitle.id}", + ); + }, + ), + ]; + + return ListView.builder( + itemCount: allSubtitles.length, + itemBuilder: (context, index) { + final currentItem = allSubtitles[index]; + final title = currentItem.language ?? + currentItem.title ?? + currentItem.id; + + final isExternal = currentItem.uri; + + return ListTile( + title: Text( + "${languages.containsKey(title) ? languages[title]! : title == "no" ? "No subtitle" : title} ${isExternal ? "(External)" : ""}", + ), + selected: widget.player.state.track.subtitle.id == + currentItem.id, + onTap: () async { + await widget.player.setSubtitleTrack(currentItem); + if (context.mounted) Navigator.pop(context); + }, + ); + }, + ); + } + }, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart b/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart new file mode 100644 index 0000000..69f21ed --- /dev/null +++ b/lib/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; +import 'package:madari_client/utils/load_language.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import 'mobile_video_player.dart'; + +class VideoViewerMobile extends StatefulWidget { + final VoidCallback onSubtitleSelect; + final VoidCallback onLibrarySelect; + final Player player; + final DocSource source; + final VideoController controller; + final VoidCallback onAudioSelect; + final PlaybackConfig config; + final GlobalKey videoKey; + + const VideoViewerMobile({ + super.key, + required this.onLibrarySelect, + required this.onSubtitleSelect, + required this.player, + required this.source, + required this.controller, + required this.onAudioSelect, + required this.config, + required this.videoKey, + }); + + @override + State createState() => _VideoViewerMobileState(); +} + +class _VideoViewerMobileState extends State { + final Logger _logger = Logger('_VideoViewerMobileState'); + bool isScaled = false; + + @override + build(BuildContext context) { + final mobile = getMobileVideoPlayer( + context, + onLibrarySelect: widget.onLibrarySelect, + player: widget.player, + source: widget.source, + onSubtitleClick: widget.onSubtitleSelect, + onAudioClick: widget.onAudioSelect, + toggleScale: () { + setState(() { + isScaled = !isScaled; + }); + }, + ); + String subtitleStyleName = widget.config.subtitleStyle ?? 'Normal'; + String subtitleStyleColor = widget.config.subtitleColor ?? 'white'; + double subtitleSize = widget.config.subtitleSize; + + Color hexToColor(String hexColor) { + final hexCode = hexColor.replaceAll('#', ''); + try { + return Color(int.parse('0x$hexCode')); + } catch (e) { + return Colors.white; + } + } + + FontStyle getFontStyleFromString(String styleName) { + switch (styleName.toLowerCase()) { + case 'italic': + return FontStyle.italic; + case 'normal': + default: + return FontStyle.normal; + } + } + + FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName); + return MaterialVideoControlsTheme( + fullscreen: mobile, + normal: mobile, + child: Video( + subtitleViewConfiguration: SubtitleViewConfiguration( + style: TextStyle( + color: hexToColor(subtitleStyleColor), + fontSize: subtitleSize, + fontStyle: currentFontStyle, + fontWeight: FontWeight.bold, + ), + ), + fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight, + pauseUponEnteringBackgroundMode: true, + key: widget.videoKey, + onExitFullscreen: () async { + await defaultExitNativeFullscreen(); + if (context.mounted) Navigator.of(context).pop(); + }, + controller: widget.controller, + controls: MaterialVideoControls, + ), + ); + } +} diff --git a/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart b/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart index b257c5c..867827f 100644 --- a/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart +++ b/lib/features/doc_viewer/container/video_viewer/video_viewer_ui.dart @@ -1,12 +1,226 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:madari_client/features/connections/service/base_connection_service.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/audio_track_selector.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/subtitle_selector.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart'; +import 'package:madari_client/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart'; +import 'package:madari_client/features/doc_viewer/types/doc_source.dart'; +import 'package:madari_client/utils/load_language.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import '../../../../utils/tv_detector.dart'; +import '../../../connections/widget/base/render_stream_list.dart'; +import 'desktop_video_player.dart'; + +class VideoViewerUi extends StatefulWidget { + final VideoController controller; + final Player player; + final PlaybackConfig config; + final DocSource source; + final VoidCallback onLibrarySelect; + final String title; + final BaseConnectionService? service; + final LibraryItem? meta; -class VideoViewerUi extends StatelessWidget { const VideoViewerUi({ super.key, + required this.controller, + required this.player, + required this.config, + required this.source, + required this.onLibrarySelect, + required this.title, + required this.service, + this.meta, }); + @override + State createState() => _VideoViewerUiState(); +} + +class _VideoViewerUiState extends State { + late final GlobalKey key = GlobalKey(); + final Logger _logger = Logger('_VideoViewerUiState'); + + final List listeners = []; + + bool defaultConfigSelected = false; + + bool subtitleSelectionHandled = false; + bool audioSelectionHandled = false; + + void setDefaultAudioTracks(Tracks tracks) { + if (defaultConfigSelected == true && + (tracks.audio.length <= 1 || tracks.audio.length <= 1)) { + return; + } + + defaultConfigSelected = true; + + widget.controller.player.setRate(widget.config.playbackSpeed); + + final defaultSubtitle = widget.config.defaultSubtitleTrack; + final defaultAudio = widget.config.defaultAudioTrack; + + for (final item in tracks.audio) { + if ((defaultAudio == item.id || + defaultAudio == item.language || + defaultAudio == item.title) && + audioSelectionHandled == false) { + widget.controller.player.setAudioTrack(item); + audioSelectionHandled = true; + break; + } + } + + if (widget.config.disableSubtitle) { + for (final item in tracks.subtitle) { + if ((item.id == "no" || item.language == "no" || item.title == "no") && + subtitleSelectionHandled == false) { + widget.controller.player.setSubtitleTrack(item); + subtitleSelectionHandled = true; + } + } + } else { + for (final item in tracks.subtitle) { + if ((defaultSubtitle == item.id || + defaultSubtitle == item.language || + defaultSubtitle == item.title) && + subtitleSelectionHandled == false) { + subtitleSelectionHandled = true; + widget.controller.player.setSubtitleTrack(item); + break; + } + } + } + } + + @override + void initState() { + super.initState(); + + final listenerComplete = widget.player.stream.completed.listen((completed) { + if (completed) { + widget.onLibrarySelect(); + } + }); + + listeners.add(listenerComplete); + + if (!kIsWeb) { + if (Platform.isAndroid || Platform.isIOS) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + key.currentState?.enterFullscreen(); + }); + } + } + + final listener = widget.player.stream.tracks.listen((tracks) { + if (mounted) { + setDefaultAudioTracks(tracks); + } + }); + + listeners.add(listener); + } + + @override + void dispose() { + super.dispose(); + + for (final listener in listeners) { + listener.cancel(); + } + } + @override Widget build(BuildContext context) { - return Container(); + return _buildBody(context); + } + + _buildBody(BuildContext context) { + if (DeviceDetector.isTV()) { + return MaterialTvVideoControlsTheme( + fullscreen: const MaterialTvVideoControlsThemeData(), + normal: const MaterialTvVideoControlsThemeData(), + child: Video( + width: MediaQuery.of(context).size.width, + fit: BoxFit.fitWidth, + controller: widget.controller, + controls: MaterialTvVideoControls, + ), + ); + } + + switch (Theme.of(context).platform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return VideoViewerMobile( + onLibrarySelect: widget.onLibrarySelect, + onSubtitleSelect: onSubtitleSelect, + player: widget.player, + source: widget.source, + controller: widget.controller, + onAudioSelect: onAudioSelect, + config: widget.config, + videoKey: key, + ); + default: + return _buildDesktop(context); + } + } + + _buildDesktop(BuildContext context) { + final desktop = getDesktopControls( + context, + player: widget.player, + source: widget.source, + onAudioSelect: onAudioSelect, + onSubtitleSelect: onSubtitleSelect, + ); + + return MaterialDesktopVideoControlsTheme( + normal: desktop, + fullscreen: desktop, + child: Video( + key: key, + width: MediaQuery.of(context).size.width, + fit: BoxFit.fitWidth, + controller: widget.controller, + controls: MaterialDesktopVideoControls, + ), + ); + } + + onAudioSelect() { + _logger.info('Audio track selection triggered.'); + + showCupertinoModalPopup( + context: context, + builder: (ctx) => AudioTrackSelector( + player: widget.player, + config: widget.config, + ), + ); + } + + onSubtitleSelect() { + _logger.info('Subtitle selection triggered.'); + + showCupertinoModalPopup( + context: context, + builder: (ctx) => SubtitleSelector( + player: widget.player, + config: widget.config, + service: widget.service, + meta: widget.meta, + ), + ); } } diff --git a/lib/features/settings/screen/trakt_integration_screen.dart b/lib/features/settings/screen/trakt_integration_screen.dart index 7d4dc0b..b027ddd 100644 --- a/lib/features/settings/screen/trakt_integration_screen.dart +++ b/lib/features/settings/screen/trakt_integration_screen.dart @@ -26,7 +26,6 @@ class _TraktIntegrationState extends State { _loadSelectedCategories(); } - // Check if the user is logged in checkIsLoggedIn() { final traktToken = pb.authStore.record!.getStringValue("trakt_token"); @@ -35,7 +34,6 @@ class _TraktIntegrationState extends State { }); } - // Load selected categories from the database void _loadSelectedCategories() async { final record = pb.authStore.record!; final config = record.get("config") ?? {}; @@ -52,7 +50,6 @@ class _TraktIntegrationState extends State { }); } - // Save selected categories to the database void _saveSelectedCategories() async { final record = pb.authStore.record!; final config = record.get("config") ?? {}; @@ -120,7 +117,6 @@ class _TraktIntegrationState extends State { checkIsLoggedIn(); } - // Show the "Add Category" dialog Future _showAddCategoryDialog() async { return showDialog( context: context, @@ -167,7 +163,6 @@ class _TraktIntegrationState extends State { ); } - // Reorder categories void _onReorder(int oldIndex, int newIndex) { setState(() { if (newIndex > oldIndex) { diff --git a/lib/features/trakt/containers/up_next.container.dart b/lib/features/trakt/containers/up_next.container.dart index fdb94e8..a2f9994 100644 --- a/lib/features/trakt/containers/up_next.container.dart +++ b/lib/features/trakt/containers/up_next.container.dart @@ -159,6 +159,7 @@ class TraktContainerState extends State { try { _logger.info('Refreshing data for ${widget.loadId}'); _cachedItems = []; + _currentPage = 0; await _loadData(); } catch (e) {} } diff --git a/lib/features/trakt/service/trakt.service.dart b/lib/features/trakt/service/trakt.service.dart index a827d3c..aa5acb6 100644 --- a/lib/features/trakt/service/trakt.service.dart +++ b/lib/features/trakt/service/trakt.service.dart @@ -205,8 +205,10 @@ class TraktService { } } - Stream> getUpNextSeries( - {int page = 1, int itemsPerPage = 5}) async* { + Stream> getUpNextSeries({ + int page = 1, + int itemsPerPage = 5, + }) async* { await initStremioService(); if (!isEnabled()) { @@ -220,7 +222,30 @@ class TraktService { final List watchedShows = await _makeRequest('$_baseUrl/sync/watched/shows'); - final progressFutures = watchedShows.map((show) async { + final startIndex = (page - 1) * itemsPerPage; + final endIndex = startIndex + itemsPerPage; + + final items = watchedShows.where((show) { + try { + show['show']['ids']['trakt']; + show['show']['ids']['imdb']; + return true; + } catch (e) { + return false; + } + }).toList(); + + if (startIndex >= items.length) { + yield []; + return; + } + + final paginatedItems = items.sublist( + startIndex, + endIndex > items.length ? items.length : endIndex, + ); + + final progressFutures = paginatedItems.map((show) async { final showId = show['show']['ids']['trakt']; final imdb = show['show']['ids']['imdb']; @@ -262,19 +287,7 @@ class TraktService { final results = await Future.wait(progressFutures); final validResults = results.whereType().toList(); - // Pagination logic - final startIndex = (page - 1) * itemsPerPage; - final endIndex = startIndex + itemsPerPage; - - if (startIndex >= validResults.length) { - yield []; - return; - } - - final paginatedResults = validResults.sublist( - startIndex, - endIndex > validResults.length ? validResults.length : endIndex, - ); + final paginatedResults = validResults; yield paginatedResults; } catch (e, stack) { @@ -298,54 +311,57 @@ class TraktService { final Map progress = {}; - final result = await stremioService!.getBulkItem( - continueWatching - .map((movie) { - try { - 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'], - externalIds: movie['show']['ids'], - episodeExternalIds: movie['episode']['ids'], - ); - } - - final imdb = movie['movie']['ids']['imdb']; - progress[imdb] = movie['progress']; + final metaList = continueWatching + .map((movie) { + try { + if (movie['type'] == 'episode') { + progress[movie['show']['ids']['imdb']] = movie['progress']; return Meta( - type: "movie", - id: imdb, + type: "series", + id: movie['show']['ids']['imdb'], progress: movie['progress'], + nextSeason: movie['episode']['season'], + nextEpisode: movie['episode']['number'], + nextEpisodeTitle: movie['episode']['title'], + externalIds: movie['show']['ids'], + episodeExternalIds: movie['episode']['ids'], ); - } catch (e) { - _logger.warning('Error mapping movie: $e'); - return null; } - }) - .whereType() - .toList(), - ); - // Pagination logic + final imdb = movie['movie']['ids']['imdb']; + progress[imdb] = movie['progress']; + + return Meta( + type: "movie", + id: imdb, + progress: movie['progress'], + ); + } catch (e) { + _logger.warning('Error mapping movie: $e'); + return null; + } + }) + .whereType() + .toList(); + final startIndex = (page - 1) * itemsPerPage; final endIndex = startIndex + itemsPerPage; - if (startIndex >= result.length) { + if (startIndex >= metaList.length) { return []; } - return result.sublist( - startIndex, - endIndex > result.length ? result.length : endIndex, + final result = await stremioService!.getBulkItem( + metaList + .sublist( + startIndex, + endIndex > metaList.length ? metaList.length : endIndex, + ) + .toList(), ); + + return result; } catch (e, stack) { _logger.severe('Error fetching continue watching: $e', stack); return []; diff --git a/lib/pages/home_tab.page.dart b/lib/pages/home_tab.page.dart index 415e7ff..177f6e4 100644 --- a/lib/pages/home_tab.page.dart +++ b/lib/pages/home_tab.page.dart @@ -159,7 +159,7 @@ class _HomeTabPageState extends State { return const Text("Loading"); } - if (data.data.isEmpty) { + if (data.data.isEmpty && widget.defaultLibraries != null) { return Padding( padding: const EdgeInsets.only( bottom: 24,