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 @@ + + Adwaita Icon Template + + + + + + + + + + + + + + + + image/svg+xml + + + + GNOME Design Team + + + + + Adwaita Icon Template + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + \ 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: