@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Премахнато от продължаване на гледането",
|
||||
"errorLoading": "Грешка: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.",
|
||||
"liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.",
|
||||
"fileInfoNotAvailable": "Информацията за файла не е налична",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Търсенето е неуспешно: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Изтече времето за връзка при зареждане на ${context}",
|
||||
"connectionFailed": "Не може да се осъществи връзка с медиен сървър",
|
||||
"unableToLoad": "Не може да се зареди ${context}. Опитайте отново.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Fjernet fra Fortsæt med at se",
|
||||
"errorLoading": "Fejl: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.",
|
||||
"liveStreamInterrupted": "Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.",
|
||||
"fileInfoNotAvailable": "Filinfo ikke tilgængelig",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Søgning mislykkedes: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Forbindelsen fik timeout under indlæsning af ${context}",
|
||||
"connectionFailed": "Kan ikke oprette forbindelse til medieserver",
|
||||
"unableToLoad": "Kunne ikke indlæse ${context}. Prøv igen.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Aus „Weiterschauen“ entfernt",
|
||||
"errorLoading": "Fehler: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.",
|
||||
"liveStreamInterrupted": "Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.",
|
||||
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Suche fehlgeschlagen: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Zeitüberschreitung beim Laden von ${context}",
|
||||
"connectionFailed": "Keine Verbindung zum Medienserver möglich",
|
||||
"unableToLoad": "${context} konnte nicht geladen werden. Bitte erneut versuchen.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Removed from Continue Watching",
|
||||
"errorLoading": "Error: ${error}",
|
||||
"searchPartialResults": "Some media servers could not be searched. Showing available results.",
|
||||
"streamInterrupted": "The stream was interrupted. Press play or seek to retry.",
|
||||
"liveStreamInterrupted": "The live stream was interrupted. Press play to retry.",
|
||||
"fileInfoNotAvailable": "File information not available",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Search failed: ${error}",
|
||||
"searchUnavailable": "Search could not reach any media server.",
|
||||
"connectionTimeout": "Connection timeout while loading ${context}",
|
||||
"connectionFailed": "Unable to connect to media server",
|
||||
"unableToLoad": "Unable to load ${context}. Please try again.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Eliminado de Seguir Viendo",
|
||||
"errorLoading": "Error: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.",
|
||||
"liveStreamInterrupted": "La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.",
|
||||
"fileInfoNotAvailable": "Información de archivo no disponible",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Error en la búsqueda: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Tiempo de conexión agotado al cargar ${context}",
|
||||
"connectionFailed": "No se puede conectar al servidor multimedia",
|
||||
"unableToLoad": "No se pudo cargar ${context}. Inténtalo de nuevo.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Supprimé de « Continuer à regarder »",
|
||||
"errorLoading": "Erreur : ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.",
|
||||
"liveStreamInterrupted": "Le direct a été interrompu. Appuyez sur Lecture pour réessayer.",
|
||||
"fileInfoNotAvailable": "Informations sur le fichier non disponibles",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Échec de la recherche : ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Délai d'attente de connexion dépassé pendant le chargement ${context}",
|
||||
"connectionFailed": "Impossible de se connecter au serveur multimédia",
|
||||
"unableToLoad": "Impossible de charger ${context}. Réessayez.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Eltávolítva a folytatásból",
|
||||
"errorLoading": "Hiba: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Az adatfolyam megszakadt. Az újrapróbálkozáshoz indítsd el a lejátszást, vagy tekerj másik pozícióra.",
|
||||
"liveStreamInterrupted": "Az élő adás megszakadt. Nyomd meg a lejátszást az újrapróbálkozáshoz.",
|
||||
"fileInfoNotAvailable": "A fájlinformáció nem érhető el",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Keresés sikertelen: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Hálózati időtúllépés a következő betöltésekor: ${context}",
|
||||
"connectionFailed": "Nem sikerült csatlakozni a médiaszerverhez",
|
||||
"unableToLoad": "Nem sikerült betölteni a következőt: ${context}. Próbáld újra.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Rimosso da Continua a guardare",
|
||||
"errorLoading": "Errore: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o vai a un altro punto per riprovare.",
|
||||
"liveStreamInterrupted": "La diretta si è interrotta. Premi Riproduci per riprovare.",
|
||||
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Ricerca non riuscita: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Tempo scaduto per la connessione durante il caricamento di ${context}",
|
||||
"connectionFailed": "Impossibile connettersi al server multimediale",
|
||||
"unableToLoad": "Impossibile caricare ${context}. Riprova.",
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "視聴中から削除しました",
|
||||
"errorLoading": "エラー: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。",
|
||||
"liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。",
|
||||
"fileInfoNotAvailable": "ファイル情報が利用できません",
|
||||
@@ -742,6 +743,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "検索に失敗しました: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "${context}の読み込み中に接続がタイムアウトしました",
|
||||
"connectionFailed": "メディアサーバーに接続できません",
|
||||
"unableToLoad": "${context}を読み込めませんでした。もう一度お試しください。",
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "계속 시청 목록에서 제거됨",
|
||||
"errorLoading": "오류: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.",
|
||||
"liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.",
|
||||
"fileInfoNotAvailable": "파일 정보가 없습니다",
|
||||
@@ -742,6 +743,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "검색 실패: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "${context} 로드 중 연결 시간 초과",
|
||||
"connectionFailed": "미디어 서버에 연결할 수 없습니다",
|
||||
"unableToLoad": "${context}을(를) 불러올 수 없습니다. 다시 시도하세요.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Fjernet fra Fortsett å se",
|
||||
"errorLoading": "Feil: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.",
|
||||
"liveStreamInterrupted": "Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.",
|
||||
"fileInfoNotAvailable": "Filinformasjon ikke tilgjengelig",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Søk mislyktes: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Tidsavbrudd ved lasting av ${context}",
|
||||
"connectionFailed": "Kan ikke koble til medieserver",
|
||||
"unableToLoad": "Kunne ikke laste ${context}. Prøv igjen.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Verwijderd uit 'Doorgaan met kijken'",
|
||||
"errorLoading": "Fout: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.",
|
||||
"liveStreamInterrupted": "De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.",
|
||||
"fileInfoNotAvailable": "Bestandsinformatie niet beschikbaar",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Zoeken mislukt: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Time-out van verbinding tijdens het laden van ${context}",
|
||||
"connectionFailed": "Kan geen verbinding maken met mediaserver",
|
||||
"unableToLoad": "Kan ${context} niet laden. Probeer het opnieuw.",
|
||||
|
||||
@@ -560,6 +560,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Usunięto z kontynuowania oglądania",
|
||||
"errorLoading": "Błąd: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.",
|
||||
"liveStreamInterrupted": "Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.",
|
||||
"fileInfoNotAvailable": "Informacje o pliku niedostępne",
|
||||
@@ -745,6 +746,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Wyszukiwanie nie powiodło się: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Limit czasu połączenia przy ładowaniu ${context}",
|
||||
"connectionFailed": "Nie można połączyć się z serwerem multimediów",
|
||||
"unableToLoad": "Nie udało się załadować ${context}. Spróbuj ponownie.",
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Removido de Continuar assistindo",
|
||||
"errorLoading": "Erro: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "A transmissão foi interrompida. Pressione reproduzir ou avance para tentar novamente.",
|
||||
"liveStreamInterrupted": "A transmissão ao vivo foi interrompida. Pressione reproduzir para tentar novamente.",
|
||||
"fileInfoNotAvailable": "Informações do arquivo não disponíveis",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Falha na busca: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Tempo de conexão esgotado ao carregar ${context}",
|
||||
"connectionFailed": "Não foi possível conectar ao servidor de mídia",
|
||||
"unableToLoad": "Não foi possível carregar ${context}. Tente novamente.",
|
||||
|
||||
@@ -560,6 +560,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Удалено из «Продолжить просмотр»",
|
||||
"errorLoading": "Ошибка: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.",
|
||||
"liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.",
|
||||
"fileInfoNotAvailable": "Информация о файле недоступна",
|
||||
@@ -745,6 +746,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Ошибка поиска: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Таймаут подключения при загрузке ${context}",
|
||||
"connectionFailed": "Не удалось подключиться к медиасерверу",
|
||||
"unableToLoad": "Не удалось загрузить ${context}. Повторите попытку.",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 18
|
||||
/// Strings: 26499 (1472 per locale)
|
||||
/// Strings: 26501 (1472 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -1705,6 +1705,9 @@ class Translations$messages$en {
|
||||
/// en: 'Error: ${error}'
|
||||
String errorLoading({required Object error}) => 'Error: ${error}';
|
||||
|
||||
/// en: 'Some media servers could not be searched. Showing available results.'
|
||||
String get searchPartialResults => 'Some media servers could not be searched. Showing available results.';
|
||||
|
||||
/// en: 'The stream was interrupted. Press play or seek to retry.'
|
||||
String get streamInterrupted => 'The stream was interrupted. Press play or seek to retry.';
|
||||
|
||||
@@ -2281,6 +2284,9 @@ class Translations$errors$en {
|
||||
/// en: 'Search failed: ${error}'
|
||||
String searchFailed({required Object error}) => 'Search failed: ${error}';
|
||||
|
||||
/// en: 'Search could not reach any media server.'
|
||||
String get searchUnavailable => 'Search could not reach any media server.';
|
||||
|
||||
/// en: 'Connection timeout while loading ${context}'
|
||||
String connectionTimeout({required Object context}) => 'Connection timeout while loading ${context}';
|
||||
|
||||
@@ -5664,6 +5670,7 @@ extension on Translations {
|
||||
'messages.autoRemovedWatchedDownloads' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: 'Auto-removed ${n} watched download', other: 'Auto-removed ${n} watched downloads', ),
|
||||
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
|
||||
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
|
||||
'messages.searchPartialResults' => 'Some media servers could not be searched. Showing available results.',
|
||||
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
|
||||
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
|
||||
'messages.fileInfoNotAvailable' => 'File information not available',
|
||||
@@ -5835,6 +5842,7 @@ extension on Translations {
|
||||
'discover.minutesLeft' => ({required Object minutes}) => '${minutes} min left',
|
||||
'discover.moreLikeThis' => 'More Like This',
|
||||
'errors.searchFailed' => ({required Object error}) => 'Search failed: ${error}',
|
||||
'errors.searchUnavailable' => 'Search could not reach any media server.',
|
||||
'errors.connectionTimeout' => ({required Object context}) => 'Connection timeout while loading ${context}',
|
||||
'errors.connectionFailed' => 'Unable to connect to media server',
|
||||
'errors.unableToLoad' => ({required Object context}) => 'Unable to load ${context}. Please try again.',
|
||||
@@ -6166,10 +6174,10 @@ extension on Translations {
|
||||
'watchTogether.sessionCodeCopied' => 'Session code copied to clipboard',
|
||||
'watchTogether.relayUnreachable' => 'Relay server unreachable. ISP blocking may prevent Watch Together.',
|
||||
'watchTogether.reconnectingToHost' => 'Reconnecting to host...',
|
||||
'watchTogether.currentPlayback' => 'Current Playback',
|
||||
'watchTogether.joinCurrentPlayback' => 'Join Current Playback',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'watchTogether.currentPlayback' => 'Current Playback',
|
||||
'watchTogether.joinCurrentPlayback' => 'Join Current Playback',
|
||||
'watchTogether.joinCurrentPlaybackDescription' => 'Jump back into what the host is currently watching',
|
||||
'watchTogether.failedToOpenCurrentPlayback' => 'Failed to open current playback',
|
||||
'watchTogether.participantJoined' => ({required Object name}) => '${name} joined',
|
||||
|
||||
@@ -558,6 +558,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "Borttagen från Fortsätt titta",
|
||||
"errorLoading": "Fel: ${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "Strömmen avbröts. Tryck på uppspelning eller spola för att försöka igen.",
|
||||
"liveStreamInterrupted": "Liveströmmen avbröts. Tryck på uppspelning för att försöka igen.",
|
||||
"fileInfoNotAvailable": "Filinformation är inte tillgänglig",
|
||||
@@ -743,6 +744,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "Sökningen misslyckades: ${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "Anslutningen tog för lång tid när ${context} lästes in",
|
||||
"connectionFailed": "Det gick inte att ansluta till medieservern",
|
||||
"unableToLoad": "Det gick inte att läsa in ${context}. Försök igen.",
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "已從「繼續觀看」中移除",
|
||||
"errorLoading": "錯誤:${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "影片串流中斷。請按播放鍵或拖動進度條重試。",
|
||||
"liveStreamInterrupted": "直播串流中斷。請按播放鍵重試。",
|
||||
"fileInfoNotAvailable": "無法取得檔案資訊",
|
||||
@@ -742,6 +743,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "搜尋失敗:${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "載入 ${context} 時連線逾時",
|
||||
"connectionFailed": "無法連線至媒體伺服器",
|
||||
"unableToLoad": "無法載入 ${context}。請重試。",
|
||||
|
||||
@@ -557,6 +557,7 @@
|
||||
},
|
||||
"removedFromContinueWatching": "已从继续观看中移除",
|
||||
"errorLoading": "出错:${error}",
|
||||
"searchPartialResults": "",
|
||||
"streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。",
|
||||
"liveStreamInterrupted": "直播流已中断。按播放键重试。",
|
||||
"fileInfoNotAvailable": "文件信息不可用",
|
||||
@@ -742,6 +743,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"searchFailed": "搜索失败:${error}",
|
||||
"searchUnavailable": "",
|
||||
"connectionTimeout": "加载 ${context} 时连接超时",
|
||||
"connectionFailed": "无法连接到媒体服务器",
|
||||
"unableToLoad": "无法加载${context}。请重试。",
|
||||
|
||||
@@ -268,8 +268,11 @@ abstract class MediaServerClient {
|
||||
/// [Lyrics.synced]; gated by [ServerCapabilities.lyrics].
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track);
|
||||
|
||||
/// Free-text search across the user's libraries.
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100});
|
||||
/// Free-text search across the user's libraries. [limit] is a per-request
|
||||
/// candidate budget; a backend may supplement omitted media categories and
|
||||
/// return more candidates for cross-server ranking. [abort] cancels every
|
||||
/// backend request owned by this search pass.
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});
|
||||
|
||||
/// Recently-added items across all libraries.
|
||||
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -39,7 +40,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// Names the focus nodes and log lines.
|
||||
String get searchDebugLabel => widget.runtimeType.toString();
|
||||
|
||||
/// Run the actual search. Thrown errors flip [lastSearchFailed].
|
||||
/// Run the actual search. Non-cancellation errors flip [lastSearchFailed].
|
||||
Future<List<MediaItem>> performSearchQuery(String query);
|
||||
|
||||
/// A failed search was applied to the state (e.g. show a snackbar).
|
||||
@@ -51,6 +52,11 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// The field was cleared and the state reset.
|
||||
void onSearchCleared() {}
|
||||
|
||||
/// The active query was superseded or the search scope is being disposed.
|
||||
/// Implementations may cancel transport work here; the generation guard
|
||||
/// remains authoritative for preventing stale UI commits.
|
||||
void onSearchInvalidated() {}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -60,6 +66,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
onSearchInvalidated();
|
||||
searchController.removeListener(_onSearchTextChanged);
|
||||
searchController.dispose();
|
||||
searchFocusNode.dispose();
|
||||
@@ -88,6 +95,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
if (query.isEmpty) {
|
||||
_debounceTimer?.cancel();
|
||||
_searchGeneration++;
|
||||
if (_inFlightQuery != null) onSearchInvalidated();
|
||||
_inFlightQuery = null;
|
||||
setState(() {
|
||||
searchResults = [];
|
||||
@@ -118,6 +126,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
bool _invalidateStaleInFlight(String current) {
|
||||
if (_inFlightQuery == null || _inFlightQuery == current) return false;
|
||||
_searchGeneration++;
|
||||
onSearchInvalidated();
|
||||
_inFlightQuery = null;
|
||||
return true;
|
||||
}
|
||||
@@ -125,6 +134,7 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
/// Run [query] now, bypassing the debounce (submit, external refresh).
|
||||
Future<void> runSearch(String query) async {
|
||||
if (!mounted || query.isEmpty) return;
|
||||
if (_inFlightQuery != null) onSearchInvalidated();
|
||||
final generation = ++_searchGeneration;
|
||||
_inFlightQuery = query;
|
||||
setState(() {
|
||||
@@ -143,8 +153,13 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
|
||||
});
|
||||
onSearchCompleted(query, results);
|
||||
} catch (e) {
|
||||
appLogger.w('$searchDebugLabel: search failed', error: e);
|
||||
if (!mounted || generation != _searchGeneration) return;
|
||||
if (e is MediaServerHttpException && e.isCancellation) {
|
||||
_inFlightQuery = null;
|
||||
setState(() => isSearching = false);
|
||||
return;
|
||||
}
|
||||
appLogger.w('$searchDebugLabel: search failed', error: e);
|
||||
_inFlightQuery = null;
|
||||
setState(() {
|
||||
searchResults = [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../exceptions/media_server_exceptions.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
@@ -12,9 +13,11 @@ import '../mixins/debounced_media_search.dart';
|
||||
import '../mixins/mounted_set_state_mixin.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../services/data_aggregation_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/loading_indicator_box.dart';
|
||||
import '../widgets/pill_input_decoration.dart';
|
||||
@@ -35,6 +38,8 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
String? _focusResultsForQuery;
|
||||
final _tvTextInputController = TvTextInputController();
|
||||
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
|
||||
AbortController? _activeSearchAbort;
|
||||
({String query, SearchAggregationResult result})? _pendingSearchOutcome;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,24 +65,61 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
Future<List<MediaItem>> performSearchQuery(String query) async {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
throw Exception('No servers available');
|
||||
throw const _SearchUnavailableException();
|
||||
}
|
||||
return multiServerProvider.aggregationService.searchAcrossServers(query);
|
||||
|
||||
final abort = AbortController();
|
||||
_activeSearchAbort = abort;
|
||||
try {
|
||||
final result = await multiServerProvider.aggregationService.searchAcrossServers(query, abort: abort);
|
||||
abort.throwIfAborted();
|
||||
if (result.succeededServerIds.isEmpty && result.failedServerIds.isNotEmpty) {
|
||||
throw const _SearchUnavailableException();
|
||||
}
|
||||
if (result.succeededServerIds.isEmpty && result.cancelledServerIds.isNotEmpty) {
|
||||
throw MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.cancelled,
|
||||
message: 'Search was cancelled before any server completed',
|
||||
);
|
||||
}
|
||||
_pendingSearchOutcome = (query: query, result: result);
|
||||
return result.items;
|
||||
} finally {
|
||||
if (identical(_activeSearchAbort, abort)) _activeSearchAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onSearchInvalidated() {
|
||||
_activeSearchAbort?.abort();
|
||||
_activeSearchAbort = null;
|
||||
_pendingSearchOutcome = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onSearchError(Object error) {
|
||||
_focusResultsForQuery = null;
|
||||
showErrorSnackBar(context, t.errors.searchFailed(error: error));
|
||||
_pendingSearchOutcome = null;
|
||||
final message = error is _SearchUnavailableException
|
||||
? t.errors.searchUnavailable
|
||||
: t.errors.searchFailed(error: error);
|
||||
showErrorSnackBar(context, message);
|
||||
}
|
||||
|
||||
@override
|
||||
void onSearchCleared() {
|
||||
_focusResultsForQuery = null;
|
||||
_pendingSearchOutcome = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onSearchCompleted(String query, List<MediaItem> results) {
|
||||
final outcome = _pendingSearchOutcome;
|
||||
_pendingSearchOutcome = null;
|
||||
if (outcome?.query == query && outcome!.result.failedServerIds.isNotEmpty) {
|
||||
showAppSnackBar(context, t.messages.searchPartialResults);
|
||||
}
|
||||
|
||||
if (_focusResultsForQuery == null || _focusResultsForQuery != query) return;
|
||||
_focusResultsForQuery = null;
|
||||
if (results.isEmpty) return;
|
||||
@@ -294,3 +336,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _SearchUnavailableException implements Exception {
|
||||
const _SearchUnavailableException();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/external_ids.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/search_relevance.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import 'local_playback_history.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
|
||||
@@ -25,6 +26,12 @@ typedef LibraryAggregationResult = ({
|
||||
Set<String> succeededServerIds,
|
||||
Set<String> cancelledServerIds,
|
||||
});
|
||||
typedef SearchAggregationResult = ({
|
||||
List<MediaItem> items,
|
||||
Set<String> succeededServerIds,
|
||||
Set<String> cancelledServerIds,
|
||||
Set<String> failedServerIds,
|
||||
});
|
||||
|
||||
/// Whether [error] is a client-side abort (client teardown mid-request)
|
||||
/// rather than a genuine server failure. Aggregation reports these servers
|
||||
@@ -32,6 +39,14 @@ typedef LibraryAggregationResult = ({
|
||||
/// results say nothing about actual content — from a settled failure.
|
||||
bool _isCancellation(Object error) => error is MediaServerHttpException && error.isCancellation;
|
||||
|
||||
Map<String, int> _searchKindCounts(Iterable<MediaItem> items) {
|
||||
final counts = <String, int>{};
|
||||
for (final item in items) {
|
||||
counts.update(item.kind.name, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Cross-server aggregation: fans calls out to every online client and
|
||||
/// merges the results. Single-server operations now go through the
|
||||
/// [MediaServerClient] interface directly (resolved via
|
||||
@@ -506,35 +521,73 @@ class DataAggregationService {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// Search across all online servers (Plex + Jellyfin). Returns neutral
|
||||
/// [MediaItem]s.
|
||||
Future<List<MediaItem>> searchAcrossServers(String query, {int? limit}) async {
|
||||
/// Search across all online servers (Plex + Jellyfin). Per-server outcomes
|
||||
/// distinguish authoritative empty results from failed or cancelled legs.
|
||||
Future<SearchAggregationResult> searchAcrossServers(String query, {int? limit, AbortController? abort}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return [];
|
||||
return (
|
||||
items: const <MediaItem>[],
|
||||
succeededServerIds: const <String>{},
|
||||
cancelledServerIds: const <String>{},
|
||||
failedServerIds: const <String>{},
|
||||
);
|
||||
}
|
||||
|
||||
abort?.throwIfAborted();
|
||||
final clients = _serverManager.onlineClients;
|
||||
if (clients.isEmpty) return [];
|
||||
if (clients.isEmpty) {
|
||||
return (
|
||||
items: const <MediaItem>[],
|
||||
succeededServerIds: const <String>{},
|
||||
cancelledServerIds: const <String>{},
|
||||
failedServerIds: const <String>{},
|
||||
);
|
||||
}
|
||||
|
||||
final resultLimit = limit ?? defaultMediaSearchLimit;
|
||||
final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit;
|
||||
final succeededServerIds = <String>{};
|
||||
final cancelledServerIds = <String>{};
|
||||
final failedServerIds = <String>{};
|
||||
|
||||
final futures = clients.entries.map((entry) async {
|
||||
final client = entry.value;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
return await client.searchItems(query, limit: fetchLimit);
|
||||
final items = await entry.value.searchItems(query, limit: fetchLimit, abort: abort);
|
||||
succeededServerIds.add(entry.key);
|
||||
appLogger.i(
|
||||
'Search completed on ${entry.key} in ${stopwatch.elapsedMilliseconds}ms: '
|
||||
'${items.length} results ${_searchKindCounts(items)}',
|
||||
);
|
||||
return items;
|
||||
} catch (e, st) {
|
||||
appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st);
|
||||
if (_isCancellation(e)) {
|
||||
cancelledServerIds.add(entry.key);
|
||||
appLogger.d('Search cancelled on ${entry.key} after ${stopwatch.elapsedMilliseconds}ms');
|
||||
} else {
|
||||
failedServerIds.add(entry.key);
|
||||
appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st);
|
||||
}
|
||||
return <MediaItem>[];
|
||||
}
|
||||
});
|
||||
|
||||
final allResults = (await Future.wait(futures)).expand((l) => l).toList();
|
||||
final result = rankMediaSearchResults(allResults, query, limit: resultLimit);
|
||||
final allResults = (await Future.wait(futures)).expand((items) => items).toList();
|
||||
abort?.throwIfAborted();
|
||||
final items = rankMediaSearchResults(allResults, query, limit: resultLimit);
|
||||
|
||||
appLogger.i('Found ${result.length} search results across all servers');
|
||||
appLogger.i(
|
||||
'Search aggregation completed: ${items.length} results '
|
||||
'(${succeededServerIds.length} succeeded, ${cancelledServerIds.length} cancelled, '
|
||||
'${failedServerIds.length} failed) ${_searchKindCounts(items)}',
|
||||
);
|
||||
|
||||
return result;
|
||||
return (
|
||||
items: items,
|
||||
succeededServerIds: succeededServerIds,
|
||||
cancelledServerIds: cancelledServerIds,
|
||||
failedServerIds: failedServerIds,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reverse external-id lookup fanned out to every online server (see
|
||||
|
||||
@@ -1053,7 +1053,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
|
||||
// Artists come from the dedicated /Artists endpoint: `/Items?SearchTerm=`
|
||||
// only matches folder-derived MusicArtist rows (under folder names), so
|
||||
// tag-only artists would never appear in search. The artists leg is
|
||||
@@ -1067,14 +1067,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
'IncludeItemTypes': 'Movie,Series,Episode,MusicAlbum,Audio',
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
}),
|
||||
}, abort: abort),
|
||||
_safeFetchItemsArray('/Artists', {
|
||||
'userId': connection.userId,
|
||||
'searchTerm': query,
|
||||
'Limit': limit.toString(),
|
||||
...jellyfinImageQueryParameters,
|
||||
}),
|
||||
}, abort: abort),
|
||||
]);
|
||||
abort?.throwIfAborted();
|
||||
return _mapItems([...results.first, ...results[1]]);
|
||||
}
|
||||
|
||||
@@ -1848,17 +1849,21 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
Future<MediaServerResponse> _getItemsResponse(
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters,
|
||||
_HubRetryPolicy? retry,
|
||||
) {
|
||||
if (retry == null) return _http.get(path, queryParameters: queryParameters);
|
||||
_HubRetryPolicy? retry, {
|
||||
AbortController? abort,
|
||||
}) {
|
||||
if (retry == null) {
|
||||
return _http.get(path, queryParameters: queryParameters, abort: abort);
|
||||
}
|
||||
abort?.throwIfAborted();
|
||||
return retryTransientMediaServerCall(
|
||||
operation: retry.operation,
|
||||
attemptTimeouts: retry.attemptTimeouts,
|
||||
call: (timeout, abort) => _http.get(
|
||||
call: (timeout, attemptAbort) => _http.get(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
timeout: timeout,
|
||||
abort: abort,
|
||||
abort: attemptAbort,
|
||||
allowEndpointFailover: false,
|
||||
),
|
||||
);
|
||||
@@ -1868,8 +1873,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters, {
|
||||
_HubRetryPolicy? retry,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final response = await _getItemsResponse(path, queryParameters, retry);
|
||||
final response = await _getItemsResponse(path, queryParameters, retry, abort: abort);
|
||||
abort?.throwIfAborted();
|
||||
throwIfHttpError(response);
|
||||
return _itemsArray(response.data);
|
||||
}
|
||||
@@ -1878,9 +1885,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters, {
|
||||
_HubRetryPolicy? retry,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _getItemsResponse(path, queryParameters, retry);
|
||||
final response = await _getItemsResponse(path, queryParameters, retry, abort: abort);
|
||||
abort?.throwIfAborted();
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
if (data is List) {
|
||||
|
||||
@@ -9,6 +9,8 @@ mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
|
||||
Map<String, dynamic> queryParameters, {
|
||||
// ignore: unused_element_parameter
|
||||
_HubRetryPolicy? retry,
|
||||
// ignore: unused_element_parameter
|
||||
AbortController? abort,
|
||||
});
|
||||
|
||||
/// Returns `true` when this server has Live TV configured (channels
|
||||
|
||||
@@ -1388,28 +1388,84 @@ class PlexClient
|
||||
|
||||
/// Search across all libraries including individually shared items.
|
||||
/// Uses /library/search (same endpoint as Plex Web) which finds shared content.
|
||||
/// Only returns movies, shows, and music (artists/albums/tracks), filtering
|
||||
/// out other types.
|
||||
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
|
||||
/// A saturated mixed-type response is supplemented with concurrent requests
|
||||
/// for categories Plex omitted so one large library cannot starve another.
|
||||
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100, AbortController? abort}) async {
|
||||
const allSearchTypes = 'movies,tv,music';
|
||||
final primary = await _searchByTypes(query, searchTypes: allSearchTypes, limit: limit, abort: abort);
|
||||
final results = primary.items;
|
||||
if (limit <= 0 || primary.rawCount < limit) return results;
|
||||
|
||||
final presentTypes = {for (final item in results) item.type};
|
||||
final missingSearchTypes = <String>[
|
||||
if (!presentTypes.contains('movie')) 'movies',
|
||||
if (!presentTypes.contains('show')) 'tv',
|
||||
if (!presentTypes.any(const {'artist', 'album', 'track'}.contains)) 'music',
|
||||
];
|
||||
if (missingSearchTypes.isEmpty) return results;
|
||||
|
||||
appLogger.i(
|
||||
'Plex search response saturated; fetching omitted media categories '
|
||||
'(${missingSearchTypes.join(',')}; ${results.length} usable results)',
|
||||
);
|
||||
final supplemental = await Future.wait([
|
||||
for (final searchTypes in missingSearchTypes)
|
||||
_searchSupplementalByTypes(query, searchTypes: searchTypes, limit: limit, abort: abort),
|
||||
]);
|
||||
abort?.throwIfAborted();
|
||||
|
||||
final deduplicated = <String, PlexMetadataDto>{};
|
||||
for (final item in [...results, ...supplemental.expand((items) => items)]) {
|
||||
final identity = item.ratingKey.isNotEmpty
|
||||
? item.ratingKey
|
||||
: '${item.type ?? ''}:${item.guid ?? ''}:${item.title ?? ''}';
|
||||
deduplicated.putIfAbsent(identity, () => item);
|
||||
}
|
||||
return deduplicated.values.toList();
|
||||
}
|
||||
|
||||
Future<List<PlexMetadataDto>> _searchSupplementalByTypes(
|
||||
String query, {
|
||||
required String searchTypes,
|
||||
required int limit,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
try {
|
||||
final result = await _searchByTypes(query, searchTypes: searchTypes, limit: limit, abort: abort);
|
||||
return result.items;
|
||||
} catch (e, st) {
|
||||
abort?.throwIfAborted();
|
||||
if (e is MediaServerHttpException && e.isCancellation) rethrow;
|
||||
appLogger.w('Plex supplemental $searchTypes search failed; keeping primary results', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<({List<PlexMetadataDto> items, int rawCount})> _searchByTypes(
|
||||
String query, {
|
||||
required String searchTypes,
|
||||
required int limit,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final response = await _getWithFailover(
|
||||
'/library/search',
|
||||
queryParameters: {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
'searchTypes': 'movies,tv,music',
|
||||
'searchTypes': searchTypes,
|
||||
'includeCollections': 1,
|
||||
'includeExternalMedia': 1,
|
||||
'X-Plex-Container-Size': limit,
|
||||
},
|
||||
abort: abort,
|
||||
);
|
||||
|
||||
final results = <PlexMetadataDto>[];
|
||||
|
||||
final container = _getMediaContainer(response);
|
||||
if (container == null) return results;
|
||||
if (container == null) return (items: results, rawCount: 0);
|
||||
|
||||
final searchResults = container['SearchResult'] as List?;
|
||||
if (searchResults == null) return results;
|
||||
if (searchResults == null) return (items: results, rawCount: 0);
|
||||
|
||||
for (final result in searchResults) {
|
||||
try {
|
||||
@@ -1427,7 +1483,7 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
return (items: results, rawCount: searchResults.length);
|
||||
}
|
||||
|
||||
/// Get recently added media (filtered to video content only)
|
||||
@@ -3473,8 +3529,8 @@ class PlexClient
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
|
||||
final results = await _search(query, limit: limit);
|
||||
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}) async {
|
||||
final results = await _search(query, limit: limit, abort: abort);
|
||||
return results.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user