diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc0b5317..53344718 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,6 +28,7 @@ jobs: image: ${{ steps.info.outputs.image }} labels: ${{ steps.meta.outputs.labels }} metadata-json: ${{ steps.meta.outputs.json }} + platform-matrix: ${{ steps.info.outputs.platform-matrix }} version: ${{ steps.info.outputs.version }} steps: - uses: actions/checkout@v7.0.1 @@ -55,6 +56,12 @@ jobs: echo "image=$image" echo "version=$version" } >> "$GITHUB_OUTPUT" + if [[ "$GITHUB_REF_NAME" == "dev" ]]; then + platform_matrix='{"include":[{"platform":"linux/amd64","arch":"amd64"}]}' + else + platform_matrix='{"include":[{"platform":"linux/amd64","arch":"amd64"},{"platform":"linux/arm64","arch":"arm64"}]}' + fi + echo "platform-matrix=$platform_matrix" >> "$GITHUB_OUTPUT" - name: Extract image metadata id: meta @@ -77,12 +84,7 @@ jobs: packages: write strategy: fail-fast: false - matrix: - include: - - platform: linux/amd64 - arch: amd64 - - platform: linux/arm64 - arch: arm64 + matrix: ${{ fromJSON(needs.prepare.outputs.platform-matrix) }} steps: - uses: actions/checkout@v7.0.1 @@ -174,8 +176,10 @@ jobs: METADATA_JSON: ${{ needs.prepare.outputs.metadata-json }} run: | mapfile -t digests < <(find "$DIGEST_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) - if [[ "${#digests[@]}" -ne 2 ]]; then - echo "Expected two platform digests, found ${#digests[@]}" + expected_digests=2 + if [[ "$GITHUB_REF_NAME" == "dev" ]]; then expected_digests=1; fi + if [[ "${#digests[@]}" -ne "$expected_digests" ]]; then + echo "Expected $expected_digests platform digests, found ${#digests[@]}" exit 1 fi mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON") diff --git a/README.md b/README.md index b8ad99df..de4b47ce 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@

The React web client for TypeType.

+You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md). + [![Translation status](https://translate.typetype.video/widgets/typetype/-/svg-badge.svg)](https://translate.typetype.video/engage/typetype/) This repository contains the browser application and its nginx container. It owns the user interface, routing, settings, account flows, library views, and integration with the TypeType API and playback package. diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 7cd3fddf..b2741eaf 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -172,17 +172,30 @@ "settings_blocked_label": "Gesperrter Inhalt", "settings_data_description": "Importiere, exportiere und stelle Accountdaten wieder her.", "settings_data_label": "Daten", + "settings_api_description": "Erstelle und widerrufe Schlüssel für externe Apps.", + "settings_api_label": "API", "settings_interface_description": "Wähle aus, was TypeType öffnet und hervorhebt.", "settings_interface_label": "Benutzeroberfläche", "settings_language_description": "Benutzeroberflächen-, Ton- und Untertitel- Einstellungen.", "settings_language_label": "Sprachen", "settings_language_section": "Sprache", + "settings_layout_label": "Layout", + "settings_layout_related_video_size": "Größe ähnlicher Videos", + "settings_layout_related_video_size_default": "Standard", + "settings_layout_related_video_size_description": "Bestimmt die Größe ähnlicher Video-Vorschaubilder auf der Wiedergabeseite.", + "settings_layout_related_video_size_large": "Groß", + "settings_layout_video_grid_columns": "Videos pro Zeile", + "settings_layout_video_grid_columns_description": "Legt die Spaltenzahl für Startseite, Abos und andere Videoraster fest.", "settings_playback_description": "Standardeinstellungen für Qualität, Geschwindigkeit, automatische Wiedergabe und „Nur Ton“.", "settings_playback_label": "Wiedergabe", "settings_privacy_description": "Wiedergabeverlauf, Sitzungen und Datenschutz des Kontos verwalten.", "settings_privacy_label": "Datenschutz", "settings_rss_description": "Private Feeds für dieses Konto verwalten.", "settings_services_description": "Wähle den Anbieter, welcher standardmäßig genutzt wird.", + "settings_notifications_description": "Steuere Benachrichtigungen und wähle Kanäle einzeln aus.", + "settings_channel_notifications": "Kanalbenachrichtigungen", + "settings_channel_notifications_description": "Neue Kanäle sind standardmäßig deaktiviert. Aktiviere nur die Kanäle, deren Veröffentlichungen du sehen möchtest.", + "settings_search_channels": "Kanäle suchen", "settings_services_label": "Dienste", "settings_subtitle": "Passe Wiedergabe, Inhalte, Datenschutz und Kontodaten an. Die Änderungen gelten für dein gesamtes TypeType-Konto.", "settings_subtitle_default_description": "Aktiviere Untertitel standardmäßig", @@ -238,6 +251,21 @@ "account_save": "Konto speichern", "account_saved": "Konto aktualisiert", "account_update_failed": "Aktualisierung fehlgeschlagen", + "presence_title": "Präsenz-Zugriff", + "presence_description": "Erstelle einen widerrufbaren Schlüssel, der nur sehen kann, ob gerade etwas läuft.", + "presence_token_once": "Kopiere diesen Schlüssel jetzt. Er wird nicht erneut angezeigt.", + "presence_copy": "Schlüssel kopieren", + "presence_copied": "Schlüssel kopiert", + "presence_name_label": "Schlüsselname", + "presence_name_placeholder": "Discord RPC", + "presence_create": "Schlüssel erstellen", + "presence_created": "Präsenz-Schlüssel erstellt", + "presence_create_failed": "Präsenz-Schlüssel konnte nicht erstellt werden", + "presence_revoke": "Widerrufen", + "presence_revoke_failed": "Präsenz-Schlüssel konnte nicht widerrufen werden", + "presence_load_failed": "Präsenz-Schlüssel konnten nicht geladen werden", + "presence_empty": "Noch keine API-Schlüssel.", + "api_sign_in_required": "Melde dich an, um API-Schlüssel zu verwalten.", "account_password_title": "Passwort", "account_password_description": "Setzte das Passwort für dein Konto zurück.", "account_password_reset": "Passwort zurücksetzen", @@ -285,7 +313,12 @@ "portability_import_title": "Daten importieren", "portability_import_description": "Vorschau anzeigen und auswählen, was wiederhergestellt werden soll.", "portability_import_from": "Importieren von", + "portability_auto_detect": "Automatische Erkennung", + "portability_auto_import_starting": "Die erkannten Daten werden automatisch importiert. Du kannst diese Seite verlassen, der Fortschritt läuft weiter.", "portability_choose_or_drop": "Sicherungskopie auswählen oder ablegen", + "portability_zip_choose_source": "Das ZIP-Archiv ist lesbar, passt aber nicht zur ausgewählten Importquelle. Wähle die Quelle, die dieses Backup erstellt hat.", + "portability_invalid_zip": "Diese Datei ist kein lesbares ZIP-Archiv. Wähle das vollständige Original-Backup, nicht eine einzelne Datei oder einen unvollständigen Teil.", + "portability_upload_too_large_client": "Diese Sicherungskopie überschreitet das Importlimit von 512 MB. Verwende das Originalarchiv einer unterstützten Quelle oder exportiere es erneut in kleineren Teilen.", "portability_drop_original_prefix": "Lege die originale", "portability_drop_original_suffix": "Datei ab. Es wird nichts importiert, bis du die Vorschau überprüft hast.", "portability_duplicate_records": "Doppelte Daten gefunden", @@ -302,6 +335,11 @@ "portability_job_analyzing": "Sicherungskopie analysieren", "portability_job_ready": "Bereit zu importieren", "portability_job_importing": "Daten werden importiert", + "portability_stage_subscriptions": "Abonnements werden importiert", + "portability_stage_recent_history": "Zuletzt angesehene Videos werden importiert", + "portability_stage_playlists": "Wiedergabelisten werden erstellt", + "portability_stage_history": "Älterer Wiedergabeverlauf wird importiert", + "portability_stage_remaining": "Weitere Daten werden importiert", "portability_job_generating": "Export wird erstellt", "portability_job_import_completed": "Import abgeschlossen", "portability_job_export_ready": "Export bereit", @@ -337,6 +375,10 @@ "portability_guide_generic_open_suffix": "und suche dessen Aktion zum Exportieren oder zum erstellen einer Sicherungskopie.", "portability_guide_generic_download": "Lade die originale Sicherungskopie herunter, ohne sie zu verändern oder zu extrahieren.", "portability_guide_generic_drop": "Lege die Datei unten ab. TypeType erstellt eine Vorschau der Daten bevor etwas importiert wird.", + "portability_guide_auto_description": "Wähle die originale Sicherungskopie. TypeType erkennt die kompatible Quelle anhand ihrer tatsächlichen Struktur.", + "portability_guide_auto_step_one": "Exportiere die Sicherungskopie aus deinem aktuellen YouTube-Client.", + "portability_guide_auto_step_two": "Lasse die originale Datei oder das Archiv unverändert.", + "portability_guide_auto_step_three": "Prüfe die erkannte Quelle und die Vorschau vor dem Import.", "portability_stale_job_toast": "Der vorherige Vorgang ist nicht mehr verfügbar. Starte von vorne.", "portability_category_subscriptions": "Abonnements", "portability_category_subscriptions_detail": "Kanäle, denen du folgst", @@ -529,6 +571,7 @@ "ui_community_then_neutral_frame": "Community, dann neutraler Rahmen", "ui_confidence": "Überzeugung", "ui_connect_with_youtube": "Mit YouTube verbinden", + "ui_reconnect_with_youtube": "Erneut mit YouTube verbinden", "ui_connect_youtube_inside_typetype": "Verbinde YouTube innerhalb von TypeType", "ui_connect_youtube_on_typetype": "Verbinde YouTube auf TypeType", "ui_content": "Inhalt", @@ -669,7 +712,7 @@ "ui_no_private_feeds": "Keine privaten Feeds", "ui_no_private_feeds_have_been_created": "Es wurden keine privaten Feeds erstellt.", "ui_no_results_for": "Keine Ergebnisse für “", - "ui_no_shorts_available_right_now": "Derzeit keine Shorts verfügbar.", + "ui_no_shorts_available_right_now": "Noch sind keine Shorts verfügbar. Abonniere einige Kanäle oder importiere deine YouTube-Abonnements, um diesen Feed zu füllen, und versuche es bei Bedarf später erneut.", "ui_no_subscriptions_yet": "Noch keine Abonnements", "ui_no_subscriptions_yet_2": "Noch keine Abonnements.", "ui_no_videos_saved_for_later": "Keine Videos für später gespeichert.", @@ -942,6 +985,12 @@ "ui_connect_youtube_to_access_this_video": "Verbinde YouTube, um dieses Video ansehen zu können.", "ui_this_video_is_currently_unavailable": "Dieses Video ist derzeit nicht verfügbar", "ui_failed_to_load_stream": "Laden des Streams fehlgeschlagen.", + "ui_error_provider_blocked": "Der Videoanbieter blockiert Anfragen von diesem TypeType-Server. Versuche es mit einem VPN oder einem anderen ausgehenden Netzwerk für den Server und probiere es erneut.", + "ui_error_region_restricted": "Dieses Video ist in der Region des Servers nicht verfügbar. Ein VPN oder ein anderes ausgehendes Netzwerk kann helfen.", + "ui_error_no_playable_stream": "TypeType konnte keinen kompatiblen Stream für dieses Video finden. Versuche es später erneut oder wähle ein anderes Video.", + "ui_error_private_video": "Dieses Video ist privat oder nicht mehr verfügbar.", + "ui_error_age_restricted": "Für dieses Video ist eine Altersbestätigung erforderlich. Verbinde YouTube und versuche es erneut.", + "ui_error_stream_unavailable": "Der Videodienst konnte keinen abspielbaren Stream bereitstellen. Versuche es später erneut.", "ui_mute_zen_music": "Zen-Musik stumm schalten", "ui_completing_sign_in_with_your_provider": "Die Anmeldung bei deinem Anbieter wird abgeschlossen.", "ui_could_not_load_player": "Player konnte nicht geladen werden.", @@ -993,6 +1042,8 @@ "ui_export_completed": "Export fertiggestellt", "ui_restore_failed": "Wiederherstellung fehlgeschlagen", "ui_autoplay_next_video": "Nächstes Video automatisch wiedergeben", + "ui_autoplay_on_open": "Autoplay beim Öffnen", + "ui_automatically_start_playback_when_a_video_is_opened": "Wiedergabe automatisch starten, wenn ein Video geöffnet wird.", "ui_automatically_continue_with_the_first_item_in_the_suggestions_column": "Automatisch mit dem ersten Eintrag in der Vorschlagsspalte fortfahren.", "ui_dearrow_titles_and_thumbnails": "DeArrow Titel und Vorschaubilder", "ui_use_community_written_titles_and_representative_thumbnails_for_youtub": "Verwende Community-Titel und aussagekräftige Vorschaubilder für YouTube-Videos.", @@ -1160,6 +1211,7 @@ "ui_not_connected": "Nicht verbunden", "ui_typetype_can_use_this_session": "TypeType kann diese Sitzung nutzen, wenn die Extraktion es verlangt.", "ui_youtube_rejected_stored_session": "YouTube hat die gespeicherte Sitzung abgelehnt. Erstelle einen neuen Code.", + "ui_reconnect_youtube_to_access_this_video": "Verbinde YouTube erneut, um dieses Video anzusehen.", "ui_no_youtube_session_connected": "Es ist keine YouTube Sitzung verbunden.", "ui_prepare": "Vorbereiten", "ui_download": "Herunterladen", @@ -1321,5 +1373,126 @@ "community_lemmy_acknowledge": "ICH HABE ES VERSTANDEN! Alles klar", "portability_preparing_upload": "Kontodaten werden vorbereitet und hochgeladen...", "portability_takeout_metadata_limit": "Die Kontodaten in diesem Archiv überschreiten die Importgrenzen oder sind verschlüsselt. Exportiere YouTube separat in kleineren ZIP-Dateien.", - "portability_takeout_no_metadata": "Keine YouTube-Kontodaten gefunden. Wähle eine originale Takeout-ZIP mit deinem Verlauf, deinen Abonnements oder deinen Wiedergabelisten statt eines reinen Video-Teils oder einer ZIP-Datei, die weitere ZIP-Dateien enthält." + "portability_takeout_no_metadata": "Keine YouTube-Kontodaten in diesem Archiv gefunden.", + "portability_takeout_storage_required": "Der Browser hat nicht genügend temporären Speicherplatz, um dieses Takeout-Archiv zu verarbeiten. Schaffe Speicherplatz oder verwende ein Browserprofil mit mehr verfügbarem Speicherplatz.", + "ui_remote_diagnostics": "Diagnose", + "ui_copy_diagnostics": "Diagnose kopieren", + "ui_diagnostics_copied": "Kopiert", + "ui_remote_diagnostics_hint": "Teile diese Zeilen in einem Fehlerbericht. Sie enthalten keine Passwörter und keine Cookie-Werte.", + "sg_add": "Hinzufügen", + "sg_add_groups": "Gruppen hinzufügen", + "sg_added": "{count} Kanäle zu {group} hinzugefügt.", + "sg_all_channels": "Alle Kanäle", + "sg_all_subscriptions": "Alle Abonnements", + "sg_select_page": "Seite auswählen ({count})", + "sg_ungrouped": "Nicht gruppiert", + "sg_channels_load_error": "Deine Kanäle konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_back_channels": "Zurück zu den Kanälen", + "sg_back_to_results": "Zurück zu den Ergebnissen", + "sg_change_filters": "Wähle eine andere Gruppe oder ändere deine Suche.", + "sg_channel_count": "{count} Kanäle", + "sg_channel_saved": "Gruppen für {channel} aktualisiert.", + "sg_channels": "Abonnierte Kanäle", + "sg_clear": "Auswahl aufheben", + "sg_clear_confirmation": "Alle Gruppenzugehörigkeiten der {count} ausgewählten Kanäle entfernen, auch außerhalb dieser Ansicht? Du bleibst bei ihnen abonniert.", + "sg_delete_confirmation": "Die Gruppe und die Zugehörigkeiten von {count} Kanälen werden gelöscht. Die Kanäle und deine Abonnements bleiben bestehen.", + "sg_delete_group": "Gruppe löschen", + "sg_delete_named": "„{group}“ löschen?", + "sg_duplicate_name": "Eine Gruppe mit diesem Namen existiert bereits. Wähle einen anderen Namen.", + "sg_edit_named": "Gruppen für {channel} bearbeiten", + "sg_empty_feed": "Noch keine Videos in dieser Ansicht.", + "sg_empty_subscriptions": "Abonniere zuerst Kanäle und organisiere sie dann hier. Gruppen kannst du bereits erstellen.", + "sg_feed_filter": "Abonnements nach Gruppe filtern", + "sg_group_actions": "Aktionen für {group}", + "sg_group_created": "{group} erstellt.", + "sg_group_deleted": "{group} gelöscht. Deine Abonnements bleiben bestehen.", + "sg_group_filters": "Kanäle nach Gruppe filtern", + "sg_group_renamed": "Gruppe umbenannt.", + "sg_groups": "Gruppen", + "sg_hidden_selected": "{count} außerhalb dieser Ansicht", + "sg_in_group": "In Gruppe", + "sg_invalid_name": "Der Gruppenname muss zwischen 1 und 100 Zeichen lang sein.", + "sg_load_error": "Deine Gruppen konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_refresh_error": "Die Gruppen konnten nicht aktualisiert werden. Die angezeigten Daten sind möglicherweise veraltet. Versuche es erneut, um die Bearbeitung fortzusetzen.", + "sg_refreshing": "Wird aktualisiert…", + "sg_load_more": "Mehr anzeigen · {shown} von {total}", + "sg_loading": "Gruppen und Kanäle werden geladen…", + "sg_manage_groups": "Gruppen verwalten", + "sg_manager_description": "Organisiere deine Abonnements. Ein Kanal kann mehreren Gruppen angehören.", + "sg_membership_filter": "Gruppenzugehörigkeit filtern", + "sg_memberships": "Gruppen des Kanals", + "sg_memberships_cleared": "Gruppenzugehörigkeiten entfernt. Du bleibst bei diesen Kanälen abonniert.", + "sg_new_group": "Neue Gruppe", + "sg_no_channel_match": "Keine Kanäle in dieser Ansicht", + "sg_no_group_match": "Keine passenden Gruppen. Erstelle eine Gruppe in der Seitenleiste.", + "sg_no_groups": "Erstelle oben deine erste Gruppe. Kanäle können auch ohne Gruppe bleiben.", + "sg_not_in_group": "Nicht in Gruppe", + "sg_outside_named": "Nicht in {group}", + "sg_partial_failure": "Änderungen für {count} Kanäle konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut.", + "sg_remove": "Entfernen", + "sg_remove_all": "Aus allen Gruppen entfernen", + "sg_remove_named": "{group} entfernen", + "sg_removed": "{count} Kanäle aus {group} entfernt.", + "sg_rename_group": "Gruppe umbenennen", + "sg_retry": "Erneut versuchen", + "sg_save_error": "Änderungen konnten nicht gespeichert werden. Prüfe deine Verbindung und versuche es erneut.", + "sg_saving": "Änderungen werden gespeichert…", + "sg_search_channels": "Kanäle suchen", + "sg_search_groups": "Gruppen suchen", + "sg_select_hint": "Kanäle zur gemeinsamen Bearbeitung auswählen", + "sg_selected": "{count} ausgewählt", + "sg_show_selected": "Auswahl anzeigen", + "sg_target_group": "Zielgruppe", + "sg_one_channel": "1 Kanal", + "sg_added_one": "1 Kanal zu {group} hinzugefügt.", + "sg_removed_one": "1 Kanal aus {group} entfernt.", + "sg_clear_one_confirmation": "Alle Gruppenzugehörigkeiten des ausgewählten Kanals entfernen, auch außerhalb dieser Ansicht? Du bleibst bei ihm abonniert.", + "sg_delete_one_confirmation": "Die Gruppe und die Zugehörigkeit von 1 Kanal werden gelöscht. Der Kanal und dein Abonnement bleiben bestehen.", + "sg_partial_one_failure": "Änderungen für 1 Kanal konnten nicht gespeichert werden. Einige Änderungen waren möglicherweise erfolgreich. Prüfe die aktualisierten Gruppen und versuche es erneut.", + "subscriptions_feed_load_error": "Deine Abonnements konnten nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.", + "subscriptions_feed_next_page_error": "Weitere Videos konnten nicht geladen werden. Bereits geladene Videos bleiben verfügbar.", + "sg_page_range": "{start}–{end} von {total}", + "sg_previous_page": "Vorherige Seite", + "sg_next_page": "Nächste Seite", + "sg_more_groups": "Weitere Gruppen ({count})", + "sg_no_group_results": "Keine Gruppen entsprechen deiner Suche.", + "ui_bilibili_session": "BiliBili-Sitzung", + "ui_bilibili_session_connected": "Verbunden", + "ui_bilibili_session_not_connected": "Nicht verbunden", + "ui_bilibili_session_needs_reconnect": "Sitzung abgelaufen, neu verbinden", + "ui_bilibili_session_disconnect": "Trennen", + "ui_bilibili_session_connect": "Verbinden", + "ui_bilibili_session_reconnect": "Neu verbinden", + "ui_bilibili_session_loading": "Wird geladen...", + "ui_bilibili_session_scan_qr": "Mit der BiliBili-App scannen", + "ui_bilibili_session_scanned": "Gescannt! Auf dem Telefon bestätigen.", + "ui_bilibili_session_cancel": "Abbrechen", + "ui_bilibili_session_connected_toast": "Verbunden!", + "ui_bilibili_session_qr_expired": "QR-Code abgelaufen", + "ui_bilibili_session_try_again": "Erneut versuchen", + "ui_bilibili_session_retry": "Wiederholen", + "ui_bilibili_session_not_configured": "BiliBili sign-in is not enabled on this instance.", + "ui_bilibili_session_qr_unavailable": "BiliBili did not provide a QR code. Try again in a moment.", + "ui_bilibili_session_rate_limited": "BiliBili is temporarily rate-limiting sign-in. Try again later.", + "ui_bilibili_session_qr_poll_error": "The BiliBili sign-in could not be completed.", + "ui_bilibili_session_expires_in": "expires in {days} {plural}", + "ui_services": "Dienste", + "ui_bilibili_remote_sign_in": "BiliBili-Fernanmeldung", + "ui_connect_bilibili_inside_typetype": "BiliBili in TypeType verbinden", + "ui_typetype_generates_a_qr_code": "TypeType generiert einen QR-Code für die BiliBili-Anmeldung.", + "ui_use_secondary_bilibili_account": "Verwenden Sie ein zweites BiliBili-Konto.", + "ui_connect_with_bilibili": "Mit BiliBili verbinden", + "ui_bilibili_session_description": "TypeType kann diese Sitzung verwenden.", + "ui_bilibili_expires_at": "Läuft ab am", + "ui_bilibili_what_happens": "Was passiert", + "ui_bilibili_info_intro": "Der QR-Code wird von TypeType generiert.", + "ui_bilibili_info_scan": "Sie scannen den QR-Code mit der BiliBili-App.", + "ui_bilibili_info_cookies": "Cookies werden serverseitig erfasst.", + "ui_bilibili_info_temporary": "Der QR-Code ist temporär.", + "ui_bilibili_info_after": "Nach der Verbindung verwenden BiliBili-Videos Ihre Sitzung.", + "portability_prepare_scanning": "Archiv wird untersucht", + "portability_prepare_extracting": "Takeout-Teile werden entpackt", + "portability_prepare_packing": "YouTube-Kontodaten werden vorbereitet", + "portability_prepare_uploading": "Vorbereitete Daten werden hochgeladen", + "portability_resume_prepared_upload": "Vorbereiteten Upload fortsetzen" } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b002ba80..0002f79c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -174,17 +174,30 @@ "settings_blocked_label": "Blocked content", "settings_data_description": "Import, restore, and export account data.", "settings_data_label": "Data", + "settings_api_description": "Create and revoke keys for external apps.", + "settings_api_label": "API", "settings_interface_description": "Choose what TypeType opens and highlights.", "settings_interface_label": "Interface", "settings_language_description": "Interface, audio, and subtitle preferences.", "settings_language_label": "Languages", "settings_language_section": "Language", + "settings_layout_label": "Layout", + "settings_layout_related_video_size": "Related video size", + "settings_layout_related_video_size_default": "Default", + "settings_layout_related_video_size_description": "Choose how large related video thumbnails appear on the watch page.", + "settings_layout_related_video_size_large": "Large", + "settings_layout_video_grid_columns": "Videos per row", + "settings_layout_video_grid_columns_description": "Sets the number of columns on home, subscription and other video grids.", "settings_playback_description": "Quality, speed, autoplay, and audio-only defaults.", "settings_playback_label": "Playback", "settings_privacy_description": "Control history, sessions, and account privacy.", "settings_privacy_label": "Privacy", "settings_rss_description": "Manage private feeds for this account.", "settings_services_description": "Select the provider used by default.", + "settings_notifications_description": "Control notification popups and choose channels individually.", + "settings_channel_notifications": "Channel notifications", + "settings_channel_notifications_description": "New channels are disabled by default. Enable only the channels you want to hear from.", + "settings_search_channels": "Search channels", "settings_services_label": "Services", "settings_subtitle": "Personalize playback, content, privacy, and account data. Changes follow your TypeType account.", "settings_subtitle_default_description": "Enable subtitles by default", @@ -240,6 +253,21 @@ "account_save": "Save account", "account_saved": "Account updated", "account_update_failed": "Update failed", + "presence_title": "Presence access", + "presence_description": "Create a revocable key that can only see whether something is playing now.", + "presence_token_once": "Copy this key now. It will not be shown again.", + "presence_copy": "Copy key", + "presence_copied": "Key copied", + "presence_name_label": "Key name", + "presence_name_placeholder": "Discord RPC", + "presence_create": "Create key", + "presence_created": "Presence key created", + "presence_create_failed": "Unable to create presence key", + "presence_revoke": "Revoke", + "presence_revoke_failed": "Unable to revoke presence key", + "presence_load_failed": "Unable to load presence keys", + "presence_empty": "No API keys yet.", + "api_sign_in_required": "Sign in to manage API keys.", "account_password_title": "Password", "account_password_description": "Reset your account password.", "account_password_reset": "Reset password", @@ -286,11 +314,17 @@ "portability_no_import_format": "No import format is available.", "portability_preparing_upload": "Preparing and uploading account data...", "portability_takeout_metadata_limit": "The account data in this archive exceeds the import limits or is encrypted. Export YouTube separately with smaller ZIP parts.", - "portability_takeout_no_metadata": "No YouTube account data was found. Select an original Takeout ZIP containing your history, subscriptions or playlists, rather than a video-only part or a ZIP containing other ZIPs.", + "portability_takeout_no_metadata": "No YouTube account data was found. Select the original Takeout ZIP containing your history, subscriptions or playlists, not a video-only ZIP part.", + "portability_takeout_storage_required": "The browser does not have enough temporary storage to process this Takeout archive. Free space or use a browser profile with more available storage.", "portability_import_title": "Import data", "portability_import_description": "Preview and choose what to restore.", "portability_import_from": "Import from", + "portability_auto_detect": "Automatic detection", + "portability_auto_import_starting": "Importing the detected data automatically. You can leave this page; progress continues.", "portability_choose_or_drop": "Choose or drop a backup", + "portability_zip_choose_source": "This ZIP archive is readable, but it does not match the selected import source. Choose the source that produced this backup.", + "portability_invalid_zip": "This file is not a readable ZIP archive. Select the complete original backup archive, not a standalone file or an incomplete part.", + "portability_upload_too_large_client": "This backup is larger than the 512 MB import limit. Use the original archive from a supported source, or export it again in smaller parts.", "portability_drop_original_prefix": "Drop the original", "portability_drop_original_suffix": "file. Nothing is imported until you review the preview.", "portability_duplicate_records": "duplicate records found", @@ -307,6 +341,11 @@ "portability_job_analyzing": "Analyzing backup", "portability_job_ready": "Ready to import", "portability_job_importing": "Importing data", + "portability_stage_subscriptions": "Importing subscriptions", + "portability_stage_recent_history": "Importing the latest watched videos", + "portability_stage_playlists": "Creating playlists", + "portability_stage_history": "Importing older watch history", + "portability_stage_remaining": "Importing remaining data", "portability_job_generating": "Generating export", "portability_job_import_completed": "Import completed", "portability_job_export_ready": "Export ready", @@ -342,6 +381,10 @@ "portability_guide_generic_open_suffix": "and find its backup or export action.", "portability_guide_generic_download": "Download the original backup file without editing or extracting it.", "portability_guide_generic_drop": "Drop the file below. TypeType will preview the data before anything is imported.", + "portability_guide_auto_description": "Choose the original backup. TypeType detects the compatible source from its actual structure.", + "portability_guide_auto_step_one": "Export the backup from your current YouTube client.", + "portability_guide_auto_step_two": "Keep the original file or archive unchanged.", + "portability_guide_auto_step_three": "Review the detected source and preview before importing.", "portability_stale_job_toast": "The previous operation is no longer available. Start again.", "portability_category_subscriptions": "Subscriptions", "portability_category_subscriptions_detail": "Channels you follow", @@ -535,6 +578,7 @@ "ui_community_then_neutral_frame": "Community, then neutral frame", "ui_confidence": "Confidence", "ui_connect_with_youtube": "Connect with YouTube", + "ui_reconnect_with_youtube": "Reconnect with YouTube", "ui_connect_youtube_inside_typetype": "Connect YouTube inside TypeType", "ui_connect_youtube_on_typetype": "Connect YouTube on TypeType", "ui_content": "Content", @@ -685,7 +729,7 @@ "ui_no_private_feeds": "No private feeds", "ui_no_private_feeds_have_been_created": "No private feeds have been created.", "ui_no_results_for": "No results for “", - "ui_no_shorts_available_right_now": "No shorts available right now.", + "ui_no_shorts_available_right_now": "No Shorts are available yet. Subscribe to some channels or import your YouTube subscriptions to fill this feed, then check back later if needed.", "ui_no_subscriptions_yet": "No subscriptions yet", "ui_no_subscriptions_yet_2": "No subscriptions yet.", "ui_no_videos_saved_for_later": "No videos saved for later.", @@ -958,6 +1002,12 @@ "ui_connect_youtube_to_access_this_video": "Connect YouTube to access this video.", "ui_this_video_is_currently_unavailable": "This video is currently unavailable", "ui_failed_to_load_stream": "Failed to load stream.", + "ui_error_provider_blocked": "The video provider is blocking requests from this TypeType server. Try a VPN or another outbound network for the server, then retry.", + "ui_error_region_restricted": "This video is not available in the server's region. A VPN or another outbound network may help.", + "ui_error_no_playable_stream": "TypeType could not find a compatible stream for this video. Try again later or choose another video.", + "ui_error_private_video": "This video is private or no longer available.", + "ui_error_age_restricted": "This video requires age verification. Connect YouTube and try again.", + "ui_error_stream_unavailable": "The video service could not provide a playable stream. Try again later.", "ui_mute_zen_music": "Mute zen music", "ui_completing_sign_in_with_your_provider": "Completing sign-in with your provider.", "ui_could_not_load_player": "Could not load player.", @@ -1009,6 +1059,8 @@ "ui_export_completed": "Export completed", "ui_restore_failed": "Restore failed", "ui_autoplay_next_video": "Autoplay next video", + "ui_autoplay_on_open": "Autoplay on open", + "ui_automatically_start_playback_when_a_video_is_opened": "Automatically start playback when a video is opened.", "ui_automatically_continue_with_the_first_item_in_the_suggestions_column": "Automatically continue with the first item in the suggestions column.", "ui_dearrow_titles_and_thumbnails": "DeArrow titles and thumbnails", "ui_use_community_written_titles_and_representative_thumbnails_for_youtub": "Use community-written titles and representative thumbnails for YouTube videos.", @@ -1176,6 +1228,7 @@ "ui_not_connected": "Not connected", "ui_typetype_can_use_this_session": "TypeType can use this session when extraction needs it.", "ui_youtube_rejected_stored_session": "YouTube rejected the stored session. Create a new code.", + "ui_reconnect_youtube_to_access_this_video": "Reconnect YouTube to access this video.", "ui_no_youtube_session_connected": "No YouTube session is connected.", "ui_prepare": "Prepare", "ui_download": "Download", @@ -1321,5 +1374,125 @@ "ui_remote_phase_capturing_session": "Capturing session", "ui_remote_phase_connected": "Connected", "ui_remote_phase_closed": "Closed", - "ui_remote_phase_error": "Error" + "ui_remote_phase_error": "Error", + "ui_remote_diagnostics": "Diagnostics", + "ui_copy_diagnostics": "Copy diagnostics", + "ui_diagnostics_copied": "Copied", + "ui_remote_diagnostics_hint": "Share these lines in a bug report. They contain no passwords and no cookie values.", + "sg_add": "Add", + "sg_add_groups": "Add groups", + "sg_added": "Added {count} channels to {group}.", + "sg_all_channels": "All channels", + "sg_all_subscriptions": "All subscriptions", + "sg_select_page": "Select page ({count})", + "sg_ungrouped": "Ungrouped", + "sg_channels_load_error": "Your channels could not be loaded. Check your connection and try again.", + "sg_back_channels": "Back to channels", + "sg_back_to_results": "Back to results", + "sg_change_filters": "Try another group or change your search.", + "sg_channel_count": "{count} channels", + "sg_channel_saved": "Updated groups for {channel}.", + "sg_channels": "Subscribed channels", + "sg_clear": "Clear selection", + "sg_clear_confirmation": "Remove all group memberships from {count} selected channels, including any outside this view? You will stay subscribed to them.", + "sg_delete_confirmation": "This deletes the group and removes its memberships for {count} channels. The channels and your subscriptions will be kept.", + "sg_delete_group": "Delete group", + "sg_delete_named": "Delete “{group}”?", + "sg_duplicate_name": "A group with this name already exists. Choose another name.", + "sg_edit_named": "Edit groups for {channel}", + "sg_empty_feed": "No videos in this view yet.", + "sg_empty_subscriptions": "Subscribe to channels first, then organize them here. You can already create groups.", + "sg_feed_filter": "Filter subscriptions by group", + "sg_group_actions": "Actions for {group}", + "sg_group_created": "Created {group}.", + "sg_group_deleted": "Deleted {group}. Your subscriptions are unchanged.", + "sg_group_filters": "Filter channels by group", + "sg_group_renamed": "Group renamed.", + "sg_groups": "Groups", + "sg_hidden_selected": "{count} outside this view", + "sg_in_group": "In group", + "sg_invalid_name": "Use a group name between 1 and 100 characters.", + "sg_load_error": "Your groups could not be loaded. Check your connection and try again.", + "sg_refresh_error": "Groups could not be refreshed. The data shown may be out of date. Editing is paused until you try again.", + "sg_refreshing": "Refreshing…", + "sg_load_more": "Show more · {shown} of {total}", + "sg_loading": "Loading your groups and channels…", + "sg_manage_groups": "Manage groups", + "sg_manager_description": "Organize your subscriptions. A channel can belong to more than one group.", + "sg_membership_filter": "Group membership filter", + "sg_memberships": "Channel groups", + "sg_memberships_cleared": "Group memberships removed. You are still subscribed to these channels.", + "sg_new_group": "New group", + "sg_no_channel_match": "No channels in this view", + "sg_no_group_match": "No matching groups. Create a group in the sidebar.", + "sg_no_groups": "Create your first group above. Leaving channels ungrouped is fine too.", + "sg_not_in_group": "Not in group", + "sg_outside_named": "Not in {group}", + "sg_partial_failure": "Changes for {count} channels could not be saved. Some changes may have succeeded. Review the updated memberships and retry.", + "sg_remove": "Remove", + "sg_remove_all": "Remove from all groups", + "sg_remove_named": "Remove {group}", + "sg_removed": "Removed {count} channels from {group}.", + "sg_rename_group": "Rename group", + "sg_retry": "Try again", + "sg_save_error": "Changes could not be saved. Check your connection and try again.", + "sg_saving": "Saving changes…", + "sg_search_channels": "Search channels", + "sg_search_groups": "Search groups", + "sg_select_hint": "Select channels to edit in bulk", + "sg_selected": "{count} selected", + "sg_show_selected": "Show selected", + "sg_target_group": "Target group", + "sg_one_channel": "1 channel", + "sg_added_one": "Added 1 channel to {group}.", + "sg_removed_one": "Removed 1 channel from {group}.", + "sg_clear_one_confirmation": "Remove all group memberships from the selected channel, even if it is outside this view? You will stay subscribed to it.", + "sg_delete_one_confirmation": "This deletes the group and its membership for 1 channel. The channel and your subscription will be kept.", + "sg_partial_one_failure": "Changes for 1 channel could not be saved. Some changes may have succeeded. Review the updated memberships and retry.", + "subscriptions_feed_load_error": "Your subscriptions could not be loaded. Check your connection and try again.", + "subscriptions_feed_next_page_error": "More videos could not be loaded. Your loaded videos are still available.", + "sg_page_range": "{start}–{end} of {total}", + "sg_previous_page": "Previous page", + "sg_next_page": "Next page", + "sg_more_groups": "Additional groups ({count})", + "sg_no_group_results": "No groups match your search.", + "ui_bilibili_session": "BiliBili Session", + "ui_bilibili_session_connected": "Connected", + "ui_bilibili_session_not_connected": "Not connected", + "ui_bilibili_session_needs_reconnect": "Session expired, reconnect", + "ui_bilibili_session_disconnect": "Disconnect", + "ui_bilibili_session_connect": "Connect", + "ui_bilibili_session_reconnect": "Reconnect", + "ui_bilibili_session_loading": "Loading...", + "ui_bilibili_session_scan_qr": "Scan with the BiliBili app", + "ui_bilibili_session_scanned": "Scanned! Confirm on your phone.", + "ui_bilibili_session_cancel": "Cancel", + "ui_bilibili_session_connected_toast": "Connected!", + "ui_bilibili_session_qr_expired": "QR code expired", + "ui_bilibili_session_try_again": "Try again", + "ui_bilibili_session_retry": "Retry", + "ui_bilibili_session_not_configured": "BiliBili sign-in is not enabled on this instance.", + "ui_bilibili_session_qr_unavailable": "BiliBili did not provide a QR code. Try again in a moment.", + "ui_bilibili_session_rate_limited": "BiliBili is temporarily rate-limiting sign-in. Try again later.", + "ui_bilibili_session_qr_poll_error": "The BiliBili sign-in could not be completed.", + "ui_bilibili_session_expires_in": "expires in {days} {plural}", + "ui_services": "Services", + "ui_bilibili_remote_sign_in": "BiliBili remote sign-in", + "ui_connect_bilibili_inside_typetype": "Connect BiliBili inside TypeType", + "ui_typetype_generates_a_qr_code": "TypeType generates a QR code for BiliBili sign-in. Cookies are captured server-side, encrypted, and never sent to the web UI.", + "ui_use_secondary_bilibili_account": "Use a secondary BiliBili account. The QR code is temporary and expires after 3 minutes.", + "ui_connect_with_bilibili": "Connect with BiliBili", + "ui_bilibili_session_description": "TypeType can use this session when extraction needs it.", + "ui_bilibili_expires_at": "Expires at", + "ui_bilibili_what_happens": "What happens", + "ui_bilibili_info_intro": "The QR code is generated by TypeType and scanned with the BiliBili mobile app.", + "ui_bilibili_info_scan": "You scan the QR code with the BiliBili app on your phone.", + "ui_bilibili_info_cookies": "Cookies are captured server-side, encrypted, and never sent to the web UI.", + "ui_bilibili_info_temporary": "The QR code is temporary and expires after 3 minutes.", + "ui_bilibili_info_after": "After connection, BiliBili videos and streams use your BiliBili session.", + "portability_prepare_scanning": "Scanning archive", + "portability_prepare_extracting": "Extracting Takeout parts", + "portability_prepare_packing": "Preparing YouTube account data", + "portability_prepare_uploading": "Uploading prepared data", + "portability_resume_prepared_upload": "Retry prepared upload" } diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 27a153c9..e3d2154f 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -174,17 +174,30 @@ "settings_blocked_label": "Contenu bloqué", "settings_data_description": "Importez, restaurez et exportez les données du compte.", "settings_data_label": "Données", + "settings_api_description": "Créez et révoquez des clés pour les applications externes.", + "settings_api_label": "API", "settings_interface_description": "Choisissez ce que TypeType ouvre et met en avant.", "settings_interface_label": "Interface", "settings_language_description": "Préférences d'interface, d'audio et de sous-titres.", "settings_language_label": "Langues", "settings_language_section": "Langue", + "settings_layout_label": "Disposition", + "settings_layout_related_video_size": "Taille des vidéos associées", + "settings_layout_related_video_size_default": "Par défaut", + "settings_layout_related_video_size_description": "Choisit la taille des vignettes associées sur la page de lecture.", + "settings_layout_related_video_size_large": "Grande", + "settings_layout_video_grid_columns": "Vidéos par ligne", + "settings_layout_video_grid_columns_description": "Définit le nombre de colonnes sur l'accueil, les abonnements et les autres grilles.", "settings_playback_description": "Qualité, vitesse, lecture automatique et audio uniquement.", "settings_playback_label": "Lecture", "settings_privacy_description": "Contrôlez l'historique, les sessions et la confidentialité du compte.", "settings_privacy_label": "Confidentialité", "settings_rss_description": "Gérez les flux privés de ce compte.", "settings_services_description": "Sélectionnez le fournisseur utilisé par défaut.", + "settings_notifications_description": "Contrôlez les notifications et choisissez les chaînes individuellement.", + "settings_channel_notifications": "Notifications par chaîne", + "settings_channel_notifications_description": "Les nouvelles chaînes sont désactivées par défaut. Activez uniquement celles dont vous voulez suivre les publications.", + "settings_search_channels": "Rechercher des chaînes", "settings_services_label": "Services", "settings_subtitle": "Personnalisez la lecture, le contenu, la confidentialité et les données de votre compte.", "settings_subtitle_default_description": "Activer les sous-titres par défaut", @@ -240,6 +253,21 @@ "account_save": "Enregistrer le compte", "account_saved": "Compte mis à jour", "account_update_failed": "Échec de la mise à jour", + "presence_title": "Accès de présence", + "presence_description": "Créez une clé révocable qui peut uniquement voir si une lecture est en cours.", + "presence_token_once": "Copiez cette clé maintenant. Elle ne sera plus affichée.", + "presence_copy": "Copier la clé", + "presence_copied": "Clé copiée", + "presence_name_label": "Nom de la clé", + "presence_name_placeholder": "Discord RPC", + "presence_create": "Créer une clé", + "presence_created": "Clé de présence créée", + "presence_create_failed": "Impossible de créer la clé de présence", + "presence_revoke": "Révoquer", + "presence_revoke_failed": "Impossible de révoquer la clé de présence", + "presence_load_failed": "Impossible de charger les clés de présence", + "presence_empty": "Aucune clé API pour le moment.", + "api_sign_in_required": "Connectez-vous pour gérer les clés API.", "account_password_title": "Mot de passe", "account_password_description": "Réinitialisez le mot de passe de votre compte.", "account_password_reset": "Réinitialiser le mot de passe", @@ -286,11 +314,17 @@ "portability_no_import_format": "Aucun format d'import n'est disponible.", "portability_preparing_upload": "Préparation et envoi des données du compte...", "portability_takeout_metadata_limit": "Les données de compte de cette archive dépassent les limites d'import ou sont chiffrées. Exportez YouTube séparément avec des parties ZIP plus petites.", - "portability_takeout_no_metadata": "Aucune donnée de compte YouTube trouvée. Sélectionnez un ZIP Takeout original contenant votre historique, vos abonnements ou vos playlists, plutôt qu'une partie contenant uniquement des vidéos ou un ZIP contenant d'autres ZIP.", + "portability_takeout_no_metadata": "Aucune donnée de compte YouTube trouvée. Sélectionnez le ZIP Takeout original contenant votre historique, vos abonnements ou vos playlists, pas une partie ZIP contenant uniquement des vidéos.", + "portability_takeout_storage_required": "Le navigateur ne dispose pas d’assez d’espace temporaire pour traiter cette archive Takeout. Libérez de l’espace ou utilisez un profil de navigateur disposant de plus d’espace.", "portability_import_title": "Importer des données", "portability_import_description": "Prévisualisez les données et choisissez ce qui doit être restauré.", "portability_import_from": "Importer depuis", + "portability_auto_detect": "Détection automatique", + "portability_auto_import_starting": "Import automatique des données détectées. Vous pouvez quitter cette page, la progression continue.", "portability_choose_or_drop": "Choisissez ou déposez une sauvegarde", + "portability_zip_choose_source": "Cette archive ZIP est lisible, mais elle ne correspond pas à la source d’import sélectionnée. Choisissez la source qui a produit cette sauvegarde.", + "portability_invalid_zip": "Le fichier n’est pas une archive ZIP lisible. Sélectionnez l’archive complète d’origine, pas un fichier isolé ni une partie incomplète.", + "portability_upload_too_large_client": "Cette sauvegarde dépasse la limite d’import de 512 Mo. Utilisez l’archive d’origine d’une source prise en charge ou relancez un export en parties plus petites.", "portability_drop_original_prefix": "Déposez le fichier original", "portability_drop_original_suffix": "de sauvegarde. Rien ne sera importé avant la validation de l'aperçu.", "portability_duplicate_records": "enregistrements en double trouvés", @@ -307,6 +341,11 @@ "portability_job_analyzing": "Analyse de la sauvegarde", "portability_job_ready": "Prêt à importer", "portability_job_importing": "Import des données", + "portability_stage_subscriptions": "Import des abonnements", + "portability_stage_recent_history": "Import des vidéos regardées récemment", + "portability_stage_playlists": "Création des playlists", + "portability_stage_history": "Import de l’historique plus ancien", + "portability_stage_remaining": "Import des autres données", "portability_job_generating": "Génération de l'export", "portability_job_import_completed": "Import terminé", "portability_job_export_ready": "Export prêt", @@ -342,6 +381,10 @@ "portability_guide_generic_open_suffix": "et recherchez son action de sauvegarde ou d'export.", "portability_guide_generic_download": "Téléchargez la sauvegarde originale sans la modifier ni l'extraire.", "portability_guide_generic_drop": "Déposez le fichier ici. TypeType prévisualisera les données avant tout import.", + "portability_guide_auto_description": "Choisissez la sauvegarde d'origine. TypeType détecte la source compatible à partir de sa structure réelle.", + "portability_guide_auto_step_one": "Exportez la sauvegarde depuis votre client YouTube actuel.", + "portability_guide_auto_step_two": "Conservez le fichier ou l'archive d'origine sans le modifier.", + "portability_guide_auto_step_three": "Vérifiez la source détectée et l'aperçu avant l'import.", "portability_stale_job_toast": "L'opération précédente n'est plus disponible. Recommencez.", "portability_category_subscriptions": "Abonnements", "portability_category_subscriptions_detail": "Chaînes suivies", @@ -534,7 +577,8 @@ "ui_community_title": "Community title", "ui_community_then_neutral_frame": "Community, then neutral frame", "ui_confidence": "Confidence", - "ui_connect_with_youtube": "Connecter with YouTube", + "ui_connect_with_youtube": "Se connecter à YouTube", + "ui_reconnect_with_youtube": "Se reconnecter à YouTube", "ui_connect_youtube_inside_typetype": "Connecter YouTube inside TypeType", "ui_connect_youtube_on_typetype": "Connecter YouTube on TypeType", "ui_content": "Contenu", @@ -685,7 +729,7 @@ "ui_no_private_feeds": "Aucun flux privé", "ui_no_private_feeds_have_been_created": "No private feeds have been created.", "ui_no_results_for": "Aucun résultat pour « ", - "ui_no_shorts_available_right_now": "No shorts available right now.", + "ui_no_shorts_available_right_now": "Aucun Short n’est encore disponible. Abonnez-vous à des chaînes ou importez vos abonnements YouTube pour remplir ce fil, puis revenez plus tard si nécessaire.", "ui_no_subscriptions_yet": "Aucun subscriptions pour le moment.", "ui_no_subscriptions_yet_2": "Aucun abonnement pour le moment.", "ui_no_videos_saved_for_later": "Aucune vidéo enregistrée pour plus tard.", @@ -957,7 +1001,13 @@ "ui_error_occurs_when_fetching_the_page_try_increase_the_loading_timeout": "Error occurs when fetching the page. Try increase the loading timeout in Settings.", "ui_connect_youtube_to_access_this_video": "Connect YouTube to access this video.", "ui_this_video_is_currently_unavailable": "This video is currently unavailable", - "ui_failed_to_load_stream": "Échec lors de load stream..", + "ui_failed_to_load_stream": "Échec du chargement du flux.", + "ui_error_provider_blocked": "Le fournisseur vidéo bloque les requêtes de ce serveur TypeType. Essayez un VPN ou un autre réseau sortant pour le serveur, puis réessayez.", + "ui_error_region_restricted": "Cette vidéo n’est pas disponible dans la région du serveur. Un VPN ou un autre réseau sortant peut aider.", + "ui_error_no_playable_stream": "TypeType n’a trouvé aucun flux compatible pour cette vidéo. Réessayez plus tard ou choisissez une autre vidéo.", + "ui_error_private_video": "Cette vidéo est privée ou n’est plus disponible.", + "ui_error_age_restricted": "Cette vidéo nécessite une vérification de l’âge. Connectez YouTube puis réessayez.", + "ui_error_stream_unavailable": "Le service vidéo n’a pas pu fournir de flux lisible. Réessayez plus tard.", "ui_mute_zen_music": "Mute zen music", "ui_completing_sign_in_with_your_provider": "Completing sign-in with your provider.", "ui_could_not_load_player": "Could not load player.", @@ -1009,6 +1059,8 @@ "ui_export_completed": "Export terminé", "ui_restore_failed": "Restore failed", "ui_autoplay_next_video": "Autoplay next video", + "ui_autoplay_on_open": "Lecture automatique à l'ouverture", + "ui_automatically_start_playback_when_a_video_is_opened": "Démarrer automatiquement la lecture quand une vidéo est ouverte.", "ui_automatically_continue_with_the_first_item_in_the_suggestions_column": "Automatically continue with the first item in the suggestions column.", "ui_dearrow_titles_and_thumbnails": "DeArrow titles and thumbnails", "ui_use_community_written_titles_and_representative_thumbnails_for_youtub": "Use community-written titles and representative thumbnails for YouTube videos.", @@ -1176,6 +1228,7 @@ "ui_not_connected": "Non connecté", "ui_typetype_can_use_this_session": "TypeType peut utiliser cette session lorsque l’extraction en a besoin.", "ui_youtube_rejected_stored_session": "YouTube a rejeté la session enregistrée. Créez un nouveau code.", + "ui_reconnect_youtube_to_access_this_video": "Reconnectez YouTube pour accéder à cette vidéo.", "ui_no_youtube_session_connected": "Aucune session YouTube n’est connectée.", "ui_prepare": "Préparer", "ui_download": "Télécharger", @@ -1321,5 +1374,125 @@ "ui_remote_phase_capturing_session": "Capture de la session", "ui_remote_phase_connected": "Connecté", "ui_remote_phase_closed": "Fermé", - "ui_remote_phase_error": "Erreur" + "ui_remote_phase_error": "Erreur", + "ui_remote_diagnostics": "Diagnostics", + "ui_copy_diagnostics": "Copier les diagnostics", + "ui_diagnostics_copied": "Copié", + "ui_remote_diagnostics_hint": "Partage ces lignes dans un rapport de bug. Elles ne contiennent ni mot de passe ni valeur de cookie.", + "sg_add": "Ajouter", + "sg_add_groups": "Ajouter des groupes", + "sg_added": "{count} chaînes ajoutées à {group}.", + "sg_all_channels": "Toutes les chaînes", + "sg_all_subscriptions": "Tous les abonnements", + "sg_select_page": "Sélectionner la page ({count})", + "sg_ungrouped": "Non groupées", + "sg_channels_load_error": "Impossible de charger vos chaînes. Vérifiez votre connexion et réessayez.", + "sg_back_channels": "Retour aux chaînes", + "sg_back_to_results": "Retour aux résultats", + "sg_change_filters": "Essayez un autre groupe ou modifiez votre recherche.", + "sg_channel_count": "{count} chaînes", + "sg_channel_saved": "Groupes de {channel} mis à jour.", + "sg_channels": "Chaînes suivies", + "sg_clear": "Effacer la sélection", + "sg_clear_confirmation": "Retirer les {count} chaînes sélectionnées de tous leurs groupes, y compris celles hors de cette vue ? Vous resterez abonné à ces chaînes.", + "sg_delete_confirmation": "Le groupe et ses appartenances pour {count} chaînes seront supprimés. Les chaînes et vos abonnements seront conservés.", + "sg_delete_group": "Supprimer le groupe", + "sg_delete_named": "Supprimer « {group} » ?", + "sg_duplicate_name": "Un groupe porte déjà ce nom. Choisissez un autre nom.", + "sg_edit_named": "Modifier les groupes de {channel}", + "sg_empty_feed": "Aucune vidéo dans cette vue pour le moment.", + "sg_empty_subscriptions": "Abonnez-vous à des chaînes, puis organisez-les ici. Vous pouvez déjà créer des groupes.", + "sg_feed_filter": "Filtrer les abonnements par groupe", + "sg_group_actions": "Actions pour {group}", + "sg_group_created": "Groupe {group} créé.", + "sg_group_deleted": "Groupe {group} supprimé. Vos abonnements sont conservés.", + "sg_group_filters": "Filtrer les chaînes par groupe", + "sg_group_renamed": "Groupe renommé.", + "sg_groups": "Groupes", + "sg_hidden_selected": "{count} hors de cette vue", + "sg_in_group": "Dans le groupe", + "sg_invalid_name": "Le nom du groupe doit contenir entre 1 et 100 caractères.", + "sg_load_error": "Impossible de charger vos groupes. Vérifiez votre connexion et réessayez.", + "sg_refresh_error": "Impossible d’actualiser les groupes. Les données affichées peuvent être obsolètes. Réessayez pour reprendre la modification.", + "sg_refreshing": "Actualisation…", + "sg_load_more": "Afficher plus · {shown} sur {total}", + "sg_loading": "Chargement des groupes et des chaînes…", + "sg_manage_groups": "Gérer les groupes", + "sg_manager_description": "Organisez vos abonnements. Une chaîne peut appartenir à plusieurs groupes.", + "sg_membership_filter": "Filtre d’appartenance au groupe", + "sg_memberships": "Groupes de la chaîne", + "sg_memberships_cleared": "Appartenances aux groupes supprimées. Vous restez abonné à ces chaînes.", + "sg_new_group": "Nouveau groupe", + "sg_no_channel_match": "Aucune chaîne dans cette vue", + "sg_no_group_match": "Aucun groupe correspondant. Créez un groupe dans le volet latéral.", + "sg_no_groups": "Créez votre premier groupe ci-dessus. Les chaînes peuvent aussi rester sans groupe.", + "sg_not_in_group": "Hors du groupe", + "sg_outside_named": "Hors de {group}", + "sg_partial_failure": "Les modifications de {count} chaînes n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez.", + "sg_remove": "Retirer", + "sg_remove_all": "Retirer de tous les groupes", + "sg_remove_named": "Retirer {group}", + "sg_removed": "{count} chaînes retirées de {group}.", + "sg_rename_group": "Renommer le groupe", + "sg_retry": "Réessayer", + "sg_save_error": "Impossible d’enregistrer les modifications. Vérifiez votre connexion et réessayez.", + "sg_saving": "Enregistrement…", + "sg_search_channels": "Rechercher des chaînes", + "sg_search_groups": "Rechercher des groupes", + "sg_select_hint": "Sélectionnez des chaînes à modifier ensemble", + "sg_selected": "{count} sélectionnés", + "sg_show_selected": "Voir la sélection", + "sg_target_group": "Groupe cible", + "sg_one_channel": "1 chaîne", + "sg_added_one": "1 chaîne ajoutée à {group}.", + "sg_removed_one": "1 chaîne retirée de {group}.", + "sg_clear_one_confirmation": "Retirer la chaîne sélectionnée de tous ses groupes, même si elle est hors de cette vue ? Vous resterez abonné à cette chaîne.", + "sg_delete_one_confirmation": "Le groupe et son appartenance pour 1 chaîne seront supprimés. La chaîne et votre abonnement seront conservés.", + "sg_partial_one_failure": "Les modifications de 1 chaîne n’ont pas pu être enregistrées. Certaines ont pu réussir. Vérifiez les groupes actualisés et réessayez.", + "subscriptions_feed_load_error": "Impossible de charger vos abonnements. Vérifiez votre connexion et réessayez.", + "subscriptions_feed_next_page_error": "Impossible de charger plus de vidéos. Les vidéos déjà chargées restent disponibles.", + "sg_page_range": "{start}–{end} sur {total}", + "sg_previous_page": "Page précédente", + "sg_next_page": "Page suivante", + "sg_more_groups": "Groupes supplémentaires ({count})", + "sg_no_group_results": "Aucun groupe ne correspond à votre recherche.", + "ui_bilibili_session": "Session BiliBili", + "ui_bilibili_session_connected": "Connecté", + "ui_bilibili_session_not_connected": "Non connecté", + "ui_bilibili_session_needs_reconnect": "Session expirée, reconnecter", + "ui_bilibili_session_disconnect": "Déconnecter", + "ui_bilibili_session_connect": "Connecter", + "ui_bilibili_session_reconnect": "Reconnecter", + "ui_bilibili_session_loading": "Chargement...", + "ui_bilibili_session_scan_qr": "Scannez le code QR avec l’application BiliBili", + "ui_bilibili_session_scanned": "Scanné ! Confirmez sur votre téléphone.", + "ui_bilibili_session_cancel": "Annuler", + "ui_bilibili_session_connected_toast": "Connecté !", + "ui_bilibili_session_qr_expired": "Code QR expiré", + "ui_bilibili_session_try_again": "Réessayer", + "ui_bilibili_session_retry": "Réessayer", + "ui_bilibili_session_not_configured": "La connexion BiliBili n’est pas activée sur cette instance.", + "ui_bilibili_session_qr_unavailable": "BiliBili n’a pas fourni de code QR. Réessayez dans un instant.", + "ui_bilibili_session_rate_limited": "BiliBili limite temporairement les connexions. Réessayez plus tard.", + "ui_bilibili_session_qr_poll_error": "La connexion BiliBili n’a pas pu être terminée.", + "ui_bilibili_session_expires_in": "expire dans {days} {plural}", + "ui_services": "Services", + "ui_bilibili_remote_sign_in": "Connexion BiliBili à distance", + "ui_connect_bilibili_inside_typetype": "Connecter BiliBili à TypeType", + "ui_typetype_generates_a_qr_code": "TypeType génère un code QR pour vous connecter à BiliBili. Les cookies sont capturés et chiffrés côté serveur ; ils ne sont jamais envoyés à l’interface web.", + "ui_use_secondary_bilibili_account": "Utilisez un compte BiliBili secondaire. Le code QR est temporaire et expire après trois minutes.", + "ui_connect_with_bilibili": "Connecter avec BiliBili", + "ui_bilibili_session_description": "TypeType peut utiliser cette session lorsqu’une extraction BiliBili en a besoin.", + "ui_bilibili_expires_at": "Expire le", + "ui_bilibili_what_happens": "Ce qui se passe", + "ui_bilibili_info_intro": "Le code QR est généré par TypeType et scanné avec l’application BiliBili.", + "ui_bilibili_info_scan": "Scannez le code QR avec l’application BiliBili sur votre téléphone.", + "ui_bilibili_info_cookies": "Les cookies sont capturés et chiffrés côté serveur ; ils ne sont jamais envoyés à l’interface web.", + "ui_bilibili_info_temporary": "Le code QR est temporaire et expire après 3 minutes.", + "ui_bilibili_info_after": "Après la connexion, les vidéos BiliBili utilisent votre session.", + "portability_prepare_scanning": "Analyse de l’archive", + "portability_prepare_extracting": "Extraction des parties Takeout", + "portability_prepare_packing": "Préparation des données YouTube", + "portability_prepare_uploading": "Envoi des données préparées", + "portability_resume_prepared_upload": "Reprendre l’envoi préparé" } diff --git a/apps/web/package.json b/apps/web/package.json index 616e9a6d..7d249d9f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.8.1", + "version": "1.9.0", "type": "module", "scripts": { "dev": "vite", @@ -14,13 +14,15 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tanstack/react-router": "^1.170.33", - "@typetype/mse": "0.1.60", + "@typetype/mse": "0.1.70", "@vidstack/react": "1.12.13", "@zip.js/zip.js": "^2.13.1", "dashjs": "^5.2.1", "hls.js": "1.7.2", "lucide-react": "^1.43.0", "media-icons": "1.1.5", + "mpegts.js": "1.8.2", + "qrcode.react": "4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", "simple-icons": "^16.30.0", diff --git a/apps/web/public/youtube-session-reconnect.gif b/apps/web/public/youtube-session-reconnect.gif new file mode 100644 index 00000000..9c28ea2f Binary files /dev/null and b/apps/web/public/youtube-session-reconnect.gif differ diff --git a/apps/web/src/components/bilibili-icon.tsx b/apps/web/src/components/bilibili-icon.tsx new file mode 100644 index 00000000..6caaaa71 --- /dev/null +++ b/apps/web/src/components/bilibili-icon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; +import { siBilibili } from "simple-icons"; + +type Props = SVGProps & { + title?: string; +}; + +export function BiliBiliIcon({ title, ...props }: Props) { + return ( + + + + ); +} diff --git a/apps/web/src/components/bilibili-session-banner.tsx b/apps/web/src/components/bilibili-session-banner.tsx new file mode 100644 index 00000000..fa1c1b6c --- /dev/null +++ b/apps/web/src/components/bilibili-session-banner.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "../hooks/use-auth"; +import { fetchBiliBiliSessionStatus } from "../lib/api-bilibili-session"; +import { m } from "../paraglide/messages.js"; + +export function BiliBiliSessionBanner({ sourceUrl }: { sourceUrl: string }) { + const { authReady, isAuthed } = useAuth(); + const isBiliBili = sourceUrl.includes("bilibili.com") || sourceUrl.includes("b23.tv"); + const shouldQuery = authReady && isAuthed && isBiliBili; + const { data: status } = useQuery({ + queryKey: ["bilibili-session"], + queryFn: fetchBiliBiliSessionStatus, + enabled: shouldQuery, + staleTime: 5 * 60 * 1000, + }); + if (!shouldQuery || !status || status.status === "connected") return null; + return ( +
+ {m.ui_bilibili_session_not_connected()} + + {m.ui_bilibili_session_connect()} + +
+ ); +} diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx index 32072b28..cc414f23 100644 --- a/apps/web/src/components/channel-avatar.tsx +++ b/apps/web/src/components/channel-avatar.tsx @@ -1,9 +1,13 @@ -import { useState } from "react"; +import { UserRound } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Skeleton } from "./skeleton"; type Props = { src: string; name: string; className?: string; + pending?: boolean; + priority?: boolean; }; function getInitial(name: string): string { @@ -20,31 +24,74 @@ function getInitial(name: string): string { return name[0].toUpperCase(); } -export function ChannelAvatar({ src, name, className = "w-8 h-8" }: Props) { +export function ChannelAvatar({ src, name, className = "w-8 h-8", pending, priority }: Props) { const [failedSrc, setFailedSrc] = useState(null); + const [loadedSrc, setLoadedSrc] = useState(null); + const [missingExpired, setMissingExpired] = useState(false); + const [retryState, setRetryState] = useState({ src, count: 0 }); + const hasSource = src.trim().length > 0; const failed = failedSrc === src; - - if (!src || failed) { - return ( -
- {getInitial(name)} -
- ); - } + const loaded = loadedSrc === src; + const retryCount = retryState.src === src ? retryState.count : 0; + useEffect(() => { + if (hasSource || pending === false) { + setMissingExpired(false); + return; + } + if (pending === true) return; + setMissingExpired(false); + const timer = window.setTimeout(() => setMissingExpired(true), MISSING_AVATAR_GRACE_MS); + return () => window.clearTimeout(timer); + }, [hasSource, pending]); + const missing = + !hasSource && !failed && (pending === true || (pending === undefined && !missingExpired)); + const loading = missing || (hasSource && !failed && !loaded); + const state = loading ? "loading" : hasSource && !failed ? "ready" : "fallback"; return ( - setFailedSrc(src)} +
+ data-avatar-state={state} + aria-busy={loading} + > + {loading && } + {!loading && + (name.trim() ? ( + {getInitial(name)} + ) : ( +
); } + +function avatarRetrySrc(src: string, retryCount: number): string { + if (retryCount === 0) return src; + const url = new URL(src, window.location.href); + url.searchParams.set("_tt_avatar_retry", String(retryCount)); + return url.toString(); +} + +const MAX_AVATAR_RETRIES = 2; +const MISSING_AVATAR_GRACE_MS = 1_500; diff --git a/apps/web/src/components/channel-filter-bar.tsx b/apps/web/src/components/channel-filter-bar.tsx index 386b0b58..e913a59b 100644 --- a/apps/web/src/components/channel-filter-bar.tsx +++ b/apps/web/src/components/channel-filter-bar.tsx @@ -17,6 +17,7 @@ type Props = { sort: ChannelSort; query: string; tab: ChannelTab; + tabsAvailable: boolean; searchAvailable: boolean; onSearch: (query: string) => void; onTabChange: (tab: ChannelTab) => void; @@ -27,6 +28,7 @@ export function ChannelFilterBar({ sort, query, tab, + tabsAvailable, searchAvailable, onSearch, onTabChange, @@ -53,7 +55,7 @@ export function ChannelFilterBar({ return (
- {searchAvailable && ( + {tabsAvailable && (
{channelTabs().map((item) => ( +
+ {serviceAlternatives(settings.defaultService).map((service) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index d77bb27c..095166a9 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -7,8 +7,10 @@ import { useAuthToasts } from "../hooks/use-auth-toasts"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { useMobile } from "../hooks/use-mobile"; import { useSearchShortcut } from "../hooks/use-search-shortcut"; +import { useSettings } from "../hooks/use-settings"; import { isAuthPage } from "../lib/auth-routes"; import { LEMMY_COMMUNITY_URL } from "../lib/community-announcement"; +import { defaultLandingPath } from "../lib/default-landing"; import { m } from "../paraglide/messages.js"; import { useUiStore } from "../stores/ui-store"; import { InterfaceLanguagePicker } from "./interface-language-picker"; @@ -16,6 +18,7 @@ import { NavbarAccountControls } from "./navbar-account-controls"; import { NavbarLeadingControl } from "./navbar-leading-control"; import { NavbarNotifications } from "./navbar-notifications"; import { NavbarSearch } from "./navbar-search"; +import { NavbarServicePicker } from "./navbar-service-picker"; import { ServiceIcon } from "./service-icon"; import { Toast } from "./toast"; @@ -35,6 +38,8 @@ export function Navbar() { const toggleSidebar = useUiStore((s) => s.toggleSidebar); const toggleMobileSidebar = useUiStore((s) => s.toggleMobileSidebar); const { status, isAuthed, isGuest, isAdmin, me, signOut } = useAuth(); + const { settings } = useSettings(); + const logoTarget = defaultLandingPath(settings.defaultLandingPage) ?? "/"; const pathname = useRouterState({ select: (state) => state.location.pathname }); const authPage = isAuthPage(pathname); const canOpenSearch = !authPage; @@ -63,7 +68,7 @@ export function Navbar() { onBack={handleBack} onToggleSidebar={isMobile ? toggleMobileSidebar : toggleSidebar} /> - + TypeType TYPETYPE @@ -75,7 +80,7 @@ export function Navbar() { rel="noreferrer" aria-label={m.shell_support_typetype()} title={m.shell_support_typetype()} - className="inline-flex h-8 shrink-0 items-center gap-1.5 px-1 text-xs font-medium text-fg-muted hover:text-fg" + className="hidden h-8 shrink-0 items-center gap-1.5 px-1 text-xs font-medium text-fg-muted hover:text-fg sm:inline-flex" > {m.shell_support()} @@ -86,7 +91,7 @@ export function Navbar() { rel="noreferrer" aria-label="Lemmy" title="Lemmy" - className="inline-flex h-8 shrink-0 items-center gap-1.5 px-1 text-xs font-medium text-fg-muted hover:text-fg" + className="hidden h-8 shrink-0 items-center gap-1.5 px-1 text-xs font-medium text-fg-muted hover:text-fg sm:inline-flex" > Lemmy @@ -107,6 +112,7 @@ export function Navbar() { {canOpenSearch && !isMobile && }
+ {canOpenSearch && } import("./persistent-watch-player-host").then((module) => ({ @@ -9,8 +11,21 @@ const PersistentWatchPlayerHost = lazy(() => export function PersistentWatchPlayer() { const hasEntry = usePersistentWatchPlayerStore((state) => state.entry !== null); - if (!hasEntry) return null; + return ; +} + +function PersistentWatchPlayerRouteEntry() { + const href = useRouterState({ select: (state) => state.location.href }); + const entry = usePersistentWatchPlayerStore((state) => state.entry); + const close = usePersistentWatchPlayerStore((state) => state.close); + const keepPlayer = entry ? shouldKeepPersistentPlayerForRoute(entry.streamId, href) : true; + + useLayoutEffect(() => { + if (entry && !keepPlayer) close(entry.owner); + }, [close, entry, keepPlayer]); + + if (!entry || !keepPlayer) return null; return ( diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx index 6a6b4ee5..2cbc455c 100644 --- a/apps/web/src/components/player-defaults.tsx +++ b/apps/web/src/components/player-defaults.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from "react"; import { normalizeDefaultPlaybackSpeed } from "../lib/playback-speed"; +import { qualityLabelHeight, qualityOptionHeight } from "../lib/player-quality"; import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { useAudioOptions, @@ -7,7 +8,11 @@ import { useMediaState, useVideoQualityOptions, } from "../lib/vidstack"; -import { includesOriginal, normalizeLanguageTag } from "./player-language"; +import { + hasMultipleLanguageTracks, + includesOriginal, + normalizeLanguageTag, +} from "./player-language"; const QUALITY_OPTIONS = { sort: "descending" } as const; @@ -30,13 +35,6 @@ type PlaybackSpeedDefaultProps = { preference?: SabrPlaybackRatePreference; }; -function qualityLabelHeight(label: string): number | null { - const match = label.match(/(\d+)/); - if (!match) return null; - const height = Number(match[1]); - return Number.isFinite(height) ? height : null; -} - export function PlayerDefaults({ defaultQuality, defaultPlaybackSpeed, @@ -74,7 +72,7 @@ export function PlayerDefaults({ const defaultHeight = qualityLabelHeight(defaultQuality); const exactMatch = qualityOptions.find((o) => o.label === defaultQuality); const heightMatch = qualityOptions.find( - (o) => defaultHeight !== null && o.quality?.height === defaultHeight, + (o) => defaultHeight !== null && qualityOptionHeight(o) === defaultHeight, ); const match = exactMatch ?? heightMatch; if (!match) return; @@ -114,11 +112,14 @@ export function PlayerDefaults({ : (audioOptions.find((option) => option.track.id === preferredDefaultAudioTrackId) ?? audioOptions.find((option) => option.track.id === originalAudioTrackId)); + const hasMultipleLanguages = hasMultipleLanguageTracks(audioOptions); + const missingOriginalByContract = forceOriginal && originalAudioTrackId === null; const missingOriginalByHeuristic = - forceOriginal && originalAudioTrackId === undefined && !match; + forceOriginal && originalAudioTrackId === undefined && hasMultipleLanguages && !match; if ( (missingOriginalByContract || missingOriginalByHeuristic) && + hasMultipleLanguages && !originalMissingNotified.current ) { originalMissingNotified.current = true; diff --git a/apps/web/src/components/player-language.ts b/apps/web/src/components/player-language.ts index 08fc13af..c2608844 100644 --- a/apps/web/src/components/player-language.ts +++ b/apps/web/src/components/player-language.ts @@ -8,3 +8,15 @@ export function includesOriginal(value: string | undefined): boolean { if (!value) return false; return value.toLowerCase().includes("original"); } + +export function hasMultipleLanguageTracks( + options: readonly { track: { language?: string | null } }[], +): boolean { + const languages = new Set(); + for (const option of options) { + const language = normalizeLanguageTag(option.track.language); + if (language) languages.add(language); + if (languages.size > 1) return true; + } + return false; +} diff --git a/apps/web/src/components/player-play-pause-indicator.tsx b/apps/web/src/components/player-play-pause-indicator.tsx deleted file mode 100644 index 37b60454..00000000 --- a/apps/web/src/components/player-play-pause-indicator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Pause, Play } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useMediaState } from "../lib/vidstack"; - -export function PlayerPlayPauseIndicator() { - const paused = useMediaState("paused"); - const canPlay = useMediaState("canPlay"); - const [pulse, setPulse] = useState(0); - const prevPausedRef = useRef(paused); - const readyRef = useRef(false); - - useEffect(() => { - if (!canPlay) return; - if (!readyRef.current) { - readyRef.current = true; - prevPausedRef.current = paused; - return; - } - if (paused === prevPausedRef.current) return; - prevPausedRef.current = paused; - setPulse((n) => n + 1); - }, [paused, canPlay]); - - useEffect(() => { - if (pulse === 0) return; - const timer = window.setTimeout(() => setPulse(0), 500); - return () => window.clearTimeout(timer); - }, [pulse]); - - if (pulse === 0) return null; - const Icon = paused ? Pause : Play; - return ( -
-
-
-
- ); -} diff --git a/apps/web/src/components/player-seeker.tsx b/apps/web/src/components/player-seeker.tsx index 1d0649e7..41a44d35 100644 --- a/apps/web/src/components/player-seeker.tsx +++ b/apps/web/src/components/player-seeker.tsx @@ -3,9 +3,10 @@ import { recordClientEvent } from "../lib/client-debug-log"; import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack"; const SEEK_SETTLE_DELAY_MS = 750; +const MAX_SEEK_APPLIES = 12; function seekable(media: HTMLMediaElement, target: number) { - if (media.readyState === 0 && !Number.isFinite(media.duration)) return false; + if (media.readyState < 2) return false; if (media.seekable.length === 0) return true; for (let index = 0; index < media.seekable.length; index += 1) { if (target >= media.seekable.start(index) && target <= media.seekable.end(index)) return true; @@ -30,9 +31,10 @@ export function PlayerSeeker({ startTime }: { startTime: number }) { const root = player?.el; let timeout = 0; let applying = false; + let attempts = 0; function seekMedia(media: HTMLMediaElement) { - if (seeked.current || applying) return; + if (!canPlay || seeked.current || applying || attempts >= MAX_SEEK_APPLIES) return; if (!seekable(media, target)) { recordClientEvent("player.seek_wait", { targetMs: Math.round(target * 1000), @@ -42,6 +44,7 @@ export function PlayerSeeker({ startTime }: { startTime: number }) { return; } applying = true; + attempts += 1; remote.seek(target); recordClientEvent("player.seek_apply", { targetMs: Math.round(target * 1000), @@ -63,7 +66,6 @@ export function PlayerSeeker({ startTime }: { startTime: number }) { }, SEEK_SETTLE_DELAY_MS); } - if (canPlay) remote.seek(target); if (!root) return; const rootElement = root; let cleanup: (() => void) | null = null; diff --git a/apps/web/src/components/portability-format-picker.tsx b/apps/web/src/components/portability-format-picker.tsx index 81b29c18..af7334d1 100644 --- a/apps/web/src/components/portability-format-picker.tsx +++ b/apps/web/src/components/portability-format-picker.tsx @@ -2,11 +2,14 @@ import { Check, ChevronDown } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { PortabilityFormatDescriptor } from "../lib/api-portability"; import { FORMAT_NAMES } from "../lib/portability-catalog"; +import { m } from "../paraglide/messages.js"; import { PortabilityFormatIcon } from "./portability-format-icon"; +type FormatOption = Pick; + type Props = { label: string; - formats: PortabilityFormatDescriptor[]; + formats: FormatOption[]; value: string; onChange: (format: string) => void; }; @@ -15,6 +18,8 @@ export function PortabilityFormatPicker({ label, formats, value, onChange }: Pro const [open, setOpen] = useState(false); const root = useRef(null); const selected = formats.find((format) => format.format === value) ?? formats[0]; + const formatName = (format: string) => + format === "auto" ? m.portability_auto_detect() : (FORMAT_NAMES[format] ?? format); useEffect(() => { if (!open) return; @@ -45,9 +50,11 @@ export function PortabilityFormatPicker({ label, formats, value, onChange }: Pro > - {FORMAT_NAMES[selected.format] ?? selected.format} + {formatName(selected.format)} - .{selected.defaultExtension} + {selected.defaultExtension && ( + .{selected.defaultExtension} + )}
); diff --git a/apps/web/src/components/portability-import-source-picker.tsx b/apps/web/src/components/portability-import-source-picker.tsx new file mode 100644 index 00000000..cf5c0e0e --- /dev/null +++ b/apps/web/src/components/portability-import-source-picker.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { portabilityArchiveIssue } from "../lib/portability-archive"; +import { m } from "../paraglide/messages.js"; +import { PortabilityImportDropzone } from "./portability-import-dropzone"; + +type Props = { + busy: boolean; + extension?: string; + label: string; + hint: string; + onFile: (file: File | undefined) => void; +}; + +export function PortabilityImportSourcePicker({ busy, extension, label, hint, onFile }: Props) { + const [sourceError, setSourceError] = useState(null); + + async function choose(file: File | undefined) { + if (!file || busy) return; + const issue = await portabilityArchiveIssue(file, extension); + setSourceError( + issue === "invalid-zip" + ? m.portability_invalid_zip() + : issue === "wrong-format" + ? m.portability_zip_choose_source() + : null, + ); + if (!issue) onFile(file); + } + + return ( + <> + + {sourceError && ( +

+ {sourceError} +

+ )} + + ); +} diff --git a/apps/web/src/components/portability-job-status.tsx b/apps/web/src/components/portability-job-status.tsx index e68fe714..baed5a1a 100644 --- a/apps/web/src/components/portability-job-status.tsx +++ b/apps/web/src/components/portability-job-status.tsx @@ -1,6 +1,10 @@ import { AlertTriangle, Check, CheckCircle2, Copy, LoaderCircle, X } from "lucide-react"; import { useState } from "react"; -import type { PortabilityCategory, PortabilityJob } from "../lib/api-portability"; +import type { + PortabilityCategory, + PortabilityImportStage, + PortabilityJob, +} from "../lib/api-portability"; import { categoryLabel } from "../lib/portability-catalog"; import { m } from "../paraglide/messages.js"; import { getLocale, type Locale } from "../paraglide/runtime.js"; @@ -42,10 +46,31 @@ function progressUnit(unit: PortabilityProgressUnit, locale: Locale): string { return m.portability_progress_bytes({}, { locale }); } +export function portabilityImportStageLabel( + stage: PortabilityImportStage, + category: PortabilityCategory | null | undefined, + locale: Locale = getLocale(), +): string { + if (stage === "subscriptions") return m.portability_stage_subscriptions({}, { locale }); + if (stage === "recentHistory") return m.portability_stage_recent_history({}, { locale }); + if (stage === "playlists") return m.portability_stage_playlists({}, { locale }); + if (stage === "history") return m.portability_stage_history({}, { locale }); + const remaining = m.portability_stage_remaining({}, { locale }); + return category ? `${remaining} - ${categoryLabel(category, locale)}` : remaining; +} + +function stagePercent(job: PortabilityJob): number | null { + const progress = job.progress; + if (!progress?.stageTotal || progress.stageTotal <= 0) return null; + return Math.min(100, Math.round(((progress.stageProcessed ?? 0) / progress.stageTotal) * 100)); +} + export function PortabilityJobStatus({ job, onCancel, cancelling, locale = getLocale() }: Props) { const [copied, setCopied] = useState(false); const percent = progressPercent(job); const active = ACTIVE.has(job.state); + const currentStage = job.progress?.stage; + const currentStagePercent = stagePercent(job); const Icon = active ? LoaderCircle : job.state === "failed" ? AlertTriangle : CheckCircle2; const reference = [ `${m.portability_job_label({}, { locale })}: ${job.id}`, @@ -128,6 +153,41 @@ export function PortabilityJobStatus({ job, onCancel, cancelling, locale = getLo /> )} + {active && currentStage && ( +
+
+ + {portabilityImportStageLabel(currentStage, job.progress?.category, locale)} + + + {job.progress?.stageTotal != null + ? m.portability_progress_of( + { + processed: (job.progress.stageProcessed ?? 0).toLocaleString(), + total: job.progress.stageTotal.toLocaleString(), + }, + { locale }, + ) + : (job.progress?.stageProcessed ?? 0).toLocaleString()} + +
+
+
+
+
+ )} {job.result && Object.keys(job.result).length > 0 && (
{Object.entries(job.result).map(([category, count]) => ( diff --git a/apps/web/src/components/portability-preparation-toast.tsx b/apps/web/src/components/portability-preparation-toast.tsx new file mode 100644 index 00000000..43bf0636 --- /dev/null +++ b/apps/web/src/components/portability-preparation-toast.tsx @@ -0,0 +1,83 @@ +import { LoaderCircle } from "lucide-react"; +import type { PortabilityPreparationProgress } from "../lib/portability-preparation-progress"; +import { m } from "../paraglide/messages.js"; +import { getLocale, type Locale } from "../paraglide/runtime.js"; +import "../styles/notification-toast.css"; + +function phaseLabel(phase: PortabilityPreparationProgress["phase"], locale: Locale): string { + if (phase === "scanning") return m.portability_prepare_scanning({}, { locale }); + if (phase === "extracting") return m.portability_prepare_extracting({}, { locale }); + if (phase === "packing") return m.portability_prepare_packing({}, { locale }); + return m.portability_prepare_uploading({}, { locale }); +} + +function progressPercent(progress: PortabilityPreparationProgress): number | null { + if (!progress.total || progress.total <= 0) return null; + return Math.min(100, Math.round((progress.processed / progress.total) * 100)); +} + +function byteCount(value: number, locale: Locale): string { + return new Intl.NumberFormat(locale, { + style: "unit", + unit: "byte", + unitDisplay: "short", + maximumFractionDigits: 1, + }).format(value); +} + +export function PortabilityPreparationToast({ + progress, +}: { + progress: PortabilityPreparationProgress; +}) { + const locale = getLocale(); + const detail = phaseLabel(progress.phase, locale); + const percent = progressPercent(progress); + const amount = + progress.total === null + ? null + : m.portability_progress_of( + { + processed: byteCount(progress.processed, locale), + total: byteCount(progress.total, locale), + }, + { locale }, + ); + + return ( + + ); +} diff --git a/apps/web/src/components/portability-progress-host.tsx b/apps/web/src/components/portability-progress-host.tsx new file mode 100644 index 00000000..06bd01ea --- /dev/null +++ b/apps/web/src/components/portability-progress-host.tsx @@ -0,0 +1,237 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, CheckCircle2, LoaderCircle, X } from "lucide-react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useAuth } from "../hooks/use-auth"; +import { usePersistedPortabilityJob } from "../hooks/use-persisted-portability-job"; +import { usePortabilityJob } from "../hooks/use-portability-job"; +import type { PortabilityCategory, PortabilityJob } from "../lib/api-portability"; +import { + getPortabilityPreparationProgress, + subscribePortabilityPreparationProgress, +} from "../lib/portability-preparation-progress"; +import { SUBSCRIPTION_FEED_KEY } from "../lib/subscription-queries"; +import { m } from "../paraglide/messages.js"; +import { getLocale } from "../paraglide/runtime.js"; +import { portabilityImportStageLabel } from "./portability-job-status"; +import { PortabilityPreparationToast } from "./portability-preparation-toast"; +import "../styles/notification-toast.css"; + +const REFRESH_INTERVAL_MS = 2_000; +const IMPORT_QUERY_KEYS = [ + ["subscriptions"], + SUBSCRIPTION_FEED_KEY, + ["playlists"], + ["saved-playlists"], + ["history"], + ["history-all"], + ["favorites"], + ["watch-later"], +]; + +function queryKeysForCategory(category: PortabilityCategory | null | undefined) { + if (category === "subscriptions") return [["subscriptions"], SUBSCRIPTION_FEED_KEY]; + if (category === "playlists") return [["playlists"], ["saved-playlists"]]; + if (category === "history") return [["history"], ["history-all"]] as const; + if (category === "favorites") return [["favorites"]]; + if (category === "watchLater") return [["watch-later"]]; + return []; +} + +function percent(job: PortabilityJob): number | null { + const progress = job.progress; + if (!progress?.total || progress.total <= 0) return null; + return Math.min(100, Math.round((progress.processed / progress.total) * 100)); +} + +export function PortabilityProgressHost() { + const { isAuthed, me } = useAuth(); + const queryClient = useQueryClient(); + const [jobId, setJobId] = usePersistedPortabilityJob("typetype-portability-import-job"); + const preparation = useSyncExternalStore( + subscribePortabilityPreparationProgress, + getPortabilityPreparationProgress, + getPortabilityPreparationProgress, + ); + const localPreparation = isAuthed && preparation?.ownerId === me?.id ? preparation : null; + const job = usePortabilityJob(isAuthed ? jobId : null); + const [finished, setFinished] = useState(false); + const wasRunning = useRef(false); + const observedJobId = useRef(jobId); + const seenCheckpoint = useRef(0); + const refreshTimes = useRef(new Map()); + const refreshedOnTerminal = useRef(null); + const state = job.data?.state; + const active = + isAuthed && + job.data?.kind === "import" && + (state === "queued" || state === "analyzing" || state === "applying"); + const visible = isAuthed && (localPreparation !== null || active || finished); + + useEffect(() => { + if (!job.missing || !jobId) return; + setJobId(null); + setFinished(false); + }, [job.missing, jobId, setJobId]); + + useEffect(() => { + if (observedJobId.current !== jobId) { + observedJobId.current = jobId; + wasRunning.current = false; + seenCheckpoint.current = 0; + refreshTimes.current.clear(); + refreshedOnTerminal.current = null; + setFinished(false); + } + if (active) { + wasRunning.current = true; + setFinished(false); + return; + } + if (!wasRunning.current || !state || !["completed", "failed", "cancelled"].includes(state)) + return; + wasRunning.current = false; + setFinished(true); + const timer = window.setTimeout(() => setFinished(false), 6_000); + return () => window.clearTimeout(timer); + }, [active, jobId, state]); + + useEffect(() => { + const checkpoint = job.data?.progress?.checkpoint ?? 0; + if (!active || checkpoint <= seenCheckpoint.current) return; + seenCheckpoint.current = checkpoint; + const category = job.data?.progress?.category; + if (!category) return; + const refreshedAt = refreshTimes.current.get(category) ?? 0; + if (Date.now() - refreshedAt < REFRESH_INTERVAL_MS) return; + refreshTimes.current.set(category, Date.now()); + void Promise.all( + queryKeysForCategory(category).map((queryKey) => + queryClient.invalidateQueries({ queryKey: [...queryKey] }), + ), + ); + }, [active, job.data?.progress?.category, job.data?.progress?.checkpoint, queryClient]); + + useEffect(() => { + const value = job.data; + if ( + value?.kind !== "import" || + !["completed", "failed", "cancelled"].includes(value.state) || + refreshedOnTerminal.current === value.id + ) { + return; + } + refreshedOnTerminal.current = value.id; + void Promise.all( + IMPORT_QUERY_KEYS.map((queryKey) => + queryClient.invalidateQueries({ queryKey: [...queryKey] }), + ), + ); + }, [job.data, queryClient]); + + useEffect(() => { + const root = document.documentElement; + if (visible) root.dataset.portabilityProgressVisible = "true"; + else delete root.dataset.portabilityProgressVisible; + return () => { + delete root.dataset.portabilityProgressVisible; + }; + }, [visible]); + + const value = job.data; + const progress = value?.progress; + const stage = progress?.stage; + const progressPercent = value ? percent(value) : null; + const locale = getLocale(); + + if (localPreparation) return ; + if (!value || !visible) return null; + const activeJob = ["queued", "analyzing", "applying"].includes(value.state); + const title = + value.state === "queued" + ? m.portability_job_waiting({}, { locale }) + : value.state === "analyzing" + ? m.portability_job_analyzing({}, { locale }) + : value.state === "failed" + ? m.portability_job_failed({}, { locale }) + : value.state === "cancelled" + ? m.portability_job_cancelled({}, { locale }) + : value.state === "completed" + ? m.portability_job_import_completed({}, { locale }) + : m.portability_job_importing({}, { locale }); + const detail = stage ? portabilityImportStageLabel(stage, progress?.category, locale) : title; + const progressText = progress + ? progress.total != null + ? m.portability_progress_of( + { + processed: progress.processed.toLocaleString(), + total: progress.total.toLocaleString(), + }, + { locale }, + ) + : progress.processed.toLocaleString() + : null; + + return ( + + ); +} diff --git a/apps/web/src/components/quality-selector.tsx b/apps/web/src/components/quality-selector.tsx index 4610d618..c9940ea7 100644 --- a/apps/web/src/components/quality-selector.tsx +++ b/apps/web/src/components/quality-selector.tsx @@ -3,6 +3,7 @@ import { useRef } from "react"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { useDashPlayerSnapshot } from "../lib/dash-player-store"; import { dashQualityOptions, selectDashTrack, selectedDashHeight } from "../lib/dash-video"; +import { qualityOptionHeight } from "../lib/player-quality"; import { sabrResolutionOptions } from "../lib/sabr-quality-selection"; import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack"; import { @@ -23,7 +24,8 @@ const QUALITY_OPTIONS = { sort: "descending" } as const; type QualityOption = ReturnType[number]; function qualityValue(option: QualityOption): string { - return String(option.quality?.height ?? option.label); + const height = qualityOptionHeight(option); + return height !== null ? String(height) : option.label; } function collectResolutionOptions(options: QualityOption[]): QualityOption[] { @@ -131,7 +133,7 @@ export function QualitySelector() { const radioOptions = filteredOptions.map((o) => ({ label: o.label, value: qualityValue(o) })); if (filteredOptions.length <= 1) return null; - if (filteredOptions.every((o) => (o.quality?.height ?? 0) === 0)) return null; + if (filteredOptions.every((o) => (qualityOptionHeight(o) ?? 0) === 0)) return null; if (!selected) return null; const current = selected.label; diff --git a/apps/web/src/components/related-card-skeleton.tsx b/apps/web/src/components/related-card-skeleton.tsx index 592c9770..2d836307 100644 --- a/apps/web/src/components/related-card-skeleton.tsx +++ b/apps/web/src/components/related-card-skeleton.tsx @@ -1,7 +1,16 @@ -export function RelatedCardSkeleton() { +import { relatedVideoThumbnailClassName } from "../lib/layout-preferences"; +import type { RelatedVideoSize } from "../types/user"; + +type Props = { + size?: RelatedVideoSize; +}; + +export function RelatedCardSkeleton({ size = "default" }: Props) { return (
-
+
diff --git a/apps/web/src/components/related-card.tsx b/apps/web/src/components/related-card.tsx index a7066731..b0896fdd 100644 --- a/apps/web/src/components/related-card.tsx +++ b/apps/web/src/components/related-card.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { useClientLocale } from "../hooks/use-client-locale"; import { useDeArrowBranding } from "../hooks/use-dearrow"; import { formatDuration, formatPublishedDate, formatViews } from "../lib/format"; +import { relatedVideoThumbnailClassName } from "../lib/layout-preferences"; import { isVideoWatched } from "../lib/watch-progress"; import { watchRouteSearch } from "../lib/watch-url"; import { useWatchNavigationStore } from "../stores/watch-navigation-store"; @@ -19,11 +20,13 @@ import { WatchedBadge } from "./watched-badge"; type Props = { stream: VideoStream; relatedStreams?: VideoStream[]; + size?: "default" | "large"; progressMs?: number; }; -function RelatedCardComponent({ stream, relatedStreams, progressMs = 0 }: Props) { +function RelatedCardComponent({ stream, relatedStreams, size = "default", progressMs = 0 }: Props) { const locale = useClientLocale(); + const thumbnailClassName = relatedVideoThumbnailClassName(size); const setNavigation = useWatchNavigationStore((state) => state.setNavigation); const { title, thumbnail } = useDeArrowBranding( stream.id, @@ -42,7 +45,7 @@ function RelatedCardComponent({ stream, relatedStreams, progressMs = 0 }: Props) to="/watch" search={watchRouteSearch(stream.id)} preload="intent" - className="relative w-32 aspect-video rounded-md overflow-hidden bg-surface-strong flex-shrink-0 sm:w-40" + className={`relative ${thumbnailClassName} aspect-video rounded-md overflow-hidden bg-surface-strong flex-shrink-0`} onClick={() => setNavigation(stream, relatedStreams)} > uniqueStreams(filter(streams)), [filter, streams]); const progressByUrl = useVideoProgressMap(visible); @@ -33,7 +35,7 @@ export function RelatedVideos({ streams, isLoading = false }: Props) {
{isLoading - ? SKELETON_KEYS.map((k) => ) + ? SKELETON_KEYS.map((k) => ) : visible.map((stream, index) => (
diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx index 02ce97c7..cc831e36 100644 --- a/apps/web/src/components/sabr-mse-player.tsx +++ b/apps/web/src/components/sabr-mse-player.tsx @@ -7,7 +7,10 @@ import { useSabrMediaSettings } from "../hooks/use-sabr-media-settings"; import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch"; import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch"; import { toAbsoluteApiUrl } from "../lib/env"; +import { addPlaybackTraceHeader } from "../lib/playback-trace"; import { guardAutoplay, SabrAutoplayAttempt, SabrAutoplayDeadline } from "../lib/sabr-autoplay"; +import { observeSabrMsePlayback } from "../lib/sabr-mse-playback-trace"; +import * as sabrNativePause from "../lib/sabr-native-pause"; import { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { isAbortError } from "../lib/sabr-playback-retry"; import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek"; @@ -59,7 +62,7 @@ export function SabrMsePlayer({ onPositionReaderChange, onVolumeChange, }); - const reportError = useSabrErrorReporter(errorReportedRef, onError); + const reportError = useSabrErrorReporter(errorReportedRef, onError, video, config); const { latestEngineHandlers, setQualityTransitioning } = useSabrEngineHandlers( latestHandlers, reportError, @@ -93,36 +96,29 @@ export function SabrMsePlayer({ audioOnly: initialConfig.audioOnly, isLive: initialConfig.isLive, startTimeMs: initialStartTimeMs, - headers: headersRef.current, + headers: addPlaybackTraceHeader(new Headers(headersRef.current)), }); + const stopPlaybackTrace = observeSabrMsePlayback(video, config.videoId, engine, reportError); engineRef.current = engine; - qualityRef.current = { - videoItag: initialConfig.videoItag, - audioItag: initialConfig.audioItag, - audioTrackId: initialConfig.audioTrackId, - }; - const offError = engine.on("error", (event) => { - if (event.type === "error") reportError(event.error, event.recoveryPositionMs); - }); - const volumeChange = () => { - if (!settingsReadyRef.current || engine.isApplyingTransientMediaState()) return; - latestHandlers().onVolumeChange?.(video.volume, video.muted); - }; + qualityRef.current = initialConfig; + let engineLoaded = false; + const { unregister: unregisterMediaElementObservers, settle: settlePlaybackRate } = + sabrNativePause.registerSabrPlaybackRateObservers( + video, + settingsReadyRef, + engine, + latestHandlers, + playbackRate, + ); const offPosition = registerPosition(video, videoHandoffRef.current, config.videoId); - let playbackRateSettled = false, - engineLoaded = false; - const playbackRateChange = () => { - playbackRate.capture(video, !playbackRateSettled || engine.isApplyingTransientMediaState()); - }; - const settlePlaybackRate = () => { - if (engine.isApplyingTransientMediaState()) return; - playbackRate.apply(video, false); - playbackRateSettled = true; - }; const playEngine = () => engine.play().then(settlePlaybackRate); playbackRate.initialize(video); - video.addEventListener("volumechange", volumeChange); - video.addEventListener("ratechange", playbackRateChange); + const unregisterNativePause = sabrNativePause.registerSabrNativePause( + video, + engine, + seekingRef, + sabrNativePause.createSabrNativePauseHandler(video, pendingPlayRef, autoplayAttempt), + ); const autoplayDeadline = new SabrAutoplayDeadline(() => { if (!autoplayAttempt.expire()) return; pendingPlayRef.current = false; @@ -189,11 +185,11 @@ export function SabrMsePlayer({ latestHandlers().onPositionReaderChange(() => positionMs(video)); return () => { captureCleanupPosition(video, videoHandoffRef.current, config.videoId); - offError(); + stopPlaybackTrace(); unguardAutoplay(); unregisterControls(); - video.removeEventListener("volumechange", volumeChange); - video.removeEventListener("ratechange", playbackRateChange); + unregisterMediaElementObservers(); + unregisterNativePause(); offPosition(); video.removeEventListener("canplay", startAutoplay); window.clearInterval(autoplayTimer); diff --git a/apps/web/src/components/shorts-error.tsx b/apps/web/src/components/shorts-error.tsx index 76e5dcc1..6b595be1 100644 --- a/apps/web/src/components/shorts-error.tsx +++ b/apps/web/src/components/shorts-error.tsx @@ -6,13 +6,36 @@ type Props = { message: string; onRetry: () => void; onNext: () => void; + youtubeSessionAction?: "connect" | "reconnect"; youtubeSessionReturnTo?: string; }; -export function ShortsError({ message, onRetry, onNext, youtubeSessionReturnTo }: Props) { +export function ShortsError({ + message, + onRetry, + onNext, + youtubeSessionAction, + youtubeSessionReturnTo, +}: Props) { return (
+ {youtubeSessionAction === "reconnect" && youtubeSessionReturnTo ? ( + + + + ) : null}

{message}

{youtubeSessionReturnTo ? ( @@ -22,7 +45,9 @@ export function ShortsError({ message, onRetry, onNext, youtubeSessionReturnTo } className="inline-flex h-9 items-center gap-2 rounded-md bg-fg px-3 text-xs font-medium text-app hover:bg-white" > - {m.ui_connect_with_youtube()} + {youtubeSessionAction === "reconnect" + ? m.ui_reconnect_with_youtube() + : m.ui_connect_with_youtube()} ) : (
+
+
+

{m.nav_shorts()}

+

+ {m.ui_no_shorts_available_right_now()} +

+
+ + {m.groups_preview_open_subscriptions()} + + + {m.ui_import_from_youtube()} + +
+
+
); } const hasPrev = index > 0; @@ -120,8 +140,9 @@ export function ShortsPlayerShell({ targetUrl }: Props) { streamError={streamError} errorMessage={errorMessage} isMemberOnlyShort={isMemberOnlyShort} + youtubeSessionAction={youtubeSessionAction ?? undefined} youtubeSessionReturnTo={ - needsYoutubeSession + youtubeSessionAction ? youtubeSessionReturnToForShorts(toPublicWatchParam(activeId)) : undefined } diff --git a/apps/web/src/components/shorts-player-stage-types.ts b/apps/web/src/components/shorts-player-stage-types.ts index 34460267..29194b9e 100644 --- a/apps/web/src/components/shorts-player-stage-types.ts +++ b/apps/web/src/components/shorts-player-stage-types.ts @@ -11,6 +11,7 @@ export type ShortsPlayerStageProps = { streamError: boolean; errorMessage: string; isMemberOnlyShort: boolean; + youtubeSessionAction?: "connect" | "reconnect"; youtubeSessionReturnTo?: string; hasPrev: boolean; hasNext: boolean; diff --git a/apps/web/src/components/shorts-player-stage.tsx b/apps/web/src/components/shorts-player-stage.tsx index c1b45197..54e73f7d 100644 --- a/apps/web/src/components/shorts-player-stage.tsx +++ b/apps/web/src/components/shorts-player-stage.tsx @@ -24,6 +24,7 @@ export function ShortsPlayerStage({ streamError, errorMessage, isMemberOnlyShort, + youtubeSessionAction, youtubeSessionReturnTo, hasPrev, hasNext, @@ -129,6 +130,7 @@ export function ShortsPlayerStage({ message={playbackMessage} onRetry={retry} onNext={onNext} + youtubeSessionAction={youtubeSessionAction} youtubeSessionReturnTo={youtubeSessionReturnTo} /> )} diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx index e3257ae0..10be0b13 100644 --- a/apps/web/src/components/sidebar.tsx +++ b/apps/web/src/components/sidebar.tsx @@ -1,5 +1,4 @@ -import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; -import { siBilibili, siNiconico, siYoutube } from "simple-icons"; +import { Link, useRouterState } from "@tanstack/react-router"; import { useAuth } from "../hooks/use-auth"; import { useInterfaceLocale } from "../hooks/use-interface-locale"; import { useMobile } from "../hooks/use-mobile"; @@ -8,22 +7,7 @@ import { getStoredAdminSection } from "../lib/admin-console-section"; import { logoutSession } from "../lib/auth-session"; import { m } from "../paraglide/messages.js"; import { useUiStore } from "../stores/ui-store"; -import type { ServiceId } from "../types/user"; import { NAV_ITEMS } from "./nav-items"; -import { ServiceIcon } from "./service-icon"; - -type Service = { - id: ServiceId; - label: string; - path: string; - color: string; -}; - -const SERVICES: Service[] = [ - { id: 0, label: "YouTube", path: siYoutube.path, color: "#FF0000" }, - { id: 6, label: "NicoNico", path: siNiconico.path, color: "#aaaaaa" }, - { id: 5, label: "BiliBili", path: siBilibili.path, color: "#00A1D6" }, -]; const BTN_BASE = "flex h-10 w-full items-center border-l-2 transition-colors"; const BTN_ACTIVE = "border-accent text-fg"; @@ -62,19 +46,9 @@ export function Sidebar({ overlay = false }: Props) { const closeMobileSidebar = useUiStore((s) => s.closeMobileSidebar); const visualCollapsed = overlay ? false : collapsed; const { isAdmin, isAuthed, signOut } = useAuth(); - const { settings, update } = useSettings(); - const service = settings.defaultService; - const navigate = useNavigate(); + const { settings } = useSettings(); const loc = useRouterState({ select: (s) => s.location }); - function handleServiceClick(id: ServiceId) { - update.mutate({ defaultService: id }); - if (isMobile) closeMobileSidebar(); - if (loc.pathname !== "/search") return; - const q = new URLSearchParams(loc.searchStr).get("q") ?? ""; - navigate({ to: "/search", search: { q, service: id } }); - } - const adminSearch = { section: getStoredAdminSection() }; const navItems = NAV_ITEMS.filter((item) => { if (item.adminOnly && !isAdmin) return false; @@ -143,34 +117,6 @@ export function Sidebar({ overlay = false }: Props) { })}
-
- {(!visualCollapsed || isMobile) && ( -

- {m.nav_services()} -

- )} - {SERVICES.map((svc) => ( - - ))} -
{isMobile && isAuthed && (
+ + + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-channel-list.tsx b/apps/web/src/components/subscription-groups/group-channel-list.tsx new file mode 100644 index 00000000..179ca543 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-list.tsx @@ -0,0 +1,64 @@ +import type { useGroupChannelPage } from "../../hooks/use-group-channel-page"; +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupChannelRow } from "./group-channel-row"; +import { GroupPagination } from "./group-pagination"; + +type Props = { + channels: GroupedSubscription[]; + pagination: ReturnType["pagination"]; + label: string; + groups: SubscriptionGroup[]; + selected: ReadonlySet; + editing: string | null; + drafts: ReadonlyMap>; + busy: boolean; + disabled: boolean; + onToggle: (url: string) => void; + onDraft: (url: string, ids: Set) => void; + onCancel: () => void; + onSave: (channel: GroupedSubscription, ids: Set) => Promise; +}; + +export function GroupChannelList(props: Props): React.JSX.Element { + const { pagination } = props; + return ( +
+
+ {props.channels.length === 0 ? ( +
+

{m.sg_no_channel_match()}

+

{m.sg_change_filters()}

+
+ ) : ( +
    + {props.channels.map((channel) => ( + { + props.onToggle(channel.channelUrl); + }} + onDraft={(ids) => props.onDraft(channel.channelUrl, ids)} + onCancel={props.onCancel} + onSave={(ids) => props.onSave(channel, ids)} + /> + ))} +
+ )} +
+ +
+ ); +} diff --git a/apps/web/src/components/subscription-groups/group-channel-row.tsx b/apps/web/src/components/subscription-groups/group-channel-row.tsx new file mode 100644 index 00000000..589e48fe --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-channel-row.tsx @@ -0,0 +1,121 @@ +import { useId, useRef } from "react"; +import { proxyImage } from "../../lib/proxy"; +import { m } from "../../paraglide/messages.js"; +import type { GroupedSubscription, SubscriptionGroup } from "../../types/subscription-groups"; +import { ChannelAvatar } from "../channel-avatar"; +import { ChannelRouteLink } from "../channel-route-link"; +import { ChannelGroupEditor } from "./channel-group-editor"; + +type Props = { + channel: GroupedSubscription; + groups: SubscriptionGroup[]; + selected: boolean; + editing: boolean; + draft: ReadonlySet | undefined; + disabled: boolean; + busy: boolean; + onSelect: () => void; + onDraft: (ids: Set) => void; + onCancel: () => void; + onSave: (ids: Set) => Promise; +}; + +export function GroupChannelRow(props: Props): React.JSX.Element { + const { channel, groups, editing } = props; + const selectionId = useId(); + const checkbox = useRef(null); + const memberships = groups.filter((group) => channel.groupIds.includes(group.id)); + function cancel(): void { + props.onCancel(); + checkbox.current?.focus(); + } + return ( +
  • + +
    + + +
    + + {channel.name} + +

    + {channel.channelUrl.replace(/^https?:\/\/(www\.)?/, "")} +

    +
    + {editing ? ( + { + if (await props.onSave(ids)) cancel(); + }} + /> + ) : ( +
    group.name).join(", ")} + > + {memberships.length > 0 ? ( + <> + {memberships.slice(0, 2).map((group) => ( + + {group.name} + + ))} + {memberships.length > 2 && ( + + + + {m.sg_more_groups({ count: memberships.length - 2 })}:{" "} + {memberships + .slice(2) + .map((group) => group.name) + .join(", ")} + + + )} + + ) : ( + {m.sg_add_groups()} + )} +
    + )} +
    +
  • + ); +} diff --git a/apps/web/src/components/subscription-groups/group-combobox.tsx b/apps/web/src/components/subscription-groups/group-combobox.tsx new file mode 100644 index 00000000..39344469 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-combobox.tsx @@ -0,0 +1,112 @@ +import { ChevronDown, Plus, X } from "lucide-react"; +import { useGroupCombobox } from "../../hooks/use-group-combobox"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; + +type Props = { + groups: SubscriptionGroup[]; + selected: ReadonlySet; + disabled: boolean; + onToggle: (id: string) => void; +}; + +export function GroupCombobox({ groups, selected, disabled, onToggle }: Props): React.JSX.Element { + const combo = useGroupCombobox(groups, selected, onToggle); + const expanded = combo.open && !disabled; + return ( +
    { + if (!event.currentTarget.contains(event.relatedTarget)) combo.setOpen(false); + }} + > +
    +
    + {combo.chosen.map((group) => ( + + ))} + = 0 ? `${combo.listId}-${combo.active}` : undefined + } + autoComplete="off" + placeholder={m.sg_add_groups()} + value={combo.query} + onChange={(event) => combo.search(event.target.value)} + onClick={() => combo.setOpen(true)} + onKeyDown={combo.onKeyDown} + className="h-6 w-20 min-w-16 flex-1 bg-transparent px-1 text-xs text-fg placeholder:text-fg-muted outline-none" + /> +
    + +
    + {expanded && ( +
    + {combo.matches.map((group, index) => ( + + ))} + {combo.matches.length === 0 && ( +

    + {m.sg_no_group_match()} +

    + )} +
    + )} +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx b/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx new file mode 100644 index 00000000..74ae52fa --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-confirm-dialog.tsx @@ -0,0 +1,64 @@ +import { useEffect, useId, useRef } from "react"; +import { m } from "../../paraglide/messages.js"; + +type Props = { + title: string; + description: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; +}; + +export function GroupConfirmDialog({ + title, + description, + confirmLabel, + onConfirm, + onCancel, +}: Props): React.JSX.Element { + const dialog = useRef(null); + const cancel = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + useEffect(() => { + const previousFocus = document.activeElement; + const element = dialog.current; + element?.showModal(); + cancel.current?.focus(); + return () => { + element?.close(); + if (previousFocus instanceof HTMLElement) previousFocus.focus(); + }; + }, []); + return ( + { + event.preventDefault(); + onCancel(); + }} + className="m-auto w-[min(28rem,90vw)] border border-border-strong bg-surface p-5 text-fg backdrop:bg-black/60" + > +

    + {title} +

    +

    + {description} +

    +
    + + +
    +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager-data.tsx b/apps/web/src/components/subscription-groups/group-manager-data.tsx new file mode 100644 index 00000000..53c4508e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-data.tsx @@ -0,0 +1,64 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { m } from "../../paraglide/messages.js"; + +type ManagedQuery = Pick< + UseQueryResult, + "data" | "isPending" | "isError" | "isFetching" | "refetch" +>; +type Props = { + groups: ManagedQuery; + channels: ManagedQuery; + selection?: ManagedQuery; + children: ReactNode; +}; + +export function GroupManagerData({ + groups, + channels, + selection, + children, +}: Props): React.JSX.Element { + const queries = selection ? [groups, channels, selection] : [groups, channels]; + const loaded = queries.every((query) => query.data !== undefined); + const failed = queries.some((query) => query.isError); + const refreshing = queries.some((query) => query.isFetching); + if (queries.some((query) => query.isPending)) + return ( +

    + {m.sg_loading()} +

    + ); + return ( + <> + {failed && ( +
    +

    + {loaded ? m.sg_refresh_error() : m.sg_load_error()} +

    + +
    + )} + {loaded && ( +
    + {children} +
    + )} + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager-header.tsx b/apps/web/src/components/subscription-groups/group-manager-header.tsx new file mode 100644 index 00000000..c694dbce --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager-header.tsx @@ -0,0 +1,18 @@ +import { Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { m } from "../../paraglide/messages.js"; + +export function GroupManagerHeader(): React.JSX.Element { + return ( +
    +
    +

    {m.sg_manage_groups()}

    +

    {m.sg_manager_description()}

    +
    + + + {m.sg_back_channels()} + +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-manager.tsx b/apps/web/src/components/subscription-groups/group-manager.tsx new file mode 100644 index 00000000..106f82f9 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-manager.tsx @@ -0,0 +1,169 @@ +import { useMemo } from "react"; +import { useGroupManager } from "../../hooks/use-group-manager"; +import { useInterfaceLocale } from "../../hooks/use-interface-locale"; +import { useSubscriptionGroups } from "../../hooks/use-subscription-groups"; +import { + createSubscriptionGroup, + renameSubscriptionGroup, + updateGroupMemberships, +} from "../../lib/api-subscription-groups"; +import { channelMembershipChanges } from "../../lib/subscription-group-selection"; +import { m } from "../../paraglide/messages.js"; +import { GroupChannelList } from "./group-channel-list"; +import { GroupConfirmDialog } from "./group-confirm-dialog"; +import { GroupManagerData } from "./group-manager-data"; +import { GroupManagerHeader } from "./group-manager-header"; +import { GroupSidebar } from "./group-sidebar"; +import { GroupToolbar } from "./group-toolbar"; +import "../../styles/subscription-groups.css"; + +export function GroupManager(): React.JSX.Element { + useInterfaceLocale(); + const groupsQuery = useSubscriptionGroups(); + const groups = useMemo( + () => [...(groupsQuery.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), + [groupsQuery.data], + ); + const { + actions, + page, + selectionQuery, + activeFilter, + activeGroup, + excluded, + query, + chosen, + validSelected, + visible, + hiddenCount, + filterName, + disabled, + editing, + drafts, + onlySelected, + confirmationProps, + setExcluded, + setQuery, + selectResults, + setOnlySelected, + setDraft, + setConfirmation, + changeFilter, + toggle, + clearSelection, + bulk, + confirm, + } = useGroupManager(groups, groupsQuery.isSuccess); + const channelsQuery = onlySelected ? selectionQuery : page.query; + const total = page.query.data?.totalSubscriptions ?? 0; + return ( +
    + + + + actions.run( + () => createSubscriptionGroup(name), + m.sg_group_created({ group: name }), + "groups", + ) + } + onRename={(id, name) => + actions.run(() => renameSubscriptionGroup(id, name), m.sg_group_renamed(), "groups") + } + onDelete={setConfirmation} + onCancelRename={actions.clearError} + /> +
    + 0 && + visible.every((channel) => validSelected.has(channel.channelUrl)) + } + onlySelected={onlySelected} + disabled={disabled} + busy={actions.busy} + onQuery={setQuery} + onExcluded={setExcluded} + onSelectResults={selectResults} + onClear={clearSelection} + onOnlySelected={() => setOnlySelected(!onlySelected)} + onBulk={bulk} + onRemoveAll={() => setConfirmation("clear")} + /> + {actions.error && ( +

    + {actions.error} +

    + )} + {actions.notice && ( +

    + {actions.notice} +

    + )} + {total === 0 && page.query.isSuccess ? ( +
    +

    {m.ui_no_subscriptions_yet_2()}

    +

    {m.sg_empty_subscriptions()}

    +
    + ) : ( + + actions.run( + () => updateGroupMemberships(channelMembershipChanges(channel, ids)), + m.sg_channel_saved({ channel: channel.name }), + ) + } + /> + )} +
    +
    + {confirmationProps && !disabled && ( + setConfirmation(null)} + onConfirm={confirm} + /> + )} +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-name-form.tsx b/apps/web/src/components/subscription-groups/group-name-form.tsx new file mode 100644 index 00000000..b0657fd7 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-name-form.tsx @@ -0,0 +1,71 @@ +import { Check, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { m } from "../../paraglide/messages.js"; + +type Props = { + initialName?: string; + busy: boolean; + onSave: (name: string) => Promise; + onCancel?: () => void; +}; + +export function GroupNameForm({ + initialName = "", + busy, + onSave, + onCancel, +}: Props): React.JSX.Element { + const [name, setName] = useState(initialName); + const input = useRef(null); + useEffect(() => { + if (initialName) { + input.current?.focus(); + input.current?.select(); + } + }, [initialName]); + return ( +
    { + if (event.key === "Escape" && onCancel && !busy) { + event.preventDefault(); + onCancel(); + } + }} + onSubmit={async (event) => { + event.preventDefault(); + if (name.trim() && (await onSave(name.trim()))) setName(""); + }} + > + setName(event.target.value)} + disabled={busy} + className="h-8 min-w-0 flex-1 border border-border bg-app px-2 text-sm text-fg placeholder:text-fg-muted" + /> + + {onCancel && ( + + )} +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-pagination.tsx b/apps/web/src/components/subscription-groups/group-pagination.tsx new file mode 100644 index 00000000..8127bd6e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-pagination.tsx @@ -0,0 +1,56 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { GroupPage } from "../../lib/group-pagination"; +import { m } from "../../paraglide/messages.js"; + +type Props = GroupPage & { + total: number; + label: string; + compact?: boolean; + disabled: boolean; + onPage: (page: number) => void; +}; + +export function GroupPagination(props: Props): React.JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-sidebar-item.tsx b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx new file mode 100644 index 00000000..56e77657 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar-item.tsx @@ -0,0 +1,121 @@ +import { MoreHorizontal } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useGroupActionMenu } from "../../hooks/use-group-action-menu"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; + +type Props = { + group: SubscriptionGroup; + active: boolean; + disabled: boolean; + onSelect: () => void; + onRename: (name: string) => Promise; + onCancelRename: () => void; + onDelete: () => void; +}; + +export function GroupSidebarItem({ + group, + active, + disabled, + onSelect, + onRename, + onCancelRename, + onDelete, +}: Props): React.JSX.Element { + const [renaming, setRenaming] = useState(false); + const menu = useGroupActionMenu(disabled); + const actionButton = menu.trigger; + const wasRenaming = useRef(false); + useEffect(() => { + if (wasRenaming.current && !renaming) actionButton.current?.focus(); + wasRenaming.current = renaming; + }, [renaming, actionButton]); + if (renaming) + return ( + { + setRenaming(false); + onCancelRename(); + }} + onSave={async (name) => { + const saved = await onRename(name); + if (saved) setRenaming(false); + return saved; + }} + /> + ); + return ( +
    + + + {menu.open && ( + + )} +
    + ); +} diff --git a/apps/web/src/components/subscription-groups/group-sidebar.tsx b/apps/web/src/components/subscription-groups/group-sidebar.tsx new file mode 100644 index 00000000..60e9e437 --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-sidebar.tsx @@ -0,0 +1,96 @@ +import { Inbox, Users } from "lucide-react"; +import { useState } from "react"; +import { useGroupPagination } from "../../hooks/use-group-pagination"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; +import { GroupNameForm } from "./group-name-form"; +import { GroupPagination } from "./group-pagination"; +import { GroupSidebarItem } from "./group-sidebar-item"; + +type Props = { + groups: SubscriptionGroup[]; + total: number; + ungrouped: number; + filter: string; + disabled: boolean; + onFilter: (value: string) => void; + onCreate: (name: string) => Promise; + onRename: (id: string, name: string) => Promise; + onCancelRename: () => void; + onDelete: (group: SubscriptionGroup) => void; +}; + +export function GroupSidebar(props: Props): React.JSX.Element { + const [query, setQuery] = useState(""); + const matches = props.groups.filter((group) => + group.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()), + ); + const pagination = useGroupPagination({ total: matches.length, rowRem: 2.25, fallbackSize: 6 }); + return ( + + ); +} diff --git a/apps/web/src/components/subscription-groups/group-toolbar.tsx b/apps/web/src/components/subscription-groups/group-toolbar.tsx new file mode 100644 index 00000000..58605e7e --- /dev/null +++ b/apps/web/src/components/subscription-groups/group-toolbar.tsx @@ -0,0 +1,146 @@ +import { ArrowLeftRight, Search, X } from "lucide-react"; +import { useState } from "react"; +import { m } from "../../paraglide/messages.js"; +import type { SubscriptionGroup } from "../../types/subscription-groups"; + +type Props = { + groups: SubscriptionGroup[]; + defaultTarget: string; + isGroup: boolean; + excluded: boolean; + query: string; + selectedCount: number; + hiddenCount: number; + resultCount: number; + allSelected: boolean; + onlySelected: boolean; + disabled: boolean; + busy: boolean; + onQuery: (value: string) => void; + onExcluded: (value: boolean) => void; + onSelectResults: () => void; + onClear: () => void; + onOnlySelected: () => void; + onBulk: (groupId: string, action: "add" | "remove") => void; + onRemoveAll: () => void; +}; + +export function GroupToolbar(props: Props): React.JSX.Element { + const [target, setTarget] = useState(props.defaultTarget); + const validTarget = props.groups.some((group) => group.id === target) ? target : ""; + return ( +
    +
    + + + +
    +
    +
    +

    + {props.busy + ? m.sg_saving() + : props.selectedCount > 0 + ? m.sg_selected({ count: props.selectedCount }) + : m.sg_select_hint()} +

    + {props.hiddenCount > 0 && ( +

    + {m.sg_hidden_selected({ count: props.hiddenCount })} +

    + )} +
    + {(props.selectedCount > 0 || props.onlySelected) && ( + + )} + + + + + +
    +
    + ); +} diff --git a/apps/web/src/components/subscriptions-header.tsx b/apps/web/src/components/subscriptions-header.tsx index 0a1265f2..bce79a27 100644 --- a/apps/web/src/components/subscriptions-header.tsx +++ b/apps/web/src/components/subscriptions-header.tsx @@ -7,6 +7,7 @@ type Props = { count: number; onVideosIntent?: () => void; onChannelsIntent?: () => void; + group?: string; }; function linkClass(active: boolean): string { @@ -15,7 +16,13 @@ function linkClass(active: boolean): string { : "border-transparent text-fg-muted hover:border-border-strong hover:text-fg"; } -export function SubscriptionsHeader({ active, count, onVideosIntent, onChannelsIntent }: Props) { +export function SubscriptionsHeader({ + active, + count, + group = "all", + onVideosIntent, + onChannelsIntent, +}: Props) { return (
    @@ -30,6 +37,7 @@ export function SubscriptionsHeader({ active, count, onVideosIntent, onChannelsI