diff --git a/assets/player_icons/celluloid.svg b/assets/player_icons/celluloid.svg
new file mode 100644
index 00000000..075431d7
--- /dev/null
+++ b/assets/player_icons/celluloid.svg
@@ -0,0 +1,82 @@
+
\ No newline at end of file
diff --git a/assets/player_icons/iina.png b/assets/player_icons/iina.png
new file mode 100644
index 00000000..02fa0f79
Binary files /dev/null and b/assets/player_icons/iina.png differ
diff --git a/assets/player_icons/infuse.png b/assets/player_icons/infuse.png
new file mode 100644
index 00000000..53e353c4
Binary files /dev/null and b/assets/player_icons/infuse.png differ
diff --git a/assets/player_icons/just_player.png b/assets/player_icons/just_player.png
new file mode 100644
index 00000000..f1b49535
Binary files /dev/null and b/assets/player_icons/just_player.png differ
diff --git a/assets/player_icons/mpv.svg b/assets/player_icons/mpv.svg
new file mode 100644
index 00000000..a2538b7d
--- /dev/null
+++ b/assets/player_icons/mpv.svg
@@ -0,0 +1,46 @@
+
\ No newline at end of file
diff --git a/assets/player_icons/mx_player.svg b/assets/player_icons/mx_player.svg
new file mode 100644
index 00000000..91938cec
--- /dev/null
+++ b/assets/player_icons/mx_player.svg
@@ -0,0 +1,21 @@
+
\ No newline at end of file
diff --git a/assets/player_icons/potplayer.png b/assets/player_icons/potplayer.png
new file mode 100644
index 00000000..e59b04fe
Binary files /dev/null and b/assets/player_icons/potplayer.png differ
diff --git a/assets/player_icons/vlc.svg b/assets/player_icons/vlc.svg
new file mode 100644
index 00000000..2bae566a
--- /dev/null
+++ b/assets/player_icons/vlc.svg
@@ -0,0 +1,74 @@
+
+
\ No newline at end of file
diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json
index bafb0bec..4d50a016 100644
--- a/lib/i18n/de.i18n.json
+++ b/lib/i18n/de.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Audioausgabe",
"performanceOverlay": "Leistungsanzeige"
+ },
+ "externalPlayer": {
+ "title": "Externer Player",
+ "useExternalPlayer": "Externen Player verwenden",
+ "useExternalPlayerDescription": "Videos in einer externen App statt im integrierten Player öffnen",
+ "selectPlayer": "Player auswählen",
+ "systemDefault": "Systemstandard",
+ "addCustomPlayer": "Benutzerdefinierten Player hinzufügen",
+ "playerName": "Playername",
+ "playerCommand": "Befehl",
+ "playerPackage": "Paketname",
+ "playerUrlScheme": "URL-Schema",
+ "customPlayer": "Benutzerdefinierter Player",
+ "off": "Aus",
+ "launchFailed": "Externer Player konnte nicht geöffnet werden",
+ "appNotInstalled": "${name} ist nicht installiert",
+ "playInExternalPlayer": "In externem Player abspielen"
}
}
diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json
index a3a2baef..76269b14 100644
--- a/lib/i18n/en.i18n.json
+++ b/lib/i18n/en.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Audio Output",
"performanceOverlay": "Performance Overlay"
+ },
+ "externalPlayer": {
+ "title": "External Player",
+ "useExternalPlayer": "Use External Player",
+ "useExternalPlayerDescription": "Open videos in an external app instead of the built-in player",
+ "selectPlayer": "Select Player",
+ "systemDefault": "System Default",
+ "addCustomPlayer": "Add Custom Player",
+ "playerName": "Player Name",
+ "playerCommand": "Command",
+ "playerPackage": "Package Name",
+ "playerUrlScheme": "URL Scheme",
+ "customPlayer": "Custom Player",
+ "off": "Off",
+ "launchFailed": "Failed to open external player",
+ "appNotInstalled": "${name} is not installed",
+ "playInExternalPlayer": "Play in External Player"
}
}
diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json
index fc0eeb68..25a77486 100644
--- a/lib/i18n/es.i18n.json
+++ b/lib/i18n/es.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Salida de audio",
"performanceOverlay": "Indicador de rendimiento"
+ },
+ "externalPlayer": {
+ "title": "Reproductor externo",
+ "useExternalPlayer": "Usar reproductor externo",
+ "useExternalPlayerDescription": "Abrir vídeos en una app externa en lugar del reproductor integrado",
+ "selectPlayer": "Seleccionar reproductor",
+ "systemDefault": "Predeterminado del sistema",
+ "addCustomPlayer": "Añadir reproductor personalizado",
+ "playerName": "Nombre del reproductor",
+ "playerCommand": "Comando",
+ "playerPackage": "Nombre del paquete",
+ "playerUrlScheme": "Esquema URL",
+ "customPlayer": "Reproductor personalizado",
+ "off": "Desactivado",
+ "launchFailed": "No se pudo abrir el reproductor externo",
+ "appNotInstalled": "${name} no está instalado",
+ "playInExternalPlayer": "Reproducir en reproductor externo"
}
}
diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json
index 2c394f26..d3a603c0 100644
--- a/lib/i18n/fr.i18n.json
+++ b/lib/i18n/fr.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Sortie audio",
"performanceOverlay": "Superposition de performance"
+ },
+ "externalPlayer": {
+ "title": "Lecteur externe",
+ "useExternalPlayer": "Utiliser un lecteur externe",
+ "useExternalPlayerDescription": "Ouvrir les vidéos dans une application externe au lieu du lecteur intégré",
+ "selectPlayer": "Sélectionner le lecteur",
+ "systemDefault": "Par défaut du système",
+ "addCustomPlayer": "Ajouter un lecteur personnalisé",
+ "playerName": "Nom du lecteur",
+ "playerCommand": "Commande",
+ "playerPackage": "Nom du paquet",
+ "playerUrlScheme": "Schéma URL",
+ "customPlayer": "Lecteur personnalisé",
+ "off": "Désactivé",
+ "launchFailed": "Impossible d'ouvrir le lecteur externe",
+ "appNotInstalled": "${name} n'est pas installé",
+ "playInExternalPlayer": "Lire dans un lecteur externe"
}
}
diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json
index 6c19d6e1..6741c342 100644
--- a/lib/i18n/it.i18n.json
+++ b/lib/i18n/it.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Uscita audio",
"performanceOverlay": "Overlay prestazioni"
+ },
+ "externalPlayer": {
+ "title": "Lettore esterno",
+ "useExternalPlayer": "Usa lettore esterno",
+ "useExternalPlayerDescription": "Apri i video in un'app esterna invece del lettore integrato",
+ "selectPlayer": "Seleziona lettore",
+ "systemDefault": "Predefinito di sistema",
+ "addCustomPlayer": "Aggiungi lettore personalizzato",
+ "playerName": "Nome lettore",
+ "playerCommand": "Comando",
+ "playerPackage": "Nome pacchetto",
+ "playerUrlScheme": "Schema URL",
+ "customPlayer": "Lettore personalizzato",
+ "off": "Disattivato",
+ "launchFailed": "Impossibile aprire il lettore esterno",
+ "appNotInstalled": "${name} non è installato",
+ "playInExternalPlayer": "Riproduci in lettore esterno"
}
}
diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json
index 5c90a72e..e6f73655 100644
--- a/lib/i18n/ko.i18n.json
+++ b/lib/i18n/ko.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "오디오 출력",
"performanceOverlay": "성능 오버레이"
+ },
+ "externalPlayer": {
+ "title": "외부 플레이어",
+ "useExternalPlayer": "외부 플레이어 사용",
+ "useExternalPlayerDescription": "내장 플레이어 대신 외부 앱에서 동영상 열기",
+ "selectPlayer": "플레이어 선택",
+ "systemDefault": "시스템 기본값",
+ "addCustomPlayer": "사용자 정의 플레이어 추가",
+ "playerName": "플레이어 이름",
+ "playerCommand": "명령어",
+ "playerPackage": "패키지 이름",
+ "playerUrlScheme": "URL 스킴",
+ "customPlayer": "사용자 정의 플레이어",
+ "off": "꺼짐",
+ "launchFailed": "외부 플레이어를 열 수 없습니다",
+ "appNotInstalled": "${name}이(가) 설치되어 있지 않습니다",
+ "playInExternalPlayer": "외부 플레이어에서 재생"
}
}
diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json
index 5a133175..f4a651fb 100644
--- a/lib/i18n/nl.i18n.json
+++ b/lib/i18n/nl.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Audio-uitvoer",
"performanceOverlay": "Prestatie-overlay"
+ },
+ "externalPlayer": {
+ "title": "Externe speler",
+ "useExternalPlayer": "Externe speler gebruiken",
+ "useExternalPlayerDescription": "Open video's in een externe app in plaats van de ingebouwde speler",
+ "selectPlayer": "Speler selecteren",
+ "systemDefault": "Systeemstandaard",
+ "addCustomPlayer": "Aangepaste speler toevoegen",
+ "playerName": "Spelernaam",
+ "playerCommand": "Commando",
+ "playerPackage": "Pakketnaam",
+ "playerUrlScheme": "URL-schema",
+ "customPlayer": "Aangepaste speler",
+ "off": "Uit",
+ "launchFailed": "Kan externe speler niet openen",
+ "appNotInstalled": "${name} is niet geïnstalleerd",
+ "playInExternalPlayer": "Afspelen in externe speler"
}
}
diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart
index ee76c869..9aedac5f 100644
--- a/lib/i18n/strings.g.dart
+++ b/lib/i18n/strings.g.dart
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 9
-/// Strings: 6084 (676 per locale)
+/// Strings: 6219 (691 per locale)
///
-/// Built on 2026-02-10 at 21:45 UTC
+/// Built on 2026-02-11 at 13:27 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart
index fb942653..5cbbf0fe 100644
--- a/lib/i18n/strings_de.g.dart
+++ b/lib/i18n/strings_de.g.dart
@@ -72,6 +72,7 @@ class TranslationsDe with BaseTranslations implements T
@override late final _TranslationsShadersDe shaders = _TranslationsShadersDe._(_root);
@override late final _TranslationsCompanionRemoteDe companionRemote = _TranslationsCompanionRemoteDe._(_root);
@override late final _TranslationsVideoSettingsDe videoSettings = _TranslationsVideoSettingsDe._(_root);
+ @override late final _TranslationsExternalPlayerDe externalPlayer = _TranslationsExternalPlayerDe._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsDe implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => 'Leistungsanzeige';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerDe implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerDe._(this._root);
+
+ final TranslationsDe _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => 'Externer Player';
+ @override String get useExternalPlayer => 'Externen Player verwenden';
+ @override String get useExternalPlayerDescription => 'Videos in einer externen App statt im integrierten Player öffnen';
+ @override String get selectPlayer => 'Player auswählen';
+ @override String get systemDefault => 'Systemstandard';
+ @override String get addCustomPlayer => 'Benutzerdefinierten Player hinzufügen';
+ @override String get playerName => 'Playername';
+ @override String get playerCommand => 'Befehl';
+ @override String get playerPackage => 'Paketname';
+ @override String get playerUrlScheme => 'URL-Schema';
+ @override String get customPlayer => 'Benutzerdefinierter Player';
+ @override String get off => 'Aus';
+ @override String get launchFailed => 'Externer Player konnte nicht geöffnet werden';
+ @override String appNotInstalled({required Object name}) => '${name} ist nicht installiert';
+ @override String get playInExternalPlayer => 'In externem Player abspielen';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsDe implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsDe._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsDe {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audioausgabe',
'videoSettings.performanceOverlay' => 'Leistungsanzeige',
+ 'externalPlayer.title' => 'Externer Player',
+ 'externalPlayer.useExternalPlayer' => 'Externen Player verwenden',
+ 'externalPlayer.useExternalPlayerDescription' => 'Videos in einer externen App statt im integrierten Player öffnen',
+ 'externalPlayer.selectPlayer' => 'Player auswählen',
+ 'externalPlayer.systemDefault' => 'Systemstandard',
+ 'externalPlayer.addCustomPlayer' => 'Benutzerdefinierten Player hinzufügen',
+ 'externalPlayer.playerName' => 'Playername',
+ 'externalPlayer.playerCommand' => 'Befehl',
+ 'externalPlayer.playerPackage' => 'Paketname',
+ 'externalPlayer.playerUrlScheme' => 'URL-Schema',
+ 'externalPlayer.customPlayer' => 'Benutzerdefinierter Player',
+ 'externalPlayer.off' => 'Aus',
+ 'externalPlayer.launchFailed' => 'Externer Player konnte nicht geöffnet werden',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} ist nicht installiert',
+ 'externalPlayer.playInExternalPlayer' => 'In externem Player abspielen',
_ => null,
};
}
diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart
index 2d1f8050..51b135e5 100644
--- a/lib/i18n/strings_en.g.dart
+++ b/lib/i18n/strings_en.g.dart
@@ -75,6 +75,7 @@ class Translations with BaseTranslations {
late final TranslationsShadersEn shaders = TranslationsShadersEn._(_root);
late final TranslationsCompanionRemoteEn companionRemote = TranslationsCompanionRemoteEn._(_root);
late final TranslationsVideoSettingsEn videoSettings = TranslationsVideoSettingsEn._(_root);
+ late final TranslationsExternalPlayerEn externalPlayer = TranslationsExternalPlayerEn._(_root);
}
// Path: app
@@ -2098,6 +2099,60 @@ class TranslationsVideoSettingsEn {
String get performanceOverlay => 'Performance Overlay';
}
+// Path: externalPlayer
+class TranslationsExternalPlayerEn {
+ TranslationsExternalPlayerEn._(this._root);
+
+ final Translations _root; // ignore: unused_field
+
+ // Translations
+
+ /// en: 'External Player'
+ String get title => 'External Player';
+
+ /// en: 'Use External Player'
+ String get useExternalPlayer => 'Use External Player';
+
+ /// en: 'Open videos in an external app instead of the built-in player'
+ String get useExternalPlayerDescription => 'Open videos in an external app instead of the built-in player';
+
+ /// en: 'Select Player'
+ String get selectPlayer => 'Select Player';
+
+ /// en: 'System Default'
+ String get systemDefault => 'System Default';
+
+ /// en: 'Add Custom Player'
+ String get addCustomPlayer => 'Add Custom Player';
+
+ /// en: 'Player Name'
+ String get playerName => 'Player Name';
+
+ /// en: 'Command'
+ String get playerCommand => 'Command';
+
+ /// en: 'Package Name'
+ String get playerPackage => 'Package Name';
+
+ /// en: 'URL Scheme'
+ String get playerUrlScheme => 'URL Scheme';
+
+ /// en: 'Custom Player'
+ String get customPlayer => 'Custom Player';
+
+ /// en: 'Off'
+ String get off => 'Off';
+
+ /// en: 'Failed to open external player'
+ String get launchFailed => 'Failed to open external player';
+
+ /// en: '${name} is not installed'
+ String appNotInstalled({required Object name}) => '${name} is not installed';
+
+ /// en: 'Play in External Player'
+ String get playInExternalPlayer => 'Play in External Player';
+}
+
// Path: hotkeys.actions
class TranslationsHotkeysActionsEn {
TranslationsHotkeysActionsEn._(this._root);
@@ -3180,6 +3235,21 @@ extension on Translations {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio Output',
'videoSettings.performanceOverlay' => 'Performance Overlay',
+ 'externalPlayer.title' => 'External Player',
+ 'externalPlayer.useExternalPlayer' => 'Use External Player',
+ 'externalPlayer.useExternalPlayerDescription' => 'Open videos in an external app instead of the built-in player',
+ 'externalPlayer.selectPlayer' => 'Select Player',
+ 'externalPlayer.systemDefault' => 'System Default',
+ 'externalPlayer.addCustomPlayer' => 'Add Custom Player',
+ 'externalPlayer.playerName' => 'Player Name',
+ 'externalPlayer.playerCommand' => 'Command',
+ 'externalPlayer.playerPackage' => 'Package Name',
+ 'externalPlayer.playerUrlScheme' => 'URL Scheme',
+ 'externalPlayer.customPlayer' => 'Custom Player',
+ 'externalPlayer.off' => 'Off',
+ 'externalPlayer.launchFailed' => 'Failed to open external player',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed',
+ 'externalPlayer.playInExternalPlayer' => 'Play in External Player',
_ => null,
};
}
diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart
index 8df5148c..ef065fea 100644
--- a/lib/i18n/strings_es.g.dart
+++ b/lib/i18n/strings_es.g.dart
@@ -72,6 +72,7 @@ class TranslationsEs with BaseTranslations implements T
@override late final _TranslationsShadersEs shaders = _TranslationsShadersEs._(_root);
@override late final _TranslationsCompanionRemoteEs companionRemote = _TranslationsCompanionRemoteEs._(_root);
@override late final _TranslationsVideoSettingsEs videoSettings = _TranslationsVideoSettingsEs._(_root);
+ @override late final _TranslationsExternalPlayerEs externalPlayer = _TranslationsExternalPlayerEs._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsEs implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => 'Indicador de rendimiento';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerEs implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerEs._(this._root);
+
+ final TranslationsEs _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => 'Reproductor externo';
+ @override String get useExternalPlayer => 'Usar reproductor externo';
+ @override String get useExternalPlayerDescription => 'Abrir vídeos en una app externa en lugar del reproductor integrado';
+ @override String get selectPlayer => 'Seleccionar reproductor';
+ @override String get systemDefault => 'Predeterminado del sistema';
+ @override String get addCustomPlayer => 'Añadir reproductor personalizado';
+ @override String get playerName => 'Nombre del reproductor';
+ @override String get playerCommand => 'Comando';
+ @override String get playerPackage => 'Nombre del paquete';
+ @override String get playerUrlScheme => 'Esquema URL';
+ @override String get customPlayer => 'Reproductor personalizado';
+ @override String get off => 'Desactivado';
+ @override String get launchFailed => 'No se pudo abrir el reproductor externo';
+ @override String appNotInstalled({required Object name}) => '${name} no está instalado';
+ @override String get playInExternalPlayer => 'Reproducir en reproductor externo';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsEs implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsEs._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsEs {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Salida de audio',
'videoSettings.performanceOverlay' => 'Indicador de rendimiento',
+ 'externalPlayer.title' => 'Reproductor externo',
+ 'externalPlayer.useExternalPlayer' => 'Usar reproductor externo',
+ 'externalPlayer.useExternalPlayerDescription' => 'Abrir vídeos en una app externa en lugar del reproductor integrado',
+ 'externalPlayer.selectPlayer' => 'Seleccionar reproductor',
+ 'externalPlayer.systemDefault' => 'Predeterminado del sistema',
+ 'externalPlayer.addCustomPlayer' => 'Añadir reproductor personalizado',
+ 'externalPlayer.playerName' => 'Nombre del reproductor',
+ 'externalPlayer.playerCommand' => 'Comando',
+ 'externalPlayer.playerPackage' => 'Nombre del paquete',
+ 'externalPlayer.playerUrlScheme' => 'Esquema URL',
+ 'externalPlayer.customPlayer' => 'Reproductor personalizado',
+ 'externalPlayer.off' => 'Desactivado',
+ 'externalPlayer.launchFailed' => 'No se pudo abrir el reproductor externo',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} no está instalado',
+ 'externalPlayer.playInExternalPlayer' => 'Reproducir en reproductor externo',
_ => null,
};
}
diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart
index b4c0a902..7f6d1ee3 100644
--- a/lib/i18n/strings_fr.g.dart
+++ b/lib/i18n/strings_fr.g.dart
@@ -72,6 +72,7 @@ class TranslationsFr with BaseTranslations implements T
@override late final _TranslationsShadersFr shaders = _TranslationsShadersFr._(_root);
@override late final _TranslationsCompanionRemoteFr companionRemote = _TranslationsCompanionRemoteFr._(_root);
@override late final _TranslationsVideoSettingsFr videoSettings = _TranslationsVideoSettingsFr._(_root);
+ @override late final _TranslationsExternalPlayerFr externalPlayer = _TranslationsExternalPlayerFr._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsFr implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => 'Superposition de performance';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerFr implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerFr._(this._root);
+
+ final TranslationsFr _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => 'Lecteur externe';
+ @override String get useExternalPlayer => 'Utiliser un lecteur externe';
+ @override String get useExternalPlayerDescription => 'Ouvrir les vidéos dans une application externe au lieu du lecteur intégré';
+ @override String get selectPlayer => 'Sélectionner le lecteur';
+ @override String get systemDefault => 'Par défaut du système';
+ @override String get addCustomPlayer => 'Ajouter un lecteur personnalisé';
+ @override String get playerName => 'Nom du lecteur';
+ @override String get playerCommand => 'Commande';
+ @override String get playerPackage => 'Nom du paquet';
+ @override String get playerUrlScheme => 'Schéma URL';
+ @override String get customPlayer => 'Lecteur personnalisé';
+ @override String get off => 'Désactivé';
+ @override String get launchFailed => 'Impossible d\'ouvrir le lecteur externe';
+ @override String appNotInstalled({required Object name}) => '${name} n\'est pas installé';
+ @override String get playInExternalPlayer => 'Lire dans un lecteur externe';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsFr implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsFr._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsFr {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Sortie audio',
'videoSettings.performanceOverlay' => 'Superposition de performance',
+ 'externalPlayer.title' => 'Lecteur externe',
+ 'externalPlayer.useExternalPlayer' => 'Utiliser un lecteur externe',
+ 'externalPlayer.useExternalPlayerDescription' => 'Ouvrir les vidéos dans une application externe au lieu du lecteur intégré',
+ 'externalPlayer.selectPlayer' => 'Sélectionner le lecteur',
+ 'externalPlayer.systemDefault' => 'Par défaut du système',
+ 'externalPlayer.addCustomPlayer' => 'Ajouter un lecteur personnalisé',
+ 'externalPlayer.playerName' => 'Nom du lecteur',
+ 'externalPlayer.playerCommand' => 'Commande',
+ 'externalPlayer.playerPackage' => 'Nom du paquet',
+ 'externalPlayer.playerUrlScheme' => 'Schéma URL',
+ 'externalPlayer.customPlayer' => 'Lecteur personnalisé',
+ 'externalPlayer.off' => 'Désactivé',
+ 'externalPlayer.launchFailed' => 'Impossible d\'ouvrir le lecteur externe',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} n\'est pas installé',
+ 'externalPlayer.playInExternalPlayer' => 'Lire dans un lecteur externe',
_ => null,
};
}
diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart
index 6209e695..eca8118a 100644
--- a/lib/i18n/strings_it.g.dart
+++ b/lib/i18n/strings_it.g.dart
@@ -72,6 +72,7 @@ class TranslationsIt with BaseTranslations implements T
@override late final _TranslationsShadersIt shaders = _TranslationsShadersIt._(_root);
@override late final _TranslationsCompanionRemoteIt companionRemote = _TranslationsCompanionRemoteIt._(_root);
@override late final _TranslationsVideoSettingsIt videoSettings = _TranslationsVideoSettingsIt._(_root);
+ @override late final _TranslationsExternalPlayerIt externalPlayer = _TranslationsExternalPlayerIt._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsIt implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => 'Overlay prestazioni';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerIt implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerIt._(this._root);
+
+ final TranslationsIt _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => 'Lettore esterno';
+ @override String get useExternalPlayer => 'Usa lettore esterno';
+ @override String get useExternalPlayerDescription => 'Apri i video in un\'app esterna invece del lettore integrato';
+ @override String get selectPlayer => 'Seleziona lettore';
+ @override String get systemDefault => 'Predefinito di sistema';
+ @override String get addCustomPlayer => 'Aggiungi lettore personalizzato';
+ @override String get playerName => 'Nome lettore';
+ @override String get playerCommand => 'Comando';
+ @override String get playerPackage => 'Nome pacchetto';
+ @override String get playerUrlScheme => 'Schema URL';
+ @override String get customPlayer => 'Lettore personalizzato';
+ @override String get off => 'Disattivato';
+ @override String get launchFailed => 'Impossibile aprire il lettore esterno';
+ @override String appNotInstalled({required Object name}) => '${name} non è installato';
+ @override String get playInExternalPlayer => 'Riproduci in lettore esterno';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsIt implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsIt._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsIt {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Uscita audio',
'videoSettings.performanceOverlay' => 'Overlay prestazioni',
+ 'externalPlayer.title' => 'Lettore esterno',
+ 'externalPlayer.useExternalPlayer' => 'Usa lettore esterno',
+ 'externalPlayer.useExternalPlayerDescription' => 'Apri i video in un\'app esterna invece del lettore integrato',
+ 'externalPlayer.selectPlayer' => 'Seleziona lettore',
+ 'externalPlayer.systemDefault' => 'Predefinito di sistema',
+ 'externalPlayer.addCustomPlayer' => 'Aggiungi lettore personalizzato',
+ 'externalPlayer.playerName' => 'Nome lettore',
+ 'externalPlayer.playerCommand' => 'Comando',
+ 'externalPlayer.playerPackage' => 'Nome pacchetto',
+ 'externalPlayer.playerUrlScheme' => 'Schema URL',
+ 'externalPlayer.customPlayer' => 'Lettore personalizzato',
+ 'externalPlayer.off' => 'Disattivato',
+ 'externalPlayer.launchFailed' => 'Impossibile aprire il lettore esterno',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} non è installato',
+ 'externalPlayer.playInExternalPlayer' => 'Riproduci in lettore esterno',
_ => null,
};
}
diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart
index 1b1b606f..e23a0461 100644
--- a/lib/i18n/strings_ko.g.dart
+++ b/lib/i18n/strings_ko.g.dart
@@ -72,6 +72,7 @@ class TranslationsKo with BaseTranslations implements T
@override late final _TranslationsShadersKo shaders = _TranslationsShadersKo._(_root);
@override late final _TranslationsCompanionRemoteKo companionRemote = _TranslationsCompanionRemoteKo._(_root);
@override late final _TranslationsVideoSettingsKo videoSettings = _TranslationsVideoSettingsKo._(_root);
+ @override late final _TranslationsExternalPlayerKo externalPlayer = _TranslationsExternalPlayerKo._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsKo implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => '성능 오버레이';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerKo implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerKo._(this._root);
+
+ final TranslationsKo _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => '외부 플레이어';
+ @override String get useExternalPlayer => '외부 플레이어 사용';
+ @override String get useExternalPlayerDescription => '내장 플레이어 대신 외부 앱에서 동영상 열기';
+ @override String get selectPlayer => '플레이어 선택';
+ @override String get systemDefault => '시스템 기본값';
+ @override String get addCustomPlayer => '사용자 정의 플레이어 추가';
+ @override String get playerName => '플레이어 이름';
+ @override String get playerCommand => '명령어';
+ @override String get playerPackage => '패키지 이름';
+ @override String get playerUrlScheme => 'URL 스킴';
+ @override String get customPlayer => '사용자 정의 플레이어';
+ @override String get off => '꺼짐';
+ @override String get launchFailed => '외부 플레이어를 열 수 없습니다';
+ @override String appNotInstalled({required Object name}) => '${name}이(가) 설치되어 있지 않습니다';
+ @override String get playInExternalPlayer => '외부 플레이어에서 재생';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsKo implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsKo._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsKo {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '오디오 출력',
'videoSettings.performanceOverlay' => '성능 오버레이',
+ 'externalPlayer.title' => '외부 플레이어',
+ 'externalPlayer.useExternalPlayer' => '외부 플레이어 사용',
+ 'externalPlayer.useExternalPlayerDescription' => '내장 플레이어 대신 외부 앱에서 동영상 열기',
+ 'externalPlayer.selectPlayer' => '플레이어 선택',
+ 'externalPlayer.systemDefault' => '시스템 기본값',
+ 'externalPlayer.addCustomPlayer' => '사용자 정의 플레이어 추가',
+ 'externalPlayer.playerName' => '플레이어 이름',
+ 'externalPlayer.playerCommand' => '명령어',
+ 'externalPlayer.playerPackage' => '패키지 이름',
+ 'externalPlayer.playerUrlScheme' => 'URL 스킴',
+ 'externalPlayer.customPlayer' => '사용자 정의 플레이어',
+ 'externalPlayer.off' => '꺼짐',
+ 'externalPlayer.launchFailed' => '외부 플레이어를 열 수 없습니다',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name}이(가) 설치되어 있지 않습니다',
+ 'externalPlayer.playInExternalPlayer' => '외부 플레이어에서 재생',
_ => null,
};
}
diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart
index 2597674a..53b5585d 100644
--- a/lib/i18n/strings_nl.g.dart
+++ b/lib/i18n/strings_nl.g.dart
@@ -72,6 +72,7 @@ class TranslationsNl with BaseTranslations implements T
@override late final _TranslationsShadersNl shaders = _TranslationsShadersNl._(_root);
@override late final _TranslationsCompanionRemoteNl companionRemote = _TranslationsCompanionRemoteNl._(_root);
@override late final _TranslationsVideoSettingsNl videoSettings = _TranslationsVideoSettingsNl._(_root);
+ @override late final _TranslationsExternalPlayerNl externalPlayer = _TranslationsExternalPlayerNl._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsNl implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => 'Prestatie-overlay';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerNl implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerNl._(this._root);
+
+ final TranslationsNl _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => 'Externe speler';
+ @override String get useExternalPlayer => 'Externe speler gebruiken';
+ @override String get useExternalPlayerDescription => 'Open video\'s in een externe app in plaats van de ingebouwde speler';
+ @override String get selectPlayer => 'Speler selecteren';
+ @override String get systemDefault => 'Systeemstandaard';
+ @override String get addCustomPlayer => 'Aangepaste speler toevoegen';
+ @override String get playerName => 'Spelernaam';
+ @override String get playerCommand => 'Commando';
+ @override String get playerPackage => 'Pakketnaam';
+ @override String get playerUrlScheme => 'URL-schema';
+ @override String get customPlayer => 'Aangepaste speler';
+ @override String get off => 'Uit';
+ @override String get launchFailed => 'Kan externe speler niet openen';
+ @override String appNotInstalled({required Object name}) => '${name} is niet geïnstalleerd';
+ @override String get playInExternalPlayer => 'Afspelen in externe speler';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsNl implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsNl._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsNl {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Audio-uitvoer',
'videoSettings.performanceOverlay' => 'Prestatie-overlay',
+ 'externalPlayer.title' => 'Externe speler',
+ 'externalPlayer.useExternalPlayer' => 'Externe speler gebruiken',
+ 'externalPlayer.useExternalPlayerDescription' => 'Open video\'s in een externe app in plaats van de ingebouwde speler',
+ 'externalPlayer.selectPlayer' => 'Speler selecteren',
+ 'externalPlayer.systemDefault' => 'Systeemstandaard',
+ 'externalPlayer.addCustomPlayer' => 'Aangepaste speler toevoegen',
+ 'externalPlayer.playerName' => 'Spelernaam',
+ 'externalPlayer.playerCommand' => 'Commando',
+ 'externalPlayer.playerPackage' => 'Pakketnaam',
+ 'externalPlayer.playerUrlScheme' => 'URL-schema',
+ 'externalPlayer.customPlayer' => 'Aangepaste speler',
+ 'externalPlayer.off' => 'Uit',
+ 'externalPlayer.launchFailed' => 'Kan externe speler niet openen',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is niet geïnstalleerd',
+ 'externalPlayer.playInExternalPlayer' => 'Afspelen in externe speler',
_ => null,
};
}
diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart
index 48fbcf83..638329d1 100644
--- a/lib/i18n/strings_sv.g.dart
+++ b/lib/i18n/strings_sv.g.dart
@@ -72,6 +72,7 @@ class TranslationsSv with BaseTranslations implements T
@override late final _TranslationsShadersSv shaders = _TranslationsShadersSv._(_root);
@override late final _TranslationsCompanionRemoteSv companionRemote = _TranslationsCompanionRemoteSv._(_root);
@override late final _TranslationsVideoSettingsSv videoSettings = _TranslationsVideoSettingsSv._(_root);
+ @override late final _TranslationsExternalPlayerSv externalPlayer = _TranslationsExternalPlayerSv._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsSv implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => 'Prestandaöverlägg';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerSv implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerSv._(this._root);
+
+ final TranslationsSv _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => 'Extern spelare';
+ @override String get useExternalPlayer => 'Använd extern spelare';
+ @override String get useExternalPlayerDescription => 'Öppna videor i en extern app istället för den inbyggda spelaren';
+ @override String get selectPlayer => 'Välj spelare';
+ @override String get systemDefault => 'Systemstandard';
+ @override String get addCustomPlayer => 'Lägg till anpassad spelare';
+ @override String get playerName => 'Spelarnamn';
+ @override String get playerCommand => 'Kommando';
+ @override String get playerPackage => 'Paketnamn';
+ @override String get playerUrlScheme => 'URL-schema';
+ @override String get customPlayer => 'Anpassad spelare';
+ @override String get off => 'Av';
+ @override String get launchFailed => 'Kunde inte öppna extern spelare';
+ @override String appNotInstalled({required Object name}) => '${name} är inte installerad';
+ @override String get playInExternalPlayer => 'Spela i extern spelare';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsSv implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsSv._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsSv {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => 'Ljudutgång',
'videoSettings.performanceOverlay' => 'Prestandaöverlägg',
+ 'externalPlayer.title' => 'Extern spelare',
+ 'externalPlayer.useExternalPlayer' => 'Använd extern spelare',
+ 'externalPlayer.useExternalPlayerDescription' => 'Öppna videor i en extern app istället för den inbyggda spelaren',
+ 'externalPlayer.selectPlayer' => 'Välj spelare',
+ 'externalPlayer.systemDefault' => 'Systemstandard',
+ 'externalPlayer.addCustomPlayer' => 'Lägg till anpassad spelare',
+ 'externalPlayer.playerName' => 'Spelarnamn',
+ 'externalPlayer.playerCommand' => 'Kommando',
+ 'externalPlayer.playerPackage' => 'Paketnamn',
+ 'externalPlayer.playerUrlScheme' => 'URL-schema',
+ 'externalPlayer.customPlayer' => 'Anpassad spelare',
+ 'externalPlayer.off' => 'Av',
+ 'externalPlayer.launchFailed' => 'Kunde inte öppna extern spelare',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} är inte installerad',
+ 'externalPlayer.playInExternalPlayer' => 'Spela i extern spelare',
_ => null,
};
}
diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart
index 3b1a6380..5b1e3f6c 100644
--- a/lib/i18n/strings_zh.g.dart
+++ b/lib/i18n/strings_zh.g.dart
@@ -72,6 +72,7 @@ class TranslationsZh with BaseTranslations implements T
@override late final _TranslationsShadersZh shaders = _TranslationsShadersZh._(_root);
@override late final _TranslationsCompanionRemoteZh companionRemote = _TranslationsCompanionRemoteZh._(_root);
@override late final _TranslationsVideoSettingsZh videoSettings = _TranslationsVideoSettingsZh._(_root);
+ @override late final _TranslationsExternalPlayerZh externalPlayer = _TranslationsExternalPlayerZh._(_root);
}
// Path: app
@@ -961,6 +962,30 @@ class _TranslationsVideoSettingsZh implements TranslationsVideoSettingsEn {
@override String get performanceOverlay => '性能监控';
}
+// Path: externalPlayer
+class _TranslationsExternalPlayerZh implements TranslationsExternalPlayerEn {
+ _TranslationsExternalPlayerZh._(this._root);
+
+ final TranslationsZh _root; // ignore: unused_field
+
+ // Translations
+ @override String get title => '外部播放器';
+ @override String get useExternalPlayer => '使用外部播放器';
+ @override String get useExternalPlayerDescription => '在外部应用中打开视频,而不是使用内置播放器';
+ @override String get selectPlayer => '选择播放器';
+ @override String get systemDefault => '系统默认';
+ @override String get addCustomPlayer => '添加自定义播放器';
+ @override String get playerName => '播放器名称';
+ @override String get playerCommand => '命令';
+ @override String get playerPackage => '包名';
+ @override String get playerUrlScheme => 'URL 方案';
+ @override String get customPlayer => '自定义播放器';
+ @override String get off => '关闭';
+ @override String get launchFailed => '无法打开外部播放器';
+ @override String appNotInstalled({required Object name}) => '${name} 未安装';
+ @override String get playInExternalPlayer => '在外部播放器中播放';
+}
+
// Path: hotkeys.actions
class _TranslationsHotkeysActionsZh implements TranslationsHotkeysActionsEn {
_TranslationsHotkeysActionsZh._(this._root);
@@ -1821,6 +1846,21 @@ extension on TranslationsZh {
'videoSettings.hdr' => 'HDR',
'videoSettings.audioOutput' => '音频输出',
'videoSettings.performanceOverlay' => '性能监控',
+ 'externalPlayer.title' => '外部播放器',
+ 'externalPlayer.useExternalPlayer' => '使用外部播放器',
+ 'externalPlayer.useExternalPlayerDescription' => '在外部应用中打开视频,而不是使用内置播放器',
+ 'externalPlayer.selectPlayer' => '选择播放器',
+ 'externalPlayer.systemDefault' => '系统默认',
+ 'externalPlayer.addCustomPlayer' => '添加自定义播放器',
+ 'externalPlayer.playerName' => '播放器名称',
+ 'externalPlayer.playerCommand' => '命令',
+ 'externalPlayer.playerPackage' => '包名',
+ 'externalPlayer.playerUrlScheme' => 'URL 方案',
+ 'externalPlayer.customPlayer' => '自定义播放器',
+ 'externalPlayer.off' => '关闭',
+ 'externalPlayer.launchFailed' => '无法打开外部播放器',
+ 'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} 未安装',
+ 'externalPlayer.playInExternalPlayer' => '在外部播放器中播放',
_ => null,
};
}
diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json
index bb23c980..d0590a67 100644
--- a/lib/i18n/sv.i18n.json
+++ b/lib/i18n/sv.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "Ljudutgång",
"performanceOverlay": "Prestandaöverlägg"
+ },
+ "externalPlayer": {
+ "title": "Extern spelare",
+ "useExternalPlayer": "Använd extern spelare",
+ "useExternalPlayerDescription": "Öppna videor i en extern app istället för den inbyggda spelaren",
+ "selectPlayer": "Välj spelare",
+ "systemDefault": "Systemstandard",
+ "addCustomPlayer": "Lägg till anpassad spelare",
+ "playerName": "Spelarnamn",
+ "playerCommand": "Kommando",
+ "playerPackage": "Paketnamn",
+ "playerUrlScheme": "URL-schema",
+ "customPlayer": "Anpassad spelare",
+ "off": "Av",
+ "launchFailed": "Kunde inte öppna extern spelare",
+ "appNotInstalled": "${name} är inte installerad",
+ "playInExternalPlayer": "Spela i extern spelare"
}
}
diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json
index f3c30837..3dff9b66 100644
--- a/lib/i18n/zh.i18n.json
+++ b/lib/i18n/zh.i18n.json
@@ -758,5 +758,22 @@
"hdr": "HDR",
"audioOutput": "音频输出",
"performanceOverlay": "性能监控"
+ },
+ "externalPlayer": {
+ "title": "外部播放器",
+ "useExternalPlayer": "使用外部播放器",
+ "useExternalPlayerDescription": "在外部应用中打开视频,而不是使用内置播放器",
+ "selectPlayer": "选择播放器",
+ "systemDefault": "系统默认",
+ "addCustomPlayer": "添加自定义播放器",
+ "playerName": "播放器名称",
+ "playerCommand": "命令",
+ "playerPackage": "包名",
+ "playerUrlScheme": "URL 方案",
+ "customPlayer": "自定义播放器",
+ "off": "关闭",
+ "launchFailed": "无法打开外部播放器",
+ "appNotInstalled": "${name} 未安装",
+ "playInExternalPlayer": "在外部播放器中播放"
}
}
diff --git a/lib/models/external_player_models.dart b/lib/models/external_player_models.dart
new file mode 100644
index 00000000..9d6f59d9
--- /dev/null
+++ b/lib/models/external_player_models.dart
@@ -0,0 +1,238 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:url_launcher/url_launcher.dart';
+
+import '../utils/app_logger.dart';
+
+typedef PlayerLauncher = Future Function(String url);
+
+enum CustomPlayerType { command, urlScheme }
+
+class ExternalPlayer {
+ final String id;
+ final String name;
+ final String? iconAsset;
+ final bool isAvailable;
+ final PlayerLauncher launch;
+ final bool isCustom;
+ final String? customValue; // Only for custom player serialization
+ final CustomPlayerType? customType;
+
+ ExternalPlayer({
+ required this.id,
+ required this.name,
+ this.iconAsset,
+ this.isAvailable = true,
+ required this.launch,
+ this.isCustom = false,
+ this.customValue,
+ this.customType,
+ });
+
+ factory ExternalPlayer.custom({
+ required String id,
+ required String name,
+ required String value,
+ required CustomPlayerType type,
+ }) {
+ return ExternalPlayer(
+ id: id,
+ name: name,
+ isCustom: true,
+ customValue: value,
+ customType: type,
+ launch: (url) => _launchCustom(value, url, type),
+ );
+ }
+
+ factory ExternalPlayer.fromJson(Map json) {
+ final id = json['id'] as String;
+ final known = KnownPlayers.findById(id);
+ if (known != null) return known;
+ return ExternalPlayer.custom(
+ id: id,
+ name: json['name'] as String,
+ value: json['customValue'] as String? ?? '',
+ type: json['customType'] == 'urlScheme' ? CustomPlayerType.urlScheme : CustomPlayerType.command,
+ );
+ }
+
+ Map toJson() => {
+ 'id': id,
+ 'name': name,
+ if (isCustom) 'isCustom': true,
+ if (customValue != null) 'customValue': customValue,
+ if (customType != null) 'customType': customType == CustomPlayerType.urlScheme ? 'urlScheme' : 'command',
+ };
+
+ String toJsonString() => json.encode(toJson());
+
+ static ExternalPlayer fromJsonString(String jsonString) =>
+ ExternalPlayer.fromJson(json.decode(jsonString) as Map);
+
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) || other is ExternalPlayer && runtimeType == other.runtimeType && id == other.id;
+
+ @override
+ int get hashCode => id.hashCode;
+}
+
+// --- Launch helpers ---
+
+Future _launchWithUrl(String url) async {
+ return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
+}
+
+Future _launchAndroidIntent(String url, {required String package}) async {
+ final intentUri = Uri.parse(
+ 'intent:$url#Intent;'
+ 'action=android.intent.action.VIEW;'
+ 'type=video/*;'
+ 'package=$package;'
+ 'end',
+ );
+ try {
+ return await launchUrl(intentUri, mode: LaunchMode.externalApplication);
+ } catch (_) {
+ return _launchWithUrl(url);
+ }
+}
+
+Future _launchUrlScheme(String scheme, String url) async {
+ final playerUrl = scheme.contains('url=') ? '$scheme${Uri.encodeComponent(url)}' : '$scheme$url';
+ final uri = Uri.parse(playerUrl);
+ if (await canLaunchUrl(uri)) {
+ return launchUrl(uri, mode: LaunchMode.externalApplication);
+ }
+ return false;
+}
+
+Future _launchMacApp(String appName, String url) async {
+ try {
+ await Process.start('open', ['-a', appName, url], mode: ProcessStartMode.detached);
+ return true;
+ } catch (e) {
+ appLogger.w('Failed to launch $appName via open -a', error: e);
+ return false;
+ }
+}
+
+Future _launchCommand(String command, String url) async {
+ try {
+ final result = await Process.start(command, [url], mode: ProcessStartMode.detached);
+ appLogger.d('Launched $command with PID: ${result.pid}');
+ return true;
+ } catch (e) {
+ appLogger.w('Failed to launch $command', error: e);
+ return false;
+ }
+}
+
+Future _launchCustom(String value, String url, CustomPlayerType type) async {
+ if (type == CustomPlayerType.urlScheme) {
+ return _launchUrlScheme(value, url);
+ }
+ // Command type
+ if (Platform.isAndroid) {
+ return _launchAndroidIntent(url, package: value);
+ } else if (Platform.isMacOS) {
+ // Try PATH first (e.g. mpv), fall back to open -a (e.g. VLC)
+ if (await _launchCommand(value, url)) return true;
+ return _launchMacApp(value, url);
+ } else {
+ return _launchCommand(value, url);
+ }
+}
+
+// --- Known Players ---
+
+class KnownPlayers {
+ static final systemDefault = ExternalPlayer(id: 'system_default', name: 'System Default', launch: _launchWithUrl);
+
+ static final _allPlayers = [
+ systemDefault,
+ ExternalPlayer(
+ id: 'vlc',
+ name: 'VLC',
+ iconAsset: 'assets/player_icons/vlc.svg',
+ isAvailable: Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
+ launch: (url) async {
+ if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'org.videolan.vlc');
+ if (Platform.isIOS) return _launchUrlScheme('vlc://', url);
+ if (Platform.isMacOS) return _launchMacApp('VLC', url);
+ return _launchCommand('vlc', url);
+ },
+ ),
+ ExternalPlayer(
+ id: 'mpv',
+ name: 'mpv',
+ iconAsset: 'assets/player_icons/mpv.svg',
+ isAvailable: Platform.isAndroid || Platform.isMacOS || Platform.isLinux || Platform.isWindows,
+ launch: (url) async {
+ if (Platform.isAndroid) return _launchAndroidIntent(url, package: 'is.xyz.mpv');
+ return _launchCommand('mpv', url);
+ },
+ ),
+ ExternalPlayer(
+ id: 'iina',
+ name: 'IINA',
+ iconAsset: 'assets/player_icons/iina.png',
+ isAvailable: Platform.isMacOS,
+ launch: (url) => _launchUrlScheme('iina://weblink?url=', url),
+ ),
+ ExternalPlayer(
+ id: 'mx_player',
+ name: 'MX Player',
+ iconAsset: 'assets/player_icons/mx_player.svg',
+ isAvailable: Platform.isAndroid,
+ launch: (url) => _launchAndroidIntent(url, package: 'com.mxtech.videoplayer.ad'),
+ ),
+ ExternalPlayer(
+ id: 'just_player',
+ name: 'Just Player',
+ iconAsset: 'assets/player_icons/just_player.png',
+ isAvailable: Platform.isAndroid,
+ launch: (url) => _launchAndroidIntent(url, package: 'com.brouken.player'),
+ ),
+ ExternalPlayer(
+ id: 'infuse',
+ name: 'Infuse',
+ iconAsset: 'assets/player_icons/infuse.png',
+ isAvailable: Platform.isIOS,
+ launch: (url) => _launchUrlScheme('infuse://x-callback-url/play?url=', url),
+ ),
+ ExternalPlayer(
+ id: 'potplayer',
+ name: 'PotPlayer',
+ iconAsset: 'assets/player_icons/potplayer.png',
+ isAvailable: Platform.isWindows,
+ launch: (url) async {
+ if (await _launchUrlScheme('potplayer://', url)) return true;
+ return _launchCommand('PotPlayerMini64', url);
+ },
+ ),
+ ExternalPlayer(
+ id: 'celluloid',
+ name: 'Celluloid',
+ iconAsset: 'assets/player_icons/celluloid.svg',
+ isAvailable: Platform.isLinux,
+ launch: (url) => _launchCommand('celluloid', url),
+ ),
+ ];
+
+ /// Get players available on the current platform
+ static List getForCurrentPlatform() {
+ return _allPlayers.where((p) => p.isAvailable).toList();
+ }
+
+ /// Find a known player by ID
+ static ExternalPlayer? findById(String id) {
+ try {
+ return _allPlayers.firstWhere((p) => p.id == id);
+ } catch (_) {
+ return null;
+ }
+ }
+}
diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart
new file mode 100644
index 00000000..a3ca5980
--- /dev/null
+++ b/lib/screens/settings/external_player_screen.dart
@@ -0,0 +1,265 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:plezy/widgets/app_icon.dart';
+import 'package:material_symbols_icons/symbols.dart';
+import '../../i18n/strings.g.dart';
+import '../../models/external_player_models.dart';
+import '../../services/settings_service.dart';
+import '../../widgets/desktop_app_bar.dart';
+
+class ExternalPlayerScreen extends StatefulWidget {
+ const ExternalPlayerScreen({super.key});
+
+ @override
+ State createState() => _ExternalPlayerScreenState();
+}
+
+class _ExternalPlayerScreenState extends State {
+ late SettingsService _settingsService;
+ bool _isLoading = true;
+
+ bool _useExternalPlayer = false;
+ ExternalPlayer _selectedPlayer = KnownPlayers.systemDefault;
+ List _customPlayers = [];
+
+ @override
+ void initState() {
+ super.initState();
+ _loadSettings();
+ }
+
+ Future _loadSettings() async {
+ _settingsService = await SettingsService.getInstance();
+
+ setState(() {
+ _useExternalPlayer = _settingsService.getUseExternalPlayer();
+ _selectedPlayer = _settingsService.getSelectedExternalPlayer();
+ _customPlayers = _settingsService.getCustomExternalPlayers();
+ _isLoading = false;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (_isLoading) {
+ return const Scaffold(body: Center(child: CircularProgressIndicator()));
+ }
+
+ final knownPlayers = KnownPlayers.getForCurrentPlatform();
+
+ return Scaffold(
+ body: CustomScrollView(
+ slivers: [
+ CustomAppBar(title: Text(t.externalPlayer.title), pinned: true),
+ SliverPadding(
+ padding: const EdgeInsets.all(16),
+ sliver: SliverList(
+ delegate: SliverChildListDelegate([
+ Card(
+ child: SwitchListTile(
+ secondary: const AppIcon(Symbols.open_in_new_rounded, fill: 1),
+ title: Text(t.externalPlayer.useExternalPlayer),
+ subtitle: Text(t.externalPlayer.useExternalPlayerDescription),
+ value: _useExternalPlayer,
+ onChanged: (value) async {
+ setState(() => _useExternalPlayer = value);
+ await _settingsService.setUseExternalPlayer(value);
+ },
+ ),
+ ),
+ if (_useExternalPlayer) ...[
+ const SizedBox(height: 16),
+ Card(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Text(
+ t.externalPlayer.selectPlayer,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
+ ),
+ ),
+ // Known players
+ ...knownPlayers.map((player) => _buildPlayerTile(player)),
+ // Custom players
+ if (_customPlayers.isNotEmpty) const Divider(),
+ ..._customPlayers.map((player) => _buildPlayerTile(player, isCustom: true)),
+ // Add custom player button
+ const Divider(),
+ ListTile(
+ leading: const AppIcon(Symbols.add_rounded, fill: 1),
+ title: Text(t.externalPlayer.addCustomPlayer),
+ onTap: _showAddCustomPlayerDialog,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ]),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPlayerTile(ExternalPlayer player, {bool isCustom = false}) {
+ final isSelected = _selectedPlayer.id == player.id;
+
+ Widget leading;
+ if (player.iconAsset != null) {
+ leading = ClipRRect(
+ borderRadius: BorderRadius.circular(6),
+ child: player.iconAsset!.endsWith('.svg')
+ ? SvgPicture.asset(player.iconAsset!, width: 32, height: 32)
+ : Image.asset(player.iconAsset!, width: 32, height: 32, errorBuilder: (_, __, ___) {
+ return const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
+ }),
+ );
+ } else if (player.id == 'system_default') {
+ leading = const AppIcon(Symbols.open_in_new_rounded, fill: 1, size: 32);
+ } else {
+ leading = const AppIcon(Symbols.play_circle_rounded, fill: 1, size: 32);
+ }
+
+ return ListTile(
+ leading: leading,
+ title: Text(player.id == 'system_default' ? t.externalPlayer.systemDefault : player.name),
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (isCustom)
+ IconButton(
+ icon: const AppIcon(Symbols.delete_rounded, fill: 1, size: 20),
+ onPressed: () => _deleteCustomPlayer(player),
+ ),
+ AppIcon(
+ isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
+ fill: 1,
+ color: isSelected ? Theme.of(context).colorScheme.primary : null,
+ ),
+ ],
+ ),
+ onTap: () async {
+ setState(() => _selectedPlayer = player);
+ await _settingsService.setSelectedExternalPlayer(player);
+ },
+ );
+ }
+
+ Future _deleteCustomPlayer(ExternalPlayer player) async {
+ await _settingsService.removeCustomExternalPlayer(player.id);
+ setState(() {
+ _customPlayers.removeWhere((p) => p.id == player.id);
+ _selectedPlayer = _settingsService.getSelectedExternalPlayer();
+ });
+ }
+
+ Future _showAddCustomPlayerDialog() async {
+ final nameController = TextEditingController();
+ final valueController = TextEditingController();
+ var selectedType = CustomPlayerType.command;
+
+ final result = await showDialog(
+ context: context,
+ builder: (context) => StatefulBuilder(
+ builder: (context, setDialogState) {
+ final isUrlScheme = selectedType == CustomPlayerType.urlScheme;
+ final String fieldLabel;
+ final String fieldHint;
+ if (isUrlScheme) {
+ fieldLabel = t.externalPlayer.playerUrlScheme;
+ fieldHint = 'myplayer://play?url=';
+ } else if (Platform.isAndroid) {
+ fieldLabel = t.externalPlayer.playerPackage;
+ fieldHint = 'com.example.player';
+ } else {
+ fieldLabel = t.externalPlayer.playerCommand;
+ fieldHint = Platform.isMacOS ? 'mpv' : '/usr/bin/player';
+ }
+
+ return AlertDialog(
+ title: Text(t.externalPlayer.addCustomPlayer),
+ content: SizedBox(
+ width: 300,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: nameController,
+ decoration: InputDecoration(
+ labelText: t.externalPlayer.playerName,
+ hintText: 'My Player',
+ ),
+ autofocus: true,
+ textInputAction: TextInputAction.next,
+ ),
+ const SizedBox(height: 16),
+ SizedBox(
+ width: double.infinity,
+ child: SegmentedButton(
+ segments: [
+ ButtonSegment(
+ value: CustomPlayerType.command,
+ label: Text(Platform.isAndroid
+ ? t.externalPlayer.playerPackage
+ : t.externalPlayer.playerCommand),
+ ),
+ ButtonSegment(
+ value: CustomPlayerType.urlScheme,
+ label: Text(t.externalPlayer.playerUrlScheme),
+ ),
+ ],
+ selected: {selectedType},
+ onSelectionChanged: (value) {
+ setDialogState(() => selectedType = value.first);
+ },
+ ),
+ ),
+ const SizedBox(height: 16),
+ TextField(
+ controller: valueController,
+ decoration: InputDecoration(
+ labelText: fieldLabel,
+ hintText: fieldHint,
+ ),
+ textInputAction: TextInputAction.done,
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
+ FilledButton(
+ onPressed: () {
+ if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) {
+ Navigator.pop(context, true);
+ }
+ },
+ child: Text(t.common.save),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+
+ if (result != true) return;
+
+ final id = 'custom_${DateTime.now().millisecondsSinceEpoch}';
+ final newPlayer = ExternalPlayer.custom(
+ id: id,
+ name: nameController.text,
+ value: valueController.text,
+ type: selectedType,
+ );
+
+ await _settingsService.addCustomExternalPlayer(newPlayer);
+ setState(() {
+ _customPlayers.add(newPlayer);
+ });
+ }
+}
diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart
index b4b796fd..79ff93c0 100644
--- a/lib/screens/settings/settings_screen.dart
+++ b/lib/screens/settings/settings_screen.dart
@@ -31,6 +31,7 @@ import '../../providers/companion_remote_provider.dart';
import '../../screens/companion_remote/mobile_remote_screen.dart';
import '../../widgets/companion_remote/remote_session_dialog.dart';
import 'about_screen.dart';
+import 'external_player_screen.dart';
import 'logs_screen.dart';
import 'mpv_config_screen.dart';
import 'subtitle_styling_screen.dart';
@@ -68,6 +69,7 @@ class _SettingsScreenState extends State with FocusableTab {
static const _kShowUnwatchedCount = 'show_unwatched_count';
static const _kRequireProfileSelectionOnOpen = 'require_profile_selection_on_open';
static const _kPlayerBackend = 'player_backend';
+ static const _kExternalPlayer = 'external_player';
static const _kHardwareDecoding = 'hardware_decoding';
static const _kMatchContentFrameRate = 'match_content_frame_rate';
static const _kBufferSize = 'buffer_size';
@@ -115,6 +117,8 @@ class _SettingsScreenState extends State with FocusableTab {
bool _matchContentFrameRate = false;
bool _useExoPlayer = true; // Android only: ExoPlayer vs MPV
bool _requireProfileSelectionOnOpen = false;
+ bool _useExternalPlayer = false;
+ String _selectedExternalPlayerName = '';
// Update checking state
bool _isCheckingForUpdate = false;
@@ -182,6 +186,8 @@ class _SettingsScreenState extends State with FocusableTab {
_matchContentFrameRate = _settingsService.getMatchContentFrameRate();
_useExoPlayer = _settingsService.getUseExoPlayer();
_requireProfileSelectionOnOpen = _settingsService.getRequireProfileSelectionOnOpen();
+ _useExternalPlayer = _settingsService.getUseExternalPlayer();
+ _selectedExternalPlayerName = _settingsService.getSelectedExternalPlayer().name;
_isLoading = false;
});
}
@@ -410,6 +416,22 @@ class _SettingsScreenState extends State with FocusableTab {
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showPlayerBackendDialog(),
),
+ ListTile(
+ focusNode: _focusTracker.get(_kExternalPlayer),
+ leading: const AppIcon(Symbols.open_in_new_rounded, fill: 1),
+ title: Text(t.externalPlayer.title),
+ subtitle: Text(_useExternalPlayer ? _selectedExternalPlayerName : t.externalPlayer.off),
+ trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
+ onTap: () async {
+ await Navigator.push(context, MaterialPageRoute(builder: (context) => const ExternalPlayerScreen()));
+ // Reload to reflect any changes
+ final s = await settings.SettingsService.getInstance();
+ setState(() {
+ _useExternalPlayer = s.getUseExternalPlayer();
+ _selectedExternalPlayerName = s.getSelectedExternalPlayer().name;
+ });
+ },
+ ),
SwitchListTile(
focusNode: _focusTracker.get(_kHardwareDecoding),
secondary: const AppIcon(Symbols.hardware_rounded, fill: 1),
diff --git a/lib/services/external_player_service.dart b/lib/services/external_player_service.dart
new file mode 100644
index 00000000..1fbddbf9
--- /dev/null
+++ b/lib/services/external_player_service.dart
@@ -0,0 +1,50 @@
+import 'package:flutter/material.dart';
+
+import '../models/external_player_models.dart';
+import '../models/plex_metadata.dart';
+import '../utils/app_logger.dart';
+import '../utils/snackbar_helper.dart';
+import '../i18n/strings.g.dart';
+import 'plex_client.dart';
+import 'settings_service.dart';
+
+class ExternalPlayerService {
+ static Future launch({
+ required BuildContext context,
+ required PlexMetadata metadata,
+ required PlexClient client,
+ int mediaIndex = 0,
+ }) async {
+ try {
+ final playbackData = await client.getVideoPlaybackData(
+ metadata.ratingKey,
+ mediaIndex: mediaIndex,
+ );
+
+ if (!playbackData.hasValidVideoUrl) {
+ if (context.mounted) {
+ showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
+ }
+ return false;
+ }
+
+ final videoUrl = playbackData.videoUrl!;
+ final settings = await SettingsService.getInstance();
+ final player = settings.getSelectedExternalPlayer();
+
+ appLogger.d('Launching external player: ${player.name} with URL: $videoUrl');
+
+ final launched = await player.launch(videoUrl);
+ if (!launched && context.mounted) {
+ showErrorSnackBar(context, t.externalPlayer.appNotInstalled(name: player.name));
+ }
+ return launched;
+ } catch (e) {
+ appLogger.e('Failed to launch external player', error: e);
+ if (context.mounted) {
+ showErrorSnackBar(context, t.externalPlayer.launchFailed);
+ }
+ return false;
+ }
+ }
+}
diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart
index f488c917..7744c95e 100644
--- a/lib/services/settings_service.dart
+++ b/lib/services/settings_service.dart
@@ -4,6 +4,7 @@ import '../models/hotkey_model.dart';
import 'package:plezy/utils/app_logger.dart';
import '../i18n/strings.g.dart';
import '../models/mpv_config_models.dart';
+import '../models/external_player_models.dart';
import 'base_shared_preferences_service.dart';
import '../utils/platform_detector.dart';
@@ -70,6 +71,9 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyShowUnwatchedCount = 'show_unwatched_count';
static const String _keyGlobalShaderPreset = 'global_shader_preset';
static const String _keyRequireProfileSelectionOnOpen = 'require_profile_selection_on_open';
+ static const String _keyUseExternalPlayer = 'use_external_player';
+ static const String _keySelectedExternalPlayer = 'selected_external_player';
+ static const String _keyCustomExternalPlayers = 'custom_external_players';
SettingsService._();
@@ -1048,6 +1052,61 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyRequireProfileSelectionOnOpen) ?? false;
}
+ // External Player
+ Future setUseExternalPlayer(bool enabled) async {
+ await prefs.setBool(_keyUseExternalPlayer, enabled);
+ }
+
+ bool getUseExternalPlayer() {
+ return prefs.getBool(_keyUseExternalPlayer) ?? false;
+ }
+
+ Future setSelectedExternalPlayer(ExternalPlayer player) async {
+ await prefs.setString(_keySelectedExternalPlayer, player.toJsonString());
+ }
+
+ ExternalPlayer getSelectedExternalPlayer() {
+ final jsonString = prefs.getString(_keySelectedExternalPlayer);
+ if (jsonString == null) return KnownPlayers.systemDefault;
+ try {
+ return ExternalPlayer.fromJsonString(jsonString);
+ } catch (_) {
+ return KnownPlayers.systemDefault;
+ }
+ }
+
+ Future setCustomExternalPlayers(List players) async {
+ final jsonString = json.encode(players.map((p) => p.toJson()).toList());
+ await prefs.setString(_keyCustomExternalPlayers, jsonString);
+ }
+
+ List getCustomExternalPlayers() {
+ final jsonString = prefs.getString(_keyCustomExternalPlayers);
+ if (jsonString == null) return [];
+ try {
+ final List decoded = json.decode(jsonString);
+ return decoded.map((e) => ExternalPlayer.fromJson(e as Map)).toList();
+ } catch (_) {
+ return [];
+ }
+ }
+
+ Future addCustomExternalPlayer(ExternalPlayer player) async {
+ final players = getCustomExternalPlayers();
+ players.add(player);
+ await setCustomExternalPlayers(players);
+ }
+
+ Future removeCustomExternalPlayer(String id) async {
+ final players = getCustomExternalPlayers();
+ players.removeWhere((p) => p.id == id);
+ await setCustomExternalPlayers(players);
+ // If the removed player was selected, reset to system default
+ if (getSelectedExternalPlayer().id == id) {
+ await setSelectedExternalPlayer(KnownPlayers.systemDefault);
+ }
+ }
+
// Reset all settings to defaults
Future resetAllSettings() async {
await Future.wait([
@@ -1098,6 +1157,9 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyShowUnwatchedCount),
prefs.remove(_keyGlobalShaderPreset),
prefs.remove(_keyRequireProfileSelectionOnOpen),
+ prefs.remove(_keyUseExternalPlayer),
+ prefs.remove(_keySelectedExternalPlayer),
+ prefs.remove(_keyCustomExternalPlayers),
]);
}
diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart
index fa30aad3..979b6a06 100644
--- a/lib/utils/video_player_navigation.dart
+++ b/lib/utils/video_player_navigation.dart
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import '../mpv/mpv.dart';
import '../models/plex_metadata.dart';
import '../screens/video_player_screen.dart';
+import '../services/external_player_service.dart';
import '../services/settings_service.dart';
+import '../utils/provider_extensions.dart';
import 'app_logger.dart';
const String kVideoPlayerRouteName = '/video_player';
@@ -54,6 +56,26 @@ Future navigateToVideoPlayer(
}
}
+ // Check if external player is enabled (skip for offline mode)
+ if (!isOffline) {
+ try {
+ final settingsService = await SettingsService.getInstance();
+ if (settingsService.getUseExternalPlayer()) {
+ final client = context.getClientForMetadata(metadata);
+ final launched = await ExternalPlayerService.launch(
+ context: context,
+ metadata: metadata,
+ client: client,
+ mediaIndex: mediaIndex,
+ );
+ if (launched) return null;
+ // Fall through to built-in player on failure
+ }
+ } catch (e) {
+ appLogger.w('External player launch failed, falling back to built-in player', error: e);
+ }
+ }
+
// Prevent stacking an identical video player when already active
if (!usePushReplacement &&
VideoPlayerScreenState.activeRatingKey == metadata.ratingKey &&
diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart
index 90e2e0d0..d141ffb8 100644
--- a/lib/widgets/media_context_menu.dart
+++ b/lib/widgets/media_context_menu.dart
@@ -16,6 +16,7 @@ import '../utils/library_refresh_notifier.dart';
import '../utils/snackbar_helper.dart';
import '../utils/dialogs.dart';
import '../utils/focus_utils.dart';
+import '../services/external_player_service.dart';
import '../focus/dpad_navigator.dart';
import '../screens/media_detail_screen.dart';
import '../screens/season_detail_screen.dart';
@@ -217,6 +218,17 @@ class MediaContextMenuState extends State {
menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo));
}
+ // Play in External Player (for episodes and movies)
+ if (mediaType == PlexMediaType.episode || mediaType == PlexMediaType.movie) {
+ menuActions.add(
+ _MenuAction(
+ value: 'play_external',
+ icon: Symbols.open_in_new_rounded,
+ label: t.externalPlayer.playInExternalPlayer,
+ ),
+ );
+ }
+
// Download options (for episodes, movies, shows, and seasons)
if (mediaType == PlexMediaType.episode ||
mediaType == PlexMediaType.movie ||
@@ -417,6 +429,10 @@ class MediaContextMenuState extends State {
await _handleDelete(context, isCollection, isPlaylist);
break;
+ case 'play_external':
+ await _handlePlayExternal(context);
+ break;
+
case 'download':
await _handleDownload(context);
break;
@@ -981,6 +997,18 @@ class MediaContextMenuState extends State {
}
}
+ /// Handle play in external player action
+ Future _handlePlayExternal(BuildContext context) async {
+ final metadata = widget.item as PlexMetadata;
+ final client = _getClientForItem();
+
+ await ExternalPlayerService.launch(
+ context: context,
+ metadata: metadata,
+ client: client,
+ );
+ }
+
/// Handle download action
Future _handleDownload(BuildContext context) async {
final downloadProvider = Provider.of(context, listen: false);
diff --git a/pubspec.lock b/pubspec.lock
index 42d82bf7..5a078048 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.4"
+ android_intent_plus:
+ dependency: "direct main"
+ description:
+ name: android_intent_plus
+ sha256: "2329378af63f49b985cb2e110ac784d08374f1e2b1984be77ba9325b1c8cce11"
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.3.1"
ansicolor:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 10cf5cb0..9289c2b9 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -52,6 +52,7 @@ dependencies:
dart_discord_presence: ^1.1.0
flutter_svg: ^2.2.3
mobile_scanner: ^6.0.2
+ android_intent_plus: ^5.0.2
dev_dependencies:
flutter_test:
@@ -72,6 +73,7 @@ flutter:
- assets/go-noto-current-regular.ttf
- assets/shaders/nvscaler/
- assets/shaders/anime4k/
+ - assets/player_icons/
- assets/rating_icons/
flutter_launcher_icons: