diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index c18baaf51..cbc79e535 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -7,4 +7,7 @@ + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d37f1beae..9e5d05e5c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,4 +40,7 @@ + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index a02466d87..c9553b10a 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -8,4 +8,7 @@ + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6782e2b7f..cca779f7c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -61,6 +61,11 @@ Main UIStatusBarHidden + LSApplicationQueriesSchemes + + plex + https + UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/lib/extensions/string/links.dart b/lib/extensions/string/links.dart index 5fc9dd5e8..cfe137d9a 100644 --- a/lib/extensions/string/links.dart +++ b/lib/extensions/string/links.dart @@ -1,17 +1,17 @@ import 'package:lunasea/system/logger.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; extension StringAsLinksExtension on String { - Future _launchUniversal(uri) async { - return await launchUrl( + Future _launchUniversal(String uri) async { + return await launchUrlString( uri, webOnlyWindowName: '_blank', - mode: LaunchMode.externalNonBrowserApplication, + mode: LaunchMode.externalApplication, ); } - Future _launchDefault(uri) async { - return await launchUrl( + Future _launchDefault(String uri) async { + return await launchUrlString( uri, webOnlyWindowName: '_blank', mode: LaunchMode.platformDefault, @@ -20,9 +20,8 @@ extension StringAsLinksExtension on String { Future openLink() async { try { - Uri uri = Uri.parse(this); - if (await _launchUniversal(uri)) return; - await _launchDefault(uri); + if (await _launchUniversal(this)) return; + await _launchDefault(this); } catch (error, stack) { LunaLogger().error( 'Unable to open URL', @@ -32,6 +31,10 @@ extension StringAsLinksExtension on String { } } + Future canOpenUrl() async { + return canLaunchUrlString(this); + } + Future openImdb() async => await 'https://www.imdb.com/title/$this'.openLink(); diff --git a/lib/modules/tautulli/core/state.dart b/lib/modules/tautulli/core/state.dart index 570bc26b8..c90b44c95 100644 --- a/lib/modules/tautulli/core/state.dart +++ b/lib/modules/tautulli/core/state.dart @@ -33,6 +33,7 @@ class TautulliState extends LunaModuleState { _playCountByPlatformStreamTypeGraph = null; _playCountByUserStreamTypeGraph = null; _librariesTable = null; + _serverIdentity = null; _searchQuery = ''; // Clear user data @@ -52,6 +53,7 @@ class TautulliState extends LunaModuleState { resetActivity(); resetUsers(); resetHistory(); + resetServerIdentity(); notifyListeners(); } @@ -194,6 +196,24 @@ class TautulliState extends LunaModuleState { notifyListeners(); } + /////////////////////// + /// SERVER IDENTITY /// + /////////////////////// + + Future? _serverIdentity; + Future? get serverIdentity => _serverIdentity; + set serverIdentity(Future? serverIdentity) { + _serverIdentity = serverIdentity; + notifyListeners(); + } + + void resetServerIdentity() { + if (_api != null) { + _serverIdentity = _api!.miscellaneous.getServerIdentity(); + } + notifyListeners(); + } + ////////////////// /// STATISTICS /// ////////////////// diff --git a/lib/modules/tautulli/routes/media_details/route.dart b/lib/modules/tautulli/routes/media_details/route.dart index 039e5f64c..0466fc1d7 100644 --- a/lib/modules/tautulli/routes/media_details/route.dart +++ b/lib/modules/tautulli/routes/media_details/route.dart @@ -42,6 +42,12 @@ class _State extends State { title: 'Media Details', scrollControllers: TautulliMediaDetailsNavigationBar.scrollControllers, pageController: _pageController, + actions: [ + TautulliMediaDetailsOpenPlexButton( + ratingKey: widget.ratingKey, + mediaType: widget.mediaType, + ), + ], ); } diff --git a/lib/modules/tautulli/routes/media_details/widgets.dart b/lib/modules/tautulli/routes/media_details/widgets.dart index 9233f1f5e..0f6575e10 100644 --- a/lib/modules/tautulli/routes/media_details/widgets.dart +++ b/lib/modules/tautulli/routes/media_details/widgets.dart @@ -4,3 +4,4 @@ export 'widgets/metadata_header.dart'; export 'widgets/metadata_metadata.dart'; export 'widgets/metadata_summary.dart'; export 'widgets/navigation_bar.dart'; +export 'widgets/open_plex_button.dart'; diff --git a/lib/modules/tautulli/routes/media_details/widgets/open_plex_button.dart b/lib/modules/tautulli/routes/media_details/widgets/open_plex_button.dart new file mode 100644 index 000000000..49568b7c8 --- /dev/null +++ b/lib/modules/tautulli/routes/media_details/widgets/open_plex_button.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:lunasea/core.dart'; +import 'package:lunasea/extensions/string/links.dart'; +import 'package:lunasea/modules/tautulli.dart'; +import 'package:lunasea/utils/links.dart'; + +class TautulliMediaDetailsOpenPlexButton extends StatelessWidget { + final TautulliMediaType mediaType; + final int ratingKey; + + const TautulliMediaDetailsOpenPlexButton({ + Key? key, + required this.mediaType, + required this.ratingKey, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: context.watch().serverIdentity, + builder: (context, snapshot) { + if (_isValidMediaType() && snapshot.hasData) { + return LunaIconButton.appBar( + icon: LunaIcons.PLEX, + onPressed: () => _openPlex(snapshot.data as TautulliServerIdentity), + ); + } + return const SizedBox(); + }, + ); + } + + bool _isValidMediaType() { + const invalidTypes = [ + TautulliMediaType.TRACK, + TautulliMediaType.PHOTO, + ]; + return !invalidTypes.contains(mediaType); + } + + Future _openPlex(TautulliServerIdentity identity) async { + final mobile = LunaLinkedContent.plexMobile( + identity.machineIdentifier!, + ratingKey, + ); + + if (await mobile.canOpenUrl()) { + mobile.openLink(); + return; + } + + final web = LunaLinkedContent.plexWeb( + identity.machineIdentifier!, + ratingKey, + mediaType == TautulliMediaType.CLIP, + ); + web.openLink(); + } +} diff --git a/lib/utils/links.dart b/lib/utils/links.dart index 7e25d3288..c21404e55 100644 --- a/lib/utils/links.dart +++ b/lib/utils/links.dart @@ -1,5 +1,6 @@ import 'package:lunasea/core.dart'; import 'package:lunasea/extensions/string/links.dart'; +import 'package:lunasea/system/platform.dart'; enum LinkedContentType { MOVIE, @@ -28,29 +29,55 @@ enum LunaLinkedContent { static String? imdb(String? id) { if (id == null) return null; - String base = 'https://www.imdb.com'; + const base = 'https://www.imdb.com'; return '$base/title/$id'; } static String? letterboxd(int? id) { if (id == null) return null; - String base = 'https://letterboxd.com'; + const base = 'https://letterboxd.com'; return '$base/tmdb/$id'; } static String? musicBrainz(String? id) { if (id == null) return null; - String base = 'https://musicbrainz.org/artist'; + const base = 'https://musicbrainz.org/artist'; return '$base/$id'; } + static String plexMobile( + String plexIdentifier, + int ratingKey, + ) { + if (LunaPlatform.isAndroid) { + const base = 'plex://server://'; + const path = '/com.plexapp.plugins.library/library/metadata/'; + return '$base$plexIdentifier$path$ratingKey'; + } else { + const base = 'plex://preplay/?server='; + const path = '&metadataKey=/library/metadata/'; + return '$base$plexIdentifier$path$ratingKey'; + } + } + + static String plexWeb( + String plexIdentifier, + int ratingKey, [ + bool useLegacy = false, + ]) { + const base = 'https://app.plex.tv/desktop#!/server/'; + const path = '/details?key=%2Flibrary%2Fmetadata%2F'; + final legacy = useLegacy ? '&legacy=1' : ''; + return '$base$plexIdentifier$path$ratingKey$legacy'; + } + static String? theMovieDB(dynamic id, LinkedContentType type) { if (id == null) return null; - String base = 'https://www.themoviedb.org'; - String baseImage = 'https://image.tmdb.org/t/p'; + const base = 'https://www.themoviedb.org'; + const baseImage = 'https://image.tmdb.org/t/p'; switch (type) { case LinkedContentType.MOVIE: @@ -70,7 +97,7 @@ enum LunaLinkedContent { static String? trakt(int? id, LinkedContentType type) { if (id == null) return null; - String base = 'https://trakt.tv'; + const base = 'https://trakt.tv'; switch (type) { case LinkedContentType.MOVIE: @@ -84,14 +111,14 @@ enum LunaLinkedContent { static String? tvMaze(int? id) { if (id == null) return null; - String base = 'https://www.tvmaze.com'; + const base = 'https://www.tvmaze.com'; return '$base/shows/$id'; } static String? theTVDB(int? id, LinkedContentType type) { if (id == null) return null; - String base = 'https://thetvdb.com'; + const base = 'https://thetvdb.com'; switch (type) { case LinkedContentType.MOVIE: @@ -107,7 +134,7 @@ enum LunaLinkedContent { static String? youtube(String? id) { if (id == null) return null; - String base = 'https://www.youtube.com'; + const base = 'https://www.youtube.com'; return '$base/watch?v=$id'; } diff --git a/pubspec.lock b/pubspec.lock index 30cd32440..88e5ebb35 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1612,5 +1612,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=2.18.0 <4.0.0" + dart: ">=2.18.0 <3.0.0" flutter: ">=3.3.0"