mirror of
https://github.com/madari-media/madari-oss.git
synced 2026-01-11 22:40:23 +00:00
fix: added external subtitles
This commit is contained in:
parent
e772a2b815
commit
2a2874fb3a
15 changed files with 846 additions and 402 deletions
|
|
@ -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<List<Subtitle>> getSubtitles(Meta record) async* {
|
||||
final List<Subtitle> 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<PaginatedResult<LibraryItem>> getItems(
|
||||
LibraryRecord library, {
|
||||
|
|
@ -586,3 +644,69 @@ class StremioConfig {
|
|||
|
||||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'SubEncoding': subEncoding,
|
||||
'lang': lang,
|
||||
'm': m,
|
||||
'g': g,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SubtitleResponse {
|
||||
final List<Subtitle> subtitles;
|
||||
final int? cacheMaxAge;
|
||||
|
||||
const SubtitleResponse({
|
||||
required this.subtitles,
|
||||
required this.cacheMaxAge,
|
||||
});
|
||||
|
||||
factory SubtitleResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SubtitleResponse(
|
||||
subtitles: (json['subtitles'] as List)
|
||||
.map((e) => Subtitle.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
cacheMaxAge: json['cacheMaxAge'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'subtitles': subtitles.map((e) => e.toJson()).toList(),
|
||||
'cacheMaxAge': cacheMaxAge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
|
||||
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<StremioItemSeasonSelector>
|
|||
),
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<VideoViewer> {
|
||||
StreamSubscription? _subTracks;
|
||||
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
|
||||
Timer? _timer;
|
||||
late final Player player = Player(
|
||||
|
|
@ -52,7 +47,6 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
title: "Madari",
|
||||
),
|
||||
);
|
||||
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
|
||||
final Logger _logger = Logger('VideoPlayer');
|
||||
|
||||
double get currentProgressInPercentage {
|
||||
|
|
@ -120,64 +114,8 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
),
|
||||
);
|
||||
|
||||
List<SubtitleTrack> subtitles = [];
|
||||
List<AudioTrack> audioTracks = [];
|
||||
Map<String, String> 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<VideoViewer> {
|
|||
|
||||
PlaybackConfig config = getPlaybackConfig();
|
||||
|
||||
bool defaultConfigSelected = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -234,14 +170,6 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
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<VideoViewer> {
|
|||
}
|
||||
});
|
||||
|
||||
_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<VideoViewer> {
|
|||
}
|
||||
});
|
||||
|
||||
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<VideoViewer> {
|
|||
}
|
||||
}
|
||||
|
||||
bool isScaled = false;
|
||||
|
||||
late StreamSubscription<bool> _streamComplete;
|
||||
late StreamSubscription<bool> _streamListen;
|
||||
late StreamSubscription<dynamic> _duration;
|
||||
|
|
@ -400,8 +305,6 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
overlays: [],
|
||||
);
|
||||
_timer?.cancel();
|
||||
_subTracks?.cancel();
|
||||
_streamComplete.cancel();
|
||||
_streamListen.cancel();
|
||||
_duration.cancel();
|
||||
|
||||
|
|
@ -422,226 +325,15 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
@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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AudioTrackSelector> createState() => _AudioTrackSelectorState();
|
||||
}
|
||||
|
||||
class _AudioTrackSelectorState extends State<AudioTrackSelector> {
|
||||
List<AudioTrack> audioTracks = [];
|
||||
Map<String, String> 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,6 @@ import 'package:window_manager/window_manager.dart';
|
|||
MaterialDesktopVideoControlsThemeData getDesktopControls(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required List<SubtitleTrack> subtitles,
|
||||
required List<AudioTrack> audioTracks,
|
||||
required Player player,
|
||||
Widget? library,
|
||||
required Function() onSubtitleSelect,
|
||||
|
|
|
|||
|
|
@ -9,13 +9,10 @@ import '../../types/doc_source.dart';
|
|||
MaterialVideoControlsThemeData getMobileVideoPlayer(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required List<SubtitleTrack> subtitles,
|
||||
required List<AudioTrack> 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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeasonSelector extends StatelessWidget {
|
||||
const SeasonSelector({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, List<Subtitle>> 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<SubtitleSelector> createState() => _SubtitleSelectorState();
|
||||
}
|
||||
|
||||
class _SubtitleSelectorState extends State<SubtitleSelector> {
|
||||
List<SubtitleTrack> subtitles = [];
|
||||
Map<String, String> languages = {};
|
||||
Stream<List<Subtitle>>? externalSubtitles;
|
||||
|
||||
late StreamSubscription<List<String>> _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<List<Subtitle>>(
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoState> 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<VideoViewerMobile> createState() => _VideoViewerMobileState();
|
||||
}
|
||||
|
||||
class _VideoViewerMobileState extends State<VideoViewerMobile> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VideoViewerUi> createState() => _VideoViewerUiState();
|
||||
}
|
||||
|
||||
class _VideoViewerUiState extends State<VideoViewerUi> {
|
||||
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
|
||||
final Logger _logger = Logger('_VideoViewerUiState');
|
||||
|
||||
final List<StreamSubscription> 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
_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<TraktIntegration> {
|
|||
});
|
||||
}
|
||||
|
||||
// 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<TraktIntegration> {
|
|||
});
|
||||
}
|
||||
|
||||
// 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<TraktIntegration> {
|
|||
checkIsLoggedIn();
|
||||
}
|
||||
|
||||
// Show the "Add Category" dialog
|
||||
Future<void> _showAddCategoryDialog() async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
|
|
@ -167,7 +163,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
);
|
||||
}
|
||||
|
||||
// Reorder categories
|
||||
void _onReorder(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ class TraktContainerState extends State<TraktContainer> {
|
|||
try {
|
||||
_logger.info('Refreshing data for ${widget.loadId}');
|
||||
_cachedItems = [];
|
||||
_currentPage = 0;
|
||||
await _loadData();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,8 +205,10 @@ class TraktService {
|
|||
}
|
||||
}
|
||||
|
||||
Stream<List<LibraryItem>> getUpNextSeries(
|
||||
{int page = 1, int itemsPerPage = 5}) async* {
|
||||
Stream<List<LibraryItem>> getUpNextSeries({
|
||||
int page = 1,
|
||||
int itemsPerPage = 5,
|
||||
}) async* {
|
||||
await initStremioService();
|
||||
|
||||
if (!isEnabled()) {
|
||||
|
|
@ -220,7 +222,30 @@ class TraktService {
|
|||
final List<dynamic> 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<Meta>().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<String, double> 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<Meta>()
|
||||
.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<Meta>()
|
||||
.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 [];
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ class _HomeTabPageState extends State<HomeTabPage> {
|
|||
return const Text("Loading");
|
||||
}
|
||||
|
||||
if (data.data.isEmpty) {
|
||||
if (data.data.isEmpty && widget.defaultLibraries != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 24,
|
||||
|
|
|
|||
Loading…
Reference in a new issue