diff --git a/bin/l10n_organizer.dart b/bin/l10n_organizer.dart new file mode 100644 index 00000000..c6b03089 --- /dev/null +++ b/bin/l10n_organizer.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; +import 'dart:io'; + +void main() { + final files = Directory('${Directory.current.path}/lib/l10n') + .listSync() + .whereType(); + final mirrorFile = File('${Directory.current.path}/lib/l10n/app_en.arb'); + + final mirrorContent = mirrorFile.readAsStringSync(); + final mirrorMap = Map.from(json.decode(mirrorContent)); + + for (final file in files) { + if (file.path == mirrorFile.path) continue; + + final content = file.readAsStringSync(); + final contentMap = Map.from(json.decode(content)); + + final newContentMap = { + for (final key in mirrorMap.keys) key: contentMap[key] ?? mirrorMap[key], + }; + + final newContent = + const JsonEncoder.withIndent(' ').convert(newContentMap); + file.writeAsString(newContent); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0f16dfd1..7238afe2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -46,6 +46,7 @@ "serverNotAddedErrorDescription": "Please check the entered details and ensure the server is online.\n\nIf you are connecting remote, make sure the 7001 and 7002 ports are open to the Bluecherry server!", "noServersAvailable": "No servers available", "error": "Error", + "videoError": "An error happened while trying to play the video.", "ok": "OK", "retry": "Retry", "removeCamera": "Remove Camera", @@ -75,11 +76,6 @@ "fps": "FPS", "date": "Date", "lastUpdate": "Last Update", - "theme": "Theme", - "themeDescription": "Change the appearance of the app", - "system": "System", - "light": "Light", - "dark": "Dark", "screens": "Screens", "directCamera": "Direct Camera", "addServer": "Add Server", @@ -94,7 +90,6 @@ } } }, - "pressBackAgainToExit": "Press back button again to exit", "servers": "Servers", "nServers": "{n, plural, =0{No servers} =1{1 server} other{{n} servers}}", "@nServers": { @@ -128,37 +123,16 @@ "no": "No", "about": "About", "versionText": "Copyright © 2022, Bluecherry LLC.\nAll rights reserved.", - "snooze15": "15 minutes", - "snooze30": "30 minutes", - "snooze60": "1 hour", - "miscellaneous": "Miscellaneous", - "snoozeNotifications": "Snooze Notifications", - "notSnoozed": "Not snoozing", - "snoozeNotificationsUntil": "Snooze notifications until", - "snoozedUntil": "Snoozed until {time}", - "@snoozedUntil": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "cameraViewFit": "Camera Image Fit", - "contain": "Contain", - "fill": "Fill", - "cover": "Cover", "gettingDevices": "Getting devices...", "noDevices": "No devices", "noEventsLoaded": "NO EVENTS LOADED", "noEventsLoadedTips": "• Select the cameras you want to see the events\n• Use the calendar to select a specific date or a date range \n• Use the \"Filter\" button to perform the search", "invalidResponse": "Invalid response received from the server", "cameraOptions": "Options", - "notificationClickBehavior": "Notification Click Behavior", "showFullscreenCamera": "Show in fullscreen", "openInANewWindow": "Open in a new window", "enableAudio": "Enable audio", "disableAudio": "Disable audio", - "showEventsScreen": "Show events history", "addNewServer": "Add new server", "disconnectServer": "Disconnect", "serverOptions": "Server options", @@ -182,6 +156,14 @@ }, "newLayout": "New layout", "editLayout": "Edit layout", + "editSpecificLayout": "Edit {layoutName}", + "@editSpecificLayout": { + "placeholders": { + "layoutName": { + "type": "String" + } + } + }, "exportLayout": "Export layout", "importLayout": "Import layout", "failedToImportMessage": "While attempting to import {layoutName}, we found a device that is connected to a server you are not connected to. Please, connect to the server and try again.\nServer: {server_ip}:{server_port}", @@ -212,6 +194,7 @@ "downloaded": "Downloaded", "downloading": "Downloading", "seeInDownloads": "See in Downloads", + "downloadPath": "Download directory", "delete": "Delete", "showInFiles": "Show in Files", "noDownloads": "You haven't downloaded anything yet :/", @@ -233,7 +216,6 @@ } } }, - "downloadPath": "Download directory", "playbackOptions": "PLAYBACK OPTIONS", "play": "Play", "pause": "Pause", @@ -331,6 +313,7 @@ "setResolutionDescription": "The resolution of the video stream can highly impact the performance of the app. Set the resolution to a lower value to improve performance, or to a higher value to improve quality. You can set the default resolution to every camera in the settings", "hd": "High definition", "defaultResolution": "Default resolution", + "automaticResolution": "Automatic", "p1080": "1080p", "p720": "720p", "p480": "480p", @@ -366,10 +349,62 @@ } } }, + "windows": "Windows", + "linux": "Linux {env}", + "@linux": { + "placeholders": { + "env": { + "type": "String" + } + } + }, "@CURRENT TASKS": {}, "currentTasks": "Current tasks", "noCurrentTasks": "No tasks", "taskFetchingEvent": "Fetching events", "taskFetchingEventsPlayback": "Fetching events playback", - "taskDownloadingEvent": "Downloading event" -} + "taskDownloadingEvent": "Downloading event", + "@@@SETTINGS": {}, + "@@APPEARANCE": {}, + "theme": "Theme", + "themeDescription": "Change the appearance of the app", + "system": "System", + "light": "Light", + "dark": "Dark", + "@@MISC": {}, + "general": "General", + "miscellaneous": "Miscellaneous", + "@Snoozing": {}, + "snooze15": "15 minutes", + "snooze30": "30 minutes", + "snooze60": "1 hour", + "snoozeNotifications": "Snooze Notifications", + "notSnoozed": "Not snoozing", + "snoozeNotificationsUntil": "Snooze notifications until", + "snoozedUntil": "Snoozed until {time}", + "@snoozedUntil": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "@Notification click": {}, + "notificationClickBehavior": "Notification Click Behavior", + "showEventsScreen": "Show events history", + "@@STREAMING": {}, + "streamingSetings": "Streaming settings", + "streamingType": "Streaming type", + "rtspProtocol": "RTSP Protocol", + "camerasSettings": "Cameras settings", + "renderingQuality": "Rendering quality", + "renderingQualityDescription": "The quality of the video rendering. The higher the quality, the more resources it takes.\nWhen automatic, the quality is selected based on the camera resolution.", + "cameraViewFit": "Camera Image Fit", + "cameraViewFitDescription": "The way the video is displayed in the view.", + "contain": "Contain", + "fill": "Fill", + "cover": "Cover", + "@@LOCALIZATION": {}, + "dateLanguage": "Date and Language", + "language": "Language" +} \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d7e749f0..688f6f0c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -39,29 +39,39 @@ "serverName": {} } }, + "serverNotAddedErrorDescription": "Please check the entered details and ensure the server is online.\n\nIf you are connecting remote, make sure the 7001 and 7002 ports are open to the Bluecherry server!", "noServersAvailable": "Aucun serveur disponible", "error": "Erreur", + "videoError": "An error happened while trying to play the video.", "ok": "OK", + "retry": "Retry", "removeCamera": "Enlever caméra", "replaceCamera": "Remplacer caméra", "reloadCamera": "Recharger caméra", "selectACamera": "Sélectionner une caméra", + "switchCamera": "Switch camera", "online": "En ligne", "offline": "Hors ligne", + "live": "LIVE", + "timedOut": "TIMED OUT", + "loading": "LOADING", + "recorded": "RECORDED", "removeFromView": "Retirer de la vue", "addToView": "Ajouter à la vue", + "addAllToView": "Add all to view", "eventBrowser": "Historique d'événements", + "eventsTimeline": "Timeline of Events", "server": "Serveur", "device": "Appareil", "event": "Évènement", "duration": "Durée", "priority": "Priorité", + "next": "Next", + "previous": "Previous", + "lastImageUpdate": "Last Image Update", + "fps": "FPS", "date": "Date", "lastUpdate": "Dernière mise à jour", - "theme": "Thème", - "system": "Système", - "light": "Clair", - "dark": "Sombre", "screens": "Écrans", "directCamera": "Caméra direct", "addServer": "Ajouter serveur", @@ -74,8 +84,16 @@ "serverName": {} } }, - "pressBackAgainToExit": "Appuyez sur le bouton retour de nouveau pour quitter", "servers": "Serveurs", + "nServers": "{n, plural, =0{No servers} =1{1 server} other{{n} servers}}", + "@nServers": { + "placeholders": { + "n": { + "type": "int", + "example": "1" + } + } + }, "dateFormat": "Format de la date", "timeFormat": "Format de l'heure", "nDevices": "{n} appareils", @@ -93,35 +111,18 @@ }, "yes": "Oui", "no": "Non", - "version": "Version", + "about": "About", "versionText": "Copyright © 2022, Bluecherry LLC.\nTout droit réservé.", - "snooze15": "15 minutes", - "snooze30": "30 minutes", - "snooze60": "1 heure", - "miscellaneous": "Divers", - "snoozeNotifications": "Mise en pause des notifications", - "notSnoozed": "Notifications actives", - "snoozeNotificationsUntil": "Notifications en pause jusqu'à", - "snoozedUntil": "Mis en pause jusqu'à {time}", - "@snoozedUntil": { - "placeholders": { - "time": {} - } - }, - "cameraViewFit": "Ajustement de la vue caméra", - "contain": "Contenir", - "fill": "Remplir", - "cover": "Couvrir", "gettingDevices": "Obtention des appareils...", "noDevices": "Aucun appareil", - "noEventsFound": "Aucun événement trouvé", + "noEventsLoaded": "NO EVENTS LOADED", + "noEventsLoadedTips": "• Select the cameras you want to see the events\n• Use the calendar to select a specific date or a date range \n• Use the \"Filter\" button to perform the search", "invalidResponse": "Réponse invalide reçu du serveur", - "notificationClickBehavior": "Action de clic sur les notifications", + "cameraOptions": "Options", "showFullscreenCamera": "Montrer en plein écran", "openInANewWindow": "Ouvrir dans une nouvelle fenêtre", "enableAudio": "Activer l'audio", "disableAudio": "Désactiver l'audio", - "showEventsScreen": "Montrer l'historique d'événements", "addNewServer": "Ajouter un nouveau serveur", "disconnectServer": "Déconnecter", "serverOptions": "Options serveur", @@ -132,22 +133,62 @@ "refreshServer": "Actualiser le serveur", "refresh": "Actualiser", "view": "Vue", + "@Layouts": {}, "cycle": "Cycle", "cycleTogglePeriod": "Période de basculement de cycle", + "fallbackLayoutName": "Layout {layout}", + "@fallbackLayoutName": { + "placeholders": { + "layout": { + "type": "int" + } + } + }, "newLayout": "Nouvelle disposition", + "editLayout": "Edit layout", + "editSpecificLayout": "Edit {layoutName}", + "@editSpecificLayout": { + "placeholders": { + "layoutName": { + "type": "String" + } + } + }, + "exportLayout": "Export layout", + "importLayout": "Import layout", + "failedToImportMessage": "While attempting to import {layoutName}, we found a device that is connected to a server you are not connected to. Please, connect to the server and try again.\nServer: {server_ip}:{server_port}", + "@failedToImportMessage": { + "placeholders": { + "layoutName": { + "type": "String" + }, + "server_ip": { + "type": "String" + }, + "server_port": { + "type": "int" + } + } + }, + "layoutImportFileCorrupted": "The file you attempted to import is corrupted or missing information.", + "layoutImportFileCorruptedWithMessage": "The file you attempted to import is corrupted or missing information: \"{message}\"", "singleView": "Vue unique", "multipleView": "Vue multiple", "compactView": "Vue compacte", "createNewLayout": "Créer une nouvelle disposition", "layoutNameHint": "Nom de la disposition", "layoutTypeHint": "Type de disposition", + "@Downloads": {}, "downloads": "Téléchargements", "download": "Télécharger", "downloaded": "Téléchargé", + "downloading": "Downloading", "seeInDownloads": "Voir dans téléchargements", + "downloadPath": "Emplacement de téléchargement", "delete": "Supprimer", "showInFiles": "Voir dans les fichiers", "noDownloads": "Vous n'avez aucun téléchargements", + "howToDownload": "Go to the \"Events History\" screen to download events.", "downloadTitle": "{event} sur {device} du serveur {server} à {date}", "@downloadTitle": { "placeholders": { @@ -157,7 +198,6 @@ "date": {} } }, - "downloadPath": "Emplacement de téléchargement", "playbackOptions": "OPTION DE LECTURE", "play": "Jouer", "pause": "Pause", @@ -175,12 +215,33 @@ }, "noRecords": "Cette caméra n'a aucun enregistrement dans la période actuelle", "filter": "Filtrer", + "timeFilter": "Time filter", "fromDate": "De", "toDate": "À", "today": "Today", "yesterday": "Yesterday", "never": "never", + "fromToDate": "{from} to {to}", + "@fromToDate": { + "placeholders": { + "from": { + "type": "String" + }, + "to": { + "type": "String" + } + } + }, "allowAlarms": "Permettre les alarmes", + "nextEvents": "Next events", + "nEvents": "{n, plural, =0{No events} =1{1 event} other{{n} events}}", + "@nEvents": { + "placeholders": { + "n": { + "type": "int" + } + } + }, "@Event Priorities": {}, "info": "Information", "warn": "Avertissement", @@ -199,10 +260,12 @@ "systemReboot": "Redémarrage", "systemPowerOutage": "Perte de courant", "unknown": "Inconnu", - "@": {}, "close": "Ouvert", "open": "Fermé", + "collapse": "Collapse", + "expand": "Expand", "@PTZ": {}, + "ptzSupported": "PTZ is supported", "enabledPTZ": "PTZ est activé", "disabledPTZ": "PTZ est désactivé", "move": "Mouvement", @@ -222,11 +285,13 @@ "deletePreset": "Delete preset", "refreshPresets": "Refresh presets", "@Resolution": {}, + "resolution": "Resolution", "selectResolution": "Select resolution", "setResolution": "Set resolution", "setResolutionDescription": "The resolution of the video stream can highly impact the performance of the app. Set the resolution to a lower value to improve performance, or to a higher value to improve quality. You can set the default resolution to every camera in the settings", "hd": "High definition", "defaultResolution": "Default resolution", + "automaticResolution": "Automatic", "p1080": "1080p", "p720": "720p", "p480": "480p", @@ -249,5 +314,71 @@ "newVersionAvailable": "New version available", "installVersion": "Install", "downloadVersion": "Download", - "learnMore": "Learn more" -} + "learnMore": "Learn more", + "failedToUpdate": "Failed to update", + "executableNotFound": "Executable not found", + "runningOn": "Running on {platform}", + "@runningOn": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "windows": "Windows", + "linux": "Linux {env}", + "@linux": { + "placeholders": { + "env": { + "type": "String" + } + } + }, + "@CURRENT TASKS": {}, + "currentTasks": "Current tasks", + "noCurrentTasks": "No tasks", + "taskFetchingEvent": "Fetching events", + "taskFetchingEventsPlayback": "Fetching events playback", + "taskDownloadingEvent": "Downloading event", + "@@@SETTINGS": {}, + "@@APPEARANCE": {}, + "theme": "Thème", + "themeDescription": "Change the appearance of the app", + "system": "Système", + "light": "Clair", + "dark": "Sombre", + "@@MISC": {}, + "general": "General", + "miscellaneous": "Divers", + "@Snoozing": {}, + "snooze15": "15 minutes", + "snooze30": "30 minutes", + "snooze60": "1 heure", + "snoozeNotifications": "Mise en pause des notifications", + "notSnoozed": "Notifications actives", + "snoozeNotificationsUntil": "Notifications en pause jusqu'à", + "snoozedUntil": "Mis en pause jusqu'à {time}", + "@snoozedUntil": { + "placeholders": { + "time": {} + } + }, + "@Notification click": {}, + "notificationClickBehavior": "Action de clic sur les notifications", + "showEventsScreen": "Montrer l'historique d'événements", + "@@STREAMING": {}, + "streamingSetings": "Streaming settings", + "streamingType": "Streaming type", + "rtspProtocol": "RTSP Protocol", + "camerasSettings": "Cameras settings", + "renderingQuality": "Rendering quality", + "renderingQualityDescription": "The quality of the video rendering. The higher the quality, the more resources it takes.\nWhen automatic, the quality is selected based on the camera resolution.", + "cameraViewFit": "Ajustement de la vue caméra", + "cameraViewFitDescription": "The way the video is displayed in the view.", + "contain": "Contenir", + "fill": "Remplir", + "cover": "Couvrir", + "@@LOCALIZATION": {}, + "dateLanguage": "Date and Language", + "language": "Language" +} \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a3ced982..9fb99d32 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -46,6 +46,7 @@ "serverNotAddedErrorDescription": "Sprawdź wprowadzone dane i upewnij się, że serwer jest online.\n\nJeśli łączysz się zdalnie to upewnij się, że porty na serwerzy Blueberry: 7001 i 7002, są otwarte!", "noServersAvailable": "Brak dostępnych serwerów", "error": "Błąd", + "videoError": "An error happened while trying to play the video.", "ok": "OK", "retry": "Spróbuj ponownie", "removeCamera": "Usuń kamerę", @@ -75,11 +76,6 @@ "fps": "FPS", "date": "Data", "lastUpdate": "Ostatnia aktualizacja", - "theme": "Motyw", - "themeDescription": "Zmień wygląd aplikacji", - "system": "Systemowy", - "light": "Jasny", - "dark": "Ciemny", "screens": "Ekrany", "directCamera": "Kamera bezpośrednia", "addServer": "Dodaj serwer", @@ -94,7 +90,6 @@ } } }, - "pressBackAgainToExit": "Naciśnij ponownie przycisk wstecz aby wyjść", "servers": "Serwery", "nServers": "{n, plural, =0{Brak serwerów} =1{1 serwer} other{{n} serwerów}}", "@nServers": { @@ -128,37 +123,16 @@ "no": "Nie", "about": "O programie", "versionText": "Copyright © 2022, Bluecherry LLC.\nAll rights reserved.", - "snooze15": "15 minut", - "snooze30": "30 minut", - "snooze60": "1 godzina", - "miscellaneous": "Różne", - "snoozeNotifications": "Uśpij powiadomienia", - "notSnoozed": "Nie usypiaj", - "snoozeNotificationsUntil": "Uśpij powiadomienia do", - "snoozedUntil": "Uśpiono do {time}", - "@snoozedUntil": { - "placeholders": { - "time": { - "type": "String" - } - } - }, - "cameraViewFit": "Dopasowanie obrazu kamery", - "contain": "Zawartość", - "fill": "Wypełnienie", - "cover": "Pokrycie", "gettingDevices": "Pobieranie urządzeń...", "noDevices": "Brak urządzeń", "noEventsLoaded": "NIE ZAŁADOWANO ZDARZEŃ", "noEventsLoadedTips": "• Wybież kamery do podglądu zdarzeń\n• Użyj kalnedarza żeby wybrać konkretną datę lub zakres \n• Użyj przycisku \"Filtr\" aby wyszukiwać", "invalidResponse": "Odebrano nieprawidłową odpowiedź z serwera", "cameraOptions": "Opcje", - "notificationClickBehavior": "Zachowanie po kliknięciu na powiadomienie", "showFullscreenCamera": "Pokaż na pełnym ekranie", "openInANewWindow": "Otwórz w nowym oknie", "enableAudio": "Włącz dźwięk", "disableAudio": "Wyłącz dźwięk", - "showEventsScreen": "Pokaż historię zdarzeń", "addNewServer": "Dodaj nowy serwer", "disconnectServer": "Rozłącz", "serverOptions": "Opcje serwera", @@ -182,6 +156,14 @@ }, "newLayout": "Nowy układ", "editLayout": "Zmień układ", + "editSpecificLayout": "Edit {layoutName}", + "@editSpecificLayout": { + "placeholders": { + "layoutName": { + "type": "String" + } + } + }, "exportLayout": "Eksportuj układ", "importLayout": "Importuj układ", "failedToImportMessage": "Podczas próby importu {layoutName}, zostało odnalezione urządzenie podłączone do serwera, z którym nie ma połączenia. Podłącz się do tego serwera i spróbuj ponownie.\nSerwer: {server_ip}:{server_port}", @@ -212,6 +194,7 @@ "downloaded": "Pobrane", "downloading": "Pobieranie", "seeInDownloads": "Zobacz w Pobranych", + "downloadPath": "Katalog pobranych", "delete": "Usuń", "showInFiles": "Pokaż w Plikach", "noDownloads": "Jeszcze niczego nie pobrano :/", @@ -233,7 +216,6 @@ } } }, - "downloadPath": "Katalog pobranych", "playbackOptions": "OPCJE ODTWARZANIA", "play": "Odtwarzaj", "pause": "Pauza", @@ -331,6 +313,7 @@ "setResolutionDescription": "Rozdzielczość strumienia wideo może mieć duży wpływ na wydajność aplikacji. Ustaw niższą rozdzielczość aby przyspieszyć działanie lub wyższą żeby zwiększyć jakość obrazu. Można ustawić rozdzielczość domyślną dla każdej kamery w ustawieniach.", "hd": "Wysoka jakość", "defaultResolution": "Rozdzielczość domyślna", + "automaticResolution": "Automatic", "p1080": "1080p", "p720": "720p", "p480": "480p", @@ -366,10 +349,62 @@ } } }, + "windows": "Windows", + "linux": "Linux {env}", + "@linux": { + "placeholders": { + "env": { + "type": "String" + } + } + }, "@CURRENT TASKS": {}, "currentTasks": "Bieżące zadania", "noCurrentTasks": "Brak zadań", "taskFetchingEvent": "Pobieranie zdarzeń", "taskFetchingEventsPlayback": "Pobieranie zdarzeń odtwarania", - "taskDownloadingEvent": "Pobieranie zdarzenia" -} + "taskDownloadingEvent": "Pobieranie zdarzenia", + "@@@SETTINGS": {}, + "@@APPEARANCE": {}, + "theme": "Motyw", + "themeDescription": "Zmień wygląd aplikacji", + "system": "Systemowy", + "light": "Jasny", + "dark": "Ciemny", + "@@MISC": {}, + "general": "General", + "miscellaneous": "Różne", + "@Snoozing": {}, + "snooze15": "15 minut", + "snooze30": "30 minut", + "snooze60": "1 godzina", + "snoozeNotifications": "Uśpij powiadomienia", + "notSnoozed": "Nie usypiaj", + "snoozeNotificationsUntil": "Uśpij powiadomienia do", + "snoozedUntil": "Uśpiono do {time}", + "@snoozedUntil": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "@Notification click": {}, + "notificationClickBehavior": "Zachowanie po kliknięciu na powiadomienie", + "showEventsScreen": "Pokaż historię zdarzeń", + "@@STREAMING": {}, + "streamingSetings": "Streaming settings", + "streamingType": "Streaming type", + "rtspProtocol": "RTSP Protocol", + "camerasSettings": "Cameras settings", + "renderingQuality": "Rendering quality", + "renderingQualityDescription": "The quality of the video rendering. The higher the quality, the more resources it takes.\nWhen automatic, the quality is selected based on the camera resolution.", + "cameraViewFit": "Dopasowanie obrazu kamery", + "cameraViewFitDescription": "The way the video is displayed in the view.", + "contain": "Zawartość", + "fill": "Wypełnienie", + "cover": "Pokrycie", + "@@LOCALIZATION": {}, + "dateLanguage": "Date and Language", + "language": "Language" +} \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb new file mode 100644 index 00000000..b4b0cfc3 --- /dev/null +++ b/lib/l10n/app_pt.arb @@ -0,0 +1,410 @@ +{ + "@@locale": "pt", + "welcome": "Bem vindo!", + "welcomeDescription": "Bem vindo ao Bluecherry Surveillance DVR!\nVamos conectar ao seu servidor DVR em un instante.", + "configure": "Configure um Servidor DVR", + "configureDescription": "Configure uma conexão com seu servidor DVR remoto", + "hostname": "Hostname", + "port": "Porta", + "name": "Nome", + "username": "Nome de usuário", + "password": "Senha", + "savePassword": "Salvar senha", + "useDefault": "Usar Padrão", + "connect": "Conectar", + "connectAutomaticallyAtStartup": "Conectar automaticamente ao iniciar", + "skip": "Pular", + "cancel": "Cancelar", + "letsGo": "Vamos lá!", + "finish": "Concluir", + "letsGoDescription": "Aqui algumas dicas de como começar", + "projectName": "Bluecherry", + "projectDescription": "Powerful Video Surveillance Software", + "website": "Website", + "purchase": "Compras", + "tip0": "Câmeras são mostradas à esquerda. Você pode dar dois cliques ou arrastar a câmera até a visualização para vê-la.", + "tip1": "Use os botões acima das câmeras para criar, salvar e alterar layouts - mesmo com câmeras de múltiplos servidores.", + "tip2": "Dê dois cliques em um servidor para abrir sua página de configuração em uma nova janela, onde você pode configurar câmeras e gravações.", + "tip3": "Aperte o ícone de eventos para abrir o histórico e assistir ou baixar as gravações.", + "errorTextField": "{field} não pode estar vazio.", + "@errorTextField": { + "placeholders": { + "field": { + "type": "String" + } + } + }, + "serverAdded": "Servidor adicionado", + "serverNotAddedError": "{serverName} não pôde ser adicionado.", + "@serverNotAddedError": { + "placeholders": { + "serverName": { + "type": "String" + } + } + }, + "serverNotAddedErrorDescription": "Por favor verifique os dados inseridos e certifique-se que o servidor está online.\n\nSe você está conectando remotamente, certifique-se que as portas 7001 e 7002 estão abertas para o servidor Bluecherry!", + "noServersAvailable": "Nenhum servidor disponível.", + "error": "Erro", + "videoError": "Ocorreu um erro ao tentar reproduzir o vídeo.", + "ok": "OK", + "retry": "Tentar novamente", + "removeCamera": "Remover Câmera", + "replaceCamera": "Substituir Câmera", + "reloadCamera": "Recarregar Câmera", + "selectACamera": "Selecione uma câmera", + "switchCamera": "Trocar câmera", + "online": "Online", + "offline": "Offline", + "live": "AO VIVO", + "timedOut": "EXPIRADO", + "loading": "CARREGANDO", + "recorded": "GRAVADO", + "removeFromView": "Remover do layout", + "addToView": "Adicionar ao layout", + "addAllToView": "Adicionar tudo ao layout", + "eventBrowser": "Histórico de eventos", + "eventsTimeline": "Linha do tempo de eventos", + "server": "Servidor", + "device": "Dispositivo", + "event": "Evento", + "duration": "Duração", + "priority": "Prioridade", + "next": "Próximo", + "previous": "Anterior", + "lastImageUpdate": "Última atualização da imagem", + "fps": "FPS", + "date": "Data", + "lastUpdate": "Última atualização", + "screens": "Câmeras", + "directCamera": "Câmera específica", + "addServer": "Adicionar servidor", + "settings": "Configurações", + "noServersAdded": "Nenhum servidor adicionado", + "editServerInfo": "Editar informações do servidor", + "editServer": "Editar servidor {serverName}", + "@editServer": { + "placeholders": { + "serverName": { + "type": "String" + } + } + }, + "servers": "Servidores", + "nServers": "{n, plural, =0{Nenhum servidor} =1{1 servidor} other{{n} servidores}}", + "@nServers": { + "placeholders": { + "n": { + "type": "int", + "example": "1" + } + } + }, + "dateFormat": "Formato de Data", + "timeFormat": "Formato de Hora", + "nDevices": "{n, plural, =0{Nenhum dispositivo} =1{1 dispositivo} other{{n} dispositivos}}", + "@nDevices": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "remove": "Remover ?", + "removeServerDescription": "{serverName} será removido. Você não poderá mais ver as câmeras deste servidor e não receberá mais notificações.", + "@removeServerDescription": { + "placeholders": { + "serverName": { + "type": "String" + } + } + }, + "yes": "Sim", + "no": "Não", + "about": "Sobre", + "versionText": "Copyright © 2022, Bluecherry LLC.\nTodos os direitos reservados.", + "gettingDevices": "Carregando dispositivos...", + "noDevices": "Nenhum dispositivo", + "noEventsLoaded": "NENHUM EVENTO CARREGADO", + "noEventsLoadedTips": "• Selecione as câmeras cujas você quer ver os eventos\n• Utilize o calendário para selecionar uma data específica ou intervalo de datas \n• Use o botão \"Filtrar\" para pesquisar", + "invalidResponse": "Resposta inválida recebida do servidor", + "cameraOptions": "Opções", + "showFullscreenCamera": "Ver em tela cheia", + "openInANewWindow": "Abrir em nova janela", + "enableAudio": "Ativar audio", + "disableAudio": "Desativar audio", + "addNewServer": "Adicionar novo servidor", + "disconnectServer": "Desconectar", + "serverOptions": "Opções do servidor", + "browseEvents": "Ver eventos", + "eventType": "Tipo do evento", + "configureServer": "Configurar servidor", + "refreshDevices": "Recarregar dispositivos", + "refreshServer": "Recarregar servidor", + "refresh": "Recarregar", + "view": "Layouts", + "@Layouts": {}, + "cycle": "Ciclo", + "cycleTogglePeriod": "Duração da alternância de layouts", + "fallbackLayoutName": "Layout {layout}", + "@fallbackLayoutName": { + "placeholders": { + "layout": { + "type": "int" + } + } + }, + "newLayout": "Novo layout", + "editLayout": "Editar layout", + "editSpecificLayout": "Editar {layoutName}", + "@editSpecificLayout": { + "placeholders": { + "layoutName": { + "type": "String" + } + } + }, + "exportLayout": "Exportar layout", + "importLayout": "Importar layout", + "failedToImportMessage": "Ao tentar importar {layoutName}, achamos um dispositívo que está conectando a um servidor que você não está conectado. Por favor, conecte-se ao servidor e tente novamente.\nServer: {server_ip}:{server_port}", + "@failedToImportMessage": { + "placeholders": { + "layoutName": { + "type": "String" + }, + "server_ip": { + "type": "String" + }, + "server_port": { + "type": "int" + } + } + }, + "layoutImportFileCorrupted": "O arquivo que você tentou importar está corrompido ou faltando informações.", + "layoutImportFileCorruptedWithMessage": "O arquivo que você tentou importar está corrompido ou faltando informações: \"{message}\"", + "singleView": "Câmera única", + "multipleView": "Múltiplas câmeras", + "compactView": "Visualização compacta", + "createNewLayout": "Criar novo layout", + "layoutNameHint": "Nome do layout", + "layoutTypeHint": "Tipo do layout", + "@Downloads": {}, + "downloads": "Downloads", + "download": "Baixar", + "downloaded": "Baixado", + "downloading": "Baixando", + "seeInDownloads": "Ver nos Downloads", + "downloadPath": "Diretório de Download", + "delete": "Deletar", + "showInFiles": "Ver no Explorador de Arquivos", + "noDownloads": "Você ainda não fez o download de nenhum evento :/", + "howToDownload": "Và ao \"Histórico de Eventos\" para fazer o download de eventos.", + "downloadTitle": "{event} de {device} do servidor {server} em {date}", + "@downloadTitle": { + "placeholders": { + "event": { + "type": "String" + }, + "device": { + "type": "String" + }, + "server": { + "type": "String" + }, + "date": { + "type": "String" + } + } + }, + "playbackOptions": "OPÇÕES DE REPRODUÇÃO", + "play": "Reproduzir", + "pause": "Pausar", + "volume": "Volume • {v}", + "@volume": { + "placeholders": { + "v": { + "type": "String" + } + } + }, + "speed": "Velocidade • {s}", + "@speed": { + "placeholders": { + "s": { + "type": "String" + } + } + }, + "noRecords": "Essa câmera não tem gravações neste período.", + "filter": "Filtrar", + "timeFilter": "Filtro de tempo", + "fromDate": "De", + "toDate": "à", + "today": "Hoje", + "yesterday": "Ontem", + "never": "Nunca", + "fromToDate": "{from} à {to}", + "@fromToDate": { + "placeholders": { + "from": { + "type": "String" + }, + "to": { + "type": "String" + } + } + }, + "allowAlarms": "Permitir alarmes", + "nextEvents": "Próximos eventos", + "nEvents": "{n, plural, =0{Nenhum evento} =1{1 evento} other{{n} eventos}}", + "@nEvents": { + "placeholders": { + "n": { + "type": "int" + } + } + }, + "@Event Priorities": {}, + "info": "Info", + "warn": "Aviso", + "alarm": "Alarme", + "critical": "Crítico", + "@Event Types": {}, + "motion": "Movimento", + "continuous": "Contínuo", + "notFound": "Não encontrado", + "cameraVideoLost": "Video Perdido", + "cameraAudioLost": "Audio Perdido", + "systemDiskSpace": "Disk Space", + "systemCrash": "Crash", + "systemBoot": "Startup", + "systemShutdown": "Desligamento", + "systemReboot": "Reincialização", + "systemPowerOutage": "Perda de energia", + "unknown": "Desconhecido", + "close": "Fechar", + "open": "Abrir", + "collapse": "Fechar", + "expand": "Expandir", + "@PTZ": {}, + "ptzSupported": "PTZ é suportado", + "enabledPTZ": "PTZ está ativado", + "disabledPTZ": "PTZ está desativado", + "move": "Movimento", + "stop": "Parar", + "noMovement": "Nenhum movimento", + "moveNorth": "Move up", + "moveSouth": "Move down", + "moveWest": "Move west", + "moveEast": "Move east", + "moveWide": "Afastar", + "moveTele": "Aproximar", + "presets": "Presets", + "noPresets": "Nenhum preset encontado", + "newPreset": "Novo preset", + "goToPreset": "Ir ao preset", + "renamePreset": "Renomear preset", + "deletePreset": "Deletar preset", + "refreshPresets": "Atualizar presets", + "@Resolution": {}, + "resolution": "Resolução", + "selectResolution": "Selecionar resolução", + "setResolution": "Definir resolução", + "setResolutionDescription": "A resolução da renderização do vídeo pode impactar fortemente o desempenho do aplicativo. Defina a resolução para um valor mais baixo para melhorar o desempenho ou para um valor mais alto para melhorar a qualidade. Você pode definir a resolução padrão nas configurações", + "hd": "Alta definição", + "defaultResolution": "Resolução padrão", + "automaticResolution": "Automático", + "p1080": "1080p", + "p720": "720p", + "p480": "480p", + "p360": "360p", + "p240": "240p", + "@updates": {}, + "updates": "Atualizações", + "upToDate": "Você está atualizado.", + "lastChecked": "Última verificação: {date}", + "@lastChecked": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "checkForUpdates": "Procurar atualizações", + "checkingForUpdates": "Procurando atualizações", + "automaticDownloadUpdates": "Baixar atualizações automaticamente", + "automaticDownloadUpdatesDescription": "Seja um dos primeiros a receber as atualizações, correções e melhorias mais recentes assim que lançadas.", + "updateHistory": "Histórico de atualizações", + "newVersionAvailable": "Nova versão disponível!", + "installVersion": "Instalar", + "downloadVersion": "Baixar", + "learnMore": "Saiba mais", + "failedToUpdate": "Falha ao atualizar", + "executableNotFound": "Executável não encontrado", + "runningOn": "Rodando no {platform}", + "@runningOn": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "windows": "Windows", + "linux": "Linux {env}", + "@linux": { + "placeholders": { + "env": { + "type": "String" + } + } + }, + "@CURRENT TASKS": {}, + "currentTasks": "Tarefas", + "noCurrentTasks": "Nenhuma tarefa", + "taskFetchingEvent": "Buscando eventos", + "taskFetchingEventsPlayback": "Fetching events playback", + "taskDownloadingEvent": "Baixando evento", + "@@@SETTINGS": {}, + "@@APPEARANCE": {}, + "theme": "Aparência", + "themeDescription": "Mude a aparência do aplicativo", + "system": "Padrão do Sistema", + "light": "Claro", + "dark": "Escuro", + "@@MISC": {}, + "general": "Geral", + "miscellaneous": "Outros", + "@Snoozing": {}, + "snooze15": "15 minutos", + "snooze30": "30 minutos", + "snooze60": "1 hora", + "snoozeNotifications": "Silenciar notificações", + "notSnoozed": "Não silenciado", + "snoozeNotificationsUntil": "Silenciar notificações até", + "snoozedUntil": "Silenciado até {time}", + "@snoozedUntil": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "@Notification click": {}, + "notificationClickBehavior": "Ação ao clicar na notificação", + "showEventsScreen": "Mostar histórico de eventos", + "@@STREAMING": {}, + "streamingSetings": "Configurações de streaming", + "streamingType": "Tipo de streaming", + "rtspProtocol": "Protocolo RTSP", + "camerasSettings": "Configurações das câmeras", + "renderingQuality": "Qualidade de renderização", + "renderingQualityDescription": "A qualidade de renderização. Quanto maior a qualidade, mais recursos são usados.\nQuando automatico, a resolução é selecionada baseada na resolução da câmera.", + "cameraViewFit": "Ajuste de imagem da câmera", + "cameraViewFitDescription": "Como o vídeo é renderizado na visualização.", + "contain": "Limitar", + "fill": "Preencher", + "cover": "Cobrir", + "@@LOCALIZATION": {}, + "dateLanguage": "Data e Idioma", + "language": "Idioma" +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index b0db212a..0b18e893 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -50,6 +50,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:unity_video_player/unity_video_player.dart'; @@ -231,11 +232,13 @@ class _UnityAppState extends State with WidgetsBindingObserver { debugShowCheckedModeBanner: false, navigatorKey: navigatorKey, navigatorObservers: [NObserver()], + locale: settings.locale, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + LocaleNamesLocalizationsDelegate(), ], supportedLocales: AppLocalizations.supportedLocales, themeMode: settings.themeMode, diff --git a/lib/models/device.dart b/lib/models/device.dart index 7c8004a8..4c842131 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -17,8 +17,11 @@ * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:bluecherry_client/models/server.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; /// A [Device] present on a server. class Device { @@ -82,14 +85,14 @@ class Device { /// If the app is running on the web, then HLS is used, otherwise RTSP is used. String get streamURL { if (kIsWeb) { - return hslURL; + return hlsURL; } else { return rtspURL; } } String get rtspURL { - return Uri( + return Uri.encodeFull(Uri( scheme: 'rtsp', userInfo: '${Uri.encodeComponent(server.login)}' ':' @@ -97,32 +100,71 @@ class Device { host: server.ip, port: server.rtspPort, path: uri, - ).toString(); + ).toString()); } String get mjpegURL { - return Uri( + return Uri.encodeFull(Uri( scheme: 'https', userInfo: '${Uri.encodeComponent(server.login)}' ':' '${Uri.encodeComponent(server.password)}', host: server.ip, - port: server.rtspPort, - path: 'media/mjpeg.php', - query: 'id=$id&multipart=true', - ).toString(); + port: server.port, + pathSegments: ['media', 'mjpeg'], + queryParameters: { + 'multipart': 'true', + 'id': '$id', + }, + ).toString()); } - String get hslURL { - return Uri( + String get hlsURL { + return Uri.encodeFull(Uri( scheme: 'https', userInfo: '${Uri.encodeComponent(server.login)}' ':' '${Uri.encodeComponent(server.password)}', host: server.ip, port: server.port, - path: 'hls/$id/index.m3u8', - ).toString(); + pathSegments: ['hls', '$id', 'index.m3u8'], + ).toString()); + } + + Future getHLSUrl([Device? device]) async { + // return hlsURL; + device ??= this; + var data = { + 'id': device.id.toString(), + 'hostname': device.server.ip, + 'port': device.server.port.toString(), + }; + + final uri = Uri( + scheme: 'https', + userInfo: '${Uri.encodeComponent(device.server.login)}' + ':' + '${Uri.encodeComponent(device.server.password)}', + host: device.server.ip, + port: device.server.port, + path: 'media/hls', + queryParameters: data, + ); + + var response = await http.get(uri); + + if (response.statusCode == 200) { + var ret = json.decode(response.body) as Map; + + if (ret['status'] == 6) { + var hlsLink = ret['msg'][0]; + return Uri.encodeFull(hlsLink); + } + } else { + debugPrint('Request failed with status: ${response.statusCode}'); + } + + return null; } /// Returns the full name of this device, including the server name. diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index d148e5df..1c63d86e 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -23,8 +23,10 @@ import 'package:bluecherry_client/providers/downloads_provider.dart'; import 'package:bluecherry_client/providers/home_provider.dart'; import 'package:bluecherry_client/utils/constants.dart'; import 'package:bluecherry_client/utils/storage.dart'; +import 'package:bluecherry_client/utils/video_player.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:system_date_time_format/system_date_time_format.dart'; import 'package:unity_video_player/unity_video_player.dart'; @@ -45,8 +47,12 @@ class SettingsProvider extends ChangeNotifier { static const kDefaultLayoutCyclingTogglePeriod = Duration(seconds: 30); static Future get kDefaultDownloadsDirectory => DownloadsManager.kDefaultDownloadsDirectory; + static const kDefaultStreamingType = StreamingType.rtsp; + static const kDefaultRTSPProtocol = RTSPProtocol.tcp; + static const kDefaultVideoQuality = RenderingQuality.automatic; // Getters. + Locale get locale => _locale; ThemeMode get themeMode => _themeMode; DateFormat get dateFormat => _dateFormat; DateFormat get timeFormat => _timeFormat; @@ -57,8 +63,16 @@ class SettingsProvider extends ChangeNotifier { String get downloadsDirectory => _downloadsDirectory; bool get layoutCyclingEnabled => _layoutCyclingEnabled; Duration get layoutCyclingTogglePeriod => _layoutCyclingTogglePeriod; + StreamingType get streamingType => _streamingType; + RTSPProtocol get rtspProtocol => _rtspProtocol; + RenderingQuality get videoQuality => _videoQuality; // Setters. + set locale(Locale value) { + _locale = value; + _save(); + } + set themeMode(ThemeMode value) { _themeMode = value; _save().then((_) { @@ -111,6 +125,24 @@ class SettingsProvider extends ChangeNotifier { _save(); } + set streamingType(StreamingType value) { + _streamingType = value; + _save(); + UnityPlayers.reloadAll(); + } + + set rtspProtocol(RTSPProtocol value) { + _rtspProtocol = value; + _save(); + UnityPlayers.reloadAll(); + } + + set videoQuality(RenderingQuality value) { + _videoQuality = value; + _save(); + } + + late Locale _locale; late ThemeMode _themeMode; late DateFormat _dateFormat; late DateFormat _timeFormat; @@ -120,6 +152,9 @@ class SettingsProvider extends ChangeNotifier { late String _downloadsDirectory; late bool _layoutCyclingEnabled; late Duration _layoutCyclingTogglePeriod; + late StreamingType _streamingType; + late RTSPProtocol _rtspProtocol; + late RenderingQuality _videoQuality; /// Initializes the [SettingsProvider] instance & fetches state from `async` /// `package:hive` method-calls. Called before [runApp]. @@ -137,6 +172,7 @@ class SettingsProvider extends ChangeNotifier { Future _save({bool notify = true}) async { await settings.write({ + kHiveLocale: locale.toLanguageTag(), kHiveThemeMode: themeMode.index, kHiveDateFormat: dateFormat.pattern!, kHiveTimeFormat: timeFormat.pattern!, @@ -146,6 +182,9 @@ class SettingsProvider extends ChangeNotifier { kHiveDownloadsDirectorySetting: downloadsDirectory, kHiveLayoutCycling: layoutCyclingEnabled, kHiveLayoutCyclingPeriod: layoutCyclingTogglePeriod.inMilliseconds, + kHiveStreamingType: streamingType.index, + kHiveStreamingProtocol: rtspProtocol.index, + kHiveVideoQuality: videoQuality.index, }); if (notify) notifyListeners(); @@ -160,12 +199,20 @@ class SettingsProvider extends ChangeNotifier { // To circumvent this, we are closing all the existing opened [Hive] [Box]es and re-opening them again. This fetches the latest data. // Though, changes are still not instant. final data = await settings.read() as Map; + if (data.containsKey(kHiveLocale)) { + _locale = Locale(data[kHiveLocale]!); + } else { + _locale = Locale(Intl.getCurrentLocale()); + } if (data.containsKey(kHiveThemeMode)) { _themeMode = ThemeMode.values[data[kHiveThemeMode]!]; } else { _themeMode = kDefaultThemeMode; } final format = SystemDateTimeFormat(); + initializeDateFormatting(_locale.languageCode); + Intl.defaultLocale = _locale.toLanguageTag(); + final systemLocale = Intl.getCurrentLocale(); final timePattern = await format.getTimePattern(); if (data.containsKey(kHiveDateFormat)) { @@ -217,6 +264,24 @@ class SettingsProvider extends ChangeNotifier { _layoutCyclingTogglePeriod = kDefaultLayoutCyclingTogglePeriod; } + if (data.containsKey(kHiveStreamingType)) { + _streamingType = StreamingType.values[data[kHiveStreamingType]!]; + } else { + _streamingType = kDefaultStreamingType; + } + + if (data.containsKey(kHiveStreamingProtocol)) { + _rtspProtocol = RTSPProtocol.values[data[kHiveStreamingProtocol]!]; + } else { + _rtspProtocol = kDefaultRTSPProtocol; + } + + if (data.containsKey(kHiveVideoQuality)) { + _videoQuality = RenderingQuality.values[data[kHiveVideoQuality]!]; + } else { + _videoQuality = kDefaultVideoQuality; + } + notifyListeners(); } @@ -265,3 +330,30 @@ enum NotificationClickBehavior { }; } } + +enum RenderingQuality { + automatic, + p1080, + p720, + p480, + p360, + p240; + + String locale(BuildContext context) { + final loc = AppLocalizations.of(context); + return switch (this) { + RenderingQuality.p1080 => loc.p1080, + RenderingQuality.p720 => loc.p720, + RenderingQuality.p480 => loc.p480, + RenderingQuality.p360 => loc.p360, + RenderingQuality.p240 => loc.p240, + RenderingQuality.automatic => loc.automaticResolution, + }; + } +} + +enum StreamingType { + rtsp, + hls, + mjpeg, +} diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart index 5cea2075..97d08128 100644 --- a/lib/providers/update_provider.dart +++ b/lib/providers/update_provider.dart @@ -384,7 +384,7 @@ class UpdateManager extends ChangeNotifier { return; } - final versions = []; + var versions = []; final doc = XmlDocument.parse(response.body); for (final item in doc.findAllElements('item')) { late String version; @@ -410,9 +410,10 @@ class UpdateManager extends ChangeNotifier { publishedAt: publishedAt, )); } - versions.sort( - (a, b) => Version.parse(a.version).compareTo(Version.parse(b.version)), - ); + // versions.sort( + // (a, b) => a.publishedAt.compareTo(b.publishedAt), + // ); + versions = versions.reversed.toList(); if (versions != this.versions) this.versions = versions; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index c1f5673a..b611fecc 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -38,6 +38,7 @@ const kHiveMobileViewTab = 'mobile_view_current_tab'; const kHiveDesktopLayouts = 'desktop_view_layouts'; const kHiveDesktopCurrentLayout = 'desktop_view_current_layout'; const kHiveNotificationToken = 'notification_token'; +const kHiveLocale = 'locale'; const kHiveThemeMode = 'theme_mode'; const kHiveDateFormat = 'date_format'; const kHiveTimeFormat = 'time_format'; @@ -51,6 +52,9 @@ const kHiveLayoutCycling = 'layout_cycling'; const kHiveLayoutCyclingPeriod = 'layout_cycling_period'; const kHiveAutomaticUpdates = 'automatic_download_updates'; const kHiveLastCheck = 'last_update_check'; +const kHiveStreamingType = 'streaming_type'; +const kHiveStreamingProtocol = 'streaming_protocol'; +const kHiveVideoQuality = 'video_quality'; /// Used as frame buffer size in [DeviceTile], and calculating aspect ratio. Only relevant on desktop. const kDeviceTileWidth = 640.0; diff --git a/lib/utils/video_player.dart b/lib/utils/video_player.dart index 744879f5..4a489c54 100644 --- a/lib/utils/video_player.dart +++ b/lib/utils/video_player.dart @@ -18,6 +18,7 @@ */ import 'package:bluecherry_client/models/device.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:flutter/foundation.dart'; import 'package:unity_video_player/unity_video_player.dart'; @@ -36,14 +37,32 @@ class UnityPlayers with ChangeNotifier { /// Helper method to create a video player with required configuration for a [Device]. static UnityVideoPlayer forDevice(Device device) { - debugPrint(device.streamURL); + final settings = SettingsProvider.instance; final controller = UnityVideoPlayer.create( - quality: UnityVideoQuality.qualityForResolutionY(device.resolutionY), + quality: switch (settings.videoQuality) { + RenderingQuality.p1080 => UnityVideoQuality.p1080, + RenderingQuality.p720 => UnityVideoQuality.p720, + RenderingQuality.p480 => UnityVideoQuality.p480, + RenderingQuality.p360 => UnityVideoQuality.p360, + RenderingQuality.p240 => UnityVideoQuality.p240, + RenderingQuality.automatic => + UnityVideoQuality.qualityForResolutionY(device.resolutionY), + }, ) - ..setDataSource(device.streamURL) ..setVolume(0.0) ..setSpeed(1.0); + Future setSource() async { + final source = switch (settings.streamingType) { + StreamingType.rtsp => device.rtspURL, + StreamingType.hls => (await device.getHLSUrl()) ?? device.hlsURL, + StreamingType.mjpeg => device.mjpegURL, + }; + controller.setDataSource(source); + } + + setSource(); + return controller; } diff --git a/lib/widgets/desktop_buttons.dart b/lib/widgets/desktop_buttons.dart index e4e3f1b6..8435d178 100644 --- a/lib/widgets/desktop_buttons.dart +++ b/lib/widgets/desktop_buttons.dart @@ -129,7 +129,6 @@ class _WindowButtonsState extends State with WindowListener { final canPop = navigatorKey.currentState?.canPop() ?? false; return Material( - color: theme.appBarTheme.backgroundColor, child: Stack(children: [ DragToMoveArea( child: Row(children: [ diff --git a/lib/widgets/device_grid/desktop/desktop_device_grid.dart b/lib/widgets/device_grid/desktop/desktop_device_grid.dart index 5acc5be8..41fc5a24 100644 --- a/lib/widgets/device_grid/desktop/desktop_device_grid.dart +++ b/lib/widgets/device_grid/desktop/desktop_device_grid.dart @@ -435,29 +435,23 @@ class _DesktopTileViewportState extends State { ); final error = UnityVideoView.maybeOf(context)?.error; - if (error != null) { - return Stack(children: [ - Positioned.fill(child: ErrorWarning(message: error)), - PositionedDirectional( - top: 4, - end: 4, - child: closeButton, - ), - PositionedDirectional( - bottom: 6.0, - end: 6.0, - child: VideoStatusLabel( - video: UnityVideoView.of(context), - device: widget.device, - ), - ), - ]); - } - final video = UnityVideoView.maybeOf(context); - final isSubView = AlternativeWindow.maybeOf(context) != null; + final reloadButton = IconButton( + icon: Icon( + Icons.replay_outlined, + shadows: outlinedText(), + ), + tooltip: loc.reloadCamera, + color: Colors.white, + iconSize: 18.0, + onPressed: () async { + await UnityPlayers.reloadDevice(widget.device); + setState(() {}); + }, + ); + Widget foreground = PTZController( enabled: ptzEnabled, device: widget.device, @@ -465,6 +459,8 @@ class _DesktopTileViewportState extends State { final states = HoverButton.of(context).states; return Stack(children: [ + if (error != null) + Positioned.fill(child: ErrorWarning(message: error)), Padding( padding: const EdgeInsetsDirectional.symmetric( horizontal: 12.0, @@ -496,7 +492,7 @@ class _DesktopTileViewportState extends State { child: PTZData(commands: commands), ), if (video != null) ...[ - if (!widget.controller!.isSeekable) + if (!widget.controller!.isSeekable && error == null) const Center( child: SizedBox( height: 20.0, @@ -511,118 +507,105 @@ class _DesktopTileViewportState extends State { end: 0, start: 0, bottom: 4.0, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (states.isHovering) ...[ - const SizedBox(width: 12.0), - if (widget.device.hasPTZ) - PTZToggleButton( - ptzEnabled: ptzEnabled, - onChanged: (enabled) => - setState(() => ptzEnabled = enabled), - ), - const Spacer(), - () { - final isMuted = volume == 0.0; - - return IconButton( - icon: Icon( - isMuted - ? Icons.volume_mute_rounded - : Icons.volume_up_rounded, - shadows: outlinedText(), - ), - tooltip: isMuted ? loc.enableAudio : loc.disableAudio, - color: Colors.white, - iconSize: 18.0, - onPressed: () async { - if (isMuted) { - await widget.controller!.setVolume(1.0); - } else { - await widget.controller!.setVolume(0.0); - } - - updateVolume(); - }, - ); - }(), - if (isDesktopPlatform && !isSubView) - IconButton( - icon: Icon( - Icons.open_in_new_sharp, - shadows: outlinedText(), - ), - tooltip: loc.openInANewWindow, - color: Colors.white, - iconSize: 18.0, - onPressed: () { - widget.device.openInANewWindow(); - }, + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + if (states.isHovering && error == null) ...[ + const SizedBox(width: 12.0), + if (widget.device.hasPTZ) + PTZToggleButton( + ptzEnabled: ptzEnabled, + onChanged: (enabled) => + setState(() => ptzEnabled = enabled), + ), + const Spacer(), + () { + final isMuted = volume == 0.0; + + return IconButton( + icon: Icon( + isMuted + ? Icons.volume_mute_rounded + : Icons.volume_up_rounded, + shadows: outlinedText(), ), - if (!isSubView) - IconButton( - icon: Icon( - Icons.fullscreen_rounded, - shadows: outlinedText(), - ), - tooltip: loc.showFullscreenCamera, - color: Colors.white, - iconSize: 18.0, - onPressed: () async { - var player = UnityPlayers.players[widget.device]; - var isLocalController = false; - if (player == null) { - player = UnityPlayers.forDevice(widget.device); - isLocalController = true; - } - - await Navigator.of(context).pushNamed( - '/fullscreen', - arguments: { - 'device': widget.device, - 'player': player, - 'ptzEnabled': ptzEnabled, - }, - ); - if (isLocalController) await player.release(); - }, + tooltip: isMuted ? loc.enableAudio : loc.disableAudio, + color: Colors.white, + iconSize: 18.0, + onPressed: () async { + if (isMuted) { + await widget.controller!.setVolume(1.0); + } else { + await widget.controller!.setVolume(0.0); + } + + updateVolume(); + }, + ); + }(), + if (isDesktopPlatform && !isSubView) + IconButton( + icon: Icon( + Icons.open_in_new_sharp, + shadows: outlinedText(), ), + tooltip: loc.openInANewWindow, + color: Colors.white, + iconSize: 18.0, + onPressed: () { + widget.device.openInANewWindow(); + }, + ), + if (!isSubView) IconButton( icon: Icon( - Icons.replay_outlined, + Icons.fullscreen_rounded, shadows: outlinedText(), ), - tooltip: loc.reloadCamera, + tooltip: loc.showFullscreenCamera, color: Colors.white, iconSize: 18.0, onPressed: () async { - await UnityPlayers.reloadDevice(widget.device); - setState(() {}); + var player = UnityPlayers.players[widget.device]; + var isLocalController = false; + if (player == null) { + player = UnityPlayers.forDevice(widget.device); + isLocalController = true; + } + + await Navigator.of(context).pushNamed( + '/fullscreen', + arguments: { + 'device': widget.device, + 'player': player, + 'ptzEnabled': ptzEnabled, + }, + ); + if (isLocalController) await player.release(); }, ), - CameraViewFitButton( - fit: context - .findAncestorWidgetOfExactType() - ?.fit ?? - SettingsProvider.instance.cameraViewFit, - onChanged: widget.onFitChanged, - ), - ], - const SizedBox(width: 12.0), - Padding( - padding: const EdgeInsetsDirectional.only( - end: 6.0, - bottom: 6.0, - ), - child: VideoStatusLabel( - video: video, - device: widget.device, - ), + reloadButton, + CameraViewFitButton( + fit: context + .findAncestorWidgetOfExactType() + ?.fit ?? + SettingsProvider.instance.cameraViewFit, + onChanged: widget.onFitChanged, ), + ] else ...[ + const Spacer(), + if (states.isHovering) reloadButton, ], - ), + const SizedBox(width: 12.0), + Padding( + padding: const EdgeInsetsDirectional.only( + end: 6.0, + bottom: 6.0, + ), + child: VideoStatusLabel( + video: video, + device: widget.device, + ), + ), + ]), ), if (!isSubView && view.currentLayout.devices.contains(widget.device)) diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart index b4e5b147..8d162096 100644 --- a/lib/widgets/device_grid/desktop/layout_manager.dart +++ b/lib/widgets/device_grid/desktop/layout_manager.dart @@ -571,7 +571,7 @@ class _EditLayoutDialogState extends State { return AlertDialog( title: Row( children: [ - Expanded(child: Text('Edit ${widget.layout.name}')), + Expanded(child: Text(loc.editSpecificLayout(widget.layout.name))), if (view.layouts.length > 1) IconButton( icon: Icon( diff --git a/lib/widgets/device_grid/video_status_label.dart b/lib/widgets/device_grid/video_status_label.dart index 96de7404..b191a5ed 100644 --- a/lib/widgets/device_grid/video_status_label.dart +++ b/lib/widgets/device_grid/video_status_label.dart @@ -57,10 +57,10 @@ class _VideoStatusLabelState extends State { String get _source => widget.video.player.dataSource!; bool get isLive => widget.video.player.dataSource != null && - // It is only LIVE if it starts with rtsp or is hsl + // It is only LIVE if it starts with rtsp or is hls (_source.startsWith('rtsp') || - _source.contains('media/mjpeg.php') || - _source.endsWith('index.m3u8') /* hsl */); + _source.contains('media/mjpeg') || + _source.contains('.m3u8') /* hls */); _VideoLabel get status => widget.video.error != null ? _VideoLabel.error diff --git a/lib/widgets/error_warning.dart b/lib/widgets/error_warning.dart index 3618edcd..4c000f16 100644 --- a/lib/widgets/error_warning.dart +++ b/lib/widgets/error_warning.dart @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -27,27 +28,33 @@ class ErrorWarning extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.warning, - color: Colors.white70, - size: 32.0, - ), - if (message.isNotEmpty) ...[ - const SizedBox(height: 8.0), - Text( - message.toUpperCase(), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white70, - fontSize: 12.0, - ), + final loc = AppLocalizations.of(context); + return IgnorePointer( + child: ColoredBox( + color: Colors.black38, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.warning, color: Colors.white, size: 32.0), + AutoSizeText( + loc.videoError, + style: const TextStyle(color: Colors.white), + maxLines: 1, ), + if (message.isNotEmpty) ...[ + const FractionallySizedBox( + widthFactor: 0.5, + child: Divider(color: Colors.white), + ), + const SizedBox(height: 8.0), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white, fontSize: 12.0), + ), + ], ], - ], + ), ), ); } diff --git a/lib/widgets/settings/desktop/date_language.dart b/lib/widgets/settings/desktop/date_language.dart new file mode 100644 index 00000000..dba90336 --- /dev/null +++ b/lib/widgets/settings/desktop/date_language.dart @@ -0,0 +1,130 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/settings.dart'; +import 'package:bluecherry_client/widgets/settings/mobile/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; +import 'package:provider/provider.dart'; + +class LocalizationSettings extends StatelessWidget { + const LocalizationSettings({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + + return ListView(padding: DesktopSettings.verticalPadding, children: [ + Padding( + padding: DesktopSettings.horizontalPadding, + child: Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + child: const LanguageSection(), + ), + ), + const SizedBox(height: 12.0), + Padding( + padding: DesktopSettings.horizontalPadding, + child: Text(loc.dateFormat, style: theme.textTheme.titleMedium), + ), + const SizedBox(height: 8.0), + Padding( + padding: DesktopSettings.horizontalPadding, + child: Material( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.circular(8.0), + ), + child: const DateFormatSection(), + ), + ), + const SizedBox(height: 12.0), + Padding( + padding: DesktopSettings.horizontalPadding, + child: Text(loc.timeFormat, style: theme.textTheme.titleMedium), + ), + const SizedBox(height: 8.0), + Padding( + padding: DesktopSettings.horizontalPadding, + child: Material( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.circular(8.0), + ), + child: const TimeFormatSection(), + ), + ), + ]); + } +} + +class LanguageSection extends StatelessWidget { + const LanguageSection({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + final settings = context.watch(); + final currentLocale = Localizations.localeOf(context); + const locales = AppLocalizations.supportedLocales; + final names = LocaleNames.of(context)!; + + return DropdownButtonHideUnderline( + child: ListTile( + title: Text(loc.language, style: theme.textTheme.titleMedium), + trailing: DropdownButton( + value: currentLocale, + onChanged: (value) => settings.locale = value!, + items: locales.map((locale) { + final name = + names.nameOf(locale.toLanguageTag()) ?? locale.toLanguageTag(); + final nativeName = LocaleNamesLocalizationsDelegate + .nativeLocaleNames[locale.toLanguageTag()] ?? + locale.toLanguageTag(); + return DropdownMenuItem( + value: locale, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + name.uppercaseFirst(), + maxLines: 1, + softWrap: false, + ), + Text( + nativeName.uppercaseFirst(), + style: theme.textTheme.labelSmall, + ), + ], + ), + ); + }).toList(), + ), + ), + ); + } +} diff --git a/lib/widgets/settings/desktop/general.dart b/lib/widgets/settings/desktop/general.dart new file mode 100644 index 00000000..722d71cc --- /dev/null +++ b/lib/widgets/settings/desktop/general.dart @@ -0,0 +1,201 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/settings.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +class GeneralSettings extends StatelessWidget { + const GeneralSettings({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + final settings = context.watch(); + return ListView(padding: DesktopSettings.verticalPadding, children: [ + SubHeader( + loc.theme, + subtext: loc.themeDescription, + padding: DesktopSettings.horizontalPadding, + ), + ...ThemeMode.values.map((e) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: Icon(switch (e) { + ThemeMode.system => Icons.brightness_auto, + ThemeMode.light => Icons.light_mode, + ThemeMode.dark => Icons.dark_mode, + }), + ), + onTap: () { + settings.themeMode = e; + }, + trailing: Radio( + value: e, + groupValue: settings.themeMode, + onChanged: (_) => settings.themeMode = e, + ), + title: Text(switch (e) { + ThemeMode.system => loc.system, + ThemeMode.light => loc.light, + ThemeMode.dark => loc.dark, + }), + subtitle: e == ThemeMode.system + ? Text(switch (MediaQuery.platformBrightnessOf(context)) { + Brightness.dark => loc.dark, + Brightness.light => loc.light, + }) + : null, + ); + }), + SubHeader(loc.miscellaneous, padding: DesktopSettings.horizontalPadding), + CorrectedListTile( + iconData: Icons.notifications_paused, + onTap: () async { + if (settings.snoozedUntil.isAfter(DateTime.now())) { + settings.snoozedUntil = SettingsProvider.defaultSnoozedUntil; + } else { + final timeOfDay = await showTimePicker( + context: context, + helpText: loc.snoozeNotificationsUntil.toUpperCase(), + initialTime: TimeOfDay.fromDateTime(DateTime.now()), + useRootNavigator: false, + ); + if (timeOfDay != null) { + settings.snoozedUntil = DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + timeOfDay.hour, + timeOfDay.minute, + ); + } + } + }, + title: loc.snoozeNotifications, + height: 72.0, + subtitle: settings.snoozedUntil.isAfter(DateTime.now()) + ? loc.snoozedUntil( + [ + if (settings.snoozedUntil.difference(DateTime.now()) > + const Duration(hours: 24)) + settings.formatDate(settings.snoozedUntil), + settings.formatTime(settings.snoozedUntil), + ].join(' '), + ) + : loc.notSnoozed, + ), + ExpansionTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.beenhere_rounded), + ), + title: Text(loc.notificationClickBehavior), + textColor: theme.textTheme.bodyLarge?.color, + subtitle: Text( + settings.notificationClickBehavior.locale(context), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + ), + children: NotificationClickBehavior.values.map((behavior) { + return RadioListTile( + contentPadding: const EdgeInsetsDirectional.only( + start: 68.0, + end: 16.0, + ), + value: behavior, + groupValue: settings.notificationClickBehavior, + onChanged: (value) { + settings.notificationClickBehavior = behavior; + }, + secondary: Icon(behavior.icon), + controlAffinity: ListTileControlAffinity.trailing, + title: Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: Text(behavior.locale(context)), + ), + ); + }).toList(), + ), + CorrectedListTile( + iconData: Icons.folder, + trailing: Icons.navigate_next, + title: loc.downloadPath, + subtitle: settings.downloadsDirectory, + height: 72.0, + onTap: () async { + final selectedDirectory = await FilePicker.platform.getDirectoryPath( + dialogTitle: loc.downloadPath, + initialDirectory: settings.downloadsDirectory, + lockParentWindow: true, + ); + + if (selectedDirectory != null) { + settings.downloadsDirectory = Directory(selectedDirectory).path; + } + }, + ), + ExpansionTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.timelapse), + ), + title: Text(loc.cycleTogglePeriod), + textColor: theme.textTheme.bodyLarge?.color, + subtitle: Text( + settings.layoutCyclingTogglePeriod.humanReadable(context), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + ), + children: [5, 10, 30, 60, 60 * 5].map((e) { + final dur = Duration(seconds: e); + return RadioListTile( + value: dur, + groupValue: settings.layoutCyclingTogglePeriod, + onChanged: (value) { + settings.layoutCyclingTogglePeriod = dur; + }, + secondary: const Icon(null), + controlAffinity: ListTileControlAffinity.trailing, + title: Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: Text( + dur.humanReadable(context), + ), + ), + ); + }).toList(), + ), + ]); + } +} diff --git a/lib/widgets/settings/desktop/server.dart b/lib/widgets/settings/desktop/server.dart new file mode 100644 index 00000000..7d6000c1 --- /dev/null +++ b/lib/widgets/settings/desktop/server.dart @@ -0,0 +1,180 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/settings.dart'; +import 'package:bluecherry_client/widgets/settings/mobile/settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:unity_video_player/unity_video_player.dart'; + +class ServerSettings extends StatelessWidget { + const ServerSettings({super.key}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final theme = Theme.of(context); + return ListView(padding: DesktopSettings.verticalPadding, children: [ + Padding( + padding: DesktopSettings.horizontalPadding, + child: Text(loc.servers, style: theme.textTheme.titleMedium), + ), + const ServersList(), + const SizedBox(height: 8.0), + Padding( + padding: DesktopSettings.horizontalPadding, + child: Text(loc.streamingSetings, style: theme.textTheme.titleMedium), + ), + const SizedBox(height: 8.0), + const Padding( + padding: DesktopSettings.horizontalPadding, + child: StreamingSettings(), + ), + const SizedBox(height: 12.0), + Padding( + padding: DesktopSettings.horizontalPadding, + child: Text(loc.camerasSettings, style: theme.textTheme.titleMedium), + ), + const SizedBox(height: 8.0), + const Padding( + padding: DesktopSettings.horizontalPadding, + child: CamerasSettings(), + ), + ]); + } +} + +class StreamingSettings extends StatelessWidget { + const StreamingSettings({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final loc = AppLocalizations.of(context); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Material( + borderRadius: BorderRadius.circular(6.0), + child: ListTile( + title: Text(loc.streamingType), + trailing: DropdownButton( + value: settings.streamingType, + onChanged: (v) { + if (v != null) { + settings.streamingType = v; + } + }, + items: StreamingType.values.map((q) { + return DropdownMenuItem( + value: q, + child: Text(q.name.toUpperCase()), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 8.0), + Material( + borderRadius: BorderRadius.circular(6.0), + child: ListTile( + enabled: settings.streamingType == StreamingType.rtsp, + title: Text(loc.rtspProtocol), + trailing: DropdownButton( + value: settings.rtspProtocol, + onChanged: settings.streamingType == StreamingType.rtsp + ? (v) { + if (v != null) { + settings.rtspProtocol = v; + } + } + : null, + items: RTSPProtocol.values.map((p) { + return DropdownMenuItem( + value: p, + child: Text(p.name.toUpperCase()), + ); + }).toList(), + ), + ), + ), + ]); + } +} + +class CamerasSettings extends StatelessWidget { + const CamerasSettings({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final loc = AppLocalizations.of(context); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Material( + borderRadius: BorderRadius.circular(6.0), + child: ListTile( + title: Text(loc.renderingQuality), + subtitle: Text(loc.renderingQualityDescription), + trailing: DropdownButton( + value: settings.videoQuality, + onChanged: (v) { + if (v != null) { + settings.videoQuality = v; + } + }, + items: RenderingQuality.values.map((q) { + return DropdownMenuItem( + value: q, + child: Text(q.locale(context)), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 8.0), + Material( + borderRadius: BorderRadius.circular(6.0), + child: ListTile( + title: Text(loc.cameraViewFit), + subtitle: Text(loc.cameraViewFitDescription), + trailing: DropdownButton( + value: settings.cameraViewFit, + onChanged: (v) { + if (v != null) { + settings.cameraViewFit = v; + } + }, + items: UnityVideoFit.values.map((q) { + return DropdownMenuItem( + value: q, + child: Row(children: [ + Icon(q.icon), + const SizedBox(width: 8.0), + Text(q.locale(context)), + ]), + ); + }).toList(), + ), + ), + ), + ]); + } +} diff --git a/lib/widgets/settings/desktop/settings.dart b/lib/widgets/settings/desktop/settings.dart new file mode 100644 index 00000000..d301b0e3 --- /dev/null +++ b/lib/widgets/settings/desktop/settings.dart @@ -0,0 +1,109 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'package:bluecherry_client/utils/constants.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/date_language.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/general.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/server.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/updates.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class DesktopSettings extends StatefulWidget { + const DesktopSettings({super.key}); + + static const horizontalPadding = EdgeInsets.symmetric(horizontal: 24.0); + static const verticalPadding = EdgeInsets.symmetric(vertical: 16.0); + + @override + State createState() => _DesktopSettingsState(); +} + +class _DesktopSettingsState extends State { + int currentIndex = 0; + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final theme = Theme.of(context); + + return LayoutBuilder(builder: (context, constraints) { + return Row(children: [ + NavigationRail( + extended: constraints.maxWidth > + kMobileBreakpoint.width + kMobileBreakpoint.width / 4, + destinations: [ + NavigationRailDestination( + icon: const Icon(Icons.dashboard), + label: Text(loc.general), + ), + NavigationRailDestination( + icon: const Icon(Icons.dns), + label: Text(loc.servers), + ), + NavigationRailDestination( + icon: const Icon(Icons.update), + label: Text(loc.updates), + ), + NavigationRailDestination( + icon: const Icon(Icons.language), + label: Text(loc.dateLanguage), + ), + ], + selectedIndex: currentIndex, + onDestinationSelected: (index) => + setState(() => currentIndex = index), + ), + Expanded( + child: Card( + margin: EdgeInsetsDirectional.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: Radius.circular(12.0), + ), + ), + child: DropdownButtonHideUnderline( + child: Theme( + data: theme.copyWith( + cardTheme: CardTheme( + color: ElevationOverlay.applySurfaceTint( + theme.colorScheme.background, + theme.colorScheme.surfaceTint, + 4, + ), + ), + ), + child: AnimatedSwitcher( + duration: kThemeChangeDuration, + child: switch (currentIndex) { + 0 => const GeneralSettings(), + 1 => const ServerSettings(), + 2 => const UpdatesSettings(), + 3 => const LocalizationSettings(), + _ => const GeneralSettings(), + }, + ), + ), + ), + ), + ), + ]); + }); + } +} diff --git a/lib/widgets/settings/desktop/updates.dart b/lib/widgets/settings/desktop/updates.dart new file mode 100644 index 00000000..e16faf81 --- /dev/null +++ b/lib/widgets/settings/desktop/updates.dart @@ -0,0 +1,67 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:bluecherry_client/providers/update_provider.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/settings.dart'; +import 'package:bluecherry_client/widgets/settings/mobile/update.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +class UpdatesSettings extends StatelessWidget { + const UpdatesSettings({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + final update = context.watch(); + + return ListView(padding: DesktopSettings.verticalPadding, children: [ + Padding( + padding: DesktopSettings.horizontalPadding, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + loc.updates, + style: theme.textTheme.titleMedium, + ), + Text( + loc.runningOn(() { + if (Platform.isLinux) { + return loc.linux(update.linuxEnvironment ?? ''); + } else if (Platform.isWindows) { + return loc.windows; + } + + return defaultTargetPlatform.name; + }()), + style: theme.textTheme.labelSmall, + ), + ]), + ), + const AppUpdateCard(), + const AppUpdateOptions(), + const Divider(), + const About(), + ]); + } +} diff --git a/lib/widgets/settings/date_time.dart b/lib/widgets/settings/mobile/date_time.dart similarity index 77% rename from lib/widgets/settings/date_time.dart rename to lib/widgets/settings/mobile/date_time.dart index 6c3f1bcf..f9db8a4a 100644 --- a/lib/widgets/settings/date_time.dart +++ b/lib/widgets/settings/mobile/date_time.dart @@ -19,31 +19,6 @@ part of 'settings.dart'; -class DateTimeSection extends StatelessWidget { - const DateTimeSection({super.key}); - - @override - Widget build(BuildContext context) { - final loc = AppLocalizations.of(context); - return Column(children: [ - // SubHeader('Language'), - // SliverList( - // delegate: SliverChildListDelegate( - // AppLocalizations.supportedLocales.map((locale) { - // return ListTile( - // title: Text(locale.languageCode), - // ); - // }).toList(), - // ), - // ), - SubHeader(loc.dateFormat), - const DateFormatSection(), - SubHeader(loc.timeFormat), - const TimeFormatSection(), - ]); - } -} - class DateFormatSection extends StatelessWidget { const DateFormatSection({super.key}); @@ -81,6 +56,7 @@ class DateFormatSection extends StatelessWidget { maxLines: 1, softWrap: false, ), + subtitle: Text(format.pattern ?? ''), ), ); }).toList(), @@ -95,12 +71,10 @@ class DateFormatSection extends StatelessWidget { settings.dateFormat = format; }, controlAffinity: ListTileControlAffinity.trailing, - title: Padding( - padding: const EdgeInsetsDirectional.only(start: 8.0), - child: Text( - format.format(DateTime.utc(1969, 7, 20, 14, 18, 04)), - ), + title: Text( + format.format(DateTime.utc(1969, 7, 20, 14, 18, 04)), ), + subtitle: Text(format.pattern ?? ''), ); }).toList(), ); @@ -129,10 +103,8 @@ class TimeFormatSection extends StatelessWidget { groupValue: settings.timeFormat.pattern, onChanged: (value) => settings.timeFormat = format, ), - title: Padding( - padding: const EdgeInsetsDirectional.only(start: 8.0), - child: Text(format.format(date)), - ), + title: Text(format.format(date)), + subtitle: Text(format.pattern ?? ''), ); }).toList(), ); diff --git a/lib/widgets/settings/mobile/settings.dart b/lib/widgets/settings/mobile/settings.dart new file mode 100644 index 00000000..ae502a08 --- /dev/null +++ b/lib/widgets/settings/mobile/settings.dart @@ -0,0 +1,338 @@ +/* + * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). + * + * Copyright 2022 Bluecherry, LLC + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'dart:io'; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:bluecherry_client/models/server.dart'; +import 'package:bluecherry_client/providers/home_provider.dart'; +import 'package:bluecherry_client/providers/server_provider.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/providers/update_provider.dart'; +import 'package:bluecherry_client/utils/constants.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/utils/methods.dart'; +import 'package:bluecherry_client/widgets/edit_server.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/date_language.dart'; +import 'package:bluecherry_client/widgets/settings/mobile/update.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:unity_video_player/unity_video_player.dart'; +import 'package:url_launcher/url_launcher.dart'; + +part 'date_time.dart'; +part '../shared/server_tile.dart'; + +class MobileSettings extends StatefulWidget { + const MobileSettings({super.key}); + + @override + State createState() => _MobileSettingsState(); +} + +class _MobileSettingsState extends State { + @override + void initState() { + super.initState(); + SettingsProvider.instance.reload(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final theme = Theme.of(context); + final settings = context.watch(); + final update = context.watch(); + final servers = context.watch(); + + return Material( + type: MaterialType.transparency, + child: SafeArea( + bottom: false, + child: Column(children: [ + if (isMobile) + AppBar( + leading: MaybeUnityDrawerButton(context), + title: Text(loc.settings), + ), + Expanded( + child: CustomScrollView(slivers: [ + SliverToBoxAdapter( + child: SubHeader( + loc.servers, + subtext: loc.nServers(servers.servers.length), + ), + ), + const SliverToBoxAdapter(child: ServersList()), + SliverToBoxAdapter( + child: SubHeader(loc.theme, subtext: loc.themeDescription), + ), + SliverList.list( + children: ThemeMode.values.map((e) { + return ListTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: Icon(switch (e) { + ThemeMode.system => Icons.brightness_auto, + ThemeMode.light => Icons.light_mode, + ThemeMode.dark => Icons.dark_mode, + }), + ), + onTap: () => settings.themeMode = e, + trailing: Radio( + value: e, + groupValue: settings.themeMode, + onChanged: (value) { + settings.themeMode = e; + }, + ), + title: Text(switch (e) { + ThemeMode.system => loc.system, + ThemeMode.light => loc.light, + ThemeMode.dark => loc.dark, + }), + subtitle: e == ThemeMode.system + ? Text(switch (MediaQuery.platformBrightnessOf(context)) { + Brightness.dark => loc.dark, + Brightness.light => loc.light, + }) + : null, + ); + }).toList()), + SliverToBoxAdapter(child: SubHeader(loc.miscellaneous)), + SliverList.list(children: [ + CorrectedListTile( + iconData: Icons.notifications_paused, + onTap: () async { + if (settings.snoozedUntil.isAfter(DateTime.now())) { + settings.snoozedUntil = + SettingsProvider.defaultSnoozedUntil; + } else { + final timeOfDay = await showTimePicker( + context: context, + helpText: loc.snoozeNotificationsUntil.toUpperCase(), + initialTime: TimeOfDay.fromDateTime(DateTime.now()), + useRootNavigator: false, + ); + if (timeOfDay != null) { + settings.snoozedUntil = DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + timeOfDay.hour, + timeOfDay.minute, + ); + } + } + }, + title: loc.snoozeNotifications, + height: 72.0, + subtitle: settings.snoozedUntil.isAfter(DateTime.now()) + ? loc.snoozedUntil([ + if (settings.snoozedUntil.difference(DateTime.now()) > + const Duration(hours: 24)) + settings.formatDate(settings.snoozedUntil), + settings.formatTime(settings.snoozedUntil), + ].join(' ')) + : loc.notSnoozed, + ), + ExpansionTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.beenhere_rounded), + ), + title: Text(loc.notificationClickBehavior), + textColor: theme.textTheme.bodyLarge?.color, + subtitle: Text( + settings.notificationClickBehavior.locale(context), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + ), + children: NotificationClickBehavior.values.map((behavior) { + return RadioListTile( + contentPadding: const EdgeInsetsDirectional.only( + start: 68.0, + end: 16.0, + ), + value: behavior, + groupValue: settings.notificationClickBehavior, + onChanged: (value) { + settings.notificationClickBehavior = behavior; + }, + secondary: Icon(behavior.icon), + controlAffinity: ListTileControlAffinity.trailing, + title: Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: Text(behavior.locale(context)), + ), + ); + }).toList(), + ), + ExpansionTile( + leading: CircleAvatar( + backgroundColor: const Color.fromRGBO(0, 0, 0, 0), + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.fit_screen), + ), + title: Text(loc.cameraViewFit), + textColor: theme.textTheme.bodyLarge?.color, + subtitle: Text( + settings.cameraViewFit.locale(context), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + ), + children: UnityVideoFit.values.map((e) { + return RadioListTile( + contentPadding: const EdgeInsetsDirectional.only( + start: 68.0, + end: 16.0, + ), + value: e, + groupValue: settings.cameraViewFit, + onChanged: (_) => settings.cameraViewFit = e, + secondary: Icon(e.icon), + controlAffinity: ListTileControlAffinity.trailing, + title: Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: Text(e.locale(context)), + ), + ); + }).toList(), + ), + CorrectedListTile( + iconData: Icons.folder, + trailing: Icons.navigate_next, + title: loc.downloadPath, + subtitle: settings.downloadsDirectory, + height: 72.0, + onTap: () async { + final selectedDirectory = + await FilePicker.platform.getDirectoryPath( + dialogTitle: loc.downloadPath, + initialDirectory: settings.downloadsDirectory, + lockParentWindow: true, + ); + + if (selectedDirectory != null) { + settings.downloadsDirectory = + Directory(selectedDirectory).path; + } + }, + ), + ExpansionTile( + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: theme.iconTheme.color, + child: const Icon(Icons.timelapse), + ), + title: Text(loc.cycleTogglePeriod), + textColor: theme.textTheme.bodyLarge?.color, + subtitle: Text( + settings.layoutCyclingTogglePeriod.humanReadable(context), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + ), + children: [5, 10, 30, 60, 60 * 5].map((e) { + final dur = Duration(seconds: e); + return RadioListTile( + value: dur, + groupValue: settings.layoutCyclingTogglePeriod, + onChanged: (value) { + settings.layoutCyclingTogglePeriod = dur; + }, + secondary: const Icon(null), + controlAffinity: ListTileControlAffinity.trailing, + title: Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: Text(dur.humanReadable(context)), + ), + ); + }).toList(), + ), + ]), + SliverToBoxAdapter( + child: CorrectedListTile( + iconData: Icons.language, + trailing: Icons.navigate_next, + title: loc.dateLanguage, + subtitle: '${settings.dateFormat.format(DateTime.now())} ' + '${settings.timeFormat.format(DateTime.now())}; ' + '${LocaleNames.of(context)!.nameOf(settings.locale.toLanguageTag())}', + height: 72.0, + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) { + return DraggableScrollableSheet( + expand: false, + minChildSize: 0.8, + initialChildSize: 0.8, + builder: (context, controller) { + return PrimaryScrollController( + controller: controller, + child: const LocalizationSettings(), + ); + }, + ); + }, + ); + }, + ), + ), + if (update.isUpdatingSupported) ...[ + SliverToBoxAdapter( + child: SubHeader( + loc.updates, + subtext: loc.runningOn(() { + if (Platform.isLinux) { + return loc.linux(update.linuxEnvironment ?? ''); + } else if (Platform.isWindows) { + return loc.windows; + } + + return defaultTargetPlatform.name; + }()), + ), + ), + const SliverToBoxAdapter(child: AppUpdateCard()), + const SliverToBoxAdapter(child: AppUpdateOptions()), + ], + SliverToBoxAdapter(child: SubHeader(loc.about)), + const SliverToBoxAdapter(child: About()), + const SliverToBoxAdapter(child: SizedBox(height: 16.0)), + ]), + ), + ]), + ), + ); + } +} diff --git a/lib/widgets/settings/update.dart b/lib/widgets/settings/mobile/update.dart similarity index 86% rename from lib/widgets/settings/update.dart rename to lib/widgets/settings/mobile/update.dart index 821b90f7..3cf9f960 100644 --- a/lib/widgets/settings/update.dart +++ b/lib/widgets/settings/mobile/update.dart @@ -19,12 +19,14 @@ import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/providers/update_provider.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/settings.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/link.dart'; +import 'package:url_launcher/url_launcher.dart'; /// The card that displays the update information. class AppUpdateCard extends StatelessWidget { @@ -40,9 +42,10 @@ class AppUpdateCard extends StatelessWidget { if (update.hasUpdateAvailable) { final executable = update.executableFor(update.latestVersion!.version); return Card( - margin: const EdgeInsetsDirectional.only( - start: 10.0, - end: 10.0, + margin: EdgeInsetsDirectional.only( + top: 8.0, + start: DesktopSettings.horizontalPadding.left, + end: DesktopSettings.horizontalPadding.right, bottom: 6.0, ), child: Padding( @@ -106,10 +109,11 @@ class AppUpdateCard extends StatelessWidget { ); } else { return Card( - margin: const EdgeInsetsDirectional.only( - start: 10.0, - end: 10.0, + margin: EdgeInsetsDirectional.only( + top: 8.0, bottom: 6.0, + start: DesktopSettings.horizontalPadding.left, + end: DesktopSettings.horizontalPadding.right, ), child: Padding( padding: const EdgeInsets.all(8.0), @@ -300,7 +304,7 @@ class AppUpdateOptions extends StatelessWidget { const TextSpan(text: ' '), TextSpan( text: SettingsProvider.instance.dateFormat.format( - DateFormat('EEE, d MMM yyyy') + DateFormat('EEE, d MMM yyyy', 'en_US') .parse(version.publishedAt), ), style: theme.textTheme.labelSmall, @@ -337,3 +341,48 @@ class AppUpdateOptions extends StatelessWidget { ); } } + +class About extends StatelessWidget { + const About({super.key}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final theme = Theme.of(context); + final update = context.watch(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8.0), + Text(update.packageInfo.version), + const SizedBox(height: 8.0), + Text( + loc.versionText, + style: theme.textTheme.displayMedium, + ), + const SizedBox(height: 8.0), + MaterialButton( + onPressed: () { + launchUrl( + Uri.https('www.bluecherrydvr.com', '/'), + mode: LaunchMode.externalApplication, + ); + }, + padding: EdgeInsets.zero, + minWidth: 0.0, + child: Text( + loc.website, + semanticsLabel: 'www.bluecherrydvr.com', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings.dart b/lib/widgets/settings/settings.dart index d569f002..a784003d 100644 --- a/lib/widgets/settings/settings.dart +++ b/lib/widgets/settings/settings.dart @@ -17,334 +17,29 @@ * along with this program. If not, see . */ -import 'dart:io'; - -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:bluecherry_client/models/server.dart'; -import 'package:bluecherry_client/providers/home_provider.dart'; -import 'package:bluecherry_client/providers/server_provider.dart'; -import 'package:bluecherry_client/providers/settings_provider.dart'; -import 'package:bluecherry_client/providers/update_provider.dart'; import 'package:bluecherry_client/utils/constants.dart'; -import 'package:bluecherry_client/utils/extensions.dart'; -import 'package:bluecherry_client/utils/methods.dart'; -import 'package:bluecherry_client/widgets/edit_server.dart'; -import 'package:bluecherry_client/widgets/misc.dart'; -import 'package:bluecherry_client/widgets/settings/update.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; +import 'package:bluecherry_client/widgets/settings/desktop/settings.dart'; +import 'package:bluecherry_client/widgets/settings/mobile/settings.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:unity_video_player/unity_video_player.dart'; -import 'package:url_launcher/url_launcher.dart'; - -part 'date_time.dart'; -part 'server_tile.dart'; - -typedef ChangeTabCallback = void Function(int tab); -class Settings extends StatefulWidget { +class Settings extends StatelessWidget { const Settings({super.key}); - @override - State createState() => _SettingsState(); -} - -class _SettingsState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - SettingsProvider.instance.reload(); - }); - } - @override Widget build(BuildContext context) { - final loc = AppLocalizations.of(context); - final theme = Theme.of(context); - final settings = context.watch(); - final update = context.watch(); - final servers = context.watch(); + final hasDrawer = Scaffold.hasDrawer(context); return Material( type: MaterialType.transparency, - child: SafeArea( - bottom: false, - child: Column(children: [ - if (isMobile) - AppBar( - leading: MaybeUnityDrawerButton(context), - title: Text(loc.settings), - ), - Expanded( - child: CustomScrollView(slivers: [ - SliverToBoxAdapter( - child: SubHeader( - loc.servers, - subtext: loc.nServers(servers.servers.length), - ), - ), - const SliverToBoxAdapter(child: ServersList()), - SliverToBoxAdapter( - child: SubHeader( - loc.theme, - subtext: loc.themeDescription, - ), - ), - SliverList.list( - children: ThemeMode.values.map((e) { - return ListTile( - leading: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundColor: theme.iconTheme.color, - child: Icon(switch (e) { - ThemeMode.system => Icons.brightness_auto, - ThemeMode.light => Icons.light_mode, - ThemeMode.dark => Icons.dark_mode, - }), - ), - onTap: () { - settings.themeMode = e; - }, - trailing: Radio( - value: e, - groupValue: settings.themeMode, - onChanged: (value) { - settings.themeMode = e; - }, - ), - title: Text(switch (e) { - ThemeMode.system => loc.system, - ThemeMode.light => loc.light, - ThemeMode.dark => loc.dark, - }), - ); - }).toList()), - if (update.isUpdatingSupported) ...[ - SliverToBoxAdapter( - child: SubHeader( - loc.updates, - subtext: loc.runningOn(() { - if (Platform.isLinux) { - return 'Linux ${update.linuxEnvironment}'; - } else if (Platform.isWindows) { - return 'Windows'; - } - - return defaultTargetPlatform.name; - }()), - ), - ), - const SliverToBoxAdapter(child: AppUpdateCard()), - const SliverToBoxAdapter(child: AppUpdateOptions()), - ], - SliverToBoxAdapter(child: SubHeader(loc.miscellaneous)), - SliverList.list(children: [ - CorrectedListTile( - iconData: Icons.notifications_paused, - onTap: () async { - if (settings.snoozedUntil.isAfter(DateTime.now())) { - settings.snoozedUntil = - SettingsProvider.defaultSnoozedUntil; - } else { - final timeOfDay = await showTimePicker( - context: context, - helpText: loc.snoozeNotificationsUntil.toUpperCase(), - initialTime: TimeOfDay.fromDateTime(DateTime.now()), - useRootNavigator: false, - ); - if (timeOfDay != null) { - settings.snoozedUntil = DateTime( - DateTime.now().year, - DateTime.now().month, - DateTime.now().day, - timeOfDay.hour, - timeOfDay.minute, - ); - } - } - }, - title: loc.snoozeNotifications, - height: 72.0, - subtitle: settings.snoozedUntil.isAfter(DateTime.now()) - ? loc.snoozedUntil( - [ - if (settings.snoozedUntil - .difference(DateTime.now()) > - const Duration(hours: 24)) - settings.formatDate(settings.snoozedUntil), - settings.formatTime(settings.snoozedUntil), - ].join(' '), - ) - : loc.notSnoozed, - ), - ExpansionTile( - leading: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundColor: theme.iconTheme.color, - child: const Icon(Icons.beenhere_rounded), - ), - title: Text(loc.notificationClickBehavior), - textColor: theme.textTheme.bodyLarge?.color, - subtitle: Text( - settings.notificationClickBehavior.locale(context), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodySmall?.color, - ), - ), - children: NotificationClickBehavior.values.map((behavior) { - return RadioListTile( - contentPadding: const EdgeInsetsDirectional.only( - start: 68.0, - end: 16.0, - ), - value: behavior, - groupValue: settings.notificationClickBehavior, - onChanged: (value) { - settings.notificationClickBehavior = behavior; - }, - secondary: Icon(behavior.icon), - controlAffinity: ListTileControlAffinity.trailing, - title: Padding( - padding: const EdgeInsetsDirectional.only(start: 16.0), - child: Text(behavior.locale(context)), - ), - ); - }).toList(), - ), - ExpansionTile( - leading: CircleAvatar( - backgroundColor: const Color.fromRGBO(0, 0, 0, 0), - foregroundColor: theme.iconTheme.color, - child: const Icon(Icons.fit_screen), - ), - title: Text(loc.cameraViewFit), - textColor: theme.textTheme.bodyLarge?.color, - subtitle: Text( - settings.cameraViewFit.locale(context), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodySmall?.color, - ), - ), - children: UnityVideoFit.values.map((e) { - return RadioListTile( - contentPadding: const EdgeInsetsDirectional.only( - start: 68.0, - end: 16.0, - ), - value: e, - groupValue: settings.cameraViewFit, - onChanged: (value) { - settings.cameraViewFit = e; - }, - secondary: Icon(e.icon), - controlAffinity: ListTileControlAffinity.trailing, - title: Padding( - padding: const EdgeInsetsDirectional.only(start: 16.0), - child: Text(e.locale(context)), - ), - ); - }).toList(), - ), - CorrectedListTile( - iconData: Icons.folder, - trailing: Icons.navigate_next, - title: loc.downloadPath, - subtitle: settings.downloadsDirectory, - height: 72.0, - onTap: () async { - final selectedDirectory = - await FilePicker.platform.getDirectoryPath( - dialogTitle: loc.downloadPath, - initialDirectory: settings.downloadsDirectory, - lockParentWindow: true, - ); - - if (selectedDirectory != null) { - settings.downloadsDirectory = - Directory(selectedDirectory).path; - } - }, - ), - ExpansionTile( - leading: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundColor: theme.iconTheme.color, - child: const Icon(Icons.timelapse), - ), - title: Text(loc.cycleTogglePeriod), - textColor: theme.textTheme.bodyLarge?.color, - subtitle: Text( - settings.layoutCyclingTogglePeriod.humanReadable(context), - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodySmall?.color, - ), - ), - children: [5, 10, 30, 60, 60 * 5].map((e) { - final dur = Duration(seconds: e); - return RadioListTile( - value: dur, - groupValue: settings.layoutCyclingTogglePeriod, - onChanged: (value) { - settings.layoutCyclingTogglePeriod = dur; - }, - secondary: const Icon(null), - controlAffinity: ListTileControlAffinity.trailing, - title: Padding( - padding: const EdgeInsetsDirectional.only(start: 16.0), - child: Text( - dur.humanReadable(context), - ), - ), - ); - }).toList(), - ), - ]), - const SliverToBoxAdapter(child: DateTimeSection()), - SliverToBoxAdapter(child: SubHeader(loc.about)), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8.0), - Text(update.packageInfo.version), - const SizedBox(height: 8.0), - Text( - loc.versionText, - style: theme.textTheme.displayMedium, - ), - const SizedBox(height: 8.0), - MaterialButton( - onPressed: () { - launchUrl( - Uri.https('www.bluecherrydvr.com', '/'), - mode: LaunchMode.externalApplication, - ); - }, - padding: EdgeInsets.zero, - minWidth: 0.0, - child: Text( - loc.website, - semanticsLabel: 'www.bluecherrydvr.com', - style: TextStyle( - color: theme.colorScheme.primary, - ), - ), - ), - ], - ), - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 16.0)), - ]), - ), - ]), - ), + child: LayoutBuilder(builder: (context, consts) { + final width = consts.biggest.width; + + if (hasDrawer || width < kMobileBreakpoint.width) { + return const MobileSettings(); + } else { + return const DesktopSettings(); + } + }), ); } } diff --git a/lib/widgets/settings/server_tile.dart b/lib/widgets/settings/shared/server_tile.dart similarity index 99% rename from lib/widgets/settings/server_tile.dart rename to lib/widgets/settings/shared/server_tile.dart index 141e64d2..a53e7ed1 100644 --- a/lib/widgets/settings/server_tile.dart +++ b/lib/widgets/settings/shared/server_tile.dart @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -part of 'settings.dart'; +part of '../mobile/settings.dart'; typedef OnRemoveServer = void Function(BuildContext, Server); diff --git a/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart b/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart index 504ccf13..a2700704 100644 --- a/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart +++ b/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart @@ -25,6 +25,7 @@ class UnityVideoPlayerMediaKitInterface extends UnityVideoPlayerInterface { int? width, int? height, bool enableCache = false, + RTSPProtocol? rtspProtocol, }) { final player = UnityVideoPlayerMediaKit( width: width, @@ -119,6 +120,7 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { int? width, int? height, bool enableCache = false, + RTSPProtocol? rtspProtocol, }) { final pixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; if (width != null) width = (width * pixelRatio).toInt(); @@ -138,6 +140,22 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { _fps = double.parse(fps); _fpsStreamController.add(_fps); }); + platform.setProperty('msg-level', 'all=v'); + + mkPlayer.stream.log.listen((event) { + // debugPrint('${event.level} / ${event.prefix}: ${event.text}'); + if (event.level == 'fatal') { + // ignore: invalid_use_of_protected_member + platform.errorController.add(event.text); + } + }); + + platform.setProperty('tls-verify', 'no'); + platform.setProperty('insecure', 'yes'); + + if (rtspProtocol != null) { + platform.setProperty('rtsp-transport', rtspProtocol.name); + } if (enableCache) { // https://mpv.io/manual/stable/#options-cache @@ -185,7 +203,7 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { } @override - Stream get onError => mkPlayer.stream.error.map((event) => event); + Stream get onError => mkPlayer.stream.error; @override Duration get duration => mkPlayer.state.duration; diff --git a/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart b/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart index 04eaee6b..0b1cf79d 100644 --- a/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart +++ b/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart @@ -29,6 +29,11 @@ enum UnityVideoFit { } } +enum RTSPProtocol { + tcp, + udp, +} + abstract class UnityVideoPlayerInterface extends PlatformInterface { UnityVideoPlayerInterface() : super(token: _token); @@ -53,6 +58,7 @@ abstract class UnityVideoPlayerInterface extends PlatformInterface { int? width, int? height, bool enableCache = false, + RTSPProtocol? rtspProtocol, }); /// Creates a video view @@ -325,11 +331,13 @@ abstract class UnityVideoPlayer { static UnityVideoPlayer create({ UnityVideoQuality quality = UnityVideoQuality.p360, bool enableCache = false, + RTSPProtocol? rtspProtocol, }) { return UnityVideoPlayerInterface.instance.createPlayer( width: quality.resolution.width.toInt(), height: quality.resolution.height.toInt(), enableCache: enableCache, + rtspProtocol: rtspProtocol, )..quality = quality; } diff --git a/pubspec.lock b/pubspec.lock index 849b50dc..85695598 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -291,6 +291,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_localized_locales: + dependency: "direct main" + description: + name: flutter_localized_locales + sha256: "478d10535edf07292e34cb4c757882edeeaf96d5e3dbb04b42733038bd41dd3f" + url: "https://pub.dev" + source: hosted + version: "2.0.5" flutter_plugin_android_lifecycle: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 11c0d037..7031c48f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: sliver_tools: ^0.2.12 intl: ^0.18.1 + flutter_localized_locales: ^2.0.5 duration: ^3.0.12 firebase_core: 2.10.0 firebase_messaging: ^14.4.1