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"